Initial Commit
This commit is contained in:
+38
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
© 2026 NexHome. All rights reserved. All prices in USD.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 →
|
||||
</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
« 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 »
|
||||
</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 %}
|
||||
Reference in New Issue
Block a user