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/orders/{id} → Order detail (auth required)
|
||||||
- GET /shop/account/profile → Customer profile (auth required)
|
- GET /shop/account/profile → Customer profile (auth required)
|
||||||
- GET /shop/account/addresses → Address management (auth required)
|
- GET /shop/account/addresses → Address management (auth required)
|
||||||
|
- GET /shop/{slug} → Dynamic content pages (CMS): /about, /faq, /contact, etc.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -36,6 +37,7 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_customer_from_cookie_or_header, get_db
|
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
|
from models.database.user import User
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -48,7 +50,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# HELPER: Build Shop Template Context
|
# 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.
|
Build template context for shop pages.
|
||||||
|
|
||||||
@@ -57,22 +59,30 @@ def get_shop_context(request: Request, **extra_context) -> dict:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: FastAPI request object with vendor/theme in state
|
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.)
|
**extra_context: Additional variables for template (user, product_id, etc.)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with request, vendor, theme, and extra context
|
Dictionary with request, vendor, theme, navigation pages, and extra context
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
# Simple usage
|
# Simple usage
|
||||||
get_shop_context(request)
|
get_shop_context(request)
|
||||||
|
|
||||||
|
# With database session for navigation
|
||||||
|
get_shop_context(request, db=db)
|
||||||
|
|
||||||
# With extra data
|
# 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
|
# Extract from middleware state
|
||||||
vendor = getattr(request.state, 'vendor', None)
|
vendor = getattr(request.state, 'vendor', None)
|
||||||
theme = getattr(request.state, 'theme', None)
|
theme = getattr(request.state, 'theme', None)
|
||||||
clean_path = getattr(request.state, 'clean_path', request.url.path)
|
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:
|
if vendor is None:
|
||||||
logger.warning(
|
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 = {
|
context = {
|
||||||
"request": request,
|
"request": request,
|
||||||
"vendor": vendor,
|
"vendor": vendor,
|
||||||
"theme": theme,
|
"theme": theme,
|
||||||
"clean_path": clean_path,
|
"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.)
|
# Add any extra context (user, product_id, category_slug, etc.)
|
||||||
@@ -100,7 +149,12 @@ def get_shop_context(request: Request, **extra_context) -> dict:
|
|||||||
extra={
|
extra={
|
||||||
"vendor_id": vendor.id if vendor else None,
|
"vendor_id": vendor.id if vendor else None,
|
||||||
"vendor_name": vendor.name 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,
|
"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 [],
|
"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("/", response_class=HTMLResponse, include_in_schema=False)
|
||||||
@router.get("/products", 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.
|
Render shop homepage / product catalog.
|
||||||
Shows featured products and categories.
|
Shows featured products and categories.
|
||||||
@@ -130,7 +184,7 @@ async def shop_products_page(request: Request):
|
|||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"shop/products.html",
|
"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)
|
@router.get("/{slug}", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def shop_about_page(request: Request):
|
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(
|
logger.debug(
|
||||||
f"[SHOP_HANDLER] shop_products_page REACHED",
|
f"[SHOP_HANDLER] generic_content_page REACHED",
|
||||||
extra={
|
extra={
|
||||||
"path": request.url.path,
|
"path": request.url.path,
|
||||||
|
"slug": slug,
|
||||||
"vendor": getattr(request.state, 'vendor', 'NOT SET'),
|
"vendor": getattr(request.state, 'vendor', 'NOT SET'),
|
||||||
"context": getattr(request.state, 'context_type', 'NOT SET'),
|
"context": getattr(request.state, 'context_type', 'NOT SET'),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
vendor = getattr(request.state, 'vendor', None)
|
||||||
"shop/about.html",
|
vendor_id = vendor.id if vendor else None
|
||||||
get_shop_context(request)
|
|
||||||
|
# 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)
|
logger.info(
|
||||||
async def shop_contact_page(request: Request):
|
f"[SHOP_HANDLER] Content page found",
|
||||||
"""
|
|
||||||
Render contact us page.
|
|
||||||
"""
|
|
||||||
logger.debug(
|
|
||||||
f"[SHOP_HANDLER] shop_products_page REACHED",
|
|
||||||
extra={
|
extra={
|
||||||
"path": request.url.path,
|
"slug": slug,
|
||||||
"vendor": getattr(request.state, 'vendor', 'NOT SET'),
|
"page_id": page.id,
|
||||||
"context": getattr(request.state, 'context_type', 'NOT SET'),
|
"page_title": page.title,
|
||||||
|
"is_vendor_override": page.vendor_id is not None,
|
||||||
|
"vendor_id": vendor_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"shop/contact.html",
|
"shop/content-page.html",
|
||||||
get_shop_context(request)
|
get_shop_context(request, page=page)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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