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)
|
||||
)
|
||||
|
||||
|
||||
|
||||
79
app/templates/shop/content-page.html
Normal file
79
app/templates/shop/content-page.html
Normal file
@@ -0,0 +1,79 @@
|
||||
{# app/templates/shop/content-page.html #}
|
||||
{# Generic CMS content page template #}
|
||||
{% extends "shop/base.html" %}
|
||||
|
||||
{# Dynamic title from CMS #}
|
||||
{% block title %}{{ page.title }}{% endblock %}
|
||||
|
||||
{# SEO from CMS #}
|
||||
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
|
||||
{% block meta_keywords %}{{ page.meta_keywords or vendor.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
{# Breadcrumbs #}
|
||||
<div class="breadcrumb mb-6">
|
||||
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page.title }}</span>
|
||||
</div>
|
||||
|
||||
{# Page Header #}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
|
||||
{{ page.title }}
|
||||
</h1>
|
||||
|
||||
{# Optional: Show vendor override badge for debugging #}
|
||||
{% if page.vendor_id %}
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
Custom {{ vendor.name }} version
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Published date (optional) #}
|
||||
{% if page.published_at %}
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Published {{ page.published_at.strftime('%B %d, %Y') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Content #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
|
||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||
{% if page.content_format == 'markdown' %}
|
||||
{# Markdown content - future enhancement: render with markdown library #}
|
||||
<div class="markdown-content">
|
||||
{{ page.content | safe }}
|
||||
</div>
|
||||
{% else %}
|
||||
{# HTML content (default) #}
|
||||
{{ page.content | safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Last updated timestamp #}
|
||||
{% if page.updated_at %}
|
||||
<div class="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Last updated: {{ page.updated_at.strftime('%B %d, %Y') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Future enhancement: Add any CMS-specific JavaScript here
|
||||
// For example:
|
||||
// - Table of contents generation
|
||||
// - Anchor link handling
|
||||
// - Image lightbox
|
||||
// - Copy code blocks
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user