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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
391
app/api/v1/admin/platforms.py
Normal file
391
app/api/v1/admin/platforms.py
Normal 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,
|
||||
)
|
||||
114
app/api/v1/vendor/content_pages.py
vendored
114
app/api/v1/vendor/content_pages.py
vendored
@@ -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()
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
154
app/templates/admin/platforms.html
Normal file
154
app/templates/admin/platforms.html
Normal 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 %}
|
||||
75
app/templates/vendor/content-page-edit.html
vendored
75
app/templates/vendor/content-page-edit.html
vendored
@@ -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>
|
||||
|
||||
44
app/templates/vendor/content-pages.html
vendored
44
app/templates/vendor/content-pages.html
vendored
@@ -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">
|
||||
|
||||
239
docs/architecture/multi-platform-cms.md
Normal file
239
docs/architecture/multi-platform-cms.md
Normal 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);
|
||||
```
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"?`)) {
|
||||
|
||||
58
static/admin/js/platforms.js
Normal file
58
static/admin/js/platforms.js
Normal 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",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
30
static/vendor/js/content-page-edit.js
vendored
30
static/vendor/js/content-page-edit.js
vendored
@@ -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
|
||||
|
||||
19
static/vendor/js/content-pages.js
vendored
19
static/vendor/js/content-pages.js
vendored
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user