From ea8e41e6884a3a2817dd8bb66cd0adfba229875f Mon Sep 17 00:00:00 2001 From: BlackCyan Date: Thu, 11 Jun 2026 15:05:08 +0800 Subject: [PATCH] Initial Commit --- .gitignore | 38 +++ CLAUDE.md | 67 +++++ auth.py | 179 ++++++++++++ config.py | 17 ++ database.py | 14 + main.py | 51 ++++ models.py | 76 +++++ properties.py | 368 ++++++++++++++++++++++++ templates/auth/login.html | 53 ++++ templates/auth/register.html | 75 +++++ templates/base.html | 56 ++++ templates/components/footer.html | 60 ++++ templates/components/navbar.html | 71 +++++ templates/components/phone_input.html | 149 ++++++++++ templates/components/property_card.html | 61 ++++ templates/dashboard/index.html | 140 +++++++++ templates/index.html | 99 +++++++ templates/properties/create.html | 173 +++++++++++ templates/properties/detail.html | 258 +++++++++++++++++ templates/properties/edit.html | 176 ++++++++++++ templates/properties/list.html | 136 +++++++++ 21 files changed, 2317 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 auth.py create mode 100644 config.py create mode 100644 database.py create mode 100644 main.py create mode 100644 models.py create mode 100644 properties.py create mode 100644 templates/auth/login.html create mode 100644 templates/auth/register.html create mode 100644 templates/base.html create mode 100644 templates/components/footer.html create mode 100644 templates/components/navbar.html create mode 100644 templates/components/phone_input.html create mode 100644 templates/components/property_card.html create mode 100644 templates/dashboard/index.html create mode 100644 templates/index.html create mode 100644 templates/properties/create.html create mode 100644 templates/properties/detail.html create mode 100644 templates/properties/edit.html create mode 100644 templates/properties/list.html 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 %}