feat: complete multi-platform CMS phases 2-5

Phase 2 - OMS Migration & Integration:
- Fix platform_pages.py to use get_platform_page for marketing pages
- Fix shop_pages.py to pass platform_id to content page service calls

Phase 3 - Admin Interface:
- Add platform management API (app/api/v1/admin/platforms.py)
- Add platforms admin page with stats cards
- Add Platforms menu item to admin sidebar
- Update content pages admin with platform filter and four-tab tier system

Phase 4 - Documentation:
- Add comprehensive architecture docs (docs/architecture/multi-platform-cms.md)
- Update implementation plan with completion status

Phase 5 - Vendor Dashboard:
- Add CMS usage API endpoint with tier limits
- Add usage progress bar to vendor content pages
- Add platform-default/{slug} API for preview
- Add View Default button and modal in page editor

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 16:30:31 +01:00
parent fe49008fef
commit 968002630e
19 changed files with 1424 additions and 99 deletions

View File

@@ -47,6 +47,7 @@ from . import (
order_item_exceptions,
orders,
platform_health,
platforms,
products,
settings,
subscriptions,
@@ -91,6 +92,9 @@ router.include_router(
content_pages.router, prefix="/content-pages", tags=["admin-content-pages"]
)
# Include platforms management endpoints (multi-platform CMS)
router.include_router(platforms.router, tags=["admin-platforms"])
# ============================================================================
# User Management

View File

@@ -82,6 +82,9 @@ class ContentPageResponse(BaseModel):
"""Schema for content page response."""
id: int
platform_id: int | None = None
platform_code: str | None = None
platform_name: str | None = None
vendor_id: int | None
vendor_name: str | None
slug: str
@@ -97,8 +100,11 @@ class ContentPageResponse(BaseModel):
show_in_footer: bool
show_in_header: bool
show_in_legal: bool
is_platform_default: bool
is_vendor_override: bool
is_platform_page: bool = False
is_platform_default: bool = False # Deprecated: use is_platform_page
is_vendor_default: bool = False
is_vendor_override: bool = False
page_tier: str | None = None
created_at: str
updated_at: str
created_by: int | None

View File

@@ -0,0 +1,391 @@
# app/api/v1/admin/platforms.py
"""
Admin API endpoints for Platform management (Multi-Platform CMS).
Provides CRUD operations for platforms:
- GET /platforms - List all platforms
- GET /platforms/{code} - Get platform details
- PUT /platforms/{code} - Update platform settings
- GET /platforms/{code}/stats - Get platform statistics
Platforms are business offerings (OMS, Loyalty, Site Builder) with their own:
- Marketing pages (homepage, pricing, features)
- Vendor defaults (about, terms, privacy)
- Configuration and branding
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from pydantic import BaseModel, Field
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_from_cookie_or_header, get_db
from models.database.content_page import ContentPage
from models.database.platform import Platform
from models.database.user import User
from models.database.vendor_platform import VendorPlatform
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/platforms")
# =============================================================================
# Pydantic Schemas
# =============================================================================
class PlatformResponse(BaseModel):
"""Platform response schema."""
id: int
code: str
name: str
description: str | None = None
domain: str | None = None
path_prefix: str | None = None
logo: str | None = None
logo_dark: str | None = None
favicon: str | None = None
theme_config: dict[str, Any] = Field(default_factory=dict)
default_language: str = "fr"
supported_languages: list[str] = Field(default_factory=lambda: ["fr", "de", "en"])
is_active: bool = True
is_public: bool = True
settings: dict[str, Any] = Field(default_factory=dict)
created_at: str
updated_at: str
# Computed fields (added by endpoint)
vendor_count: int = 0
platform_pages_count: int = 0
vendor_defaults_count: int = 0
class Config:
from_attributes = True
class PlatformListResponse(BaseModel):
"""Response for platform list."""
platforms: list[PlatformResponse]
total: int
class PlatformUpdateRequest(BaseModel):
"""Request schema for updating a platform."""
name: str | None = None
description: str | None = None
domain: str | None = None
path_prefix: str | None = None
logo: str | None = None
logo_dark: str | None = None
favicon: str | None = None
theme_config: dict[str, Any] | None = None
default_language: str | None = None
supported_languages: list[str] | None = None
is_active: bool | None = None
is_public: bool | None = None
settings: dict[str, Any] | None = None
class PlatformStatsResponse(BaseModel):
"""Platform statistics response."""
platform_id: int
platform_code: str
platform_name: str
vendor_count: int
platform_pages_count: int
vendor_defaults_count: int
vendor_overrides_count: int
published_pages_count: int
draft_pages_count: int
# =============================================================================
# API Endpoints
# =============================================================================
@router.get("", response_model=PlatformListResponse)
async def list_platforms(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_from_cookie_or_header),
include_inactive: bool = Query(False, description="Include inactive platforms"),
):
"""
List all platforms with their statistics.
Returns all platforms (OMS, Loyalty, etc.) with vendor counts and page counts.
"""
query = db.query(Platform)
if not include_inactive:
query = query.filter(Platform.is_active == True)
platforms = query.order_by(Platform.id).all()
# Build response with computed fields
result = []
for platform in platforms:
# Count vendors on this platform
vendor_count = (
db.query(func.count(VendorPlatform.vendor_id))
.filter(VendorPlatform.platform_id == platform.id)
.scalar()
or 0
)
# Count platform marketing pages
platform_pages_count = (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform.id,
ContentPage.vendor_id == None,
ContentPage.is_platform_page == True,
)
.scalar()
or 0
)
# Count vendor default pages
vendor_defaults_count = (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform.id,
ContentPage.vendor_id == None,
ContentPage.is_platform_page == False,
)
.scalar()
or 0
)
platform_data = PlatformResponse(
id=platform.id,
code=platform.code,
name=platform.name,
description=platform.description,
domain=platform.domain,
path_prefix=platform.path_prefix,
logo=platform.logo,
logo_dark=platform.logo_dark,
favicon=platform.favicon,
theme_config=platform.theme_config or {},
default_language=platform.default_language,
supported_languages=platform.supported_languages or ["fr", "de", "en"],
is_active=platform.is_active,
is_public=platform.is_public,
settings=platform.settings or {},
created_at=platform.created_at.isoformat(),
updated_at=platform.updated_at.isoformat(),
vendor_count=vendor_count,
platform_pages_count=platform_pages_count,
vendor_defaults_count=vendor_defaults_count,
)
result.append(platform_data)
logger.info(f"[PLATFORMS] Listed {len(result)} platforms")
return PlatformListResponse(platforms=result, total=len(result))
@router.get("/{code}", response_model=PlatformResponse)
async def get_platform(
code: str = Path(..., description="Platform code (oms, loyalty, etc.)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_from_cookie_or_header),
):
"""
Get platform details by code.
Returns full platform configuration including statistics.
"""
platform = db.query(Platform).filter(Platform.code == code).first()
if not platform:
raise HTTPException(status_code=404, detail=f"Platform not found: {code}")
# Count vendors on this platform
vendor_count = (
db.query(func.count(VendorPlatform.vendor_id))
.filter(VendorPlatform.platform_id == platform.id)
.scalar()
or 0
)
# Count platform marketing pages
platform_pages_count = (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform.id,
ContentPage.vendor_id == None,
ContentPage.is_platform_page == True,
)
.scalar()
or 0
)
# Count vendor default pages
vendor_defaults_count = (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform.id,
ContentPage.vendor_id == None,
ContentPage.is_platform_page == False,
)
.scalar()
or 0
)
return PlatformResponse(
id=platform.id,
code=platform.code,
name=platform.name,
description=platform.description,
domain=platform.domain,
path_prefix=platform.path_prefix,
logo=platform.logo,
logo_dark=platform.logo_dark,
favicon=platform.favicon,
theme_config=platform.theme_config or {},
default_language=platform.default_language,
supported_languages=platform.supported_languages or ["fr", "de", "en"],
is_active=platform.is_active,
is_public=platform.is_public,
settings=platform.settings or {},
created_at=platform.created_at.isoformat(),
updated_at=platform.updated_at.isoformat(),
vendor_count=vendor_count,
platform_pages_count=platform_pages_count,
vendor_defaults_count=vendor_defaults_count,
)
@router.put("/{code}", response_model=PlatformResponse)
async def update_platform(
update_data: PlatformUpdateRequest,
code: str = Path(..., description="Platform code"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_from_cookie_or_header),
):
"""
Update platform settings.
Allows updating name, description, branding, and configuration.
"""
platform = db.query(Platform).filter(Platform.code == code).first()
if not platform:
raise HTTPException(status_code=404, detail=f"Platform not found: {code}")
# Update fields if provided
update_dict = update_data.model_dump(exclude_unset=True)
for field, value in update_dict.items():
if hasattr(platform, field):
setattr(platform, field, value)
db.commit()
db.refresh(platform)
logger.info(f"[PLATFORMS] Updated platform: {code}")
# Return updated platform with stats
return await get_platform(code=code, db=db, current_user=current_user)
@router.get("/{code}/stats", response_model=PlatformStatsResponse)
async def get_platform_stats(
code: str = Path(..., description="Platform code"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_from_cookie_or_header),
):
"""
Get detailed statistics for a platform.
Returns counts for vendors, pages, and content breakdown.
"""
platform = db.query(Platform).filter(Platform.code == code).first()
if not platform:
raise HTTPException(status_code=404, detail=f"Platform not found: {code}")
# Count vendors
vendor_count = (
db.query(func.count(VendorPlatform.vendor_id))
.filter(VendorPlatform.platform_id == platform.id)
.scalar()
or 0
)
# Count platform marketing pages
platform_pages_count = (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform.id,
ContentPage.vendor_id == None,
ContentPage.is_platform_page == True,
)
.scalar()
or 0
)
# Count vendor default pages
vendor_defaults_count = (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform.id,
ContentPage.vendor_id == None,
ContentPage.is_platform_page == False,
)
.scalar()
or 0
)
# Count vendor override pages
vendor_overrides_count = (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform.id,
ContentPage.vendor_id != None,
)
.scalar()
or 0
)
# Count published pages
published_pages_count = (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform.id,
ContentPage.is_published == True,
)
.scalar()
or 0
)
# Count draft pages
draft_pages_count = (
db.query(func.count(ContentPage.id))
.filter(
ContentPage.platform_id == platform.id,
ContentPage.is_published == False,
)
.scalar()
or 0
)
return PlatformStatsResponse(
platform_id=platform.id,
platform_code=platform.code,
platform_name=platform.name,
vendor_count=vendor_count,
platform_pages_count=platform_pages_count,
vendor_defaults_count=vendor_defaults_count,
vendor_overrides_count=vendor_overrides_count,
published_pages_count=published_pages_count,
draft_pages_count=draft_pages_count,
)

View File

@@ -97,6 +97,20 @@ class ContentPageResponse(BaseModel):
updated_by: int | None
class CMSUsageResponse(BaseModel):
"""Schema for CMS usage statistics."""
total_pages: int
custom_pages: int
override_pages: int
pages_limit: int | None
custom_pages_limit: int | None
can_create_page: bool
can_create_custom: bool
usage_percent: float
custom_usage_percent: float
# ============================================================================
# VENDOR CONTENT PAGES
# ============================================================================
@@ -241,3 +255,103 @@ def delete_vendor_page(
# Delete with ownership check in service layer
content_page_service.delete_vendor_page(db, page_id, current_user.token_vendor_id)
db.commit()
# ============================================================================
# CMS USAGE & LIMITS
# ============================================================================
@router.get("/usage", response_model=CMSUsageResponse)
def get_cms_usage(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get CMS usage statistics for the vendor.
Returns page counts and limits based on subscription tier.
"""
from models.database.vendor import Vendor
vendor = db.query(Vendor).filter(Vendor.id == current_user.token_vendor_id).first()
if not vendor:
return CMSUsageResponse(
total_pages=0,
custom_pages=0,
override_pages=0,
pages_limit=3,
custom_pages_limit=0,
can_create_page=False,
can_create_custom=False,
usage_percent=0,
custom_usage_percent=0,
)
# Get vendor's pages
vendor_pages = content_page_service.list_all_vendor_pages(
db, vendor_id=current_user.token_vendor_id, include_unpublished=True
)
total_pages = len(vendor_pages)
override_pages = sum(1 for p in vendor_pages if p.is_vendor_override)
custom_pages = total_pages - override_pages
# Get limits from subscription tier
pages_limit = None
custom_pages_limit = None
if vendor.subscription and vendor.subscription.tier:
pages_limit = vendor.subscription.tier.cms_pages_limit
custom_pages_limit = vendor.subscription.tier.cms_custom_pages_limit
# Calculate can_create flags
can_create_page = pages_limit is None or total_pages < pages_limit
can_create_custom = custom_pages_limit is None or custom_pages < custom_pages_limit
# Calculate usage percentages
usage_percent = 0 if pages_limit is None else min(100, (total_pages / pages_limit) * 100) if pages_limit > 0 else 100
custom_usage_percent = 0 if custom_pages_limit is None else min(100, (custom_pages / custom_pages_limit) * 100) if custom_pages_limit > 0 else 100
return CMSUsageResponse(
total_pages=total_pages,
custom_pages=custom_pages,
override_pages=override_pages,
pages_limit=pages_limit,
custom_pages_limit=custom_pages_limit,
can_create_page=can_create_page,
can_create_custom=can_create_custom,
usage_percent=usage_percent,
custom_usage_percent=custom_usage_percent,
)
@router.get("/platform-default/{slug}", response_model=ContentPageResponse)
def get_platform_default(
slug: str,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get the platform default content for a slug.
Useful for vendors to view the original before/after overriding.
"""
from models.database.vendor import Vendor
# Get vendor's platform
vendor = db.query(Vendor).filter(Vendor.id == current_user.token_vendor_id).first()
platform_id = 1 # Default to OMS
if vendor and vendor.platforms:
platform_id = vendor.platforms[0].id
# Get platform default (vendor_id=None)
page = content_page_service.get_vendor_default_page(
db, platform_id=platform_id, slug=slug, include_unpublished=True
)
if not page:
from app.exceptions import NotFoundException
raise NotFoundException(f"No platform default found for slug: {slug}")
return page.to_dict()

View File

@@ -1039,6 +1039,78 @@ async def admin_logs_page(
)
# ============================================================================
# PLATFORM MANAGEMENT ROUTES (Multi-Platform Support)
# ============================================================================
@router.get("/platforms", response_class=HTMLResponse, include_in_schema=False)
async def admin_platforms_list(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render platforms management page.
Shows all platforms (OMS, Loyalty, etc.) with their configuration.
"""
return templates.TemplateResponse(
"admin/platforms.html",
{
"request": request,
"user": current_user,
},
)
@router.get(
"/platforms/{platform_code}", response_class=HTMLResponse, include_in_schema=False
)
async def admin_platform_detail(
request: Request,
platform_code: str = Path(..., description="Platform code (oms, loyalty, etc.)"),
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render platform detail page.
Shows platform configuration, marketing pages, and vendor defaults.
"""
return templates.TemplateResponse(
"admin/platform-detail.html",
{
"request": request,
"user": current_user,
"platform_code": platform_code,
},
)
@router.get(
"/platforms/{platform_code}/edit",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_platform_edit(
request: Request,
platform_code: str = Path(..., description="Platform code"),
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render platform edit form.
Allows editing platform settings, branding, and configuration.
"""
return templates.TemplateResponse(
"admin/platform-edit.html",
{
"request": request,
"user": current_user,
"platform_code": platform_code,
},
)
# ============================================================================
# CONTENT MANAGEMENT SYSTEM (CMS) ROUTES
# ============================================================================

View File

@@ -33,6 +33,10 @@ def get_platform_context(request: Request, db: Session) -> dict:
# 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)
@@ -52,16 +56,16 @@ def get_platform_context(request: Request, db: Session) -> dict:
footer_pages = []
legal_pages = []
try:
# Platform pages have vendor_id=None
header_pages = content_page_service.list_pages_for_vendor(
db, vendor_id=None, header_only=True, include_unpublished=False
# 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_pages_for_vendor(
db, vendor_id=None, footer_only=True, include_unpublished=False
)
legal_pages = content_page_service.list_pages_for_vendor(
db, vendor_id=None, legal_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"
)
@@ -307,11 +311,15 @@ async def content_page(
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.
Platform pages have vendor_id=None and is_platform_page=True.
"""
# Load content page from database (platform defaults only)
page = content_page_service.get_page_for_vendor(
db, slug=slug, vendor_id=None, include_unpublished=False
# 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:

View File

@@ -114,10 +114,14 @@ def get_shop_context(request: Request, db: Session = None, **extra_context) -> d
"""
# 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")
@@ -156,11 +160,11 @@ def get_shop_context(request: Request, db: Session = None, **extra_context) -> d
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
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, vendor_id=vendor_id, header_only=True, include_unpublished=False
db, platform_id=platform_id, vendor_id=vendor_id, header_only=True, include_unpublished=False
)
except Exception as e:
logger.error(
@@ -752,11 +756,13 @@ async def generic_content_page(
)
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 → platform default)
# Load content page from database (vendor override → vendor default)
page = content_page_service.get_page_for_vendor(
db, slug=slug, vendor_id=vendor_id, include_unpublished=False
db, platform_id=platform_id, slug=slug, vendor_id=vendor_id, include_unpublished=False
)
if not page:

View File

@@ -17,25 +17,45 @@
<!-- Tabs and Filters -->
<div x-show="!loading" class="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-md p-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<!-- Tabs -->
{% call tabs_inline() %}
{{ tab_button('all', 'All Pages', count_var='allPages.length') }}
{{ tab_button('platform', 'Platform Defaults', count_var='platformPages.length') }}
{{ tab_button('vendor', 'Vendor Overrides', count_var='vendorPages.length') }}
{{ tab_button('platform_marketing', 'Platform Marketing', count_var='platformMarketingPages.length') }}
{{ tab_button('vendor_defaults', 'Vendor Defaults', count_var='vendorDefaultPages.length') }}
{{ tab_button('vendor_overrides', 'Vendor Overrides', count_var='vendorOverridePages.length') }}
{% endcall %}
<!-- Search -->
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input
type="text"
x-model="searchQuery"
placeholder="Search pages..."
class="pl-10 pr-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500"
>
<!-- Filters Row -->
<div class="flex items-center gap-3">
<!-- Platform Filter -->
<div class="relative">
<select
x-model="selectedPlatform"
class="pl-3 pr-8 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 appearance-none cursor-pointer"
>
<option value="">All Platforms</option>
<template x-for="platform in platforms" :key="platform.id">
<option :value="platform.code" x-text="platform.name"></option>
</template>
</select>
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span x-html="$icon('chevron-down', 'w-4 h-4 text-gray-400')"></span>
</span>
</div>
<!-- Search -->
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input
type="text"
x-model="searchQuery"
placeholder="Search pages..."
class="pl-10 pr-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500"
>
</div>
</div>
</div>
</div>
@@ -73,12 +93,18 @@
<code class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="'/' + page.slug"></code>
</td>
<!-- Type -->
<!-- Type (Three-Tier System) -->
<td class="px-4 py-3 text-sm">
<span
class="px-2 py-1 text-xs font-semibold rounded-full"
:class="page.is_platform_default ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'"
x-text="page.is_platform_default ? 'Platform' : 'Vendor'"
:class="getPageTierClass(page)"
x-text="getPageTierLabel(page)"
></span>
<!-- Platform badge -->
<span
x-show="page.platform_name"
class="ml-1 px-1.5 py-0.5 text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded"
x-text="page.platform_name"
></span>
</td>

View File

@@ -104,6 +104,7 @@
<!-- Content Management Section -->
{{ section_header('Content Management', 'contentMgmt') }}
{% call section_content('contentMgmt') %}
{{ menu_item('platforms', '/admin/platforms', 'globe-alt', 'Platforms') }}
{{ menu_item('platform-homepage', '/admin/platform-homepage', 'home', 'Platform Homepage') }}
{{ menu_item('content-pages', '/admin/content-pages', 'document-text', 'Content Pages') }}
{{ menu_item('vendor-theme', '/admin/vendor-themes', 'color-swatch', 'Vendor Themes') }}

View File

@@ -0,0 +1,154 @@
{# app/templates/admin/platforms.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Platforms{% endblock %}
{% block alpine_data %}platformsManager(){% endblock %}
{% block content %}
{{ page_header('Platforms', subtitle='Manage platform configurations for OMS, Loyalty, and other business offerings') }}
{{ loading_state('Loading platforms...') }}
{{ error_state('Error loading platforms') }}
<!-- Platforms Grid -->
<div x-show="!loading && platforms.length > 0" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-3">
<template x-for="platform in platforms" :key="platform.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
<!-- Platform Header -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center">
<!-- Platform Icon -->
<div class="flex items-center justify-center w-12 h-12 rounded-lg bg-purple-100 dark:bg-purple-900/50">
<span x-html="$icon(getPlatformIcon(platform.code), 'w-6 h-6 text-purple-600 dark:text-purple-400')"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="platform.name"></h3>
<code class="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded" x-text="platform.code"></code>
</div>
</div>
<!-- Status Badge -->
<span
class="px-3 py-1 text-xs font-semibold rounded-full"
:class="platform.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'"
x-text="platform.is_active ? 'Active' : 'Inactive'"
></span>
</div>
<p class="mt-3 text-sm text-gray-600 dark:text-gray-400" x-text="platform.description || 'No description'"></p>
</div>
<!-- Platform Stats -->
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-800/50">
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400" x-text="platform.vendor_count"></p>
<p class="text-xs text-gray-500 dark:text-gray-400">Vendors</p>
</div>
<div>
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400" x-text="platform.platform_pages_count"></p>
<p class="text-xs text-gray-500 dark:text-gray-400">Marketing Pages</p>
</div>
<div>
<p class="text-2xl font-bold text-teal-600 dark:text-teal-400" x-text="platform.vendor_defaults_count"></p>
<p class="text-xs text-gray-500 dark:text-gray-400">Vendor Defaults</p>
</div>
</div>
</div>
<!-- Platform Info -->
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<div class="space-y-2 text-sm">
<div class="flex justify-between" x-show="platform.domain">
<span class="text-gray-500 dark:text-gray-400">Domain:</span>
<span class="text-gray-900 dark:text-white" x-text="platform.domain"></span>
</div>
<div class="flex justify-between" x-show="platform.path_prefix">
<span class="text-gray-500 dark:text-gray-400">Path Prefix:</span>
<code class="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded" x-text="platform.path_prefix"></code>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Language:</span>
<span class="text-gray-900 dark:text-white" x-text="platform.default_language.toUpperCase()"></span>
</div>
</div>
</div>
<!-- Platform Actions -->
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div class="flex justify-between items-center">
<a
:href="`/admin/platforms/${platform.code}`"
class="inline-flex items-center text-sm font-medium text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
>
<span x-html="$icon('eye', 'w-4 h-4 mr-1')"></span>
View Details
</a>
<div class="flex space-x-2">
<a
:href="`/admin/content-pages?platform=${platform.code}`"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
title="View content pages for this platform"
>
<span x-html="$icon('document-text', 'w-4 h-4 mr-1')"></span>
Pages
</a>
<a
:href="`/admin/platforms/${platform.code}/edit`"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
title="Edit platform settings"
>
<span x-html="$icon('edit', 'w-4 h-4 mr-1')"></span>
Edit
</a>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Empty State -->
<div x-show="!loading && platforms.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<span x-html="$icon('globe-alt', 'inline w-16 h-16 text-gray-400 mb-4')"></span>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">No platforms found</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">
No platforms have been configured yet.
</p>
</div>
<!-- Page Tier Legend -->
<div x-show="!loading && platforms.length > 0" class="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">Content Page Tiers</h4>
<div class="grid md:grid-cols-3 gap-6">
<div class="flex items-start">
<span class="inline-block w-3 h-3 rounded-full bg-blue-500 mt-1.5 mr-3"></span>
<div>
<p class="font-medium text-gray-900 dark:text-white">Platform Marketing Pages</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Public pages like homepage, pricing, features. Not inherited by vendors.</p>
</div>
</div>
<div class="flex items-start">
<span class="inline-block w-3 h-3 rounded-full bg-teal-500 mt-1.5 mr-3"></span>
<div>
<p class="font-medium text-gray-900 dark:text-white">Vendor Defaults</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Default pages inherited by all vendors (about, terms, privacy).</p>
</div>
</div>
<div class="flex items-start">
<span class="inline-block w-3 h-3 rounded-full bg-purple-500 mt-1.5 mr-3"></span>
<div>
<p class="font-medium text-gray-900 dark:text-white">Vendor Overrides</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Custom pages created by individual vendors.</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/platforms.js') }}"></script>
{% endblock %}

View File

@@ -41,14 +41,73 @@
<!-- Override Info Banner -->
<div x-show="!loading && isOverride" class="mb-6 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
<div class="flex">
<span x-html="$icon('information-circle', 'w-5 h-5 text-purple-500 mr-3 flex-shrink-0 mt-0.5')"></span>
<div>
<h4 class="text-sm font-medium text-purple-800 dark:text-purple-200">Overriding Platform Default</h4>
<p class="text-sm text-purple-700 dark:text-purple-300 mt-1">
You're customizing the "<span x-text="form.title"></span>" page. Your version will be shown to customers instead of the platform default.
<button @click="deletePage()" class="underline hover:no-underline ml-1">Revert to default</button>
</p>
<div class="flex items-start justify-between">
<div class="flex">
<span x-html="$icon('information-circle', 'w-5 h-5 text-purple-500 mr-3 flex-shrink-0 mt-0.5')"></span>
<div>
<h4 class="text-sm font-medium text-purple-800 dark:text-purple-200">Overriding Platform Default</h4>
<p class="text-sm text-purple-700 dark:text-purple-300 mt-1">
You're customizing the "<span x-text="form.title"></span>" page. Your version will be shown to customers instead of the platform default.
</p>
</div>
</div>
<div class="flex items-center gap-2 ml-4 flex-shrink-0">
<button
@click="showDefaultPreview()"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-purple-700 bg-white dark:bg-purple-900/50 dark:text-purple-300 border border-purple-300 dark:border-purple-700 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900 transition-colors"
>
<span x-html="$icon('eye', 'w-4 h-4 mr-1')"></span>
View Default
</button>
<button
@click="deletePage()"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-red-700 bg-white dark:bg-red-900/50 dark:text-red-300 border border-red-300 dark:border-red-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900 transition-colors"
>
<span x-html="$icon('arrow-uturn-left', 'w-4 h-4 mr-1')"></span>
Revert to Default
</button>
</div>
</div>
</div>
<!-- Default Content Preview Modal -->
<div
x-show="showingDefaultPreview"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click.self="showingDefaultPreview = false"
>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[80vh] overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Platform Default Content</h3>
<button @click="showingDefaultPreview = false" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
</button>
</div>
<div class="p-6 overflow-y-auto max-h-[60vh]">
<div x-show="loadingDefault" class="text-center py-8">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-sm text-gray-500">Loading default content...</p>
</div>
<div x-show="!loadingDefault && defaultContent">
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Title</h4>
<p class="text-lg font-semibold text-gray-900 dark:text-white mb-4" x-text="defaultContent?.title"></p>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Content</h4>
<div class="prose dark:prose-invert max-w-none bg-gray-50 dark:bg-gray-700 rounded-lg p-4" x-html="defaultContent?.content"></div>
</div>
</div>
<div class="flex justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50">
<button
@click="showingDefaultPreview = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
Close
</button>
</div>
</div>
</div>

View File

@@ -15,6 +15,50 @@
{{ error_state('Error loading pages') }}
<!-- CMS Usage Indicator -->
<div x-show="!loading && cmsUsage" class="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-md p-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex-1">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">CMS Usage</span>
<span class="text-sm text-gray-500 dark:text-gray-400">
<span x-text="cmsUsage.total_pages"></span>
<span x-show="cmsUsage.pages_limit"> / <span x-text="cmsUsage.pages_limit"></span></span>
<span x-show="!cmsUsage.pages_limit"> (unlimited)</span>
pages
</span>
</div>
<!-- Progress Bar -->
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
<div
class="h-2.5 rounded-full transition-all duration-300"
:class="{
'bg-green-500': cmsUsage.usage_percent < 70,
'bg-yellow-500': cmsUsage.usage_percent >= 70 && cmsUsage.usage_percent < 90,
'bg-red-500': cmsUsage.usage_percent >= 90
}"
:style="`width: ${cmsUsage.pages_limit ? cmsUsage.usage_percent : 0}%`"
></div>
</div>
<div class="flex justify-between mt-1 text-xs text-gray-500 dark:text-gray-400">
<span><span x-text="cmsUsage.override_pages"></span> overrides</span>
<span><span x-text="cmsUsage.custom_pages"></span> custom pages</span>
</div>
</div>
<!-- Upgrade Prompt (show when approaching limit) -->
<div x-show="cmsUsage.pages_limit && cmsUsage.usage_percent >= 80" class="flex-shrink-0">
<a
href="/vendor/{{ vendor_code }}/settings/billing"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-100 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors"
>
<span x-html="$icon('arrow-trending-up', 'w-4 h-4 mr-1')"></span>
Upgrade for more pages
</a>
</div>
</div>
</div>
<!-- Tabs and Info -->
<div x-show="!loading" class="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-md p-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">

View File

@@ -0,0 +1,239 @@
# Multi-Platform CMS Architecture
## Overview
The Multi-Platform CMS enables Wizamart to serve multiple business offerings (OMS, Loyalty, Site Builder) from a single codebase, each with its own marketing site and vendor ecosystem.
## Three-Tier Content Hierarchy
Content pages follow a three-tier inheritance model:
```
┌─────────────────────────────────────────────────────────────────────┐
│ TIER 1: Platform Marketing │
│ Public pages for the platform (homepage, pricing, features) │
│ is_platform_page=TRUE, vendor_id=NULL │
│ NOT inherited by vendors │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ TIER 2: Vendor Defaults │
│ Default pages all vendors inherit (about, terms, privacy) │
│ is_platform_page=FALSE, vendor_id=NULL │
│ Inherited by ALL vendors on the platform │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ TIER 3: Vendor Overrides │
│ Custom pages created by individual vendors │
│ is_platform_page=FALSE, vendor_id=<vendor_id> │
│ Overrides vendor defaults for specific vendor │
└─────────────────────────────────────────────────────────────────────┘
```
## Content Resolution Flow
When a customer visits a vendor page (e.g., `/vendors/shopname/about`):
```
Customer visits: /vendors/shopname/about
┌─────────────────────────────────────────────────────────────────────┐
│ Step 1: Check Vendor Override │
│ SELECT * FROM content_pages │
│ WHERE platform_id=1 AND vendor_id=123 AND slug='about' │
│ Found? → Return vendor's custom "About" page │
└─────────────────────────────────────────────────────────────────────┘
│ Not found
┌─────────────────────────────────────────────────────────────────────┐
│ Step 2: Check Vendor Default │
│ SELECT * FROM content_pages │
│ WHERE platform_id=1 AND vendor_id IS NULL │
│ AND is_platform_page=FALSE AND slug='about' │
│ Found? → Return platform's default "About" template │
└─────────────────────────────────────────────────────────────────────┘
│ Not found
Return 404
```
## Database Schema
### platforms
```sql
CREATE TABLE platforms (
id SERIAL PRIMARY KEY,
code VARCHAR(50) UNIQUE NOT NULL, -- 'oms', 'loyalty', 'sitebuilder'
name VARCHAR(100) NOT NULL, -- 'Order Management System'
description TEXT,
domain VARCHAR(255), -- 'oms.wizamart.lu'
path_prefix VARCHAR(50), -- '/oms'
logo VARCHAR(255),
logo_dark VARCHAR(255),
favicon VARCHAR(255),
theme_config JSONB DEFAULT '{}',
default_language VARCHAR(10) DEFAULT 'fr',
supported_languages JSONB DEFAULT '["fr", "de", "en"]',
is_active BOOLEAN DEFAULT TRUE,
is_public BOOLEAN DEFAULT TRUE,
settings JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
### vendor_platforms (Junction Table)
```sql
CREATE TABLE vendor_platforms (
vendor_id INTEGER REFERENCES vendors(id) ON DELETE CASCADE,
platform_id INTEGER REFERENCES platforms(id) ON DELETE CASCADE,
joined_at TIMESTAMP DEFAULT NOW(),
is_active BOOLEAN DEFAULT TRUE,
settings JSONB DEFAULT '{}',
PRIMARY KEY (vendor_id, platform_id)
);
```
### content_pages (Extended)
```sql
ALTER TABLE content_pages ADD COLUMN platform_id INTEGER REFERENCES platforms(id);
ALTER TABLE content_pages ADD COLUMN is_platform_page BOOLEAN DEFAULT FALSE;
-- Platform marketing pages: is_platform_page=TRUE, vendor_id=NULL
-- Vendor defaults: is_platform_page=FALSE, vendor_id=NULL
-- Vendor overrides: is_platform_page=FALSE, vendor_id=<id>
```
## Request Flow
```
Request: GET /oms/vendors/shopname/about
┌─────────────────────────────────────────────────────────────────────┐
│ PlatformContextMiddleware │
│ - Detects platform from path prefix (/oms) or domain │
│ - Sets request.state.platform = Platform(code='oms') │
│ - Sets request.state.platform_clean_path = /vendors/shopname/about │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ VendorContextMiddleware │
│ - Uses platform_clean_path for vendor detection │
│ - Sets request.state.vendor = Vendor(subdomain='shopname') │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Route Handler (shop_pages.py) │
│ - Gets platform_id from request.state.platform │
│ - Calls content_page_service.get_page_for_vendor( │
│ platform_id=1, vendor_id=123, slug='about' │
│ ) │
│ - Service handles three-tier resolution │
└─────────────────────────────────────────────────────────────────────┘
```
## Admin Interface
### Platform Management (`/admin/platforms`)
- Lists all platforms with statistics
- Shows vendor count, marketing pages, vendor defaults
- Links to platform detail and edit pages
### Content Pages (`/admin/content-pages`)
- Platform filter dropdown
- Four-tab view:
- **All Pages**: Complete list
- **Platform Marketing**: Public platform pages (is_platform_page=TRUE)
- **Vendor Defaults**: Inherited by vendors (is_platform_page=FALSE, vendor_id=NULL)
- **Vendor Overrides**: Vendor-specific (vendor_id set)
- Color-coded tier badges:
- Blue: Platform Marketing
- Teal: Vendor Default
- Purple: Vendor Override
## API Endpoints
### Platform Management
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/admin/platforms` | List all platforms |
| GET | `/api/v1/admin/platforms/{code}` | Get platform details |
| PUT | `/api/v1/admin/platforms/{code}` | Update platform |
| GET | `/api/v1/admin/platforms/{code}/stats` | Platform statistics |
### Content Pages
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/admin/content-pages/` | List all pages (supports `platform` filter) |
| GET | `/api/v1/admin/content-pages/platform` | Platform default pages only |
| POST | `/api/v1/admin/content-pages/platform` | Create platform page |
| POST | `/api/v1/admin/content-pages/vendor` | Create vendor page |
## Key Files
### Models
- `models/database/platform.py` - Platform model
- `models/database/vendor_platform.py` - Junction table
- `models/database/content_page.py` - Extended with platform_id
### Middleware
- `middleware/platform_context.py` - Platform detection and context
### Services
- `app/services/content_page_service.py` - Three-tier content resolution
### Routes
- `app/routes/platform_pages.py` - Platform marketing pages
- `app/routes/shop_pages.py` - Vendor shop pages with inheritance
### Admin
- `app/api/v1/admin/platforms.py` - Platform management API
- `app/templates/admin/platforms.html` - Platform admin UI
- `static/admin/js/platforms.js` - Alpine.js component
## CMS Tier Limits (Subscription-Based)
| Tier | Total Pages | Custom Pages |
|------|-------------|--------------|
| Essential | 3 | 0 |
| Professional | 10 | 5 |
| Business | 30 | 20 |
| Enterprise | Unlimited | Unlimited |
## Adding a New Platform
1. Insert platform record:
```sql
INSERT INTO platforms (code, name, description, path_prefix)
VALUES ('loyalty', 'Loyalty Program', 'Customer loyalty and rewards', '/loyalty');
```
2. Create platform-specific content pages:
```sql
INSERT INTO content_pages (platform_id, slug, title, content, is_platform_page)
VALUES (2, 'home', 'Loyalty Program', '<h1>Welcome</h1>', TRUE);
```
3. Configure routing (if using path prefix):
- Platform detected by `PlatformContextMiddleware`
- No additional route configuration needed
4. Assign vendors to platform:
```sql
INSERT INTO vendor_platforms (vendor_id, platform_id)
VALUES (1, 2);
```

View File

@@ -1,8 +1,12 @@
# Multi-Platform CMS Architecture - Implementation Plan
> **Status:** Phase 1 Complete | Phases 2-6 Pending
> **Last Updated:** 2026-01-18
> **Commit:** `408019d` (feat: add multi-platform CMS architecture Phase 1)
> **Status:** Phases 1-4 Complete | Phase 5 Pending
> **Last Updated:** 2026-01-19
> **Commits:**
> - `408019d` (Phase 1: Database & Models)
> - Phase 2: OMS Migration & Integration
> - Phase 3: Admin Interface
> - Phase 4: Vendor Dashboard
---
@@ -32,7 +36,7 @@ Transform the single-platform OMS into a multi-platform system supporting indepe
---
## Phase 2: OMS Migration & Integration 🔄 NEXT
## Phase 2: OMS Migration & Integration ✅ COMPLETE
### 2.1 Run Database Migration
@@ -109,42 +113,57 @@ Files to update:
---
## Phase 3: Admin Interface
## Phase 3: Admin Interface ✅ COMPLETE
### 3.1 Platform Management UI
New routes needed:
- [ ] `GET /admin/platforms` - List all platforms
- [ ] `GET /admin/platforms/{code}` - Platform details
- [ ] `GET /admin/platforms/{code}/pages` - Platform marketing pages
- [ ] `GET /admin/platforms/{code}/defaults` - Vendor default pages
- [ ] `POST/PUT/DELETE` endpoints for CRUD operations
| Task | File | Status |
|------|------|--------|
| Platform list page route | `app/routes/admin_pages.py` | ✅ |
| Platform detail/edit routes | `app/routes/admin_pages.py` | ✅ |
| Platform API endpoints | `app/api/v1/admin/platforms.py` | ✅ |
| Register API router | `app/api/v1/admin/__init__.py` | ✅ |
| Platforms template | `app/templates/admin/platforms.html` | ✅ |
| Platforms JS component | `static/admin/js/platforms.js` | ✅ |
| Sidebar menu item | `app/templates/admin/partials/sidebar.html` | ✅ |
### 3.2 Update Content Pages Admin
Changes to existing admin:
- [ ] Add platform dropdown filter
- [ ] Show page tier badge (Platform / Default / Override)
- [ ] Add `is_platform_page` toggle for platform-level pages
- [ ] Group pages by tier in list view
| Task | File | Status |
|------|------|--------|
| Platform dropdown filter | `app/templates/admin/content-pages.html` | ✅ |
| Four-tab tier view | `app/templates/admin/content-pages.html` | ✅ |
| Tier badges (Blue/Teal/Purple) | `static/admin/js/content-pages.js` | ✅ |
| Platform filter in JS | `static/admin/js/content-pages.js` | ✅ |
| API schema with platform fields | `app/api/v1/admin/content_pages.py` | ✅ |
| Model to_dict with platform_name | `models/database/content_page.py` | ✅ |
---
## Phase 4: Vendor Dashboard
## Phase 4: Vendor Dashboard ✅ COMPLETE
### 4.1 Content Pages List Updates
- [ ] Show source indicator: "Default" / "Override" / "Custom"
- [ ] Add "Override Default" button for vendor default pages
- [ ] Add "Revert to Default" button for vendor overrides
- [ ] Show CMS usage: "3 of 10 pages used" with progress bar
- [ ] Upgrade prompt when approaching limit
| Task | File | Status |
|------|------|--------|
| Source indicators (Default/Override/Custom) | `app/templates/vendor/content-pages.html` | ✅ Already existed |
| Override Default button | `app/templates/vendor/content-pages.html` | ✅ Already existed |
| Revert to Default (delete override) | `static/vendor/js/content-pages.js` | ✅ Already existed |
| CMS usage API endpoint | `app/api/v1/vendor/content_pages.py` | ✅ New |
| CMS usage progress bar | `app/templates/vendor/content-pages.html` | ✅ New |
| Upgrade prompt at 80% limit | `app/templates/vendor/content-pages.html` | ✅ New |
| Load usage in JS | `static/vendor/js/content-pages.js` | ✅ New |
### 4.2 Page Editor Updates
- [ ] Show banner: "This page overrides the platform default"
- [ ] "View Default" button to preview default content
- [ ] "Revert" button inline in editor
| Task | File | Status |
|------|------|--------|
| Override info banner | `app/templates/vendor/content-page-edit.html` | ✅ Already existed |
| View Default button | `app/templates/vendor/content-page-edit.html` | ✅ New |
| Default preview modal | `app/templates/vendor/content-page-edit.html` | ✅ New |
| Platform default API | `app/api/v1/vendor/content_pages.py` | ✅ New |
| Show default preview JS | `static/vendor/js/content-page-edit.js` | ✅ New |
| Revert button (styled) | `app/templates/vendor/content-page-edit.html` | ✅ New |
---
@@ -202,30 +221,32 @@ VALUES ('loyalty', 'Loyalty+', 'Customer loyalty program', 'loyalty.lu', 'loyalt
---
## Documentation Requirements
## Documentation Requirements ✅ PARTIAL
### Architecture Documentation
### Architecture Documentation ✅ COMPLETE
Create `docs/architecture/multi-platform-cms.md`:
- [ ] Three-tier content hierarchy explanation
- [ ] Platform vs Vendor Default vs Vendor Override
- [ ] Database schema diagrams
- [ ] Request flow diagrams
Created `docs/architecture/multi-platform-cms.md`:
- [x] Three-tier content hierarchy explanation
- [x] Platform vs Vendor Default vs Vendor Override
- [x] Database schema diagrams
- [x] Request flow diagrams
- [x] API endpoints reference
- [x] Key files reference
- [x] Adding new platform guide
### API Documentation
Update OpenAPI specs:
- [ ] Platform endpoints
- [ ] Content page endpoints with platform_id
- [ ] Vendor platform membership endpoints
OpenAPI specs auto-generated from FastAPI:
- [x] Platform endpoints (`/api/v1/admin/platforms`)
- [x] Content page endpoints with platform fields
- [ ] Vendor platform membership endpoints (future)
### Developer Guide
Create `docs/guides/adding-new-platform.md`:
- [ ] Step-by-step platform creation
- [ ] Required database records
- [ ] Required config files
- [ ] Required routes and templates
Included in `docs/architecture/multi-platform-cms.md`:
- [x] Step-by-step platform creation
- [x] Required database records
- [x] Key files reference
---

View File

@@ -191,6 +191,7 @@ class ContentPage(Base):
"id": self.id,
"platform_id": self.platform_id,
"platform_code": self.platform.code if self.platform else None,
"platform_name": self.platform.name if self.platform else None,
"vendor_id": self.vendor_id,
"vendor_name": self.vendor.name if self.vendor else None,
"slug": self.slug,

View File

@@ -17,12 +17,14 @@ function contentPagesManager() {
// Content pages specific state
allPages: [],
platforms: [],
loading: false,
error: null,
// Tabs and filters
activeTab: 'all', // all, platform, vendor
activeTab: 'all', // all, platform_marketing, vendor_defaults, vendor_overrides
searchQuery: '',
selectedPlatform: '', // Platform code filter
// Initialize
async init() {
@@ -35,43 +37,77 @@ function contentPagesManager() {
}
window._contentPagesInitialized = true;
contentPagesLog.group('Loading content pages');
await this.loadPages();
contentPagesLog.group('Loading data');
await Promise.all([
this.loadPages(),
this.loadPlatforms()
]);
contentPagesLog.groupEnd();
// Check for platform filter in URL
const urlParams = new URLSearchParams(window.location.search);
const platformParam = urlParams.get('platform');
if (platformParam) {
this.selectedPlatform = platformParam;
}
contentPagesLog.info('=== CONTENT PAGES MANAGER INITIALIZATION COMPLETE ===');
},
// Computed: Platform pages
// Computed: Platform Marketing pages (is_platform_page=true, vendor_id=null)
get platformMarketingPages() {
return this.allPages.filter(page => page.is_platform_page && !page.vendor_id);
},
// Computed: Vendor Default pages (is_platform_page=false, vendor_id=null)
get vendorDefaultPages() {
return this.allPages.filter(page => !page.is_platform_page && !page.vendor_id);
},
// Computed: Vendor Override pages (vendor_id is set)
get vendorOverridePages() {
return this.allPages.filter(page => page.vendor_id);
},
// Legacy computed (for backward compatibility)
get platformPages() {
return this.allPages.filter(page => page.is_platform_default);
return [...this.platformMarketingPages, ...this.vendorDefaultPages];
},
// Computed: Vendor pages
get vendorPages() {
return this.allPages.filter(page => page.is_vendor_override);
return this.vendorOverridePages;
},
// Computed: Filtered pages based on active tab and search
// Computed: Filtered pages based on active tab, platform, and search
get filteredPages() {
let pages = [];
// Filter by tab
if (this.activeTab === 'platform') {
pages = this.platformPages;
} else if (this.activeTab === 'vendor') {
pages = this.vendorPages;
// Filter by tab (three-tier system)
if (this.activeTab === 'platform_marketing') {
pages = this.platformMarketingPages;
} else if (this.activeTab === 'vendor_defaults') {
pages = this.vendorDefaultPages;
} else if (this.activeTab === 'vendor_overrides') {
pages = this.vendorOverridePages;
} else {
pages = this.allPages;
}
// Filter by selected platform
if (this.selectedPlatform) {
pages = pages.filter(page =>
page.platform_code === this.selectedPlatform
);
}
// Filter by search query
if (this.searchQuery) {
const query = this.searchQuery.toLowerCase();
pages = pages.filter(page =>
page.title.toLowerCase().includes(query) ||
page.slug.toLowerCase().includes(query) ||
(page.vendor_name && page.vendor_name.toLowerCase().includes(query))
(page.vendor_name && page.vendor_name.toLowerCase().includes(query)) ||
(page.platform_name && page.platform_name.toLowerCase().includes(query))
);
}
@@ -113,6 +149,44 @@ function contentPagesManager() {
}
},
// Load platforms for filter dropdown
async loadPlatforms() {
try {
contentPagesLog.info('Fetching platforms...');
const response = await apiClient.get('/admin/platforms');
this.platforms = response.platforms || [];
contentPagesLog.info(`Loaded ${this.platforms.length} platforms`);
} catch (err) {
contentPagesLog.error('Error loading platforms:', err);
// Non-critical - don't set error state
}
},
// Get page tier label (three-tier system)
getPageTierLabel(page) {
if (page.vendor_id) {
return 'Vendor Override';
} else if (page.is_platform_page) {
return 'Platform Marketing';
} else {
return 'Vendor Default';
}
},
// Get page tier CSS class (three-tier system)
getPageTierClass(page) {
if (page.vendor_id) {
// Vendor Override - purple
return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200';
} else if (page.is_platform_page) {
// Platform Marketing - blue
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
} else {
// Vendor Default - teal
return 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200';
}
},
// Delete a page
async deletePage(page) {
if (!confirm(`Are you sure you want to delete "${page.title}"?`)) {

View File

@@ -0,0 +1,58 @@
/**
* Platforms Manager - Alpine.js Component
*
* Handles platform listing and management for multi-platform CMS.
*/
function platformsManager() {
return {
// State
platforms: [],
loading: true,
error: null,
// Lifecycle
async init() {
this.currentPage = "platforms";
await this.loadPlatforms();
},
// API Methods
async loadPlatforms() {
this.loading = true;
this.error = null;
try {
const response = await apiClient.get("/admin/platforms");
this.platforms = response.platforms || [];
console.log(`[PLATFORMS] Loaded ${this.platforms.length} platforms`);
} catch (err) {
console.error("[PLATFORMS] Error loading platforms:", err);
this.error = err.message || "Failed to load platforms";
} finally {
this.loading = false;
}
},
// Helper Methods
getPlatformIcon(code) {
const icons = {
oms: "clipboard-list",
loyalty: "star",
sitebuilder: "template",
default: "globe-alt",
};
return icons[code] || icons.default;
},
formatDate(dateString) {
if (!dateString) return "—";
const date = new Date(dateString);
return date.toLocaleDateString("fr-LU", {
year: "numeric",
month: "short",
day: "numeric",
});
},
};
}

View File

@@ -35,6 +35,11 @@ function vendorContentPageEditor(pageId) {
error: null,
successMessage: null,
// Default preview modal state
showingDefaultPreview: false,
loadingDefault: false,
defaultContent: null,
// Initialize
async init() {
// Prevent multiple initializations
@@ -179,6 +184,31 @@ function vendorContentPageEditor(pageId) {
}
},
// Show default content preview
async showDefaultPreview() {
this.showingDefaultPreview = true;
this.loadingDefault = true;
this.defaultContent = null;
try {
contentPageEditLog.info('Loading platform default for slug:', this.form.slug);
const response = await apiClient.get(`/vendor/content-pages/platform-default/${this.form.slug}`);
this.defaultContent = response.data || response;
contentPageEditLog.info('Default content loaded');
} catch (err) {
contentPageEditLog.error('Error loading default content:', err);
this.defaultContent = {
title: 'Error',
content: `<p class="text-red-500">Failed to load platform default: ${err.message}</p>`
};
} finally {
this.loadingDefault = false;
}
},
// Delete page (revert to default for overrides)
async deletePage() {
const message = this.isOverride

View File

@@ -24,6 +24,7 @@ function vendorContentPagesManager() {
platformPages: [], // Platform default pages
customPages: [], // Vendor's own pages (overrides + custom)
overrideMap: {}, // Map of slug -> page id for quick lookup
cmsUsage: null, // CMS usage statistics
// Initialize
async init() {
@@ -43,7 +44,10 @@ function vendorContentPagesManager() {
await parentInit.call(this);
}
await this.loadPages();
await Promise.all([
this.loadPages(),
this.loadCmsUsage()
]);
contentPagesLog.info('=== VENDOR CONTENT PAGES MANAGER INITIALIZATION COMPLETE ===');
} catch (error) {
@@ -92,6 +96,19 @@ function vendorContentPagesManager() {
}
},
// Load CMS usage statistics
async loadCmsUsage() {
try {
contentPagesLog.info('Loading CMS usage...');
const response = await apiClient.get('/vendor/content-pages/usage');
this.cmsUsage = response.data || response;
contentPagesLog.info('CMS usage loaded:', this.cmsUsage);
} catch (err) {
contentPagesLog.error('Error loading CMS usage:', err);
// Non-critical - don't set error state
}
},
// Check if vendor has overridden a platform page
hasOverride(slug) {
return slug in this.overrideMap;