Rename "shop" to "storefront" as not all platforms sell items - storefront is a more accurate term for the customer-facing interface. Changes: - Rename app/api/v1/shop/ → app/api/v1/storefront/ - Rename app/routes/shop_pages.py → app/routes/storefront_pages.py - Rename app/modules/cms/routes/api/shop.py → storefront.py - Rename tests/integration/api/v1/shop/ → storefront/ - Update API prefix from /api/v1/shop to /api/v1/storefront - Update route tags from shop-* to storefront-* - Rename get_shop_context() → get_storefront_context() - Update architecture rules to reference storefront paths - Update all test API endpoint paths This is Phase 2 of the storefront module restructure plan. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
436 lines
15 KiB
Python
436 lines
15 KiB
Python
# app/routes/platform_pages.py
|
|
"""
|
|
Platform public page routes.
|
|
|
|
These routes serve the marketing homepage, pricing page,
|
|
Letzshop vendor finder, and signup wizard.
|
|
"""
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse
|
|
from app.templates_config import templates
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import settings
|
|
from app.core.database import get_db
|
|
from app.modules.cms.services import content_page_service
|
|
from app.utils.i18n import get_jinja2_globals
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Get the templates directory
|
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
|
# TEMPLATES_DIR moved to app.templates_config
|
|
# templates imported from app.templates_config
|
|
|
|
|
|
def get_platform_context(request: Request, db: Session) -> dict:
|
|
"""Build context for platform pages."""
|
|
# Get language from request state (set by middleware)
|
|
language = getattr(request.state, "language", "fr")
|
|
|
|
# Get platform from middleware (default to OMS platform_id=1)
|
|
platform = getattr(request.state, "platform", None)
|
|
platform_id = platform.id if platform else 1
|
|
|
|
# Get translation function
|
|
i18n_globals = get_jinja2_globals(language)
|
|
|
|
context = {
|
|
"request": request,
|
|
"platform_name": "Wizamart",
|
|
"platform_domain": settings.platform_domain,
|
|
"stripe_publishable_key": settings.stripe_publishable_key,
|
|
"trial_days": settings.stripe_trial_days,
|
|
}
|
|
|
|
# Add i18n globals (_, t, current_language, SUPPORTED_LANGUAGES, etc.)
|
|
context.update(i18n_globals)
|
|
|
|
# Load CMS pages for header, footer, and legal navigation
|
|
header_pages = []
|
|
footer_pages = []
|
|
legal_pages = []
|
|
try:
|
|
# Platform marketing pages (is_platform_page=True)
|
|
header_pages = content_page_service.list_platform_pages(
|
|
db, platform_id=platform_id, header_only=True, include_unpublished=False
|
|
)
|
|
footer_pages = content_page_service.list_platform_pages(
|
|
db, platform_id=platform_id, footer_only=True, include_unpublished=False
|
|
)
|
|
# For legal pages, we need to add footer support or use a different approach
|
|
# For now, legal pages come from footer pages with show_in_legal flag
|
|
legal_pages = [] # Will be handled separately if needed
|
|
logger.debug(
|
|
f"Loaded CMS pages: {len(header_pages)} header, {len(footer_pages)} footer, {len(legal_pages)} legal"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load CMS navigation pages: {e}")
|
|
|
|
context["header_pages"] = header_pages
|
|
context["footer_pages"] = footer_pages
|
|
context["legal_pages"] = legal_pages
|
|
|
|
return context
|
|
|
|
|
|
# =============================================================================
|
|
# Homepage
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse, name="platform_homepage")
|
|
async def homepage(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Homepage handler.
|
|
|
|
Handles two scenarios:
|
|
1. Vendor on custom domain (vendor.com) → Show vendor landing page or redirect to shop
|
|
2. Platform marketing site → Show platform homepage from CMS or default template
|
|
|
|
URL routing:
|
|
- localhost:9999/ → Main marketing site ('main' platform)
|
|
- localhost:9999/platforms/oms/ → OMS platform (middleware rewrites to /)
|
|
- oms.lu/ → OMS platform (domain-based)
|
|
- shop.mycompany.com/ → Vendor landing page (custom domain)
|
|
"""
|
|
from fastapi.responses import RedirectResponse
|
|
|
|
# Get platform and vendor from middleware
|
|
platform = getattr(request.state, "platform", None)
|
|
vendor = getattr(request.state, "vendor", None)
|
|
|
|
# Scenario 1: Vendor detected (custom domain like vendor.com)
|
|
if vendor:
|
|
logger.debug(f"[HOMEPAGE] Vendor detected: {vendor.subdomain}")
|
|
|
|
# Get platform_id (use platform from context or default to 1 for OMS)
|
|
platform_id = platform.id if platform else 1
|
|
|
|
# Try to find vendor landing page (slug='landing' or 'home')
|
|
landing_page = content_page_service.get_page_for_vendor(
|
|
db, platform_id=platform_id, slug="landing", vendor_id=vendor.id, include_unpublished=False
|
|
)
|
|
|
|
if not landing_page:
|
|
landing_page = content_page_service.get_page_for_vendor(
|
|
db, platform_id=platform_id, slug="home", vendor_id=vendor.id, include_unpublished=False
|
|
)
|
|
|
|
if landing_page:
|
|
# Render landing page with selected template
|
|
from app.routes.storefront_pages import get_storefront_context
|
|
|
|
template_name = landing_page.template or "default"
|
|
template_path = f"vendor/landing-{template_name}.html"
|
|
|
|
logger.info(f"[HOMEPAGE] Rendering vendor landing page: {template_path}")
|
|
return templates.TemplateResponse(
|
|
template_path, get_storefront_context(request, db=db, page=landing_page)
|
|
)
|
|
|
|
# No landing page - redirect to shop
|
|
vendor_context = getattr(request.state, "vendor_context", None)
|
|
access_method = (
|
|
vendor_context.get("detection_method", "unknown")
|
|
if vendor_context
|
|
else "unknown"
|
|
)
|
|
|
|
if access_method == "path":
|
|
full_prefix = (
|
|
vendor_context.get("full_prefix", "/vendor/")
|
|
if vendor_context
|
|
else "/vendor/"
|
|
)
|
|
return RedirectResponse(
|
|
url=f"{full_prefix}{vendor.subdomain}/shop/", status_code=302
|
|
)
|
|
# Domain/subdomain - redirect to /shop/
|
|
return RedirectResponse(url="/shop/", status_code=302)
|
|
|
|
# Scenario 2: Platform marketing site (no vendor)
|
|
# Load platform homepage from CMS (slug='home')
|
|
platform_id = platform.id if platform else 1
|
|
|
|
cms_homepage = content_page_service.get_platform_page(
|
|
db, platform_id=platform_id, slug="home", include_unpublished=False
|
|
)
|
|
|
|
if cms_homepage:
|
|
# Use CMS-based homepage with template selection
|
|
context = get_platform_context(request, db)
|
|
context["page"] = cms_homepage
|
|
context["platform"] = platform
|
|
|
|
# Include subscription tiers for pricing section
|
|
from models.database.subscription import TIER_LIMITS, TierCode
|
|
|
|
tiers = []
|
|
for tier_code, limits in TIER_LIMITS.items():
|
|
tiers.append({
|
|
"code": tier_code.value,
|
|
"name": limits["name"],
|
|
"price_monthly": limits["price_monthly_cents"] / 100,
|
|
"price_annual": (limits["price_annual_cents"] / 100) if limits.get("price_annual_cents") else None,
|
|
"orders_per_month": limits.get("orders_per_month"),
|
|
"products_limit": limits.get("products_limit"),
|
|
"team_members": limits.get("team_members"),
|
|
"features": limits.get("features", []),
|
|
"is_popular": tier_code == TierCode.PROFESSIONAL,
|
|
"is_enterprise": tier_code == TierCode.ENTERPRISE,
|
|
})
|
|
context["tiers"] = tiers
|
|
|
|
template_name = cms_homepage.template or "default"
|
|
template_path = f"platform/homepage-{template_name}.html"
|
|
|
|
logger.info(f"[HOMEPAGE] Rendering CMS homepage with template: {template_path}")
|
|
return templates.TemplateResponse(template_path, context)
|
|
|
|
# Fallback: Default wizamart homepage (no CMS content)
|
|
logger.info("[HOMEPAGE] No CMS homepage found, using default wizamart template")
|
|
context = get_platform_context(request, db)
|
|
context["platform"] = platform
|
|
|
|
# Fetch tiers for display (use API service internally)
|
|
from models.database.subscription import TIER_LIMITS, TierCode
|
|
|
|
tiers = []
|
|
for tier_code, limits in TIER_LIMITS.items():
|
|
tiers.append({
|
|
"code": tier_code.value,
|
|
"name": limits["name"],
|
|
"price_monthly": limits["price_monthly_cents"] / 100,
|
|
"price_annual": (limits["price_annual_cents"] / 100) if limits.get("price_annual_cents") else None,
|
|
"orders_per_month": limits.get("orders_per_month"),
|
|
"products_limit": limits.get("products_limit"),
|
|
"team_members": limits.get("team_members"),
|
|
"features": limits.get("features", []),
|
|
"is_popular": tier_code == TierCode.PROFESSIONAL,
|
|
"is_enterprise": tier_code == TierCode.ENTERPRISE,
|
|
})
|
|
|
|
context["tiers"] = tiers
|
|
|
|
# Add-ons (hardcoded for now, will come from DB)
|
|
context["addons"] = [
|
|
{
|
|
"code": "domain",
|
|
"name": "Custom Domain",
|
|
"description": "Use your own domain (mydomain.com)",
|
|
"price": 15,
|
|
"billing_period": "year",
|
|
"icon": "globe",
|
|
},
|
|
{
|
|
"code": "ssl_premium",
|
|
"name": "Premium SSL",
|
|
"description": "EV certificate for trust badges",
|
|
"price": 49,
|
|
"billing_period": "year",
|
|
"icon": "shield-check",
|
|
},
|
|
{
|
|
"code": "email",
|
|
"name": "Email Package",
|
|
"description": "Professional email addresses",
|
|
"price": 5,
|
|
"billing_period": "month",
|
|
"icon": "mail",
|
|
"options": [
|
|
{"quantity": 5, "price": 5},
|
|
{"quantity": 10, "price": 9},
|
|
{"quantity": 25, "price": 19},
|
|
],
|
|
},
|
|
]
|
|
|
|
return templates.TemplateResponse(
|
|
"platform/homepage-wizamart.html",
|
|
context,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Pricing Page
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/pricing", response_class=HTMLResponse, name="platform_pricing")
|
|
async def pricing_page(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Standalone pricing page with detailed tier comparison.
|
|
"""
|
|
context = get_platform_context(request, db)
|
|
|
|
# Reuse tier data from homepage
|
|
from models.database.subscription import TIER_LIMITS, TierCode
|
|
|
|
tiers = []
|
|
for tier_code, limits in TIER_LIMITS.items():
|
|
tiers.append({
|
|
"code": tier_code.value,
|
|
"name": limits["name"],
|
|
"price_monthly": limits["price_monthly_cents"] / 100,
|
|
"price_annual": (limits["price_annual_cents"] / 100) if limits.get("price_annual_cents") else None,
|
|
"orders_per_month": limits.get("orders_per_month"),
|
|
"products_limit": limits.get("products_limit"),
|
|
"team_members": limits.get("team_members"),
|
|
"order_history_months": limits.get("order_history_months"),
|
|
"features": limits.get("features", []),
|
|
"is_popular": tier_code == TierCode.PROFESSIONAL,
|
|
"is_enterprise": tier_code == TierCode.ENTERPRISE,
|
|
})
|
|
|
|
context["tiers"] = tiers
|
|
context["page_title"] = "Pricing"
|
|
|
|
return templates.TemplateResponse(
|
|
"platform/pricing.html",
|
|
context,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Find Your Shop (Letzshop Vendor Browser)
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/find-shop", response_class=HTMLResponse, name="platform_find_shop")
|
|
async def find_shop_page(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Letzshop vendor browser page.
|
|
|
|
Allows vendors to search for and claim their Letzshop shop.
|
|
"""
|
|
context = get_platform_context(request, db)
|
|
context["page_title"] = "Find Your Letzshop Shop"
|
|
|
|
return templates.TemplateResponse(
|
|
"platform/find-shop.html",
|
|
context,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Signup Wizard
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/signup", response_class=HTMLResponse, name="platform_signup")
|
|
async def signup_page(
|
|
request: Request,
|
|
tier: str | None = None,
|
|
annual: bool = False,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Multi-step signup wizard.
|
|
|
|
Query params:
|
|
- tier: Pre-selected tier code
|
|
- annual: Pre-select annual billing
|
|
"""
|
|
context = get_platform_context(request, db)
|
|
context["page_title"] = "Start Your Free Trial"
|
|
context["selected_tier"] = tier
|
|
context["is_annual"] = annual
|
|
|
|
# Get tiers for tier selection step
|
|
from models.database.subscription import TIER_LIMITS, TierCode
|
|
|
|
tiers = []
|
|
for tier_code, limits in TIER_LIMITS.items():
|
|
tiers.append({
|
|
"code": tier_code.value,
|
|
"name": limits["name"],
|
|
"price_monthly": limits["price_monthly_cents"] / 100,
|
|
"price_annual": (limits["price_annual_cents"] / 100) if limits.get("price_annual_cents") else None,
|
|
"orders_per_month": limits.get("orders_per_month"),
|
|
"team_members": limits.get("team_members"),
|
|
"is_enterprise": tier_code == TierCode.ENTERPRISE,
|
|
})
|
|
|
|
context["tiers"] = tiers
|
|
|
|
return templates.TemplateResponse(
|
|
"platform/signup.html",
|
|
context,
|
|
)
|
|
|
|
|
|
@router.get("/signup/success", response_class=HTMLResponse, name="platform_signup_success")
|
|
async def signup_success_page(
|
|
request: Request,
|
|
vendor_code: str | None = None,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Signup success page.
|
|
|
|
Shown after successful account creation.
|
|
"""
|
|
context = get_platform_context(request, db)
|
|
context["page_title"] = "Welcome to Wizamart!"
|
|
context["vendor_code"] = vendor_code
|
|
|
|
return templates.TemplateResponse(
|
|
"platform/signup-success.html",
|
|
context,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Generic Content Pages (CMS)
|
|
# =============================================================================
|
|
# IMPORTANT: This route must be LAST as it catches all /{slug} URLs
|
|
|
|
|
|
@router.get("/{slug}", response_class=HTMLResponse, name="platform_content_page")
|
|
async def content_page(
|
|
request: Request,
|
|
slug: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Serve CMS content pages (about, contact, faq, privacy, terms, etc.).
|
|
|
|
This is a catch-all route for dynamic content pages managed via the admin CMS.
|
|
Platform pages have vendor_id=None and is_platform_page=True.
|
|
"""
|
|
# Get platform from middleware (default to OMS platform_id=1)
|
|
platform = getattr(request.state, "platform", None)
|
|
platform_id = platform.id if platform else 1
|
|
|
|
# Load platform marketing page from database
|
|
page = content_page_service.get_platform_page(
|
|
db, platform_id=platform_id, slug=slug, include_unpublished=False
|
|
)
|
|
|
|
if not page:
|
|
raise HTTPException(status_code=404, detail=f"Page not found: {slug}")
|
|
|
|
context = get_platform_context(request, db)
|
|
context["page"] = page
|
|
context["page_title"] = page.title
|
|
|
|
return templates.TemplateResponse(
|
|
"platform/content-page.html",
|
|
context,
|
|
)
|