Initial Commit

This commit is contained in:
2026-06-11 15:05:08 +08:00
commit ea8e41e688
21 changed files with 2317 additions and 0 deletions
+38
View File
@@ -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
+67
View File
@@ -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`.
+179
View File
@@ -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
+17
View File
@@ -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)
+14
View File
@@ -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()
+51
View File
@@ -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,
})
+76
View File
@@ -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"),)
+368
View File
@@ -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,
})
+53
View File
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}Login - NexHome{% endblock %}
{% block content %}
<div class="min-h-[80vh] flex items-center justify-center py-12 px-4">
<div class="max-w-md w-full">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-primary">Welcome Back</h1>
<p class="text-gray-500 mt-2">Sign in to your NexHome account</p>
</div>
{% if error %}
<div class="bg-red-100 border border-red-200 text-red-800 rounded-lg p-4 mb-6 text-sm">
{{ error }}
</div>
{% endif %}
{% if request.query_params.get('registered') %}
<div class="bg-green-100 border border-green-200 text-green-800 rounded-lg p-4 mb-6 text-sm">
Registration successful! Please sign in.
</div>
{% endif %}
<div class="bg-white rounded-2xl shadow-lg p-8">
<form method="POST" action="/auth/login" class="space-y-5">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input type="text" id="username" name="username" required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition"
placeholder="Enter your username">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" id="password" name="password" required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition"
placeholder="Enter your password">
</div>
<button type="submit"
class="w-full bg-accent hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition">
Sign In
</button>
</form>
<div class="mt-6 text-center text-sm text-gray-500">
Don't have an account?
<a href="/auth/register" class="text-accent hover:text-blue-700 font-medium">Create one here</a>
</div>
</div>
</div>
</div>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Register - NexHome{% endblock %}
{% block content %}
<div class="min-h-[80vh] flex items-center justify-center py-12 px-4">
<div class="max-w-md w-full">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-primary">Create Account</h1>
<p class="text-gray-500 mt-2">Join NexHome and find your dream property</p>
</div>
{% if errors %}
<div class="bg-red-100 border border-red-200 text-red-800 rounded-lg p-4 mb-6 text-sm">
<ul class="list-disc list-inside space-y-1">
{% for e in errors %}
<li>{{ e }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="bg-white rounded-2xl shadow-lg p-8">
<form method="POST" action="/auth/register" class="space-y-5">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username *</label>
<input type="text" id="username" name="username" required
value="{{ username|default('') }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition"
placeholder="Choose a username">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" id="email" name="email"
value="{{ email|default('') }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition"
placeholder="your@email.com">
</div>
<div>
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
<input type="text" id="full_name" name="full_name"
value="{{ full_name|default('') }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition"
placeholder="John Smith">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password *</label>
<input type="password" id="password" name="password" required minlength="6"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition"
placeholder="At least 6 characters">
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-1">Confirm Password *</label>
<input type="password" id="confirm_password" name="confirm_password" required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition"
placeholder="Re-enter your password">
</div>
<button type="submit"
class="w-full bg-accent hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition">
Create Account
</button>
</form>
<div class="mt-6 text-center text-sm text-gray-500">
Already have an account?
<a href="/auth/login" class="text-accent hover:text-blue-700 font-medium">Sign in</a>
</div>
</div>
</div>
</div>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}NexHome{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#1e3a5f',
'primary-light': '#2d5a8e',
accent: '#2563eb',
warm: '#f59e0b',
'warm-dark': '#d97706',
}
}
}
}
</script>
<style>
.hero-bg {
background: linear-gradient(135deg, #1e3a5f 0%, #2d5a8e 50%, #1e3a5f 100%);
}
.card-hover {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0,0,0,0.15);
}
</style>
{% block head %}{% endblock %}
</head>
<body class="bg-gray-50 min-h-screen flex flex-col">
{% include "components/navbar.html" %}
{% if flash %}
<div class="max-w-7xl mx-auto px-4 mt-4 w-full">
<div class="rounded-lg p-4 text-sm font-medium
{% if flash_type == 'error' %}bg-red-100 text-red-800 border border-red-200
{% else %}bg-green-100 text-green-800 border border-green-200{% endif %}">
{{ flash }}
</div>
</div>
{% endif %}
<main class="flex-1">
{% block content %}{% endblock %}
</main>
{% include "components/footer.html" %}
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
<footer class="bg-primary text-gray-300 mt-16">
<div class="max-w-7xl mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="md:col-span-1">
<div class="flex items-center space-x-2 mb-4">
<svg class="w-7 h-7 text-warm" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3L2 12h3v8h6v-6h2v6h6v-8h3L12 3z"/>
</svg>
<span class="text-lg font-bold text-white">NexHome</span>
</div>
<p class="text-sm text-gray-400">Your trusted partner in finding the perfect home. Browse thousands of properties across the United States.</p>
</div>
<!-- Quick Links -->
<div>
<h3 class="text-white font-semibold mb-4">Quick Links</h3>
<ul class="space-y-2 text-sm">
<li><a href="/" class="hover:text-warm transition">Home</a></li>
<li><a href="/properties" class="hover:text-warm transition">Browse Properties</a></li>
<li><a href="/properties/new" class="hover:text-warm transition">List a Property</a></li>
</ul>
</div>
<!-- Property Types -->
<div>
<h3 class="text-white font-semibold mb-4">Property Types</h3>
<ul class="space-y-2 text-sm">
<li><a href="/properties?type=house" class="hover:text-warm transition">Houses</a></li>
<li><a href="/properties?type=condo" class="hover:text-warm transition">Condos</a></li>
<li><a href="/properties?type=townhouse" class="hover:text-warm transition">Townhouses</a></li>
<li><a href="/properties?type=land" class="hover:text-warm transition">Land</a></li>
</ul>
</div>
<!-- Contact -->
<div>
<h3 class="text-white font-semibold mb-4">Contact Us</h3>
<ul class="space-y-2 text-sm">
<li class="flex items-center space-x-2">
<svg class="w-4 h-4 text-warm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<span>info@nexhome.com</span>
</li>
<li class="flex items-center space-x-2">
<svg class="w-4 h-4 text-warm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/>
</svg>
<span>(800) 555-1154</span>
</li>
</ul>
</div>
</div>
<div class="border-t border-gray-600 mt-10 pt-6 text-center text-sm text-gray-500">
&copy; 2026 NexHome. All rights reserved. All prices in USD.
</div>
</div>
</footer>
+71
View File
@@ -0,0 +1,71 @@
<nav class="bg-primary shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<a href="/" class="flex items-center space-x-2">
<svg class="w-8 h-8 text-warm" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3L2 12h3v8h6v-6h2v6h6v-8h3L12 3z"/>
</svg>
<span class="text-xl font-bold text-white tracking-tight">NexHome</span>
</a>
<!-- Desktop nav -->
<div class="hidden md:flex items-center space-x-6">
<a href="/" class="text-gray-200 hover:text-white transition">Home</a>
<a href="/properties" class="text-gray-200 hover:text-white transition">Buy</a>
<a href="/properties?type=rent" class="text-gray-200 hover:text-white transition">Rent</a>
<a href="/properties/new" class="text-gray-200 hover:text-white transition">Sell</a>
{% if user %}
<a href="/dashboard" class="text-gray-200 hover:text-white transition">Dashboard</a>
<div class="flex items-center space-x-3 ml-2">
<span class="text-warm font-medium text-sm">{{ user.username }}</span>
<a href="/auth/logout"
class="bg-warm hover:bg-warm-dark text-primary font-semibold text-sm px-4 py-2 rounded-lg transition">
Logout
</a>
</div>
{% else %}
<a href="/auth/login"
class="text-gray-200 hover:text-white transition font-medium text-sm">
Login
</a>
<a href="/auth/register"
class="bg-warm hover:bg-warm-dark text-primary font-semibold text-sm px-4 py-2 rounded-lg transition">
Register
</a>
{% endif %}
</div>
<!-- Mobile menu button -->
<button id="mobile-menu-btn" class="md:hidden text-gray-200 hover:text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
<!-- Mobile menu -->
<div id="mobile-menu" class="hidden md:hidden pb-4">
<div class="flex flex-col space-y-2">
<a href="/" class="text-gray-200 hover:text-white py-2">Home</a>
<a href="/properties" class="text-gray-200 hover:text-white py-2">Buy</a>
<a href="/properties?type=rent" class="text-gray-200 hover:text-white py-2">Rent</a>
<a href="/properties/new" class="text-gray-200 hover:text-white py-2">Sell</a>
{% if user %}
<a href="/dashboard" class="text-gray-200 hover:text-white py-2">Dashboard</a>
<a href="/auth/logout" class="text-warm hover:text-warm-dark py-2 font-medium">Logout ({{ user.username }})</a>
{% else %}
<a href="/auth/login" class="text-gray-200 hover:text-white py-2">Login</a>
<a href="/auth/register" class="text-warm hover:text-warm-dark py-2 font-medium">Register</a>
{% endif %}
</div>
</div>
</div>
</nav>
<script>
document.getElementById('mobile-menu-btn')?.addEventListener('click', () => {
document.getElementById('mobile-menu').classList.toggle('hidden');
});
</script>
+149
View File
@@ -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)
#}
<div class="phone-input-wrapper" id="phoneWrapper">
<label for="contact_phone_input" class="block text-sm font-medium text-gray-700 mb-1">Contact Phone</label>
<div class="flex rounded-lg border border-gray-300 overflow-hidden focus-within:ring-2 focus-within:ring-accent focus-within:border-transparent transition">
<!-- Country selector button -->
<button type="button" id="phoneCountryBtn"
class="flex items-center gap-1.5 px-3 bg-gray-50 border-r border-gray-300 hover:bg-gray-100 transition text-sm flex-shrink-0"
onclick="togglePhoneDropdown()">
<span id="phoneFlag" class="text-lg leading-none">🇺🇸</span>
<span id="phoneDialCode" class="text-gray-700 font-medium">+1</span>
<svg class="w-3 h-3 text-gray-500 ml-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<!-- Hidden input for actual phone value (with dial code) -->
<input type="hidden" name="contact_phone" id="phoneHidden" value="{{ phone_value|default('') }}">
<!-- Visible phone display -->
<input type="tel" id="phoneDisplay"
class="flex-1 px-3 py-2.5 outline-none bg-white text-sm min-w-0"
placeholder="(555) 123-4567"
autocomplete="tel"
oninput="formatPhoneInput(this)"
onfocus="formatPhoneInput(this)">
</div>
<!-- Dropdown -->
<div id="phoneDropdown"
class="hidden absolute z-50 mt-1 w-72 bg-white border border-gray-200 rounded-xl shadow-xl max-h-64 overflow-y-auto">
</div>
</div>
<script>
(function() {
const COUNTRIES = [
{ code: 'US', flag: '🇺🇸', dial: '+1', name: 'United States', format: '(###) ###-####', digitCount: 10 },
{ code: 'CA', flag: '🇨🇦', dial: '+1', name: 'Canada', format: '(###) ###-####', digitCount: 10 },
{ code: 'GB', flag: '🇬🇧', dial: '+44', name: 'United Kingdom', format: '#### ######', digitCount: 10 },
{ code: 'AU', flag: '🇦🇺', dial: '+61', name: 'Australia', format: '#### ### ###', digitCount: 9 },
{ code: 'CN', flag: '🇨🇳', dial: '+86', name: 'China', format: '### #### ####', digitCount: 11 },
{ code: 'JP', flag: '🇯🇵', dial: '+81', name: 'Japan', format: '##-####-####', digitCount: 10 },
{ code: 'KR', flag: '🇰🇷', dial: '+82', name: 'South Korea', format: '##-####-####', digitCount: 10 },
{ code: 'DE', flag: '🇩🇪', dial: '+49', name: 'Germany', format: '### ########', digitCount: 11 },
{ code: 'FR', flag: '🇫🇷', dial: '+33', name: 'France', format: '# ## ## ## ##', digitCount: 9 },
{ code: 'IN', flag: '🇮🇳', dial: '+91', name: 'India', format: '##### #####', digitCount: 10 },
{ code: 'BR', flag: '🇧🇷', dial: '+55', name: 'Brazil', format: '## #####-####', digitCount: 11 },
{ code: 'MX', flag: '🇲🇽', dial: '+52', name: 'Mexico', format: '## #### ####', digitCount: 10 },
{ code: 'SG', flag: '🇸🇬', dial: '+65', name: 'Singapore', format: '#### ####', digitCount: 8 },
{ code: 'HK', flag: '🇭🇰', dial: '+852', name: 'Hong Kong(China)', format: '#### ####', digitCount: 8 },
{ code: 'TW', flag: '🇹🇼', dial: '+886', name: 'Taiwan(China)', format: '## ### ####', digitCount: 9 },
];
let selectedCountry = COUNTRIES[0]; // Default: US
let rawDigits = '';
// Parse initial value
const hiddenInput = document.getElementById('phoneHidden');
const displayInput = document.getElementById('phoneDisplay');
if (hiddenInput.value) {
for (const c of COUNTRIES) {
if (hiddenInput.value.startsWith(c.dial)) {
selectedCountry = c;
rawDigits = hiddenInput.value.replace(c.dial, '').replace(/\D/g, '');
break;
}
}
if (!rawDigits && hiddenInput.value) {
rawDigits = hiddenInput.value.replace(/\D/g, '');
}
}
function updateDisplay() {
document.getElementById('phoneFlag').textContent = selectedCountry.flag;
document.getElementById('phoneDialCode').textContent = selectedCountry.dial;
displayInput.placeholder = formatDigits('', selectedCountry);
displayInput.value = formatDigits(rawDigits, selectedCountry);
hiddenInput.value = rawDigits.length > 0 ? selectedCountry.dial + rawDigits : '';
}
function formatDigits(digits, country) {
if (!digits) return '';
let result = '';
let di = 0;
for (let i = 0; i < country.format.length && di < digits.length; i++) {
if (country.format[i] === '#') {
result += digits[di++];
} else {
result += country.format[i];
}
}
return result;
}
window.formatPhoneInput = function(input) {
const cursorPos = input.selectionStart;
const prevLength = input.value.length;
rawDigits = input.value.replace(/\D/g, '').substring(0, selectedCountry.digitCount);
updateDisplay();
// Restore approximate cursor position
const newLength = input.value.length;
const newPos = cursorPos + (newLength - prevLength);
input.setSelectionRange(newPos, newPos);
};
window.togglePhoneDropdown = function() {
const dd = document.getElementById('phoneDropdown');
dd.classList.toggle('hidden');
if (!dd.classList.contains('hidden')) {
renderDropdown();
}
};
window.selectCountry = function(code) {
selectedCountry = COUNTRIES.find(c => c.code === code);
rawDigits = rawDigits.substring(0, selectedCountry.digitCount);
updateDisplay();
document.getElementById('phoneDropdown').classList.add('hidden');
};
function renderDropdown() {
const dd = document.getElementById('phoneDropdown');
dd.innerHTML = COUNTRIES.map(c => `
<button type="button"
onclick="selectCountry('${c.code}')"
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-accent/5 transition text-left
${c.code === selectedCountry.code ? 'bg-accent/10' : ''}">
<span class="text-xl">${c.flag}</span>
<span class="text-sm font-medium text-gray-700">${c.name}</span>
<span class="text-sm text-gray-500 ml-auto">${c.dial}</span>
</button>
`).join('');
}
// Close dropdown on outside click
document.addEventListener('click', function(e) {
const wrapper = document.getElementById('phoneWrapper');
if (!wrapper.contains(e.target)) {
document.getElementById('phoneDropdown').classList.add('hidden');
}
});
// Initial render
updateDisplay();
})();
</script>
+61
View File
@@ -0,0 +1,61 @@
{% macro property_card(prop) %}
<div class="bg-white rounded-xl shadow-md overflow-hidden card-hover">
<a href="/properties/{{ prop.id }}" class="block">
<div class="relative h-52 bg-gray-200">
{% if prop.primary_image %}
<img src="/static/{{ prop.primary_image.image_path }}"
alt="{{ prop.title }}"
class="w-full h-full object-cover">
{% else %}
<div class="flex items-center justify-center h-full text-gray-400">
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1h-2z"/>
</svg>
</div>
{% endif %}
{% if prop.is_featured %}
<span class="absolute top-3 left-3 bg-warm text-primary text-xs font-bold px-2.5 py-1 rounded-full">
Featured
</span>
{% endif %}
{% if prop.status == 'sold' %}
<span class="absolute top-3 right-3 bg-red-600 text-white text-xs font-bold px-2.5 py-1 rounded-full">
Sold
</span>
{% elif prop.status == 'pending' %}
<span class="absolute top-3 right-3 bg-yellow-500 text-white text-xs font-bold px-2.5 py-1 rounded-full">
Pending
</span>
{% endif %}
</div>
</a>
<div class="p-5">
<div class="flex items-center justify-between mb-2">
<span class="text-2xl font-bold text-primary">${{ "{:,}".format(prop.price) }}</span>
</div>
<h3 class="text-lg font-semibold text-gray-800 mb-1 truncate">
<a href="/properties/{{ prop.id }}" class="hover:text-accent transition">{{ prop.title }}</a>
</h3>
<p class="text-sm text-gray-500 mb-3 flex items-center">
<svg class="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
{{ prop.city }}, {{ prop.state }}
</p>
<div class="flex items-center justify-between text-sm text-gray-600 border-t pt-3 whitespace-nowrap">
<span>{{ prop.bedrooms }} bd</span>
<span>{{ prop.bathrooms }} ba</span>
<span>{{ "{:,}".format(prop.area_sqft) }} sqft</span>
</div>
</div>
</div>
{% endmacro %}
+140
View File
@@ -0,0 +1,140 @@
{% extends "base.html" %}
{% block title %}Dashboard - NexHome{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-10">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold text-primary">Dashboard</h1>
<p class="text-gray-500 mt-1">Welcome back, {{ user.username }}!</p>
</div>
<a href="/properties/new"
class="bg-accent hover:bg-blue-700 text-white font-semibold px-6 py-2.5 rounded-lg transition text-sm">
+ New Listing
</a>
</div>
<!-- Tabs -->
<div class="flex gap-1 bg-gray-100 p-1 rounded-lg w-fit mb-8">
<button id="tab-listings" onclick="showTab('listings')"
class="tab-btn px-6 py-2 rounded-md text-sm font-medium bg-white shadow text-primary transition">
My Listings ({{ my_properties|length }})
</button>
<button id="tab-favorites" onclick="showTab('favorites')"
class="tab-btn px-6 py-2 rounded-md text-sm font-medium text-gray-500 hover:text-gray-700 transition">
My Favorites ({{ favorites|length }})
</button>
</div>
<!-- My Listings -->
<div id="panel-listings">
{% if my_properties %}
<div class="bg-white rounded-xl shadow-md overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-left">
<tr>
<th class="px-6 py-3 font-medium">Property</th>
<th class="px-6 py-3 font-medium">Price</th>
<th class="px-6 py-3 font-medium">Status</th>
<th class="px-6 py-3 font-medium">Created</th>
<th class="px-6 py-3 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y">
{% for prop in my_properties %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4">
<div class="flex items-center space-x-3">
{% if prop.primary_image %}
<img src="/static/{{ prop.primary_image.image_path }}" alt=""
class="w-14 h-10 rounded object-cover">
{% else %}
<div class="w-14 h-10 bg-gray-200 rounded flex items-center justify-center text-gray-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3"/>
</svg>
</div>
{% endif %}
<div>
<a href="/properties/{{ prop.id }}" class="font-medium text-gray-800 hover:text-accent transition">
{{ prop.title }}
</a>
<div class="text-xs text-gray-500">{{ prop.city }}, {{ prop.state }}</div>
</div>
</div>
</td>
<td class="px-6 py-4 font-semibold text-primary">${{ "{:,}".format(prop.price) }}</td>
<td class="px-6 py-4">
<span class="px-2.5 py-1 rounded-full text-xs font-medium
{% if prop.status == 'active' %}bg-green-100 text-green-800
{% elif prop.status == 'pending' %}bg-yellow-100 text-yellow-800
{% else %}bg-red-100 text-red-800{% endif %}">
{{ prop.status|capitalize }}
</span>
</td>
<td class="px-6 py-4 text-gray-500">{{ prop.created_at.strftime("%b %d, %Y") }}</td>
<td class="px-6 py-4 text-right space-x-2">
<a href="/properties/{{ prop.id }}/edit" class="text-accent hover:text-blue-700 transition font-medium">Edit</a>
<form method="POST" action="/properties/{{ prop.id }}/delete" class="inline"
onsubmit="return confirm('Delete this listing?')">
<button type="submit" class="text-red-500 hover:text-red-700 transition font-medium">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-16 text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1h-2z"/>
</svg>
<p class="text-lg mb-4">You haven't listed any properties yet.</p>
<a href="/properties/new" class="bg-accent hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium transition inline-block">
Create Your First Listing
</a>
</div>
{% endif %}
</div>
<!-- My Favorites -->
<div id="panel-favorites" class="hidden">
{% if favorites %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% from "components/property_card.html" import property_card %}
{% for fav_prop in favorites %}
{{ property_card(fav_prop) }}
{% endfor %}
</div>
{% else %}
<div class="text-center py-16 text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
<p class="text-lg mb-4">No favorites yet.</p>
<a href="/properties" class="bg-accent hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium transition inline-block">
Browse Properties
</a>
</div>
{% endif %}
</div>
</div>
<script>
function showTab(tab) {
document.getElementById('panel-listings').classList.toggle('hidden', tab !== 'listings');
document.getElementById('panel-favorites').classList.toggle('hidden', tab !== 'favorites');
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('bg-white', 'shadow', 'text-primary');
btn.classList.add('text-gray-500');
});
const active = document.getElementById('tab-' + tab);
active.classList.add('bg-white', 'shadow', 'text-primary');
active.classList.remove('text-gray-500');
}
</script>
{% endblock %}
+99
View File
@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}NexHome - Find Your Dream Home{% endblock %}
{% block content %}
<!-- Hero -->
<section class="hero-bg text-white py-20 md:py-32">
<div class="max-w-7xl mx-auto px-4 text-center">
<h1 class="text-4xl md:text-6xl font-extrabold mb-4 tracking-tight">
Find Your <span class="text-warm">Dream Home</span>
</h1>
<p class="text-xl text-gray-300 mb-10 max-w-2xl mx-auto">
Browse thousands of homes for sale across the United States. Your perfect property is just a search away.
</p>
<!-- Search bar -->
<form action="/properties" method="GET" class="max-w-3xl mx-auto">
<div class="flex flex-col sm:flex-row gap-3 bg-white/10 backdrop-blur p-4 rounded-2xl">
<input type="text" name="search" placeholder="Search by city, state, or zip code..."
class="flex-1 px-5 py-3 rounded-xl text-gray-800 focus:ring-2 focus:ring-warm outline-none">
<button type="submit"
class="bg-warm hover:bg-warm-dark text-primary font-bold px-8 py-3 rounded-xl transition">
Search
</button>
</div>
</form>
</div>
</section>
<!-- Featured Properties -->
<section class="max-w-7xl mx-auto px-4 py-16">
<div class="flex items-center justify-between mb-8">
<div>
<h2 class="text-3xl font-bold text-primary">Featured Properties</h2>
<p class="text-gray-500 mt-1">Hand-picked properties for you</p>
</div>
<a href="/properties" class="text-accent hover:text-blue-700 font-medium transition">
View All &rarr;
</a>
</div>
{% if properties %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% from "components/property_card.html" import property_card %}
{% for prop in properties %}
{{ property_card(prop) }}
{% endfor %}
</div>
{% else %}
<div class="text-center py-16 text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1h-2z"/>
</svg>
<p class="text-lg">No featured properties yet. Be the first to list!</p>
<a href="/properties/new" class="inline-block mt-4 bg-accent hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium transition">
List a Property
</a>
</div>
{% endif %}
</section>
<!-- Stats -->
<section class="bg-white py-16">
<div class="max-w-7xl mx-auto px-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
<div>
<div class="text-4xl font-extrabold text-accent mb-2">500+</div>
<div class="text-gray-500 font-medium">Properties Listed</div>
</div>
<div>
<div class="text-4xl font-extrabold text-accent mb-2">200+</div>
<div class="text-gray-500 font-medium">Happy Clients</div>
</div>
<div>
<div class="text-4xl font-extrabold text-accent mb-2">50+</div>
<div class="text-gray-500 font-medium">Cities Covered</div>
</div>
<div>
<div class="text-4xl font-extrabold text-accent mb-2">98%</div>
<div class="text-gray-500 font-medium">Satisfaction Rate</div>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="max-w-7xl mx-auto px-4 py-16">
<div class="bg-primary rounded-2xl p-10 md:p-16 text-center text-white">
<h2 class="text-3xl md:text-4xl font-bold mb-4">Ready to Sell Your Property?</h2>
<p class="text-gray-300 mb-8 max-w-xl mx-auto">
List your property on NexHome and reach thousands of potential buyers. It's fast, easy, and free.
</p>
<a href="/properties/new"
class="inline-block bg-warm hover:bg-warm-dark text-primary font-bold px-10 py-4 rounded-xl text-lg transition">
List Your Property
</a>
</div>
</section>
{% endblock %}
+173
View File
@@ -0,0 +1,173 @@
{% extends "base.html" %}
{% block title %}List a Property - NexHome{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto px-4 py-10">
<h1 class="text-3xl font-bold text-primary mb-2">List a New Property</h1>
<p class="text-gray-500 mb-8">Fill in the details below to create your listing.</p>
{% if error %}
<div class="bg-red-100 border border-red-200 text-red-800 rounded-lg p-4 mb-6 text-sm">
{{ error }}
</div>
{% endif %}
<form method="POST" action="/properties/new" enctype="multipart/form-data" class="bg-white rounded-2xl shadow-lg p-8 space-y-6">
<!-- Title -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Property Title *</label>
<input type="text" name="title" required
value="{{ values.title|default('') }}"
placeholder="e.g. Beautiful Family Home in Austin"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description *</label>
<textarea name="description" required rows="5"
placeholder="Describe your property..."
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">{{ values.description|default('') }}</textarea>
</div>
<!-- Price & Type -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Price (USD) *</label>
<div class="relative">
<span class="absolute left-3 top-3 text-gray-500 font-medium">$</span>
<input type="number" name="price" required min="0"
value="{{ values.price|default('') }}"
placeholder="450,000"
class="w-full pl-8 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Property Type *</label>
<select name="property_type" required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
<option value="">Select type</option>
<option value="house" {% if values.property_type == 'house' %}selected{% endif %}>House</option>
<option value="condo" {% if values.property_type == 'condo' %}selected{% endif %}>Condo</option>
<option value="townhouse" {% if values.property_type == 'townhouse' %}selected{% endif %}>Townhouse</option>
<option value="land" {% if values.property_type == 'land' %}selected{% endif %}>Land</option>
</select>
</div>
</div>
<!-- Beds / Baths / Sqft -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bedrooms *</label>
<input type="number" name="bedrooms" required min="0" max="20"
value="{{ values.bedrooms|default('0') }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bathrooms *</label>
<input type="number" name="bathrooms" required min="0" max="20" step="0.5"
value="{{ values.bathrooms|default('0') }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Area (sqft) *</label>
<input type="number" name="area_sqft" required min="0"
value="{{ values.area_sqft|default('') }}"
placeholder="2000"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
</div>
<!-- Address -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Street Address *</label>
<input type="text" name="address" required
value="{{ values.address|default('') }}"
placeholder="123 Main Street"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<!-- City / State / Zip -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">City *</label>
<input type="text" name="city" required
value="{{ values.city|default('') }}"
placeholder="Austin"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">State *</label>
<input type="text" name="state" required maxlength="2"
value="{{ values.state|default('') }}"
placeholder="TX"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Zip Code *</label>
<input type="text" name="zip_code" required maxlength="10"
value="{{ values.zip_code|default('') }}"
placeholder="78701"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
</div>
<!-- Year Built -->
<div class="sm:w-1/2">
<label class="block text-sm font-medium text-gray-700 mb-1">Year Built</label>
<input type="number" name="year_built" min="1800" max="2030"
value="{{ values.year_built|default('') }}"
placeholder="e.g. 2005"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<!-- Contact Info -->
<div class="border-t pt-6">
<h3 class="text-lg font-semibold text-primary mb-4">Contact Information</h3>
<p class="text-sm text-gray-500 mb-4">Provide at least one contact method (email or phone).</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Contact Email</label>
<input type="email" name="contact_email"
value="{{ values.contact_email|default('') }}"
placeholder="seller@example.com"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div class="relative">
{% set phone_value = values.contact_phone|default("") %}
{% include "components/phone_input.html" %}
</div>
</div>
</div>
<!-- Image Upload -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Property Image</label>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-accent transition">
<svg class="w-10 h-10 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-sm text-gray-500 mb-2">Click or drag to upload an image</p>
<input type="file" name="image" accept="image/*"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-accent file:text-white hover:file:bg-blue-700 transition">
<p class="text-xs text-gray-400 mt-2">JPG, PNG or WebP. Max 5MB.</p>
</div>
</div>
<!-- Submit -->
<div class="flex gap-4 pt-4">
<button type="submit"
class="flex-1 bg-accent hover:bg-blue-700 text-white font-semibold py-3.5 rounded-lg transition">
Publish Listing
</button>
<a href="/"
class="px-8 py-3.5 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition">
Cancel
</a>
</div>
</form>
</div>
{% endblock %}
+258
View File
@@ -0,0 +1,258 @@
{% extends "base.html" %}
{% block title %}{{ prop.title }} - NexHome{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Breadcrumb -->
<nav class="text-sm text-gray-500 mb-6">
<a href="/" class="hover:text-accent transition">Home</a>
<span class="mx-2">/</span>
<a href="/properties" class="hover:text-accent transition">Properties</a>
<span class="mx-2">/</span>
<a href="/properties?city={{ prop.city }}" class="hover:text-accent transition">{{ prop.city }}</a>
<span class="mx-2">/</span>
<span class="text-gray-800">{{ prop.title }}</span>
</nav>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Image -->
<div class="relative rounded-2xl overflow-hidden bg-gray-200 h-96">
{% if primary_image %}
<img src="/static/{{ primary_image.image_path }}"
alt="{{ prop.title }}"
class="w-full h-full object-cover">
{% else %}
<div class="flex items-center justify-center h-full text-gray-400">
<svg class="w-20 h-20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1h-2z"/>
</svg>
</div>
{% endif %}
{% if prop.is_featured %}
<span class="absolute top-4 left-4 bg-warm text-primary text-sm font-bold px-3 py-1 rounded-full">
Featured
</span>
{% endif %}
{% if prop.status == 'sold' %}
<span class="absolute top-4 right-4 bg-red-600 text-white text-sm font-bold px-3 py-1 rounded-full">
Sold
</span>
{% elif prop.status == 'pending' %}
<span class="absolute top-4 right-4 bg-yellow-500 text-white text-sm font-bold px-3 py-1 rounded-full">
Pending
</span>
{% endif %}
<!-- Floating favorite heart (top-right) -->
{% if user %}
<form method="POST" action="/properties/{{ prop.id }}/favorite" class="absolute top-4 right-4 {% if prop.status == 'sold' or prop.status == 'pending' %}top-14{% endif %}">
<button type="submit"
class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg transition
{% if is_favorited %}
bg-red-500 text-white hover:bg-red-600
{% else %}
bg-white/90 text-gray-600 hover:bg-white hover:text-red-500
{% endif %}">
<svg class="w-6 h-6" fill="{% if is_favorited %}currentColor{% else %}none{% endif %}" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
</button>
</form>
{% endif %}
</div>
<!-- Thumbnail gallery -->
{% if images|length > 1 %}
<div class="flex gap-2 overflow-x-auto pb-2">
{% for img in images %}
<img src="/static/{{ img.image_path }}"
alt="Property image {{ loop.index }}"
class="h-20 w-28 object-cover rounded-lg flex-shrink-0 border-2 {% if img.is_primary %}border-accent{% else %}border-transparent{% endif %}">
{% endfor %}
</div>
{% endif %}
<!-- Title & Price -->
<div>
<div class="flex items-center justify-between flex-wrap gap-4">
<h1 class="text-3xl font-bold text-primary">{{ prop.title }}</h1>
<span class="text-3xl font-extrabold text-accent">${{ "{:,}".format(prop.price) }}</span>
</div>
<p class="flex items-center text-gray-500 mt-2">
<svg class="w-5 h-5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
{{ prop.address }}, {{ prop.city }}, {{ prop.state }} {{ prop.zip_code }}
</p>
</div>
<!-- Key Stats -->
<div class="grid grid-cols-3 gap-4 bg-white rounded-xl p-6 shadow-sm">
<div class="text-center">
<div class="text-2xl font-bold text-primary">{{ prop.bedrooms }}</div>
<div class="text-sm text-gray-500">Bedrooms</div>
</div>
<div class="text-center border-x">
<div class="text-2xl font-bold text-primary">{{ prop.bathrooms }}</div>
<div class="text-sm text-gray-500">Bathrooms</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-primary">{{ "{:,}".format(prop.area_sqft) }}</div>
<div class="text-sm text-gray-500">Sq Ft</div>
</div>
</div>
<!-- Description -->
<div class="bg-white rounded-xl p-6 shadow-sm">
<h2 class="text-xl font-bold text-primary mb-4">Description</h2>
<p class="text-gray-600 leading-relaxed whitespace-pre-line">{{ prop.description }}</p>
</div>
<!-- Details -->
<div class="bg-white rounded-xl p-6 shadow-sm">
<h2 class="text-xl font-bold text-primary mb-4">Property Details</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="flex justify-between py-2 border-b">
<span class="text-gray-500">Property Type</span>
<span class="font-medium text-gray-800 capitalize">{{ prop.property_type }}</span>
</div>
<div class="flex justify-between py-2 border-b">
<span class="text-gray-500">Year Built</span>
<span class="font-medium text-gray-800">{{ prop.year_built or 'N/A' }}</span>
</div>
<div class="flex justify-between py-2 border-b">
<span class="text-gray-500">Status</span>
<span class="font-medium text-gray-800 capitalize">{{ prop.status }}</span>
</div>
<div class="flex justify-between py-2 border-b">
<span class="text-gray-500">Listed</span>
<span class="font-medium text-gray-800">{{ prop.created_at.strftime("%B %d, %Y") }}</span>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Contact Card -->
<div class="bg-white rounded-xl p-6 shadow-sm border-2 border-accent/20">
<h3 class="font-bold text-primary mb-4 text-lg">Contact Seller</h3>
<!-- Favorite button - large and prominent -->
{% if user %}
<form method="POST" action="/properties/{{ prop.id }}/favorite" class="mb-4">
<button type="submit"
class="w-full py-3.5 rounded-xl font-semibold transition text-base flex items-center justify-center gap-2
{% if is_favorited %}
bg-red-50 text-red-600 border-2 border-red-300 hover:bg-red-100
{% else %}
bg-gray-50 text-gray-700 border-2 border-gray-200 hover:border-red-300 hover:text-red-500 hover:bg-red-50
{% endif %}">
<svg class="w-6 h-6" fill="{% if is_favorited %}currentColor{% else %}none{% endif %}" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
{% if is_favorited %}Saved to Favorites{% else %}Add to Favorites{% endif %}
</button>
</form>
{% else %}
<a href="/auth/login"
class="block w-full text-center py-3.5 rounded-xl font-semibold transition text-base bg-accent hover:bg-blue-700 text-white mb-4">
Login to Save
</a>
{% endif %}
<!-- Contact info -->
<div class="space-y-3">
{% if prop.contact_email %}
<a href="mailto:{{ prop.contact_email }}"
class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-accent/5 transition group">
<div class="w-10 h-10 bg-accent/10 rounded-full flex items-center justify-center flex-shrink-0 group-hover:bg-accent/20 transition">
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<div class="min-w-0">
<div class="text-xs text-gray-500">Email</div>
<div class="text-sm font-medium text-gray-800 truncate">{{ prop.contact_email }}</div>
</div>
</a>
{% endif %}
{% if prop.contact_phone %}
<a href="tel:{{ prop.contact_phone }}"
class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-accent/5 transition group">
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center flex-shrink-0 group-hover:bg-green-200 transition">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/>
</svg>
</div>
<div class="min-w-0">
<div class="text-xs text-gray-500">Phone</div>
<div class="text-sm font-medium text-gray-800">{{ prop.contact_phone }}</div>
</div>
</a>
{% endif %}
{% if not prop.contact_email and not prop.contact_phone %}
<!-- Fallback to owner info -->
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<div class="w-12 h-12 bg-primary rounded-full flex items-center justify-center text-white font-bold text-lg flex-shrink-0">
{{ prop.owner.username[0]|upper }}
</div>
<div class="min-w-0">
<div class="font-semibold text-gray-800">{{ prop.owner.username }}</div>
{% if prop.owner.full_name %}
<div class="text-sm text-gray-500">{{ prop.owner.full_name }}</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% if user.id == prop.owner_id %}
<div class="mt-4 pt-4 border-t space-y-2">
<a href="/properties/{{ prop.id }}/edit"
class="block w-full text-center py-2.5 bg-accent hover:bg-blue-700 text-white rounded-lg font-medium transition text-sm">
Edit Listing
</a>
<form method="POST" action="/properties/{{ prop.id }}/delete"
onsubmit="return confirm('Are you sure you want to delete this listing?')">
<button type="submit"
class="w-full py-2.5 bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 rounded-lg font-medium transition text-sm">
Delete Listing
</button>
</form>
</div>
{% endif %}
</div>
<!-- Listed By -->
<div class="bg-white rounded-xl p-6 shadow-sm">
<h3 class="font-bold text-primary mb-4">Listed By</h3>
<div class="flex items-center space-x-3">
<div class="w-12 h-12 bg-primary rounded-full flex items-center justify-center text-white font-bold text-lg">
{{ prop.owner.username[0]|upper }}
</div>
<div>
<div class="font-semibold text-gray-800">{{ prop.owner.username }}</div>
{% if prop.owner.full_name %}
<div class="text-sm text-gray-500">{{ prop.owner.full_name }}</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+176
View File
@@ -0,0 +1,176 @@
{% extends "base.html" %}
{% block title %}Edit Listing - NexHome{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto px-4 py-10">
<h1 class="text-3xl font-bold text-primary mb-2">Edit Property</h1>
<p class="text-gray-500 mb-8">Update the details of your listing.</p>
{% if error %}
<div class="bg-red-100 border border-red-200 text-red-800 rounded-lg p-4 mb-6 text-sm">
{{ error }}
</div>
{% endif %}
<form method="POST" action="/properties/{{ prop.id }}/edit" enctype="multipart/form-data" class="bg-white rounded-2xl shadow-lg p-8 space-y-6">
<!-- Title -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Property Title *</label>
<input type="text" name="title" required
value="{{ prop.title }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description *</label>
<textarea name="description" required rows="5"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">{{ prop.description }}</textarea>
</div>
<!-- Price & Type -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Price (USD) *</label>
<div class="relative">
<span class="absolute left-3 top-3 text-gray-500 font-medium">$</span>
<input type="number" name="price" required min="0"
value="{{ prop.price }}"
class="w-full pl-8 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Property Type *</label>
<select name="property_type" required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
<option value="house" {% if prop.property_type == 'house' %}selected{% endif %}>House</option>
<option value="condo" {% if prop.property_type == 'condo' %}selected{% endif %}>Condo</option>
<option value="townhouse" {% if prop.property_type == 'townhouse' %}selected{% endif %}>Townhouse</option>
<option value="land" {% if prop.property_type == 'land' %}selected{% endif %}>Land</option>
</select>
</div>
</div>
<!-- Beds / Baths / Sqft -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bedrooms *</label>
<input type="number" name="bedrooms" required min="0" max="20"
value="{{ prop.bedrooms }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bathrooms *</label>
<input type="number" name="bathrooms" required min="0" max="20" step="0.5"
value="{{ prop.bathrooms }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Area (sqft) *</label>
<input type="number" name="area_sqft" required min="0"
value="{{ prop.area_sqft }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
</div>
<!-- Address -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Street Address *</label>
<input type="text" name="address" required
value="{{ prop.address }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<!-- City / State / Zip -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">City *</label>
<input type="text" name="city" required
value="{{ prop.city }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">State *</label>
<input type="text" name="state" required maxlength="2"
value="{{ prop.state }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Zip Code *</label>
<input type="text" name="zip_code" required maxlength="10"
value="{{ prop.zip_code }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
</div>
<!-- Year Built & Status -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Year Built</label>
<input type="number" name="year_built" min="1800" max="2030"
value="{{ prop.year_built or '' }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select name="status"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
<option value="active" {% if prop.status == 'active' %}selected{% endif %}>Active</option>
<option value="pending" {% if prop.status == 'pending' %}selected{% endif %}>Pending</option>
<option value="sold" {% if prop.status == 'sold' %}selected{% endif %}>Sold</option>
</select>
</div>
</div>
<!-- Contact Info -->
<div class="border-t pt-6">
<h3 class="text-lg font-semibold text-primary mb-4">Contact Information</h3>
<p class="text-sm text-gray-500 mb-4">Provide at least one contact method (email or phone).</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Contact Email</label>
<input type="email" name="contact_email"
value="{{ prop.contact_email or '' }}"
placeholder="seller@example.com"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div class="relative">
{% set phone_value = prop.contact_phone or "" %}
{% include "components/phone_input.html" %}
</div>
</div>
</div>
<!-- Current Image -->
{% if primary_image %}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Current Image</label>
<img src="/static/{{ primary_image.image_path }}" alt="Current"
class="h-40 w-56 object-cover rounded-lg border">
</div>
{% endif %}
<!-- New Image Upload -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Replace Image</label>
<input type="file" name="image" accept="image/*"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-accent file:text-white hover:file:bg-blue-700 transition">
<p class="text-xs text-gray-400 mt-1">Leave empty to keep the current image.</p>
</div>
<!-- Submit -->
<div class="flex gap-4 pt-4">
<button type="submit"
class="flex-1 bg-accent hover:bg-blue-700 text-white font-semibold py-3.5 rounded-lg transition">
Save Changes
</button>
<a href="/properties/{{ prop.id }}"
class="px-8 py-3.5 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition">
Cancel
</a>
</div>
</form>
</div>
{% endblock %}
+136
View File
@@ -0,0 +1,136 @@
{% extends "base.html" %}
{% block title %}Browse Properties - NexHome{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-10">
<h1 class="text-3xl font-bold text-primary mb-8">Browse Properties</h1>
<div class="flex flex-col lg:flex-row gap-8">
<!-- Filter Sidebar -->
<aside class="w-full lg:w-72 flex-shrink-0">
<form action="/properties" method="GET" class="bg-white rounded-2xl shadow-md p-6 space-y-5 sticky top-4">
<h3 class="font-semibold text-primary text-lg border-b pb-3">Filters</h3>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Search</label>
<input type="text" name="search" value="{{ search|default('') }}"
placeholder="City, state, zip..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-accent outline-none">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Property Type</label>
<select name="type" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-accent outline-none">
<option value="">All Types</option>
<option value="house" {% if prop_type == 'house' %}selected{% endif %}>House</option>
<option value="condo" {% if prop_type == 'condo' %}selected{% endif %}>Condo</option>
<option value="townhouse" {% if prop_type == 'townhouse' %}selected{% endif %}>Townhouse</option>
<option value="land" {% if prop_type == 'land' %}selected{% endif %}>Land</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Price Range (USD)</label>
<div class="flex gap-2">
<input type="number" name="min_price" value="{{ min_price|default('') }}"
placeholder="Min" min="0"
class="w-1/2 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-accent outline-none">
<input type="number" name="max_price" value="{{ max_price|default('') }}"
placeholder="Max" min="0"
class="w-1/2 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-accent outline-none">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bedrooms</label>
<select name="bedrooms" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-accent outline-none">
<option value="">Any</option>
<option value="1" {% if bedrooms == '1' %}selected{% endif %}>1+</option>
<option value="2" {% if bedrooms == '2' %}selected{% endif %}>2+</option>
<option value="3" {% if bedrooms == '3' %}selected{% endif %}>3+</option>
<option value="4" {% if bedrooms == '4' %}selected{% endif %}>4+</option>
<option value="5" {% if bedrooms == '5' %}selected{% endif %}>5+</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">City</label>
<input type="text" name="city" value="{{ city_filter|default('') }}"
placeholder="e.g. Austin"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-accent outline-none">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">State</label>
<input type="text" name="state" value="{{ state_filter|default('') }}"
placeholder="e.g. TX"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-accent outline-none">
</div>
<button type="submit"
class="w-full bg-accent hover:bg-blue-700 text-white font-semibold py-2.5 rounded-lg transition text-sm">
Apply Filters
</button>
<a href="/properties" class="block text-center text-sm text-gray-500 hover:text-accent transition">
Clear all filters
</a>
</form>
</aside>
<!-- Property Grid -->
<div class="flex-1">
{% if properties %}
<div class="mb-4 text-sm text-gray-500">
Showing {{ properties|length }} propert{{ "y" if properties|length == 1 else "ies" }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{% from "components/property_card.html" import property_card %}
{% for prop in properties %}
{{ property_card(prop) }}
{% endfor %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="flex justify-center items-center gap-2 mt-10">
{% if page > 1 %}
<a href="?{{ query_string }}&page={{ page - 1 }}"
class="px-4 py-2 bg-white border border-gray-300 rounded-lg text-sm hover:bg-gray-50 transition">
&laquo; Previous
</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<span class="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium">{{ p }}</span>
{% else %}
<a href="?{{ query_string }}&page={{ p }}"
class="px-4 py-2 bg-white border border-gray-300 rounded-lg text-sm hover:bg-gray-50 transition">
{{ p }}
</a>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="?{{ query_string }}&page={{ page + 1 }}"
class="px-4 py-2 bg-white border border-gray-300 rounded-lg text-sm hover:bg-gray-50 transition">
Next &raquo;
</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="text-center py-20 text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<p class="text-lg mb-2">No properties found</p>
<p class="text-sm">Try adjusting your filters or <a href="/properties" class="text-accent hover:underline">clear all filters</a>.</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}