feat: integrate CMS into shop frontend
Implement dynamic CMS content rendering in shop frontend:
Route Handler (shop_pages.py):
- Add generic /{slug} route for all CMS pages
- Load content from database with vendor override fallback
- Update get_shop_context() to load footer/header navigation
- Pass db session to all route handlers for navigation loading
- Return 404 if page not found
Template (content-page.html):
- Generic template for rendering CMS content
- Display page title, content, and metadata
- Show vendor override badge when applicable
- Support for HTML and Markdown content formats
- SEO meta tags from database
Footer Navigation (base.html):
- Dynamic footer links loaded from database
- Automatic two-column layout based on page count
- Fallback to static links if no CMS pages configured
- Filter pages by show_in_footer flag
This completes the CMS frontend integration, enabling:
- /about, /contact, /faq, etc. to load from database
- Vendors inherit platform defaults automatically
- Vendor-specific overrides take priority
- Dynamic footer navigation from CMS
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ Routes:
|
||||
- GET /shop/account/orders/{id} → Order detail (auth required)
|
||||
- GET /shop/account/profile → Customer profile (auth required)
|
||||
- GET /shop/account/addresses → Address management (auth required)
|
||||
- GET /shop/{slug} → Dynamic content pages (CMS): /about, /faq, /contact, etc.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -36,6 +37,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_customer_from_cookie_or_header, get_db
|
||||
from app.services.content_page_service import content_page_service
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter()
|
||||
@@ -48,7 +50,7 @@ logger = logging.getLogger(__name__)
|
||||
# HELPER: Build Shop Template Context
|
||||
# ============================================================================
|
||||
|
||||
def get_shop_context(request: Request, **extra_context) -> dict:
|
||||
def get_shop_context(request: Request, db: Session = None, **extra_context) -> dict:
|
||||
"""
|
||||
Build template context for shop pages.
|
||||
|
||||
@@ -57,22 +59,30 @@ def get_shop_context(request: Request, **extra_context) -> dict:
|
||||
|
||||
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, and extra context
|
||||
Dictionary with request, vendor, theme, navigation pages, and extra context
|
||||
|
||||
Example:
|
||||
# Simple usage
|
||||
get_shop_context(request)
|
||||
|
||||
# With database session for navigation
|
||||
get_shop_context(request, db=db)
|
||||
|
||||
# With extra data
|
||||
get_shop_context(request, user=current_user, product_id=123)
|
||||
get_shop_context(request, db=db, user=current_user, product_id=123)
|
||||
"""
|
||||
# Extract from middleware state
|
||||
vendor = getattr(request.state, 'vendor', 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 detection method from vendor_context
|
||||
access_method = vendor_context.get('detection_method', 'unknown') if vendor_context else 'unknown'
|
||||
|
||||
if vendor is None:
|
||||
logger.warning(
|
||||
@@ -84,11 +94,50 @@ def get_shop_context(request: Request, **extra_context) -> dict:
|
||||
}
|
||||
)
|
||||
|
||||
# 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,
|
||||
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,
|
||||
vendor_id=vendor_id,
|
||||
header_only=True,
|
||||
include_unpublished=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[SHOP_CONTEXT] Failed to load navigation pages",
|
||||
extra={"error": str(e), "vendor_id": vendor.id if vendor else None}
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
# Add any extra context (user, product_id, category_slug, etc.)
|
||||
@@ -100,7 +149,12 @@ def get_shop_context(request: Request, **extra_context) -> dict:
|
||||
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,
|
||||
"footer_pages_count": len(footer_pages),
|
||||
"header_pages_count": len(header_pages),
|
||||
"extra_keys": list(extra_context.keys()) if extra_context else [],
|
||||
}
|
||||
)
|
||||
@@ -114,7 +168,7 @@ def get_shop_context(request: Request, **extra_context) -> dict:
|
||||
|
||||
@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):
|
||||
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Render shop homepage / product catalog.
|
||||
Shows featured products and categories.
|
||||
@@ -130,7 +184,7 @@ async def shop_products_page(request: Request):
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"shop/products.html",
|
||||
get_shop_context(request)
|
||||
get_shop_context(request, db=db)
|
||||
)
|
||||
|
||||
|
||||
@@ -517,106 +571,77 @@ async def shop_settings_page(
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STATIC CONTENT PAGES
|
||||
# DYNAMIC CONTENT PAGES (CMS)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/shop/about", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def shop_about_page(request: Request):
|
||||
@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)
|
||||
):
|
||||
"""
|
||||
Render about us page.
|
||||
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(
|
||||
f"[SHOP_HANDLER] shop_products_page REACHED",
|
||||
f"[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'),
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"shop/about.html",
|
||||
get_shop_context(request)
|
||||
vendor = getattr(request.state, 'vendor', None)
|
||||
vendor_id = vendor.id if vendor else None
|
||||
|
||||
# Load content page from database (vendor override → platform default)
|
||||
page = content_page_service.get_page_for_vendor(
|
||||
db,
|
||||
slug=slug,
|
||||
vendor_id=vendor_id,
|
||||
include_unpublished=False
|
||||
)
|
||||
|
||||
if not page:
|
||||
logger.warning(
|
||||
f"[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}")
|
||||
|
||||
@router.get("/shop/contact", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def shop_contact_page(request: Request):
|
||||
"""
|
||||
Render contact us page.
|
||||
"""
|
||||
logger.debug(
|
||||
f"[SHOP_HANDLER] shop_products_page REACHED",
|
||||
logger.info(
|
||||
f"[SHOP_HANDLER] Content page found",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"vendor": getattr(request.state, 'vendor', 'NOT SET'),
|
||||
"context": getattr(request.state, 'context_type', 'NOT SET'),
|
||||
"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(
|
||||
"shop/contact.html",
|
||||
get_shop_context(request)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/shop/faq", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def shop_faq_page(request: Request):
|
||||
"""
|
||||
Render FAQ page.
|
||||
"""
|
||||
logger.debug(
|
||||
f"[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(
|
||||
"shop/faq.html",
|
||||
get_shop_context(request)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/shop/privacy", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def shop_privacy_page(request: Request):
|
||||
"""
|
||||
Render privacy policy page.
|
||||
"""
|
||||
logger.debug(
|
||||
f"[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(
|
||||
"shop/privacy.html",
|
||||
get_shop_context(request)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/shop/terms", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def shop_terms_page(request: Request):
|
||||
"""
|
||||
Render terms and conditions page.
|
||||
"""
|
||||
logger.debug(
|
||||
f"[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(
|
||||
"shop/terms.html",
|
||||
get_shop_context(request)
|
||||
"shop/content-page.html",
|
||||
get_shop_context(request, page=page)
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user