# app/routes/storefront_pages.py """ Storefront/Customer HTML page routes using Jinja2 templates. These routes serve the public-facing storefront interface for customers. Authentication required only for account pages. Note: Previously named "shop_pages.py", renamed to "storefront" as not all platforms sell items - storefront is a more accurate term. AUTHENTICATION: - Public pages (catalog, products): No auth required - Account pages (dashboard, orders): Requires customer authentication - Customer authentication accepts: * customer_token cookie (path=/storefront) - for page navigation * Authorization header - for API calls - Customers CANNOT access admin or vendor routes Routes (all mounted at /storefront/* or /vendors/{code}/storefront/* prefix): - GET / → Shop homepage / product catalog - GET /products → Product catalog - GET /products/{id} → Product detail page - GET /categories/{slug} → Category products - GET /cart → Shopping cart - GET /checkout → Checkout process - GET /account/register → Customer registration - GET /account/login → Customer login - GET /account/dashboard → Customer dashboard (auth required) - GET /account/orders → Order history (auth required) - GET /account/orders/{id} → Order detail (auth required) - GET /account/profile → Customer profile (auth required) - GET /account/addresses → Address management (auth required) - GET /{slug} → Dynamic content pages (CMS): /about, /faq, /contact, etc. """ import logging from fastapi import APIRouter, Depends, Path, Request from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy.orm import Session from app.api.deps import get_current_customer_from_cookie_or_header, get_db from app.modules.cms.services import content_page_service from app.services.platform_settings_service import platform_settings_service from app.templates_config import templates from app.modules.customers.models import Customer router = APIRouter() logger = logging.getLogger(__name__) # ============================================================================ # HELPER: Resolve Storefront Locale # ============================================================================ def get_resolved_storefront_config(db: Session, vendor) -> dict: """ Resolve storefront locale and currency with priority: 1. Vendor's storefront_locale (if set) 2. Platform's default_storefront_locale (from AdminSetting) 3. Environment variable (from config) 4. Hardcoded fallback: 'fr-LU' Args: db: Database session vendor: Vendor model instance Returns: dict with 'locale' and 'currency' keys """ # Get platform defaults from service (handles resolution chain 2-4) platform_config = platform_settings_service.get_storefront_config(db) # Check for vendor override (step 1) locale = platform_config["locale"] if vendor and vendor.storefront_locale: locale = vendor.storefront_locale return { "locale": locale, "currency": platform_config["currency"], } # ============================================================================ # HELPER: Build Shop Template Context # ============================================================================ def get_storefront_context(request: Request, db: Session = None, **extra_context) -> dict: """ Build template context for shop pages. Automatically includes vendor and theme from middleware request.state. Additional context can be passed as keyword arguments. Args: request: FastAPI request object with vendor/theme in state db: Optional database session for loading navigation pages **extra_context: Additional variables for template (user, product_id, etc.) Returns: Dictionary with request, vendor, theme, navigation pages, and extra context Example: # Simple usage get_storefront_context(request) # With database session for navigation get_storefront_context(request, db=db) # With extra data get_storefront_context(request, db=db, user=current_user, product_id=123) """ # Extract from middleware state vendor = getattr(request.state, "vendor", None) platform = getattr(request.state, "platform", None) theme = getattr(request.state, "theme", None) clean_path = getattr(request.state, "clean_path", request.url.path) vendor_context = getattr(request.state, "vendor_context", None) # Get platform_id (default to 1 for OMS if not set) platform_id = platform.id if platform else 1 # Get detection method from vendor_context access_method = ( vendor_context.get("detection_method", "unknown") if vendor_context else "unknown" ) if vendor is None: logger.warning( "[SHOP_CONTEXT] Vendor not found in request.state", extra={ "path": request.url.path, "host": request.headers.get("host", ""), "has_vendor": False, }, ) # Calculate base URL for links # - Domain/subdomain access: base_url = "/" # - Path-based access: base_url = "/vendor/{vendor_code}/" or "/vendors/{vendor_code}/" base_url = "/" if access_method == "path" and vendor: # Use the full_prefix from vendor_context to determine which pattern was used full_prefix = ( vendor_context.get("full_prefix", "/vendor/") if vendor_context else "/vendor/" ) base_url = f"{full_prefix}{vendor.subdomain}/" # Load footer navigation pages from CMS if db session provided footer_pages = [] header_pages = [] if db and vendor: try: vendor_id = vendor.id # Get pages configured to show in footer footer_pages = content_page_service.list_pages_for_vendor( db, platform_id=platform_id, vendor_id=vendor_id, footer_only=True, include_unpublished=False ) # Get pages configured to show in header header_pages = content_page_service.list_pages_for_vendor( db, platform_id=platform_id, vendor_id=vendor_id, header_only=True, include_unpublished=False ) except Exception as e: logger.error( "[SHOP_CONTEXT] Failed to load navigation pages", extra={"error": str(e), "vendor_id": vendor.id if vendor else None}, ) # Resolve storefront locale and currency storefront_config = {"locale": "fr-LU", "currency": "EUR"} # defaults if db and vendor: storefront_config = get_resolved_storefront_config(db, vendor) context = { "request": request, "vendor": vendor, "theme": theme, "clean_path": clean_path, "access_method": access_method, "base_url": base_url, "footer_pages": footer_pages, "header_pages": header_pages, "storefront_locale": storefront_config["locale"], "storefront_currency": storefront_config["currency"], } # Add any extra context (user, product_id, category_slug, etc.) if extra_context: context.update(extra_context) logger.debug( "[SHOP_CONTEXT] Context built", extra={ "vendor_id": vendor.id if vendor else None, "vendor_name": vendor.name if vendor else None, "vendor_subdomain": vendor.subdomain if vendor else None, "has_theme": theme is not None, "access_method": access_method, "base_url": base_url, "storefront_locale": storefront_config["locale"], "storefront_currency": storefront_config["currency"], "footer_pages_count": len(footer_pages), "header_pages_count": len(header_pages), "extra_keys": list(extra_context.keys()) if extra_context else [], }, ) return context # ============================================================================ # PUBLIC SHOP ROUTES (No Authentication Required) # ============================================================================ @router.get("/", response_class=HTMLResponse, include_in_schema=False) @router.get("/products", response_class=HTMLResponse, include_in_schema=False) async def shop_products_page(request: Request, db: Session = Depends(get_db)): """ Render shop homepage / product catalog. Shows featured products and categories. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/products.html", get_storefront_context(request, db=db) ) @router.get( "/products/{product_id}", response_class=HTMLResponse, include_in_schema=False ) async def shop_product_detail_page( request: Request, product_id: int = Path(..., description="Product ID"), db: Session = Depends(get_db), ): """ Render product detail page. Shows product information, images, reviews, and buy options. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/product.html", get_storefront_context(request, db=db, product_id=product_id) ) @router.get( "/categories/{category_slug}", response_class=HTMLResponse, include_in_schema=False ) async def shop_category_page( request: Request, category_slug: str = Path(..., description="Category slug"), db: Session = Depends(get_db), ): """ Render category products page. Shows all products in a specific category. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/category.html", get_storefront_context(request, db=db, category_slug=category_slug) ) @router.get("/cart", response_class=HTMLResponse, include_in_schema=False) async def shop_cart_page(request: Request, db: Session = Depends(get_db)): """ Render shopping cart page. Shows cart items and allows quantity updates. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse("storefront/cart.html", get_storefront_context(request, db=db)) @router.get("/checkout", response_class=HTMLResponse, include_in_schema=False) async def shop_checkout_page(request: Request, db: Session = Depends(get_db)): """ Render checkout page. Handles shipping, payment, and order confirmation. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse("storefront/checkout.html", get_storefront_context(request, db=db)) @router.get("/search", response_class=HTMLResponse, include_in_schema=False) async def shop_search_page(request: Request, db: Session = Depends(get_db)): """ Render search results page. Shows products matching search query. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse("storefront/search.html", get_storefront_context(request, db=db)) # ============================================================================ # CUSTOMER ACCOUNT - PUBLIC ROUTES (No Authentication) # ============================================================================ @router.get("/account/register", response_class=HTMLResponse, include_in_schema=False) async def shop_register_page(request: Request, db: Session = Depends(get_db)): """ Render customer registration page. No authentication required. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/account/register.html", get_storefront_context(request, db=db) ) @router.get("/account/login", response_class=HTMLResponse, include_in_schema=False) async def shop_login_page(request: Request, db: Session = Depends(get_db)): """ Render customer login page. No authentication required. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/account/login.html", get_storefront_context(request, db=db) ) @router.get( "/account/forgot-password", response_class=HTMLResponse, include_in_schema=False ) async def shop_forgot_password_page(request: Request, db: Session = Depends(get_db)): """ Render forgot password page. Allows customers to reset their password. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/account/forgot-password.html", get_storefront_context(request, db=db) ) @router.get( "/account/reset-password", response_class=HTMLResponse, include_in_schema=False ) async def shop_reset_password_page( request: Request, token: str = None, db: Session = Depends(get_db) ): """ Render reset password page. User lands here after clicking the reset link in their email. Token is passed as query parameter. """ logger.debug( "[SHOP_HANDLER] shop_reset_password_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), "has_token": bool(token), }, ) return templates.TemplateResponse( "storefront/account/reset-password.html", get_storefront_context(request, db=db) ) # ============================================================================ # CUSTOMER ACCOUNT - AUTHENTICATED ROUTES # ============================================================================ @router.get("/account", response_class=RedirectResponse, include_in_schema=False) @router.get("/account/", response_class=RedirectResponse, include_in_schema=False) async def shop_account_root(request: Request): """ Redirect /shop/account or /shop/account/ to dashboard. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) # Get base_url from context for proper redirect vendor = getattr(request.state, "vendor", None) vendor_context = getattr(request.state, "vendor_context", None) access_method = ( vendor_context.get("detection_method", "unknown") if vendor_context else "unknown" ) base_url = "/" if access_method == "path" and vendor: full_prefix = ( vendor_context.get("full_prefix", "/vendor/") if vendor_context else "/vendor/" ) base_url = f"{full_prefix}{vendor.subdomain}/" return RedirectResponse(url=f"{base_url}shop/account/dashboard", status_code=302) @router.get("/account/dashboard", response_class=HTMLResponse, include_in_schema=False) async def shop_account_dashboard_page( request: Request, current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), db: Session = Depends(get_db), ): """ Render customer account dashboard. Shows account overview, recent orders, and quick links. Requires customer authentication. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/account/dashboard.html", get_storefront_context(request, user=current_customer) ) @router.get("/account/orders", response_class=HTMLResponse, include_in_schema=False) async def shop_orders_page( request: Request, current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), db: Session = Depends(get_db), ): """ Render customer orders history page. Shows all past and current orders. Requires customer authentication. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/account/orders.html", get_storefront_context(request, user=current_customer) ) @router.get( "/account/orders/{order_id}", response_class=HTMLResponse, include_in_schema=False ) async def shop_order_detail_page( request: Request, order_id: int = Path(..., description="Order ID"), current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), db: Session = Depends(get_db), ): """ Render customer order detail page. Shows detailed order information and tracking. Requires customer authentication. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/account/order-detail.html", get_storefront_context(request, user=current_customer, order_id=order_id), ) @router.get("/account/profile", response_class=HTMLResponse, include_in_schema=False) async def shop_profile_page( request: Request, current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), db: Session = Depends(get_db), ): """ Render customer profile page. Edit personal information and preferences. Requires customer authentication. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/account/profile.html", get_storefront_context(request, user=current_customer) ) @router.get("/account/addresses", response_class=HTMLResponse, include_in_schema=False) async def shop_addresses_page( request: Request, current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), db: Session = Depends(get_db), ): """ Render customer addresses management page. Manage shipping and billing addresses. Requires customer authentication. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/account/addresses.html", get_storefront_context(request, user=current_customer) ) @router.get("/account/wishlist", response_class=HTMLResponse, include_in_schema=False) async def shop_wishlist_page( request: Request, current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), db: Session = Depends(get_db), ): """ Render customer wishlist page. View and manage saved products. Requires customer authentication. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/account/wishlist.html", get_storefront_context(request, user=current_customer) ) @router.get("/account/settings", response_class=HTMLResponse, include_in_schema=False) async def shop_settings_page( request: Request, current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), db: Session = Depends(get_db), ): """ Render customer account settings page. Configure notifications, privacy, and preferences. Requires customer authentication. """ logger.debug( "[SHOP_HANDLER] shop_products_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/account/settings.html", get_storefront_context(request, user=current_customer) ) @router.get("/account/messages", response_class=HTMLResponse, include_in_schema=False) async def shop_messages_page( request: Request, current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), db: Session = Depends(get_db), ): """ Render customer messages page. View and reply to conversations with the vendor. Requires customer authentication. """ logger.debug( "[SHOP_HANDLER] shop_messages_page REACHED", extra={ "path": request.url.path, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/account/messages.html", get_storefront_context(request, db=db, user=current_customer) ) @router.get( "/account/messages/{conversation_id}", response_class=HTMLResponse, include_in_schema=False, ) async def shop_message_detail_page( request: Request, conversation_id: int = Path(..., description="Conversation ID"), current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), db: Session = Depends(get_db), ): """ Render message conversation detail page. Shows the full conversation thread. Requires customer authentication. """ logger.debug( "[SHOP_HANDLER] shop_message_detail_page REACHED", extra={ "path": request.url.path, "conversation_id": conversation_id, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) return templates.TemplateResponse( "storefront/account/messages.html", get_storefront_context( request, db=db, user=current_customer, conversation_id=conversation_id ), ) # ============================================================================ # DYNAMIC CONTENT PAGES (CMS) # ============================================================================ @router.get("/{slug}", response_class=HTMLResponse, include_in_schema=False) async def generic_content_page( request: Request, slug: str = Path(..., description="Content page slug"), db: Session = Depends(get_db), ): """ Generic content page handler (CMS). Handles dynamic content pages like: - /about, /faq, /contact, /shipping, /returns, /privacy, /terms, etc. Features: - Two-tier system: Vendor overrides take priority, fallback to platform defaults - Only shows published pages - Returns 404 if page not found This route MUST be defined last in the router to avoid conflicts with specific routes (like /products, /cart, /account, etc.) """ from fastapi import HTTPException logger.debug( "[SHOP_HANDLER] generic_content_page REACHED", extra={ "path": request.url.path, "slug": slug, "vendor": getattr(request.state, "vendor", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) vendor = getattr(request.state, "vendor", None) platform = getattr(request.state, "platform", None) vendor_id = vendor.id if vendor else None platform_id = platform.id if platform else 1 # Default to OMS # Load content page from database (vendor override → vendor default) page = content_page_service.get_page_for_vendor( db, platform_id=platform_id, slug=slug, vendor_id=vendor_id, include_unpublished=False ) if not page: logger.warning( "[SHOP_HANDLER] Content page not found", extra={ "slug": slug, "vendor_id": vendor_id, "vendor_name": vendor.name if vendor else None, }, ) raise HTTPException(status_code=404, detail=f"Page not found: {slug}") logger.info( "[SHOP_HANDLER] Content page found", extra={ "slug": slug, "page_id": page.id, "page_title": page.title, "is_vendor_override": page.vendor_id is not None, "vendor_id": vendor_id, }, ) return templates.TemplateResponse( "storefront/content-page.html", get_storefront_context(request, db=db, page=page) ) # ============================================================================ # DEBUG ENDPOINTS - For troubleshooting context issues # ============================================================================ @router.get("/debug/context", response_class=HTMLResponse, include_in_schema=False) async def debug_context(request: Request): """ DEBUG ENDPOINT: Display request context. Shows what's available in request.state. Useful for troubleshooting template variable issues. URL: /shop/debug/context """ vendor = getattr(request.state, "vendor", None) theme = getattr(request.state, "theme", None) debug_info = { "path": request.url.path, "host": request.headers.get("host", ""), "vendor": { "found": vendor is not None, "id": vendor.id if vendor else None, "name": vendor.name if vendor else None, "subdomain": vendor.subdomain if vendor else None, "is_active": vendor.is_active if vendor else None, }, "theme": { "found": theme is not None, "name": theme.get("theme_name") if theme else None, }, "clean_path": getattr(request.state, "clean_path", "NOT SET"), "context_type": str(getattr(request.state, "context_type", "NOT SET")), } # Return as JSON-like HTML for easy reading import json html_content = f"""
{json.dumps(debug_info, indent=2)}
Vendor: {"✓ Found" if vendor else "✗ Not Found"}
Theme: {"✓ Found" if theme else "✗ Not Found"}
Context Type: {str(getattr(request.state, "context_type", "NOT SET"))}
""" return HTMLResponse(content=html_content)