commit ea8e41e6884a3a2817dd8bb66cd0adfba229875f Author: BlackCyan Date: Thu Jun 11 15:05:08 2026 +0800 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c3a63b --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.egg-info/ +dist/ +build/ +*.egg + +# Virtual environment +.venv/ +venv/ +env/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Environment variables +.env +.env.local + +# OS +.DS_Store +Thumbs.db + +# Uploaded property images +static/properties/*.jpg +static/properties/*.jpeg +static/properties/*.png +static/properties/*.webp + +# Temporary files +*.log +*.tmp diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f59c887 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +NexHome — a modern American-style second-hand house sales website built with FastAPI + Jinja2 templates. Features user authentication, property listings with search/filter, property CRUD with image upload, favorites, contact info with international phone formatting, and a user dashboard. + +## Running the App + +```bash +source .venv/bin/activate + +# Recreate DB tables (first time or after model changes) +python -c "from database import engine; from models import Base; Base.metadata.drop_all(bind=engine); Base.metadata.create_all(bind=engine)" + +# Start the dev server +uvicorn main:app --reload +``` + +**Prerequisites:** +- Python 3.14 +- MySQL server running locally with a `NexHome` database +- Set the `SECRET_KEY` environment variable (used by `auth.py` for JWT signing) + +## Architecture + +**`main.py`** — FastAPI entry point. Creates the app, includes auth and properties routers, creates DB tables on startup, serves the homepage. + +**`auth.py`** — Authentication router (`/auth` prefix). User registration, login (JWT in HttpOnly cookie), logout. `get_current_user()` checks cookie first, then bearer token, returns `None` for anonymous users. + +**`config.py`** — Shared config: `SECRET_KEY`, `templates`, `bcrypt_context`, `oauth2_bearer`. Jinja2 cache disabled for Python 3.14 compatibility. + +**`database.py`** — SQLAlchemy setup with MySQL engine, `SessionLocal`, `Base`. + +**`models.py`** — ORM models: `User` (with email/full_name/phone), `Property` (with contact_email/contact_phone), `PropertyImage`, `Favorite`. + +**`properties.py`** — Property CRUD, search/filter, favorites, dashboard. Static paths (`/properties/new`) must be defined before parameterized paths (`/properties/{prop_id}`). + +## Data Flow + +Request → Router → dependency injection (`get_current_user`, `get_db`) → SQLAlchemy → Jinja2 template → HTML response. + +**TemplateResponse API:** `templates.TemplateResponse(request, "template.html", {"user": user, ...})` — request is first arg (Starlette 1.x). + +## Key Conventions + +- DB sessions: `Annotated[Session, Depends(get_db)]` (the `db_dependency` alias). +- Redirect after POST: `RedirectResponse(url="...", status_code=303)`. +- Image uploads: saved to `static/properties/` with UUID filenames, max 5MB. +- USD currency: prices stored as integer dollars, displayed as `${:,}`. +- Phone input: `templates/components/phone_input.html` — JS-powered country code selector with flags, dynamic placeholders, auto-formatting. +- Route ordering: static paths before parameterized paths in `properties.py`. + +## Template Structure + +- `templates/base.html` — Tailwind CSS CDN layout with navbar + footer +- `templates/components/` — navbar, footer, property_card macro, phone_input +- `templates/auth/` — login, register +- `templates/properties/` — list, detail, create, edit +- `templates/dashboard/` — user dashboard (my listings + favorites) + +## Known Issues + +- `database.py` exports the engine as `engin` (typo). +- No `requirements.txt` or `pyproject.toml`. +- Database credentials hardcoded in `database.py`. diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..10bb97b --- /dev/null +++ b/auth.py @@ -0,0 +1,179 @@ +from datetime import datetime, timedelta, timezone +from typing import Annotated, Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from jose import jwt, JWTError +from pydantic import BaseModel +from sqlalchemy.orm import Session +from starlette import status + +from config import SECRET_KEY, ALGORITHM, templates, bcrypt_context, oauth2_bearer +from database import SessionLocal +from models import User + +router = APIRouter( + prefix="/auth", + tags=["auth"] +) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +db_dependency = Annotated[Session, Depends(get_db)] + + +class CreateUserRequest(BaseModel): + username: str + password: str + email: str = "" + full_name: str = "" + + +class Token(BaseModel): + access_token: str + token_type: str + + +def authenticate_user(username: str, password: str, db: Session): + user = db.query(User).filter(User.username == username).first() + if not user: + return False + if not bcrypt_context.verify(password, user.hashed_password): + return False + return user + + +def create_access_token(username: str, user_id: int, expires_delta: timedelta): + encode = {"sub": username, "id": user_id} + expires = datetime.now(timezone.utc) + expires_delta + encode.update({"exp": expires}) + return jwt.encode(encode, SECRET_KEY, algorithm=ALGORITHM) + + +async def get_current_user( + request: Request, + token: Annotated[Optional[str], Depends(oauth2_bearer)], +): + cookie_token = request.cookies.get("access_token") + effective_token = cookie_token or token + if not effective_token: + return None + try: + payload = jwt.decode(effective_token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + user_id: int = payload.get("id") + if username is None or user_id is None: + return None + return {"username": username, "id": user_id} + except JWTError: + return None + + +@router.post("/", status_code=status.HTTP_201_CREATED) +async def create_user(db: db_dependency, create_user_request: CreateUserRequest): + create_user_model = User( + username=create_user_request.username, + email=create_user_request.email or None, + full_name=create_user_request.full_name or None, + hashed_password=bcrypt_context.hash(create_user_request.password), + ) + db.add(create_user_model) + db.commit() + + +@router.post("/token", response_model=Token) +async def login_for_access_token( + username: str = Form(...), + password: str = Form(...), + db: Session = Depends(get_db), +): + user = authenticate_user(username, password, db) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate user.", + ) + token = create_access_token(user.username, user.id, timedelta(minutes=60)) + return {"access_token": token, "token_type": "bearer"} + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + return templates.TemplateResponse(request, "auth/login.html", {}) + + +@router.post("/login") +async def login_submit(request: Request, db: db_dependency): + form = await request.form() + username = form.get("username", "") + password = form.get("password", "") + user = authenticate_user(username, password, db) + if not user: + return templates.TemplateResponse(request, "auth/login.html", { + "error": "Invalid username or password", + }) + token = create_access_token(user.username, user.id, timedelta(minutes=60)) + response = RedirectResponse(url="/", status_code=303) + response.set_cookie(key="access_token", value=token, httponly=True, max_age=3600, samesite="lax") + return response + + +@router.get("/register", response_class=HTMLResponse) +async def register_page(request: Request): + return templates.TemplateResponse(request, "auth/register.html", {}) + + +@router.post("/register") +async def register_submit(request: Request, db: db_dependency): + form = await request.form() + username = form.get("username", "").strip() + email = form.get("email", "").strip() + full_name = form.get("full_name", "").strip() + password = form.get("password", "") + confirm = form.get("confirm_password", "") + + errors = [] + if not username: + errors.append("Username is required.") + if not password: + errors.append("Password is required.") + if password != confirm: + errors.append("Passwords do not match.") + if len(password) < 6: + errors.append("Password must be at least 6 characters.") + if db.query(User).filter(User.username == username).first(): + errors.append("Username already taken.") + if email and db.query(User).filter(User.email == email).first(): + errors.append("Email already registered.") + + if errors: + return templates.TemplateResponse(request, "auth/register.html", { + "errors": errors, + "username": username, + "email": email, + "full_name": full_name, + }) + + new_user = User( + username=username, + email=email or None, + full_name=full_name or None, + hashed_password=bcrypt_context.hash(password), + ) + db.add(new_user) + db.commit() + return RedirectResponse(url="/auth/login?registered=1", status_code=303) + + +@router.get("/logout") +async def logout(): + response = RedirectResponse(url="/", status_code=303) + response.delete_cookie("access_token") + return response diff --git a/config.py b/config.py new file mode 100644 index 0000000..0083a46 --- /dev/null +++ b/config.py @@ -0,0 +1,17 @@ +import os +from starlette.templating import Jinja2Templates +from passlib.context import CryptContext +from fastapi.security import OAuth2PasswordBearer +import jinja2 + +SECRET_KEY = os.getenv("SECRET_KEY", "nexhome-dev-secret-key-change-in-production") +ALGORITHM = "HS256" + +# Disable Jinja2 cache to work around Python 3.14 compatibility issue +_loader = jinja2.FileSystemLoader("templates") +_env = jinja2.Environment(loader=_loader, autoescape=jinja2.select_autoescape(), cache_size=0) +templates = Jinja2Templates(env=_env) + +bcrypt_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +oauth2_bearer = OAuth2PasswordBearer(tokenUrl="auth/token", auto_error=False) diff --git a/database.py b/database.py new file mode 100644 index 0000000..22582cc --- /dev/null +++ b/database.py @@ -0,0 +1,14 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + + +DB_URL = 'mysql+pymysql://root:123456@localhost:3306/NexHome' +# DB_URL = 'sqlite:///house.db' + + +engine = create_engine(DB_URL) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() diff --git a/main.py b/main.py new file mode 100644 index 0000000..eff8443 --- /dev/null +++ b/main.py @@ -0,0 +1,51 @@ +from typing import Annotated, Optional + +from fastapi import FastAPI, Depends, Request +from sqlalchemy.orm import Session +from starlette import status +from starlette.staticfiles import StaticFiles + +import models +from auth import get_db, get_current_user, router as auth_router +from config import templates +from models import Property +from properties import router as properties_router + +app = FastAPI() + +app.include_router(auth_router) +app.include_router(properties_router) +app.mount("/static", StaticFiles(directory="static"), name="static") + +models.Base.metadata.create_all(bind=__import__("database", fromlist=["engine"]).engine) + +db_dependency = Annotated[Session, Depends(get_db)] +user_dependency = Annotated[Optional[dict], Depends(get_current_user)] + + +@app.get("/", status_code=status.HTTP_200_OK) +async def homepage(request: Request, db: db_dependency, user: user_dependency): + # Show featured properties; if none are featured, show the latest listings + featured = ( + db.query(Property) + .filter(Property.is_featured == True, Property.status == "active") + .limit(6) + .all() + ) + if not featured: + featured = ( + db.query(Property) + .filter(Property.status == "active") + .order_by(Property.created_at.desc()) + .limit(6) + .all() + ) + for prop in featured: + prop.primary_image = next( + (img for img in prop.images if img.is_primary), + prop.images[0] if prop.images else None, + ) + return templates.TemplateResponse(request, "index.html", { + "user": user, + "properties": featured, + }) diff --git a/models.py b/models.py new file mode 100644 index 0000000..d93884c --- /dev/null +++ b/models.py @@ -0,0 +1,76 @@ +from datetime import datetime, timezone +from sqlalchemy import Boolean, Column, Integer, String, Float, Text, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import relationship +from database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, index=True) + email = Column(String(100), unique=True, index=True, nullable=True) + hashed_password = Column(String(60)) + full_name = Column(String(100), nullable=True) + phone = Column(String(20), nullable=True) + is_agent = Column(Boolean, default=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + properties = relationship("Property", back_populates="owner") + favorites = relationship("Favorite", back_populates="user") + + +class Property(Base): + __tablename__ = "properties" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(200), nullable=False) + description = Column(Text, nullable=False) + price = Column(Integer, nullable=False) + property_type = Column(String(50), nullable=False) + bedrooms = Column(Integer, default=0) + bathrooms = Column(Float, default=0) + area_sqft = Column(Integer, nullable=False) + address = Column(String(300), nullable=False) + city = Column(String(100), nullable=False) + state = Column(String(50), nullable=False) + zip_code = Column(String(10), nullable=False) + year_built = Column(Integer, nullable=True) + status = Column(String(20), default="active") + is_featured = Column(Boolean, default=False) + contact_email = Column(String(100), nullable=True) + contact_phone = Column(String(30), nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc)) + + owner_id = Column(Integer, ForeignKey("users.id")) + owner = relationship("User", back_populates="properties") + images = relationship("PropertyImage", back_populates="property", cascade="all, delete-orphan") + favorites = relationship("Favorite", back_populates="property") + + +class PropertyImage(Base): + __tablename__ = "property_images" + + id = Column(Integer, primary_key=True, index=True) + image_path = Column(String(500), nullable=False) + is_primary = Column(Boolean, default=False) + sort_order = Column(Integer, default=0) + + property_id = Column(Integer, ForeignKey("properties.id")) + property = relationship("Property", back_populates="images") + + +class Favorite(Base): + __tablename__ = "favorites" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + property_id = Column(Integer, ForeignKey("properties.id"), nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + user = relationship("User", back_populates="favorites") + property = relationship("Property", back_populates="favorites") + + __table_args__ = (UniqueConstraint("user_id", "property_id", name="uq_user_property"),) diff --git a/properties.py b/properties.py new file mode 100644 index 0000000..c3a5e12 --- /dev/null +++ b/properties.py @@ -0,0 +1,368 @@ +import os +import uuid +from typing import Annotated, Optional +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session +from starlette import status + +from auth import get_db, get_current_user +from config import templates +from models import Property, PropertyImage, Favorite, User + +router = APIRouter(tags=["properties"]) + +PAGE_SIZE = 9 + + +def _primary_image(prop): + return next((img for img in prop.images if img.is_primary), prop.images[0] if prop.images else None) + + +def _apply_image(props): + for p in props: + p.primary_image = _primary_image(p) + return props + + +@router.get("/properties", response_class=HTMLResponse) +async def property_list(request: Request, db: Session = Depends(get_db)): + search = request.query_params.get("search", "").strip() + prop_type = request.query_params.get("type", "").strip() + min_price = request.query_params.get("min_price", "").strip() + max_price = request.query_params.get("max_price", "").strip() + bedrooms = request.query_params.get("bedrooms", "").strip() + city = request.query_params.get("city", "").strip() + state = request.query_params.get("state", "").strip() + page = int(request.query_params.get("page", 1)) + + query = db.query(Property).filter(Property.status == "active") + + if search: + like = f"%{search}%" + query = query.filter( + (Property.title.ilike(like)) | + (Property.city.ilike(like)) | + (Property.state.ilike(like)) | + (Property.zip_code.ilike(like)) | + (Property.address.ilike(like)) + ) + if prop_type: + query = query.filter(Property.property_type == prop_type) + if min_price: + query = query.filter(Property.price >= int(min_price)) + if max_price: + query = query.filter(Property.price <= int(max_price)) + if bedrooms: + query = query.filter(Property.bedrooms >= int(bedrooms)) + if city: + query = query.filter(Property.city.ilike(f"%{city}%")) + if state: + query = query.filter(Property.state.ilike(f"%{state}%")) + + total = query.count() + total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) + page = max(1, min(page, total_pages)) + + properties = query.order_by(Property.created_at.desc()).offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).all() + _apply_image(properties) + + params = {} + for k in ("search", "type", "min_price", "max_price", "bedrooms", "city", "state"): + v = request.query_params.get(k, "") + if v: + params[k] = v + query_string = urlencode(params) + + user = await get_current_user(request, None) + + return templates.TemplateResponse(request, "properties/list.html", { + "user": user, + "properties": properties, + "total_pages": total_pages, + "page": page, + "query_string": query_string, + "search": search, + "prop_type": prop_type, + "min_price": min_price, + "max_price": max_price, + "bedrooms": bedrooms, + "city_filter": city, + "state_filter": state, + }) + + +@router.get("/properties/new", response_class=HTMLResponse) +async def property_create_form(request: Request, db: Session = Depends(get_db)): + user = await get_current_user(request, None) + if not user: + return RedirectResponse(url="/auth/login", status_code=303) + return templates.TemplateResponse(request, "properties/create.html", { + "user": user, + "values": {}, + }) + + +async def _save_property_image(image_file: UploadFile) -> str | None: + if not image_file or not image_file.filename: + return None + ext = os.path.splitext(image_file.filename)[1].lower() + if ext not in (".jpg", ".jpeg", ".png", ".webp"): + return None + upload_dir = "static/properties" + os.makedirs(upload_dir, exist_ok=True) + filename = f"{uuid.uuid4().hex}{ext}" + filepath = os.path.join(upload_dir, filename) + content = await image_file.read() + if len(content) > 5 * 1024 * 1024: + return None + with open(filepath, "wb") as f: + f.write(content) + return f"properties/{filename}" + + +@router.post("/properties/new") +async def property_create_submit( + request: Request, + db: Session = Depends(get_db), + title: str = Form(...), + description: str = Form(...), + price: int = Form(...), + property_type: str = Form(...), + bedrooms: int = Form(0), + bathrooms: float = Form(0), + area_sqft: int = Form(...), + address: str = Form(...), + city: str = Form(...), + state: str = Form(...), + zip_code: str = Form(...), + year_built: int | None = Form(None), + contact_email: str = Form(""), + contact_phone: str = Form(""), + image: UploadFile | None = File(None), +): + user = await get_current_user(request, None) + if not user: + return RedirectResponse(url="/auth/login", status_code=303) + + contact_email = contact_email.strip() + contact_phone = contact_phone.strip() + if not contact_email and not contact_phone: + return templates.TemplateResponse(request, "properties/create.html", { + "user": user, + "values": {k: v for k, v in request._form.items()} if hasattr(request, '_form') else {}, + "error": "Please provide at least one contact method (email or phone).", + }) + + prop = Property( + title=title, + description=description, + price=price, + property_type=property_type, + bedrooms=bedrooms, + bathrooms=bathrooms, + area_sqft=area_sqft, + address=address, + city=city, + state=state.upper(), + zip_code=zip_code, + year_built=year_built or None, + contact_email=contact_email or None, + contact_phone=contact_phone or None, + owner_id=user["id"], + ) + db.add(prop) + db.flush() + + image_path = await _save_property_image(image) + if image_path: + db.add(PropertyImage(image_path=image_path, is_primary=True, sort_order=0, property_id=prop.id)) + + db.commit() + return RedirectResponse(url=f"/properties/{prop.id}", status_code=303) + + +# --------------------------------------------------------------------------- +# Parameterized routes — after all static paths +# --------------------------------------------------------------------------- + +@router.get("/properties/{prop_id}", response_class=HTMLResponse) +async def property_detail(prop_id: int, request: Request, db: Session = Depends(get_db)): + prop = db.query(Property).filter(Property.id == prop_id).first() + if not prop: + raise HTTPException(status_code=404, detail="Property not found") + + primary_image = _primary_image(prop) + images = sorted(prop.images, key=lambda img: img.sort_order) + + user = await get_current_user(request, None) + is_favorited = False + if user: + is_favorited = db.query(Favorite).filter( + Favorite.user_id == user["id"], + Favorite.property_id == prop.id, + ).first() is not None + + return templates.TemplateResponse(request, "properties/detail.html", { + "user": user, + "prop": prop, + "primary_image": primary_image, + "images": images, + "is_favorited": is_favorited, + }) + + +@router.get("/properties/{prop_id}/edit", response_class=HTMLResponse) +async def property_edit_form(prop_id: int, request: Request, db: Session = Depends(get_db)): + user = await get_current_user(request, None) + if not user: + return RedirectResponse(url="/auth/login", status_code=303) + + prop = db.query(Property).filter(Property.id == prop_id).first() + if not prop: + raise HTTPException(status_code=404, detail="Property not found") + if prop.owner_id != user["id"]: + raise HTTPException(status_code=403, detail="Not authorized") + + primary_image = _primary_image(prop) + return templates.TemplateResponse(request, "properties/edit.html", { + "user": user, + "prop": prop, + "primary_image": primary_image, + }) + + +@router.post("/properties/{prop_id}/edit") +async def property_edit_submit( + prop_id: int, + request: Request, + db: Session = Depends(get_db), + title: str = Form(...), + description: str = Form(...), + price: int = Form(...), + property_type: str = Form(...), + bedrooms: int = Form(0), + bathrooms: float = Form(0), + area_sqft: int = Form(...), + address: str = Form(...), + city: str = Form(...), + state: str = Form(...), + zip_code: str = Form(...), + year_built: int | None = Form(None), + status: str = Form("active"), + contact_email: str = Form(""), + contact_phone: str = Form(""), + image: UploadFile | None = File(None), +): + user = await get_current_user(request, None) + if not user: + return RedirectResponse(url="/auth/login", status_code=303) + + prop = db.query(Property).filter(Property.id == prop_id).first() + if not prop: + raise HTTPException(status_code=404, detail="Property not found") + if prop.owner_id != user["id"]: + raise HTTPException(status_code=403, detail="Not authorized") + + prop.title = title + prop.description = description + prop.price = price + prop.property_type = property_type + prop.bedrooms = bedrooms + prop.bathrooms = bathrooms + prop.area_sqft = area_sqft + prop.address = address + prop.city = city + prop.state = state.upper() + prop.zip_code = zip_code + prop.year_built = year_built or None + prop.contact_email = contact_email.strip() or None + prop.contact_phone = contact_phone.strip() or None + prop.status = status + + image_path = await _save_property_image(image) + if image_path: + for img in prop.images: + old_path = os.path.join("static", img.image_path) + if os.path.exists(old_path): + os.remove(old_path) + db.delete(img) + db.flush() + db.add(PropertyImage(image_path=image_path, is_primary=True, sort_order=0, property_id=prop.id)) + + db.commit() + return RedirectResponse(url=f"/properties/{prop.id}", status_code=303) + + +@router.post("/properties/{prop_id}/delete") +async def property_delete(prop_id: int, request: Request, db: Session = Depends(get_db)): + user = await get_current_user(request, None) + if not user: + return RedirectResponse(url="/auth/login", status_code=303) + + prop = db.query(Property).filter(Property.id == prop_id).first() + if not prop: + raise HTTPException(status_code=404, detail="Property not found") + if prop.owner_id != user["id"]: + raise HTTPException(status_code=403, detail="Not authorized") + + for img in prop.images: + old_path = os.path.join("static", img.image_path) + if os.path.exists(old_path): + os.remove(old_path) + + db.delete(prop) + db.commit() + return RedirectResponse(url="/dashboard", status_code=303) + + +@router.post("/properties/{prop_id}/favorite") +async def toggle_favorite(prop_id: int, request: Request, db: Session = Depends(get_db)): + user = await get_current_user(request, None) + if not user: + return RedirectResponse(url="/auth/login", status_code=303) + + existing = db.query(Favorite).filter( + Favorite.user_id == user["id"], + Favorite.property_id == prop_id, + ).first() + + if existing: + db.delete(existing) + else: + db.add(Favorite(user_id=user["id"], property_id=prop_id)) + + db.commit() + return RedirectResponse(url=f"/properties/{prop_id}", status_code=303) + + +@router.get("/dashboard", response_class=HTMLResponse) +async def dashboard(request: Request, db: Session = Depends(get_db)): + user = await get_current_user(request, None) + if not user: + return RedirectResponse(url="/auth/login", status_code=303) + + my_properties = ( + db.query(Property) + .filter(Property.owner_id == user["id"]) + .order_by(Property.created_at.desc()) + .all() + ) + _apply_image(my_properties) + + favorite_ids = [ + f.property_id + for f in db.query(Favorite).filter(Favorite.user_id == user["id"]).all() + ] + favorites = [] + if favorite_ids: + favorites = db.query(Property).filter(Property.id.in_(favorite_ids)).all() + _apply_image(favorites) + + return templates.TemplateResponse(request, "dashboard/index.html", { + "user": user, + "my_properties": my_properties, + "favorites": favorites, + }) diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..177fc00 --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block title %}Login - NexHome{% endblock %} + +{% block content %} +
+
+
+

Welcome Back

+

Sign in to your NexHome account

+
+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + + {% if request.query_params.get('registered') %} +
+ Registration successful! Please sign in. +
+ {% endif %} + +
+
+
+ + +
+ +
+ + +
+ + +
+ +
+ Don't have an account? + Create one here +
+
+
+
+{% endblock %} diff --git a/templates/auth/register.html b/templates/auth/register.html new file mode 100644 index 0000000..89bdc28 --- /dev/null +++ b/templates/auth/register.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block title %}Register - NexHome{% endblock %} + +{% block content %} +
+
+
+

Create Account

+

Join NexHome and find your dream property

+
+ + {% if errors %} +
+
    + {% for e in errors %} +
  • {{ e }}
  • + {% endfor %} +
+
+ {% endif %} + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ Already have an account? + Sign in +
+
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..30af7fc --- /dev/null +++ b/templates/base.html @@ -0,0 +1,56 @@ + + + + + + {% block title %}NexHome{% endblock %} + + + + {% block head %}{% endblock %} + + + {% include "components/navbar.html" %} + + {% if flash %} +
+
+ {{ flash }} +
+
+ {% endif %} + +
+ {% block content %}{% endblock %} +
+ + {% include "components/footer.html" %} + + diff --git a/templates/components/footer.html b/templates/components/footer.html new file mode 100644 index 0000000..0f4723c --- /dev/null +++ b/templates/components/footer.html @@ -0,0 +1,60 @@ + diff --git a/templates/components/navbar.html b/templates/components/navbar.html new file mode 100644 index 0000000..eecffa0 --- /dev/null +++ b/templates/components/navbar.html @@ -0,0 +1,71 @@ + + + diff --git a/templates/components/phone_input.html b/templates/components/phone_input.html new file mode 100644 index 0000000..aaaec46 --- /dev/null +++ b/templates/components/phone_input.html @@ -0,0 +1,149 @@ +{# Phone input component with country code selector, flags, dynamic placeholder, and auto-formatting. + Usage: {% include "components/phone_input.html" %} + Expects: name, value (optional), id (optional) +#} +
+ +
+ + + + + + + + +
+ + + +
+ + diff --git a/templates/components/property_card.html b/templates/components/property_card.html new file mode 100644 index 0000000..63d3aef --- /dev/null +++ b/templates/components/property_card.html @@ -0,0 +1,61 @@ +{% macro property_card(prop) %} +
+ +
+ {% if prop.primary_image %} + {{ prop.title }} + {% else %} +
+ + + +
+ {% endif %} + + {% if prop.is_featured %} + + Featured + + {% endif %} + + {% if prop.status == 'sold' %} + + Sold + + {% elif prop.status == 'pending' %} + + Pending + + {% endif %} +
+
+ +
+
+ ${{ "{:,}".format(prop.price) }} +
+ +

+ {{ prop.title }} +

+ +

+ + + + + {{ prop.city }}, {{ prop.state }} +

+ +
+ {{ prop.bedrooms }} bd + {{ prop.bathrooms }} ba + {{ "{:,}".format(prop.area_sqft) }} sqft +
+
+
+{% endmacro %} diff --git a/templates/dashboard/index.html b/templates/dashboard/index.html new file mode 100644 index 0000000..0ff02a0 --- /dev/null +++ b/templates/dashboard/index.html @@ -0,0 +1,140 @@ +{% extends "base.html" %} +{% block title %}Dashboard - NexHome{% endblock %} + +{% block content %} +
+
+
+

Dashboard

+

Welcome back, {{ user.username }}!

+
+ + + New Listing + +
+ + +
+ + +
+ + +
+ {% if my_properties %} +
+ + + + + + + + + + + + {% for prop in my_properties %} + + + + + + + + {% endfor %} + +
PropertyPriceStatusCreatedActions
+
+ {% if prop.primary_image %} + + {% else %} +
+ + + +
+ {% endif %} +
+ + {{ prop.title }} + +
{{ prop.city }}, {{ prop.state }}
+
+
+
${{ "{:,}".format(prop.price) }} + + {{ prop.status|capitalize }} + + {{ prop.created_at.strftime("%b %d, %Y") }} + Edit +
+ +
+
+
+ {% else %} +
+ + + +

You haven't listed any properties yet.

+ + Create Your First Listing + +
+ {% endif %} +
+ + + +
+ + +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..83066aa --- /dev/null +++ b/templates/index.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} +{% block title %}NexHome - Find Your Dream Home{% endblock %} + +{% block content %} + +
+
+

+ Find Your Dream Home +

+

+ Browse thousands of homes for sale across the United States. Your perfect property is just a search away. +

+ + +
+
+ + +
+
+
+
+ + +
+
+
+

Featured Properties

+

Hand-picked properties for you

+
+ + View All → + +
+ + {% if properties %} +
+ {% from "components/property_card.html" import property_card %} + {% for prop in properties %} + {{ property_card(prop) }} + {% endfor %} +
+ {% else %} +
+ + + +

No featured properties yet. Be the first to list!

+ + List a Property + +
+ {% endif %} +
+ + +
+
+
+
+
500+
+
Properties Listed
+
+
+
200+
+
Happy Clients
+
+
+
50+
+
Cities Covered
+
+
+
98%
+
Satisfaction Rate
+
+
+
+
+ + +
+
+

Ready to Sell Your Property?

+

+ List your property on NexHome and reach thousands of potential buyers. It's fast, easy, and free. +

+ + List Your Property + +
+
+{% endblock %} diff --git a/templates/properties/create.html b/templates/properties/create.html new file mode 100644 index 0000000..cddc761 --- /dev/null +++ b/templates/properties/create.html @@ -0,0 +1,173 @@ +{% extends "base.html" %} +{% block title %}List a Property - NexHome{% endblock %} + +{% block content %} +
+

List a New Property

+

Fill in the details below to create your listing.

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+ +
+ + +
+ + +
+ + +
+ + +
+
+ +
+ $ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+

Contact Information

+

Provide at least one contact method (email or phone).

+ +
+
+ + +
+ +
+ {% set phone_value = values.contact_phone|default("") %} + {% include "components/phone_input.html" %} +
+
+
+ + +
+ +
+ + + +

Click or drag to upload an image

+ +

JPG, PNG or WebP. Max 5MB.

+
+
+ + +
+ + + Cancel + +
+
+
+{% endblock %} diff --git a/templates/properties/detail.html b/templates/properties/detail.html new file mode 100644 index 0000000..e8d15e8 --- /dev/null +++ b/templates/properties/detail.html @@ -0,0 +1,258 @@ +{% extends "base.html" %} +{% block title %}{{ prop.title }} - NexHome{% endblock %} + +{% block content %} +
+ + + +
+ +
+ +
+ {% if primary_image %} + {{ prop.title }} + {% else %} +
+ + + +
+ {% endif %} + + {% if prop.is_featured %} + + Featured + + {% endif %} + + {% if prop.status == 'sold' %} + + Sold + + {% elif prop.status == 'pending' %} + + Pending + + {% endif %} + + + {% if user %} +
+ +
+ {% endif %} +
+ + + {% if images|length > 1 %} +
+ {% for img in images %} + Property image {{ loop.index }} + {% endfor %} +
+ {% endif %} + + +
+
+

{{ prop.title }}

+ ${{ "{:,}".format(prop.price) }} +
+

+ + + + + {{ prop.address }}, {{ prop.city }}, {{ prop.state }} {{ prop.zip_code }} +

+
+ + +
+
+
{{ prop.bedrooms }}
+
Bedrooms
+
+
+
{{ prop.bathrooms }}
+
Bathrooms
+
+
+
{{ "{:,}".format(prop.area_sqft) }}
+
Sq Ft
+
+
+ + +
+

Description

+

{{ prop.description }}

+
+ + +
+

Property Details

+
+
+ Property Type + {{ prop.property_type }} +
+
+ Year Built + {{ prop.year_built or 'N/A' }} +
+
+ Status + {{ prop.status }} +
+
+ Listed + {{ prop.created_at.strftime("%B %d, %Y") }} +
+
+
+
+ + +
+ +
+

Contact Seller

+ + + {% if user %} +
+ +
+ {% else %} + + Login to Save + + {% endif %} + + +
+ {% if prop.contact_email %} + +
+ + + +
+
+
Email
+
{{ prop.contact_email }}
+
+
+ {% endif %} + + {% if prop.contact_phone %} + +
+ + + +
+
+
Phone
+
{{ prop.contact_phone }}
+
+
+ {% endif %} + + {% if not prop.contact_email and not prop.contact_phone %} + +
+
+ {{ prop.owner.username[0]|upper }} +
+
+
{{ prop.owner.username }}
+ {% if prop.owner.full_name %} +
{{ prop.owner.full_name }}
+ {% endif %} +
+
+ {% endif %} +
+ + {% if user.id == prop.owner_id %} +
+ + Edit Listing + +
+ +
+
+ {% endif %} +
+ + +
+

Listed By

+
+
+ {{ prop.owner.username[0]|upper }} +
+
+
{{ prop.owner.username }}
+ {% if prop.owner.full_name %} +
{{ prop.owner.full_name }}
+ {% endif %} +
+
+
+
+
+
+{% endblock %} diff --git a/templates/properties/edit.html b/templates/properties/edit.html new file mode 100644 index 0000000..088337e --- /dev/null +++ b/templates/properties/edit.html @@ -0,0 +1,176 @@ +{% extends "base.html" %} +{% block title %}Edit Listing - NexHome{% endblock %} + +{% block content %} +
+

Edit Property

+

Update the details of your listing.

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+ +
+ + +
+ + +
+ + +
+ + +
+
+ +
+ $ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

Contact Information

+

Provide at least one contact method (email or phone).

+ +
+
+ + +
+ +
+ {% set phone_value = prop.contact_phone or "" %} + {% include "components/phone_input.html" %} +
+
+
+ + + {% if primary_image %} +
+ + Current +
+ {% endif %} + + +
+ + +

Leave empty to keep the current image.

+
+ + +
+ + + Cancel + +
+
+
+{% endblock %} diff --git a/templates/properties/list.html b/templates/properties/list.html new file mode 100644 index 0000000..3bf865c --- /dev/null +++ b/templates/properties/list.html @@ -0,0 +1,136 @@ +{% extends "base.html" %} +{% block title %}Browse Properties - NexHome{% endblock %} + +{% block content %} +
+

Browse Properties

+ +
+ + + + +
+ {% if properties %} +
+ Showing {{ properties|length }} propert{{ "y" if properties|length == 1 else "ies" }} +
+
+ {% from "components/property_card.html" import property_card %} + {% for prop in properties %} + {{ property_card(prop) }} + {% endfor %} +
+ + + {% if total_pages > 1 %} +
+ {% if page > 1 %} + + « Previous + + {% endif %} + + {% for p in range(1, total_pages + 1) %} + {% if p == page %} + {{ p }} + {% else %} + + {{ p }} + + {% endif %} + {% endfor %} + + {% if page < total_pages %} + + Next » + + {% endif %} +
+ {% endif %} + + {% else %} +
+ + + +

No properties found

+

Try adjusting your filters or clear all filters.

+
+ {% endif %} +
+
+
+{% endblock %}