feat: complete CMS as fully autonomous self-contained module

Transform CMS from a thin wrapper into a fully self-contained module with
all code living within app/modules/cms/:

Module Structure:
- models/: ContentPage model (canonical location with dynamic discovery)
- schemas/: Pydantic schemas for API validation
- services/: ContentPageService business logic
- exceptions/: Module-specific exceptions
- routes/api/: REST API endpoints (admin, vendor, shop)
- routes/pages/: HTML page routes (admin, vendor)
- templates/cms/: Jinja2 templates (namespaced)
- static/: JavaScript files (admin/vendor)
- locales/: i18n translations (en, fr, de, lb)

Key Changes:
- Move ContentPage model to module with dynamic model discovery
- Create Pydantic schemas package for request/response validation
- Extract API routes from app/api/v1/*/ to module
- Extract page routes from admin_pages.py/vendor_pages.py to module
- Move static JS files to module with dedicated mount point
- Update templates to use cms_static for module assets
- Add module static file mounting in main.py
- Delete old scattered files (no shims - hard errors on old imports)

This establishes the pattern for migrating other modules to be
fully autonomous and independently deployable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 22:42:46 +01:00
parent 8ff9c39845
commit ec4ec045fc
40 changed files with 878 additions and 695 deletions

View File

@@ -48,7 +48,7 @@ from . import (
background_tasks,
code_quality,
companies,
content_pages,
# content_pages - moved to app.modules.cms.routes.api.admin
customers,
dashboard,
email_templates,
@@ -89,6 +89,9 @@ from app.modules.orders.routes.admin import admin_exceptions_router as orders_ex
from app.modules.marketplace.routes.admin import admin_router as marketplace_admin_router
from app.modules.marketplace.routes.admin import admin_letzshop_router as letzshop_admin_router
# CMS module router
from app.modules.cms.routes.api.admin import router as cms_admin_router
# Create admin router
router = APIRouter()
@@ -117,10 +120,11 @@ router.include_router(vendor_domains.router, tags=["admin-vendor-domains"])
# Include vendor themes management endpoints
router.include_router(vendor_themes.router, tags=["admin-vendor-themes"])
# Include content pages management endpoints
# Include CMS module router (self-contained module)
router.include_router(
content_pages.router, prefix="/content-pages", tags=["admin-content-pages"]
cms_admin_router, prefix="/content-pages", tags=["admin-content-pages"]
)
# Legacy: content_pages.router moved to app.modules.cms.routes.api.admin
# Include platforms management endpoints (multi-platform CMS)
router.include_router(platforms.router, tags=["admin-platforms"])

View File

@@ -21,7 +21,10 @@ Authentication:
from fastapi import APIRouter
# Import shop routers
from . import addresses, auth, carts, content_pages, messages, orders, products, profile
from . import addresses, auth, carts, messages, orders, products, profile
# CMS module router
from app.modules.cms.routes.api.shop import router as cms_shop_router
# Create shop router
router = APIRouter()
@@ -51,9 +54,10 @@ router.include_router(messages.router, tags=["shop-messages"])
# Profile (authenticated)
router.include_router(profile.router, tags=["shop-profile"])
# Content pages (public)
# CMS module router (self-contained module)
router.include_router(
content_pages.router, prefix="/content-pages", tags=["shop-content-pages"]
cms_shop_router, prefix="/content-pages", tags=["shop-content-pages"]
)
# Legacy: content_pages.router moved to app.modules.cms.routes.api.shop
__all__ = ["router"]

View File

@@ -34,7 +34,7 @@ from . import (
analytics,
auth,
billing,
content_pages,
# content_pages - moved to app.modules.cms.routes.api.vendor
customers,
dashboard,
email_settings,
@@ -68,6 +68,9 @@ from app.modules.orders.routes.vendor import vendor_exceptions_router as orders_
from app.modules.marketplace.routes.vendor import vendor_router as marketplace_vendor_router
from app.modules.marketplace.routes.vendor import vendor_letzshop_router as letzshop_vendor_router
# CMS module router
from app.modules.cms.routes.api.vendor import router as cms_vendor_router
# Create vendor router
router = APIRouter()
@@ -128,8 +131,9 @@ router.include_router(billing_vendor_router, tags=["vendor-billing"])
router.include_router(features.router, tags=["vendor-features"])
router.include_router(usage.router, tags=["vendor-usage"])
# Content pages management
router.include_router(content_pages.router, tags=["vendor-content-pages"])
# CMS module router (self-contained module)
router.include_router(cms_vendor_router, tags=["vendor-content-pages"])
# Legacy: content_pages.router moved to app.modules.cms.routes.api.vendor
# Vendor info endpoint - MUST BE LAST! Has catch-all GET /{vendor_code}
router.include_router(info.router, tags=["vendor-info"])

View File

@@ -1,43 +0,0 @@
# app/exceptions/content_page.py
"""
DEPRECATED: This module has moved to app.modules.cms.exceptions
Please update your imports:
# Old (deprecated):
from app.exceptions.content_page import ContentPageNotFoundException
# New (preferred):
from app.modules.cms.exceptions import ContentPageNotFoundException
This shim re-exports from the new location for backwards compatibility.
"""
import warnings
warnings.warn(
"Import from app.modules.cms.exceptions instead of "
"app.exceptions.content_page. This shim will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
# Re-export everything from the new location
from app.modules.cms.exceptions import ( # noqa: E402, F401
ContentPageAlreadyExistsException,
ContentPageNotFoundException,
ContentPageNotPublishedException,
ContentPageSlugReservedException,
ContentPageValidationException,
UnauthorizedContentPageAccessException,
VendorNotAssociatedException,
)
__all__ = [
"ContentPageNotFoundException",
"ContentPageAlreadyExistsException",
"ContentPageSlugReservedException",
"ContentPageNotPublishedException",
"UnauthorizedContentPageAccessException",
"VendorNotAssociatedException",
"ContentPageValidationException",
]

View File

@@ -22,14 +22,9 @@ Menu Items:
- Vendor: content-pages, media
Usage:
# Preferred: Import from module directly
from app.modules.cms.services import content_page_service
from app.modules.cms.models import ContentPage
from app.modules.cms.exceptions import ContentPageNotFoundException
# Legacy: Still works via re-export shims (deprecated)
from app.services.content_page_service import content_page_service
from models.database.content_page import ContentPage
"""
from app.modules.cms.definition import cms_module

View File

@@ -9,9 +9,7 @@ This is a self-contained module with:
- Services: app.modules.cms.services
- Models: app.modules.cms.models
- Exceptions: app.modules.cms.exceptions
Templates remain in core (app/templates/admin/) for now due to
admin/base.html inheritance dependency.
- Templates: app.modules.cms.templates (namespaced as cms/)
"""
from app.modules.base import ModuleDefinition
@@ -61,8 +59,8 @@ cms_module = ModuleDefinition(
services_path="app.modules.cms.services",
models_path="app.modules.cms.models",
exceptions_path="app.modules.cms.exceptions",
# Templates remain in core for now (admin/content-pages*.html)
templates_path=None,
# Module templates (namespaced as cms/admin/*.html and cms/vendor/*.html)
templates_path="templates",
# Module-specific translations (accessible via cms.* keys)
locales_path="locales",
)

View File

@@ -2,19 +2,20 @@
"""
CMS module database models.
This package re-exports the ContentPage model from its canonical location
in models.database. SQLAlchemy models must remain in a single location to
avoid circular imports at startup time.
This is the canonical location for CMS models. Module models are automatically
discovered and registered with SQLAlchemy's Base.metadata at startup.
Usage:
from app.modules.cms.models import ContentPage
The canonical model is at: models.database.content_page.ContentPage
from app.modules.cms.models import ContentPage, MediaFile, ProductMedia
"""
# Import from canonical location to avoid circular imports
from models.database.content_page import ContentPage
from app.modules.cms.models.content_page import ContentPage
# Media models remain in core for now (used by multiple modules)
from models.database.media import MediaFile, ProductMedia
__all__ = [
"ContentPage",
"MediaFile",
"ProductMedia",
]

View File

@@ -0,0 +1,232 @@
# app/modules/cms/models/content_page.py
"""
Content Page Model
Manages static content pages (About, FAQ, Contact, Shipping, Returns, etc.)
with a three-tier hierarchy:
1. Platform Marketing Pages (is_platform_page=True, vendor_id=NULL)
- Homepage, pricing, platform about, contact
- Describes the platform/business offering itself
2. Vendor Default Pages (is_platform_page=False, vendor_id=NULL)
- Generic storefront pages that all vendors inherit
- About Us, Shipping Policy, Return Policy, etc.
3. Vendor Override/Custom Pages (is_platform_page=False, vendor_id=set)
- Vendor-specific customizations
- Either overrides a default or is a completely custom page
Features:
- Multi-platform support (each platform has its own pages)
- Three-tier content resolution
- Rich text content (HTML/Markdown)
- SEO metadata
- Published/Draft status
- Navigation placement (header, footer, legal)
"""
from datetime import UTC, datetime
from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
class ContentPage(Base):
"""
Content pages with three-tier hierarchy.
Page Types:
1. Platform Marketing Page: platform_id=X, vendor_id=NULL, is_platform_page=True
- Platform's own pages (homepage, pricing, about)
2. Vendor Default Page: platform_id=X, vendor_id=NULL, is_platform_page=False
- Fallback pages for vendors who haven't customized
3. Vendor Override/Custom: platform_id=X, vendor_id=Y, is_platform_page=False
- Vendor-specific content
Resolution Logic:
1. Check for vendor override (platform_id + vendor_id + slug)
2. Fall back to vendor default (platform_id + vendor_id=NULL + is_platform_page=False)
3. If neither exists, return 404
"""
__tablename__ = "content_pages"
id = Column(Integer, primary_key=True, index=True)
# Platform association (REQUIRED - determines which platform this page belongs to)
platform_id = Column(
Integer,
ForeignKey("platforms.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="Platform this page belongs to",
)
# Vendor association (NULL = platform page or vendor default)
vendor_id = Column(
Integer,
ForeignKey("vendors.id", ondelete="CASCADE"),
nullable=True,
index=True,
comment="Vendor this page belongs to (NULL for platform/default pages)",
)
# Distinguish platform marketing pages from vendor defaults
is_platform_page = Column(
Boolean,
default=False,
nullable=False,
comment="True = platform marketing page (homepage, pricing); False = vendor default or override",
)
# Page identification
slug = Column(
String(100), nullable=False, index=True
) # about, faq, contact, shipping, returns, etc.
title = Column(String(200), nullable=False)
# Content
content = Column(Text, nullable=False) # HTML or Markdown
content_format = Column(String(20), default="html") # html, markdown
# Template selection (for landing pages)
# Options: 'default', 'minimal', 'modern', 'full'
# Only used for landing pages (slug='landing' or 'home')
template = Column(String(50), default="default", nullable=False)
# Homepage sections (structured JSON for section-based editing)
# Only used for homepage (slug='home'). Contains hero, features, pricing, cta sections
# with multi-language support via TranslatableText pattern
sections = Column(
JSON,
nullable=True,
default=None,
comment="Structured homepage sections (hero, features, pricing, cta) with i18n",
)
# SEO
meta_description = Column(String(300), nullable=True)
meta_keywords = Column(String(300), nullable=True)
# Publishing
is_published = Column(Boolean, default=False, nullable=False)
published_at = Column(DateTime(timezone=True), nullable=True)
# Ordering (for menus, footers)
display_order = Column(Integer, default=0, nullable=False)
show_in_footer = Column(Boolean, default=True, nullable=False)
show_in_header = Column(Boolean, default=False, nullable=False)
show_in_legal = Column(Boolean, default=False, nullable=False) # Bottom bar with copyright
# Timestamps
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
nullable=False,
)
# Author tracking (admin or vendor user who created/updated)
created_by = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
updated_by = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# Relationships
platform = relationship("Platform", back_populates="content_pages")
vendor = relationship("Vendor", back_populates="content_pages")
creator = relationship("User", foreign_keys=[created_by])
updater = relationship("User", foreign_keys=[updated_by])
# Constraints
__table_args__ = (
# Unique combination: platform + vendor + slug
# Platform pages: platform_id + vendor_id=NULL + is_platform_page=True
# Vendor defaults: platform_id + vendor_id=NULL + is_platform_page=False
# Vendor overrides: platform_id + vendor_id + slug
UniqueConstraint("platform_id", "vendor_id", "slug", name="uq_platform_vendor_slug"),
# Indexes for performance
Index("idx_platform_vendor_published", "platform_id", "vendor_id", "is_published"),
Index("idx_platform_slug_published", "platform_id", "slug", "is_published"),
Index("idx_platform_page_type", "platform_id", "is_platform_page"),
)
def __repr__(self):
vendor_name = self.vendor.name if self.vendor else "PLATFORM"
return f"<ContentPage(id={self.id}, vendor={vendor_name}, slug={self.slug}, title={self.title})>"
@property
def is_vendor_default(self):
"""Check if this is a vendor default page (fallback for all vendors)."""
return self.vendor_id is None and not self.is_platform_page
@property
def is_vendor_override(self):
"""Check if this is a vendor-specific override or custom page."""
return self.vendor_id is not None
@property
def page_tier(self) -> str:
"""Get the tier level of this page for display purposes."""
if self.is_platform_page:
return "platform"
elif self.vendor_id is None:
return "vendor_default"
else:
return "vendor_override"
def to_dict(self):
"""Convert to dictionary for API responses."""
return {
"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,
"title": self.title,
"content": self.content,
"content_format": self.content_format,
"template": self.template,
"sections": self.sections,
"meta_description": self.meta_description,
"meta_keywords": self.meta_keywords,
"is_published": self.is_published,
"published_at": (
self.published_at.isoformat() if self.published_at else None
),
"display_order": self.display_order,
"show_in_footer": self.show_in_footer or False,
"show_in_header": self.show_in_header or False,
"show_in_legal": self.show_in_legal or False,
"is_platform_page": self.is_platform_page,
"is_vendor_default": self.is_vendor_default,
"is_vendor_override": self.is_vendor_override,
"page_tier": self.page_tier,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"created_by": self.created_by,
"updated_by": self.updated_by,
}

View File

@@ -0,0 +1,15 @@
# app/modules/cms/routes/api/__init__.py
"""
CMS module API routes.
Provides REST API endpoints for content page management:
- Admin API: Full CRUD for platform administrators
- Vendor API: Vendor-scoped CRUD with ownership checks
- Shop API: Public read-only access for storefronts
"""
from app.modules.cms.routes.api.admin import router as admin_router
from app.modules.cms.routes.api.vendor import router as vendor_router
from app.modules.cms.routes.api.shop import router as shop_router
__all__ = ["admin_router", "vendor_router", "shop_router"]

View File

@@ -1,4 +1,4 @@
# app/api/v1/admin/content_pages.py
# app/modules/cms/routes/api/admin.py
"""
Admin Content Pages API
@@ -11,106 +11,24 @@ Platform administrators can:
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, get_db
from app.exceptions import ValidationException
from app.services.content_page_service import content_page_service
from app.modules.cms.schemas import (
ContentPageCreate,
ContentPageUpdate,
ContentPageResponse,
HomepageSectionsResponse,
SectionUpdateResponse,
)
from app.modules.cms.services import content_page_service
from models.database.user import User
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================================================
# REQUEST/RESPONSE SCHEMAS
# ============================================================================
class ContentPageCreate(BaseModel):
"""Schema for creating a content page."""
slug: str = Field(
...,
max_length=100,
description="URL-safe identifier (about, faq, contact, etc.)",
)
title: str = Field(..., max_length=200, description="Page title")
content: str = Field(..., description="HTML or Markdown content")
content_format: str = Field(
default="html", description="Content format: html or markdown"
)
template: str = Field(
default="default",
max_length=50,
description="Template name (default, minimal, modern)",
)
meta_description: str | None = Field(
None, max_length=300, description="SEO meta description"
)
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
is_published: bool = Field(default=False, description="Publish immediately")
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
show_in_header: bool = Field(default=False, description="Show in header navigation")
show_in_legal: bool = Field(
default=False, description="Show in legal/bottom bar (next to copyright)"
)
display_order: int = Field(default=0, description="Display order (lower = first)")
vendor_id: int | None = Field(
None, description="Vendor ID (None for platform default)"
)
class ContentPageUpdate(BaseModel):
"""Schema for updating a content page."""
title: str | None = Field(None, max_length=200)
content: str | None = None
content_format: str | None = None
template: str | None = Field(None, max_length=50)
meta_description: str | None = Field(None, max_length=300)
meta_keywords: str | None = Field(None, max_length=300)
is_published: bool | None = None
show_in_footer: bool | None = None
show_in_header: bool | None = None
show_in_legal: bool | None = None
display_order: int | None = None
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
title: str
content: str
content_format: str
template: str | None = None
meta_description: str | None
meta_keywords: str | None
is_published: bool
published_at: str | None
display_order: int
show_in_footer: bool
show_in_header: bool
show_in_legal: 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
updated_by: int | None
# ============================================================================
# PLATFORM DEFAULT PAGES (vendor_id=NULL)
# ============================================================================
@@ -291,21 +209,6 @@ def delete_page(
# ============================================================================
class HomepageSectionsResponse(BaseModel):
"""Response containing homepage sections with platform language info."""
sections: dict | None = None
supported_languages: list[str] = Field(default_factory=lambda: ["fr", "de", "en"])
default_language: str = "fr"
class SectionUpdateResponse(BaseModel):
"""Response after updating sections."""
message: str
sections: dict | None = None
@router.get("/{page_id}/sections", response_model=HomepageSectionsResponse)
def get_page_sections(
page_id: int,

View File

@@ -1,4 +1,4 @@
# app/api/v1/shop/content_pages.py
# app/modules/cms/routes/api/shop.py
"""
Shop Content Pages API (Public)
@@ -9,49 +9,25 @@ No authentication required.
import logging
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.content_page_service import content_page_service
from app.modules.cms.schemas import (
PublicContentPageResponse,
ContentPageListItem,
)
from app.modules.cms.services import content_page_service
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================================================
# RESPONSE SCHEMAS
# ============================================================================
class PublicContentPageResponse(BaseModel):
"""Public content page response (no internal IDs)."""
slug: str
title: str
content: str
content_format: str
meta_description: str | None
meta_keywords: str | None
published_at: str | None
class ContentPageListItem(BaseModel):
"""Content page list item for navigation."""
slug: str
title: str
show_in_footer: bool
show_in_header: bool
display_order: int
# ============================================================================
# PUBLIC ENDPOINTS
# ============================================================================
@router.get("/navigation", response_model=list[ContentPageListItem]) # public
@router.get("/navigation", response_model=list[ContentPageListItem])
def get_navigation_pages(request: Request, db: Session = Depends(get_db)):
"""
Get list of content pages for navigation (footer/header).

View File

@@ -1,4 +1,4 @@
# app/api/v1/vendor/content_pages.py
# app/modules/cms/routes/api/vendor.py
"""
Vendor Content Pages API
@@ -14,106 +14,26 @@ Vendors can:
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, get_db
from app.services.content_page_service import content_page_service
from app.modules.cms.exceptions import ContentPageNotFoundException
from app.modules.cms.schemas import (
VendorContentPageCreate,
VendorContentPageUpdate,
ContentPageResponse,
CMSUsageResponse,
)
from app.modules.cms.services import content_page_service
from app.services.vendor_service import VendorService
from models.database.user import User
vendor_service = VendorService()
router = APIRouter(prefix="/content-pages")
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================================================
# REQUEST/RESPONSE SCHEMAS
# ============================================================================
class VendorContentPageCreate(BaseModel):
"""Schema for creating a vendor content page."""
slug: str = Field(
...,
max_length=100,
description="URL-safe identifier (about, faq, contact, etc.)",
)
title: str = Field(..., max_length=200, description="Page title")
content: str = Field(..., description="HTML or Markdown content")
content_format: str = Field(
default="html", description="Content format: html or markdown"
)
meta_description: str | None = Field(
None, max_length=300, description="SEO meta description"
)
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
is_published: bool = Field(default=False, description="Publish immediately")
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
show_in_header: bool = Field(default=False, description="Show in header navigation")
show_in_legal: bool = Field(
default=False, description="Show in legal/bottom bar (next to copyright)"
)
display_order: int = Field(default=0, description="Display order (lower = first)")
class VendorContentPageUpdate(BaseModel):
"""Schema for updating a vendor content page."""
title: str | None = Field(None, max_length=200)
content: str | None = None
content_format: str | None = None
meta_description: str | None = Field(None, max_length=300)
meta_keywords: str | None = Field(None, max_length=300)
is_published: bool | None = None
show_in_footer: bool | None = None
show_in_header: bool | None = None
show_in_legal: bool | None = None
display_order: int | None = None
class ContentPageResponse(BaseModel):
"""Schema for content page response."""
id: int
vendor_id: int | None
vendor_name: str | None
slug: str
title: str
content: str
content_format: str
meta_description: str | None
meta_keywords: str | None
is_published: bool
published_at: str | None
display_order: int
show_in_footer: bool
show_in_header: bool
show_in_legal: bool
is_platform_default: bool
is_vendor_override: bool
created_at: str
updated_at: str
created_by: int | None
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
# ============================================================================
@@ -155,6 +75,96 @@ def list_vendor_overrides(
return [page.to_dict() for page in pages]
@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.
"""
vendor = vendor_service.get_vendor_by_id_optional(db, current_user.token_vendor_id)
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.
"""
# Get vendor's platform
vendor = vendor_service.get_vendor_by_id_optional(db, current_user.token_vendor_id)
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:
raise ContentPageNotFoundException(slug)
return page.to_dict()
@router.get("/{slug}", response_model=ContentPageResponse)
def get_page(
slug: str,
@@ -258,99 +268,3 @@ 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.
"""
vendor = vendor_service.get_vendor_by_id_optional(db, current_user.token_vendor_id)
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.
"""
# Get vendor's platform
vendor = vendor_service.get_vendor_by_id_optional(db, current_user.token_vendor_id)
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

@@ -0,0 +1,13 @@
# app/modules/cms/routes/pages/__init__.py
"""
CMS module page routes (HTML rendering).
Provides Jinja2 template rendering for content page management:
- Admin pages: Platform content page management
- Vendor pages: Vendor content page management and CMS rendering
"""
from app.modules.cms.routes.pages.admin import router as admin_router
from app.modules.cms.routes.pages.vendor import router as vendor_router
__all__ = ["admin_router", "vendor_router"]

View File

@@ -0,0 +1,104 @@
# app/modules/cms/routes/pages/admin.py
"""
CMS Admin Page Routes (HTML rendering).
Admin pages for managing platform and vendor content pages.
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_menu_access
from app.templates_config import templates
from models.database.admin_menu_config import FrontendType
from models.database.user import User
router = APIRouter()
# ============================================================================
# CONTENT PAGES MANAGEMENT
# ============================================================================
@router.get("/platform-homepage", include_in_schema=False)
async def admin_platform_homepage_manager(
request: Request,
current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Deprecated: Redirects to platforms page.
Platform homepages are now managed via:
- /admin/platforms → Select platform → Homepage button
- Or directly: /admin/content-pages?platform_code={code}&slug=home
"""
return RedirectResponse(url="/admin/platforms", status_code=302)
@router.get("/content-pages", response_class=HTMLResponse, include_in_schema=False)
async def admin_content_pages_list(
request: Request,
current_user: User = Depends(require_menu_access("content-pages", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render content pages list.
Shows all platform defaults and vendor overrides with filtering.
"""
return templates.TemplateResponse(
"cms/admin/content-pages.html",
{
"request": request,
"user": current_user,
},
)
@router.get(
"/content-pages/create", response_class=HTMLResponse, include_in_schema=False
)
async def admin_content_page_create(
request: Request,
current_user: User = Depends(require_menu_access("content-pages", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render create content page form.
Allows creating new platform defaults or vendor-specific pages.
"""
return templates.TemplateResponse(
"cms/admin/content-page-edit.html",
{
"request": request,
"user": current_user,
"page_id": None, # Indicates this is a create operation
},
)
@router.get(
"/content-pages/{page_id}/edit",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_content_page_edit(
request: Request,
page_id: int = Path(..., description="Content page ID"),
current_user: User = Depends(require_menu_access("content-pages", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render edit content page form.
Allows editing existing platform or vendor content pages.
"""
return templates.TemplateResponse(
"cms/admin/content-page-edit.html",
{
"request": request,
"user": current_user,
"page_id": page_id,
},
)

View File

@@ -0,0 +1,225 @@
# app/modules/cms/routes/pages/vendor.py
"""
CMS Vendor Page Routes (HTML rendering).
Vendor pages for managing content pages and rendering CMS content.
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
from app.modules.cms.services import content_page_service
from app.services.platform_settings_service import platform_settings_service
from app.templates_config import templates
from models.database.user import User
from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================================
# HELPER: Build Vendor Dashboard Context
# ============================================================================
def get_vendor_context(
request: Request,
db: Session,
current_user: User,
vendor_code: str,
**extra_context,
) -> dict:
"""
Build template context for vendor dashboard pages.
Resolves locale/currency using the platform settings service with
vendor override support.
"""
# Load vendor from database
vendor = db.query(Vendor).filter(Vendor.subdomain == vendor_code).first()
# Get platform defaults
platform_config = platform_settings_service.get_storefront_config(db)
# Resolve with vendor override
storefront_locale = platform_config["locale"]
storefront_currency = platform_config["currency"]
if vendor and vendor.storefront_locale:
storefront_locale = vendor.storefront_locale
context = {
"request": request,
"user": current_user,
"vendor": vendor,
"vendor_code": vendor_code,
"storefront_locale": storefront_locale,
"storefront_currency": storefront_currency,
"dashboard_language": vendor.dashboard_language if vendor else "en",
}
# Add any extra context
if extra_context:
context.update(extra_context)
return context
# ============================================================================
# CONTENT PAGES MANAGEMENT
# ============================================================================
@router.get(
"/{vendor_code}/content-pages", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_content_pages_list(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render content pages management page.
Shows platform defaults (can be overridden) and vendor custom pages.
"""
return templates.TemplateResponse(
"cms/vendor/content-pages.html",
get_vendor_context(request, db, current_user, vendor_code),
)
@router.get(
"/{vendor_code}/content-pages/create",
response_class=HTMLResponse,
include_in_schema=False,
)
async def vendor_content_page_create(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render content page creation form.
"""
return templates.TemplateResponse(
"cms/vendor/content-page-edit.html",
get_vendor_context(request, db, current_user, vendor_code, page_id=None),
)
@router.get(
"/{vendor_code}/content-pages/{page_id}/edit",
response_class=HTMLResponse,
include_in_schema=False,
)
async def vendor_content_page_edit(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
page_id: int = Path(..., description="Content page ID"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render content page edit form.
"""
return templates.TemplateResponse(
"cms/vendor/content-page-edit.html",
get_vendor_context(request, db, current_user, vendor_code, page_id=page_id),
)
# ============================================================================
# DYNAMIC CONTENT PAGES (CMS) - Public Shop Display
# ============================================================================
@router.get(
"/{vendor_code}/{slug}", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_content_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
slug: str = Path(..., description="Content page slug"),
db: Session = Depends(get_db),
):
"""
Generic content page handler for vendor shop (CMS).
Handles dynamic content pages like:
- /vendors/wizamart/about, /vendors/wizamart/faq, /vendors/wizamart/contact, etc.
Features:
- Two-tier system: Vendor overrides take priority, fallback to platform defaults
- Only shows published pages
- Returns 404 if page not found or unpublished
NOTE: This is a catch-all route and must be registered LAST to avoid
shadowing other specific routes.
"""
logger.debug(
"[CMS] vendor_content_page REACHED",
extra={
"path": request.url.path,
"vendor_code": vendor_code,
"slug": slug,
"vendor": getattr(request.state, "vendor", "NOT SET"),
"context": getattr(request.state, "context_type", "NOT SET"),
},
)
vendor = getattr(request.state, "vendor", None)
vendor_id = vendor.id if vendor else None
# Load content page from database (vendor override → platform default)
page = content_page_service.get_page_for_vendor(
db, slug=slug, vendor_id=vendor_id, include_unpublished=False
)
if not page:
logger.info(
f"[CMS] Content page not found: {slug}",
extra={
"slug": slug,
"vendor_code": vendor_code,
"vendor_id": vendor_id,
},
)
raise HTTPException(status_code=404, detail="Page not found")
logger.info(
f"[CMS] Rendering page: {page.title}",
extra={
"slug": slug,
"page_id": page.id,
"is_vendor_override": page.vendor_id is not None,
"vendor_id": vendor_id,
},
)
# Resolve locale for shop template
platform_config = platform_settings_service.get_storefront_config(db)
storefront_locale = platform_config["locale"]
storefront_currency = platform_config["currency"]
if vendor and vendor.storefront_locale:
storefront_locale = vendor.storefront_locale
return templates.TemplateResponse(
"shop/content-page.html",
{
"request": request,
"page": page,
"vendor": vendor,
"vendor_code": vendor_code,
"storefront_locale": storefront_locale,
"storefront_currency": storefront_currency,
},
)

View File

@@ -0,0 +1,36 @@
# app/modules/cms/schemas/__init__.py
"""
CMS module Pydantic schemas for API request/response validation.
"""
from app.modules.cms.schemas.content_page import (
# Admin schemas
ContentPageCreate,
ContentPageUpdate,
ContentPageResponse,
HomepageSectionsResponse,
SectionUpdateResponse,
# Vendor schemas
VendorContentPageCreate,
VendorContentPageUpdate,
CMSUsageResponse,
# Public/Shop schemas
PublicContentPageResponse,
ContentPageListItem,
)
__all__ = [
# Admin
"ContentPageCreate",
"ContentPageUpdate",
"ContentPageResponse",
"HomepageSectionsResponse",
"SectionUpdateResponse",
# Vendor
"VendorContentPageCreate",
"VendorContentPageUpdate",
"CMSUsageResponse",
# Public
"PublicContentPageResponse",
"ContentPageListItem",
]

View File

@@ -0,0 +1,201 @@
# app/modules/cms/schemas/content_page.py
"""
Content Page Pydantic schemas for API request/response validation.
Schemas are organized by context:
- Admin: Full CRUD with platform-level access
- Vendor: Vendor-scoped CRUD with usage limits
- Public/Shop: Read-only public access
"""
from pydantic import BaseModel, Field
# ============================================================================
# ADMIN SCHEMAS
# ============================================================================
class ContentPageCreate(BaseModel):
"""Schema for creating a content page (admin)."""
slug: str = Field(
...,
max_length=100,
description="URL-safe identifier (about, faq, contact, etc.)",
)
title: str = Field(..., max_length=200, description="Page title")
content: str = Field(..., description="HTML or Markdown content")
content_format: str = Field(
default="html", description="Content format: html or markdown"
)
template: str = Field(
default="default",
max_length=50,
description="Template name (default, minimal, modern)",
)
meta_description: str | None = Field(
None, max_length=300, description="SEO meta description"
)
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
is_published: bool = Field(default=False, description="Publish immediately")
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
show_in_header: bool = Field(default=False, description="Show in header navigation")
show_in_legal: bool = Field(
default=False, description="Show in legal/bottom bar (next to copyright)"
)
display_order: int = Field(default=0, description="Display order (lower = first)")
vendor_id: int | None = Field(
None, description="Vendor ID (None for platform default)"
)
class ContentPageUpdate(BaseModel):
"""Schema for updating a content page (admin)."""
title: str | None = Field(None, max_length=200)
content: str | None = None
content_format: str | None = None
template: str | None = Field(None, max_length=50)
meta_description: str | None = Field(None, max_length=300)
meta_keywords: str | None = Field(None, max_length=300)
is_published: bool | None = None
show_in_footer: bool | None = None
show_in_header: bool | None = None
show_in_legal: bool | None = None
display_order: int | None = None
class ContentPageResponse(BaseModel):
"""Schema for content page response (admin/vendor)."""
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
title: str
content: str
content_format: str
template: str | None = None
meta_description: str | None
meta_keywords: str | None
is_published: bool
published_at: str | None
display_order: int
show_in_footer: bool
show_in_header: bool
show_in_legal: 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
updated_by: int | None
class HomepageSectionsResponse(BaseModel):
"""Response containing homepage sections with platform language info."""
sections: dict | None = None
supported_languages: list[str] = Field(default_factory=lambda: ["fr", "de", "en"])
default_language: str = "fr"
class SectionUpdateResponse(BaseModel):
"""Response after updating sections."""
message: str
sections: dict | None = None
# ============================================================================
# VENDOR SCHEMAS
# ============================================================================
class VendorContentPageCreate(BaseModel):
"""Schema for creating a vendor content page."""
slug: str = Field(
...,
max_length=100,
description="URL-safe identifier (about, faq, contact, etc.)",
)
title: str = Field(..., max_length=200, description="Page title")
content: str = Field(..., description="HTML or Markdown content")
content_format: str = Field(
default="html", description="Content format: html or markdown"
)
meta_description: str | None = Field(
None, max_length=300, description="SEO meta description"
)
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
is_published: bool = Field(default=False, description="Publish immediately")
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
show_in_header: bool = Field(default=False, description="Show in header navigation")
show_in_legal: bool = Field(
default=False, description="Show in legal/bottom bar (next to copyright)"
)
display_order: int = Field(default=0, description="Display order (lower = first)")
class VendorContentPageUpdate(BaseModel):
"""Schema for updating a vendor content page."""
title: str | None = Field(None, max_length=200)
content: str | None = None
content_format: str | None = None
meta_description: str | None = Field(None, max_length=300)
meta_keywords: str | None = Field(None, max_length=300)
is_published: bool | None = None
show_in_footer: bool | None = None
show_in_header: bool | None = None
show_in_legal: bool | None = None
display_order: int | None = 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
# ============================================================================
# PUBLIC/SHOP SCHEMAS
# ============================================================================
class PublicContentPageResponse(BaseModel):
"""Public content page response (no internal IDs)."""
slug: str
title: str
content: str
content_format: str
meta_description: str | None
meta_keywords: str | None
published_at: str | None
class ContentPageListItem(BaseModel):
"""Content page list item for navigation."""
slug: str
title: str
show_in_footer: bool
show_in_header: bool
display_order: int

View File

@@ -32,8 +32,7 @@ from app.modules.cms.exceptions import (
ContentPageNotFoundException,
UnauthorizedContentPageAccessException,
)
# Import from canonical location to avoid circular imports
from models.database.content_page import ContentPage
from app.modules.cms.models import ContentPage
logger = logging.getLogger(__name__)

View File

@@ -0,0 +1,466 @@
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// static/admin/js/content-page-edit.js
// Use centralized logger
const contentPageEditLog = window.LogConfig.loggers.contentPageEdit || window.LogConfig.createLogger('contentPageEdit');
// ============================================
// CONTENT PAGE EDITOR FUNCTION
// ============================================
function contentPageEditor(pageId) {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Page identifier for sidebar active state
currentPage: 'content-pages',
// Editor state
pageId: pageId,
form: {
slug: '',
title: '',
content: '',
content_format: 'html',
template: 'default',
meta_description: '',
meta_keywords: '',
is_published: false,
show_in_header: false,
show_in_footer: true,
show_in_legal: false,
display_order: 0,
platform_id: null,
vendor_id: null
},
platforms: [],
vendors: [],
loading: false,
loadingPlatforms: false,
loadingVendors: false,
saving: false,
error: null,
successMessage: null,
// ========================================
// HOMEPAGE SECTIONS STATE
// ========================================
supportedLanguages: ['fr', 'de', 'en'],
defaultLanguage: 'fr',
currentLang: 'fr',
openSection: null,
sectionsLoaded: false,
languageNames: {
en: 'English',
fr: 'Français',
de: 'Deutsch',
lb: 'Lëtzebuergesch'
},
sections: {
hero: {
enabled: true,
badge_text: { translations: {} },
title: { translations: {} },
subtitle: { translations: {} },
background_type: 'gradient',
buttons: []
},
features: {
enabled: true,
title: { translations: {} },
subtitle: { translations: {} },
features: [],
layout: 'grid'
},
pricing: {
enabled: true,
title: { translations: {} },
subtitle: { translations: {} },
use_subscription_tiers: true
},
cta: {
enabled: true,
title: { translations: {} },
subtitle: { translations: {} },
buttons: [],
background_type: 'gradient'
}
},
// Initialize
async init() {
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZING ===');
contentPageEditLog.info('Page ID:', this.pageId);
// Prevent multiple initializations
if (window._contentPageEditInitialized) {
contentPageEditLog.warn('Content page editor already initialized, skipping...');
return;
}
window._contentPageEditInitialized = true;
// Load platforms and vendors for dropdowns
await Promise.all([this.loadPlatforms(), this.loadVendors()]);
if (this.pageId) {
// Edit mode - load existing page
contentPageEditLog.group('Loading page for editing');
await this.loadPage();
contentPageEditLog.groupEnd();
// Load sections if this is a homepage
if (this.form.slug === 'home') {
await this.loadSections();
}
} else {
// Create mode - use default values
contentPageEditLog.info('Create mode - using default form values');
}
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
},
// Check if we should show section editor (property, not getter for Alpine compatibility)
isHomepage: false,
// Update isHomepage when slug changes
updateIsHomepage() {
this.isHomepage = this.form.slug === 'home';
},
// Load platforms for dropdown
async loadPlatforms() {
this.loadingPlatforms = true;
try {
contentPageEditLog.info('Loading platforms...');
const response = await apiClient.get('/admin/platforms?is_active=true');
const data = response.data || response;
this.platforms = data.platforms || data.items || data || [];
contentPageEditLog.info(`Loaded ${this.platforms.length} platforms`);
// Set default platform if not editing and no platform selected
if (!this.pageId && !this.form.platform_id && this.platforms.length > 0) {
this.form.platform_id = this.platforms[0].id;
}
} catch (err) {
contentPageEditLog.error('Error loading platforms:', err);
this.platforms = [];
} finally {
this.loadingPlatforms = false;
}
},
// Load vendors for dropdown
async loadVendors() {
this.loadingVendors = true;
try {
contentPageEditLog.info('Loading vendors...');
const response = await apiClient.get('/admin/vendors?is_active=true&limit=100');
const data = response.data || response;
this.vendors = data.vendors || data.items || data || [];
contentPageEditLog.info(`Loaded ${this.vendors.length} vendors`);
} catch (err) {
contentPageEditLog.error('Error loading vendors:', err);
this.vendors = [];
} finally {
this.loadingVendors = false;
}
},
// Load existing page
async loadPage() {
this.loading = true;
this.error = null;
try {
contentPageEditLog.info(`Fetching page ${this.pageId}...`);
const response = await apiClient.get(`/admin/content-pages/${this.pageId}`);
contentPageEditLog.debug('API Response:', response);
if (!response) {
throw new Error('Invalid API response');
}
// Handle response - API returns object directly
const page = response.data || response;
this.form = {
slug: page.slug || '',
title: page.title || '',
content: page.content || '',
content_format: page.content_format || 'html',
template: page.template || 'default',
meta_description: page.meta_description || '',
meta_keywords: page.meta_keywords || '',
is_published: page.is_published || false,
show_in_header: page.show_in_header || false,
show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
show_in_legal: page.show_in_legal || false,
display_order: page.display_order || 0,
platform_id: page.platform_id,
vendor_id: page.vendor_id
};
contentPageEditLog.info('Page loaded successfully');
// Update computed properties after loading
this.updateIsHomepage();
// Re-initialize Quill editor content after page data is loaded
// (Quill may have initialized before loadPage completed)
this.syncQuillContent();
} catch (err) {
contentPageEditLog.error('Error loading page:', err);
this.error = err.message || 'Failed to load page';
} finally {
this.loading = false;
}
},
// Sync Quill editor content after page data loads
// Quill may initialize before loadPage completes, leaving editor empty
syncQuillContent(retries = 5) {
const quillContainer = document.getElementById('content-editor');
if (!quillContainer || !quillContainer.__quill) {
// Quill not ready yet, retry
if (retries > 0) {
setTimeout(() => this.syncQuillContent(retries - 1), 100);
}
return;
}
const quill = quillContainer.__quill;
if (this.form.content && quill.root.innerHTML !== this.form.content) {
quill.root.innerHTML = this.form.content;
contentPageEditLog.debug('Synced Quill content after page load');
}
},
// ========================================
// HOMEPAGE SECTIONS METHODS
// ========================================
// Load sections for homepage
async loadSections() {
if (!this.pageId || this.form.slug !== 'home') {
contentPageEditLog.debug('Skipping section load - not a homepage');
return;
}
try {
contentPageEditLog.info('Loading homepage sections...');
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
const data = response.data || response;
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
this.defaultLanguage = data.default_language || 'fr';
this.currentLang = this.defaultLanguage;
if (data.sections) {
this.sections = this.mergeWithDefaults(data.sections);
contentPageEditLog.info('Sections loaded:', Object.keys(data.sections));
} else {
this.initializeEmptySections();
contentPageEditLog.info('No sections found - initialized empty structure');
}
this.sectionsLoaded = true;
} catch (err) {
contentPageEditLog.error('Error loading sections:', err);
}
},
// Merge loaded sections with default structure
mergeWithDefaults(loadedSections) {
const defaults = this.getDefaultSectionStructure();
// Deep merge each section
for (const key of ['hero', 'features', 'pricing', 'cta']) {
if (loadedSections[key]) {
defaults[key] = { ...defaults[key], ...loadedSections[key] };
}
}
return defaults;
},
// Get default section structure
getDefaultSectionStructure() {
const emptyTranslations = () => {
const t = {};
this.supportedLanguages.forEach(lang => t[lang] = '');
return { translations: t };
};
return {
hero: {
enabled: true,
badge_text: emptyTranslations(),
title: emptyTranslations(),
subtitle: emptyTranslations(),
background_type: 'gradient',
buttons: []
},
features: {
enabled: true,
title: emptyTranslations(),
subtitle: emptyTranslations(),
features: [],
layout: 'grid'
},
pricing: {
enabled: true,
title: emptyTranslations(),
subtitle: emptyTranslations(),
use_subscription_tiers: true
},
cta: {
enabled: true,
title: emptyTranslations(),
subtitle: emptyTranslations(),
buttons: [],
background_type: 'gradient'
}
};
},
// Initialize empty sections for all languages
initializeEmptySections() {
this.sections = this.getDefaultSectionStructure();
},
// Add a button to hero or cta section
addButton(sectionName) {
const newButton = {
text: { translations: {} },
url: '',
style: 'primary'
};
this.supportedLanguages.forEach(lang => {
newButton.text.translations[lang] = '';
});
this.sections[sectionName].buttons.push(newButton);
contentPageEditLog.debug(`Added button to ${sectionName}`);
},
// Remove a button from hero or cta section
removeButton(sectionName, index) {
this.sections[sectionName].buttons.splice(index, 1);
contentPageEditLog.debug(`Removed button ${index} from ${sectionName}`);
},
// Add a feature card
addFeature() {
const newFeature = {
icon: '',
title: { translations: {} },
description: { translations: {} }
};
this.supportedLanguages.forEach(lang => {
newFeature.title.translations[lang] = '';
newFeature.description.translations[lang] = '';
});
this.sections.features.features.push(newFeature);
contentPageEditLog.debug('Added feature card');
},
// Remove a feature card
removeFeature(index) {
this.sections.features.features.splice(index, 1);
contentPageEditLog.debug(`Removed feature ${index}`);
},
// Save sections
async saveSections() {
if (!this.pageId || !this.isHomepage) return;
try {
contentPageEditLog.info('Saving sections...');
await apiClient.put(`/admin/content-pages/${this.pageId}/sections`, this.sections);
contentPageEditLog.info('Sections saved successfully');
} catch (err) {
contentPageEditLog.error('Error saving sections:', err);
throw err;
}
},
// Save page (create or update)
async savePage() {
if (this.saving) return;
this.saving = true;
this.error = null;
this.successMessage = null;
try {
contentPageEditLog.info(this.pageId ? 'Updating page...' : 'Creating page...');
const payload = {
slug: this.form.slug,
title: this.form.title,
content: this.form.content,
content_format: this.form.content_format,
template: this.form.template,
meta_description: this.form.meta_description,
meta_keywords: this.form.meta_keywords,
is_published: this.form.is_published,
show_in_header: this.form.show_in_header,
show_in_footer: this.form.show_in_footer,
show_in_legal: this.form.show_in_legal,
display_order: this.form.display_order,
platform_id: this.form.platform_id,
vendor_id: this.form.vendor_id
};
contentPageEditLog.debug('Payload:', payload);
let response;
if (this.pageId) {
// Update existing page
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
// Also save sections if this is a homepage
if (this.isHomepage && this.sectionsLoaded) {
await this.saveSections();
}
this.successMessage = 'Page updated successfully!';
contentPageEditLog.info('Page updated');
} else {
// Create new page - use vendor or platform endpoint based on selection
const endpoint = this.form.vendor_id
? '/admin/content-pages/vendor'
: '/admin/content-pages/platform';
response = await apiClient.post(endpoint, payload);
this.successMessage = 'Page created successfully!';
contentPageEditLog.info('Page created', { endpoint, vendor_id: this.form.vendor_id });
// Redirect to edit page after creation
const pageData = response.data || response;
if (pageData && pageData.id) {
setTimeout(() => {
window.location.href = `/admin/content-pages/${pageData.id}/edit`;
}, 1500);
}
}
// Clear success message after 3 seconds
setTimeout(() => {
this.successMessage = null;
}, 3000);
} catch (err) {
contentPageEditLog.error('Error saving page:', err);
this.error = err.message || 'Failed to save page';
// Scroll to top to show error
window.scrollTo({ top: 0, behavior: 'smooth' });
} finally {
this.saving = false;
}
}
};
}

View File

@@ -0,0 +1,266 @@
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// static/admin/js/content-pages.js
// Use centralized logger
const contentPagesLog = window.LogConfig.loggers.contentPages || window.LogConfig.createLogger('contentPages');
// ============================================
// CONTENT PAGES MANAGER FUNCTION
// ============================================
function contentPagesManager() {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Page identifier for sidebar active state
currentPage: 'content-pages',
// Content pages specific state
allPages: [],
platforms: [],
loading: false,
error: null,
// Tabs and filters
activeTab: 'all', // all, platform_marketing, vendor_defaults, vendor_overrides
searchQuery: '',
selectedPlatform: '', // Platform code filter
// Initialize
async init() {
contentPagesLog.info('=== CONTENT PAGES MANAGER INITIALIZING ===');
// Prevent multiple initializations
if (window._contentPagesInitialized) {
contentPagesLog.warn('Content pages manager already initialized, skipping...');
return;
}
window._contentPagesInitialized = true;
contentPagesLog.group('Loading data');
await Promise.all([
this.loadPages(),
this.loadPlatforms()
]);
contentPagesLog.groupEnd();
// Check for platform filter in URL (support both 'platform' and 'platform_code')
const urlParams = new URLSearchParams(window.location.search);
const platformParam = urlParams.get('platform_code') || urlParams.get('platform');
if (platformParam) {
this.selectedPlatform = platformParam;
}
// Check for slug param - if specified, redirect to edit page
const slugParam = urlParams.get('slug');
if (slugParam && platformParam) {
await this.redirectToEditIfSlugMatches(platformParam, slugParam);
}
contentPagesLog.info('=== CONTENT PAGES MANAGER INITIALIZATION COMPLETE ===');
},
// 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.platformMarketingPages, ...this.vendorDefaultPages];
},
get vendorPages() {
return this.vendorOverridePages;
},
// Computed: Filtered pages based on active tab, platform, and search
get filteredPages() {
let pages = [];
// 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.platform_name && page.platform_name.toLowerCase().includes(query))
);
}
// Sort by display_order, then title
return pages.sort((a, b) => {
if (a.display_order !== b.display_order) {
return a.display_order - b.display_order;
}
return a.title.localeCompare(b.title);
});
},
// Load all content pages
async loadPages() {
this.loading = true;
this.error = null;
try {
contentPagesLog.info('Fetching all content pages...');
// Fetch all pages (platform + vendor, published + unpublished)
const response = await apiClient.get('/admin/content-pages/?include_unpublished=true');
contentPagesLog.debug('API Response:', response);
if (!response) {
throw new Error('Invalid API response');
}
// Handle response - API returns array directly
this.allPages = Array.isArray(response) ? response : (response.data || response.items || []);
contentPagesLog.info(`Loaded ${this.allPages.length} pages`);
} catch (err) {
contentPagesLog.error('Error loading content pages:', err);
this.error = err.message || 'Failed to load content pages';
} finally {
this.loading = false;
}
},
// 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
}
},
// Redirect to edit page if a specific slug is requested
async redirectToEditIfSlugMatches(platformCode, slug) {
contentPagesLog.info(`Looking for page with platform=${platformCode}, slug=${slug}`);
// Find the page matching the platform and slug
const matchingPage = this.allPages.find(page =>
page.platform_code === platformCode && page.slug === slug
);
if (matchingPage) {
contentPagesLog.info(`Found matching page: ${matchingPage.id}, redirecting to edit...`);
window.location.href = `/admin/content-pages/${matchingPage.id}/edit`;
} else {
contentPagesLog.warn(`No page found for platform=${platformCode}, slug=${slug}`);
// Show a toast and offer to create
if (slug === 'home') {
// Offer to create homepage
if (confirm(`No homepage found for ${platformCode}. Would you like to create one?`)) {
window.location.href = `/admin/content-pages/create?platform_code=${platformCode}&slug=home&is_platform_page=true`;
}
}
}
},
// 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}"?`)) {
return;
}
try {
contentPagesLog.info(`Deleting page: ${page.id}`);
await apiClient.delete(`/admin/content-pages/${page.id}`);
// Remove from local array
this.allPages = this.allPages.filter(p => p.id !== page.id);
contentPagesLog.info('Page deleted successfully');
} catch (err) {
contentPagesLog.error('Error deleting page:', err);
Utils.showToast(`Failed to delete page: ${err.message}`, 'error');
}
},
// Format date helper
formatDate(dateString) {
if (!dateString) return '—';
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return 'Today';
} else if (diffDays === 1) {
return 'Yesterday';
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
}
};
}

View File

@@ -0,0 +1,236 @@
// static/vendor/js/content-page-edit.js
// Use centralized logger
const contentPageEditLog = window.LogConfig.loggers.contentPageEdit || window.LogConfig.createLogger('contentPageEdit');
// ============================================
// VENDOR CONTENT PAGE EDITOR
// ============================================
function vendorContentPageEditor(pageId) {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Page identifier for sidebar active state
currentPage: 'content-pages',
// Editor state
pageId: pageId,
isOverride: false,
form: {
slug: '',
title: '',
content: '',
content_format: 'html',
meta_description: '',
meta_keywords: '',
is_published: false,
show_in_header: false,
show_in_footer: true,
show_in_legal: false,
display_order: 0
},
loading: false,
saving: false,
error: null,
successMessage: null,
// Default preview modal state
showingDefaultPreview: false,
loadingDefault: false,
defaultContent: null,
// Initialize
async init() {
// Prevent multiple initializations
if (window._vendorContentPageEditInitialized) {
contentPageEditLog.warn('Content page editor already initialized, skipping...');
return;
}
window._vendorContentPageEditInitialized = true;
// IMPORTANT: Call parent init first to set vendorCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
try {
contentPageEditLog.info('=== VENDOR CONTENT PAGE EDITOR INITIALIZING ===');
contentPageEditLog.info('Page ID:', this.pageId);
if (this.pageId) {
// Edit mode - load existing page
contentPageEditLog.group('Loading page for editing');
await this.loadPage();
contentPageEditLog.groupEnd();
} else {
// Create mode - use default values
contentPageEditLog.info('Create mode - using default form values');
}
contentPageEditLog.info('=== VENDOR CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
} catch (error) {
contentPageEditLog.error('Failed to initialize content page editor:', error);
}
},
// Load existing page
async loadPage() {
this.loading = true;
this.error = null;
try {
contentPageEditLog.info(`Fetching page ${this.pageId}...`);
// Use the vendor API to get page by ID
// We need to get the page details - use overrides endpoint and find by ID
const response = await apiClient.get('/vendor/content-pages/overrides');
const pages = response.data || response || [];
const page = pages.find(p => p.id === this.pageId);
if (!page) {
throw new Error('Page not found or you do not have access to it');
}
contentPageEditLog.debug('Page data:', page);
this.isOverride = page.is_vendor_override || false;
this.form = {
slug: page.slug || '',
title: page.title || '',
content: page.content || '',
content_format: page.content_format || 'html',
meta_description: page.meta_description || '',
meta_keywords: page.meta_keywords || '',
is_published: page.is_published || false,
show_in_header: page.show_in_header || false,
show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
show_in_legal: page.show_in_legal || false,
display_order: page.display_order || 0
};
contentPageEditLog.info('Page loaded successfully');
} catch (err) {
contentPageEditLog.error('Error loading page:', err);
this.error = err.message || 'Failed to load page';
} finally {
this.loading = false;
}
},
// Save page (create or update)
async savePage() {
if (this.saving) return;
this.saving = true;
this.error = null;
this.successMessage = null;
try {
contentPageEditLog.info(this.pageId ? 'Updating page...' : 'Creating page...');
const payload = {
slug: this.form.slug,
title: this.form.title,
content: this.form.content,
content_format: this.form.content_format,
meta_description: this.form.meta_description,
meta_keywords: this.form.meta_keywords,
is_published: this.form.is_published,
show_in_header: this.form.show_in_header,
show_in_footer: this.form.show_in_footer,
show_in_legal: this.form.show_in_legal,
display_order: this.form.display_order
};
contentPageEditLog.debug('Payload:', payload);
let response;
if (this.pageId) {
// Update existing page
response = await apiClient.put(`/vendor/content-pages/${this.pageId}`, payload);
this.successMessage = 'Page updated successfully!';
contentPageEditLog.info('Page updated');
} else {
// Create new page
response = await apiClient.post('/vendor/content-pages/', payload);
this.successMessage = 'Page created successfully!';
contentPageEditLog.info('Page created');
// Redirect to edit page after creation
const pageData = response.data || response;
if (pageData && pageData.id) {
setTimeout(() => {
window.location.href = `/vendor/${this.vendorCode}/content-pages/${pageData.id}/edit`;
}, 1500);
}
}
// Clear success message after 3 seconds
setTimeout(() => {
this.successMessage = null;
}, 3000);
} catch (err) {
contentPageEditLog.error('Error saving page:', err);
this.error = err.message || 'Failed to save page';
// Scroll to top to show error
window.scrollTo({ top: 0, behavior: 'smooth' });
} finally {
this.saving = false;
}
},
// 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
? 'Are you sure you want to revert to the platform default? Your customizations will be lost.'
: 'Are you sure you want to delete this page? This cannot be undone.';
if (!confirm(message)) {
return;
}
try {
contentPageEditLog.info('Deleting page:', this.pageId);
await apiClient.delete(`/vendor/content-pages/${this.pageId}`);
// Redirect back to list
window.location.href = `/vendor/${this.vendorCode}/content-pages`;
} catch (err) {
contentPageEditLog.error('Error deleting page:', err);
this.error = err.message || 'Failed to delete page';
}
}
};
}

View File

@@ -0,0 +1,222 @@
// static/vendor/js/content-pages.js
// Use centralized logger
const contentPagesLog = window.LogConfig.loggers.contentPages || window.LogConfig.createLogger('contentPages');
// ============================================
// VENDOR CONTENT PAGES MANAGER
// ============================================
function vendorContentPagesManager() {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Page identifier for sidebar active state
currentPage: 'content-pages',
// State
loading: false,
error: null,
activeTab: 'platform',
searchQuery: '',
// Data
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() {
contentPagesLog.info('=== VENDOR CONTENT PAGES MANAGER INITIALIZING ===');
// Prevent multiple initializations
if (window._vendorContentPagesInitialized) {
contentPagesLog.warn('Content pages manager already initialized, skipping...');
return;
}
window._vendorContentPagesInitialized = true;
try {
// IMPORTANT: Call parent init first to set vendorCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
await Promise.all([
this.loadPages(),
this.loadCmsUsage()
]);
contentPagesLog.info('=== VENDOR CONTENT PAGES MANAGER INITIALIZATION COMPLETE ===');
} catch (error) {
contentPagesLog.error('Failed to initialize content pages:', error);
this.error = 'Failed to initialize. Please refresh the page.';
this.loading = false;
}
},
// Load all pages
async loadPages() {
this.loading = true;
this.error = null;
try {
contentPagesLog.info('Loading content pages...');
// Load platform defaults and vendor pages in parallel
const [platformResponse, vendorResponse] = await Promise.all([
apiClient.get('/vendor/content-pages/'),
apiClient.get('/vendor/content-pages/overrides')
]);
// Platform pages - filter to only show actual platform defaults
const allPages = platformResponse.data || platformResponse || [];
this.platformPages = allPages.filter(p => p.is_platform_default);
// Vendor's custom pages (includes overrides)
this.customPages = vendorResponse.data || vendorResponse || [];
// Build override map for quick lookups
this.overrideMap = {};
this.customPages.forEach(page => {
if (page.is_vendor_override) {
this.overrideMap[page.slug] = page.id;
}
});
contentPagesLog.info(`Loaded ${this.platformPages.length} platform pages, ${this.customPages.length} vendor pages`);
} catch (err) {
contentPagesLog.error('Error loading pages:', err);
this.error = err.message || 'Failed to load pages';
} finally {
this.loading = false;
}
},
// 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;
},
// Get override page ID
getOverrideId(slug) {
return this.overrideMap[slug];
},
// Create an override for a platform page
async createOverride(platformPage) {
contentPagesLog.info('Creating override for:', platformPage.slug);
try {
// Create a new vendor page with the same slug as the platform page
const payload = {
slug: platformPage.slug,
title: platformPage.title,
content: platformPage.content,
content_format: platformPage.content_format || 'html',
meta_description: platformPage.meta_description,
meta_keywords: platformPage.meta_keywords,
is_published: true,
show_in_header: platformPage.show_in_header,
show_in_footer: platformPage.show_in_footer,
display_order: platformPage.display_order
};
const response = await apiClient.post('/vendor/content-pages/', payload);
const newPage = response.data || response;
contentPagesLog.info('Override created:', newPage.id);
// Redirect to edit the new page
window.location.href = `/vendor/${this.vendorCode}/content-pages/${newPage.id}/edit`;
} catch (err) {
contentPagesLog.error('Error creating override:', err);
this.error = err.message || 'Failed to create override';
}
},
// Delete a page
async deletePage(page) {
const message = page.is_vendor_override
? `Are you sure you want to delete your override for "${page.title}"? The platform default will be shown instead.`
: `Are you sure you want to delete "${page.title}"? This cannot be undone.`;
if (!confirm(message)) {
return;
}
try {
contentPagesLog.info('Deleting page:', page.id);
await apiClient.delete(`/vendor/content-pages/${page.id}`);
// Remove from local state
this.customPages = this.customPages.filter(p => p.id !== page.id);
// Update override map
if (page.is_vendor_override) {
delete this.overrideMap[page.slug];
}
contentPagesLog.info('Page deleted successfully');
} catch (err) {
contentPagesLog.error('Error deleting page:', err);
this.error = err.message || 'Failed to delete page';
}
},
// Filtered platform pages based on search
get filteredPlatformPages() {
if (!this.searchQuery) {
return this.platformPages;
}
const query = this.searchQuery.toLowerCase();
return this.platformPages.filter(page =>
page.title.toLowerCase().includes(query) ||
page.slug.toLowerCase().includes(query)
);
},
// Filtered custom pages based on search
get filteredCustomPages() {
if (!this.searchQuery) {
return this.customPages;
}
const query = this.searchQuery.toLowerCase();
return this.customPages.filter(page =>
page.title.toLowerCase().includes(query) ||
page.slug.toLowerCase().includes(query)
);
},
// Format date for display
formatDate(dateStr) {
if (!dateStr) return '—';
const date = new Date(dateStr);
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return date.toLocaleDateString(locale, {
day: '2-digit',
month: 'short',
year: 'numeric'
});
}
};
}

View File

@@ -1,4 +1,4 @@
{# app/templates/admin/content-page-edit.html #}
{# app/modules/cms/templates/cms/admin/content-page-edit.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state, alert_dynamic %}
{% from 'shared/macros/headers.html' import page_header_flex, back_button, action_button %}
@@ -636,5 +636,5 @@
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/content-page-edit.js') }}"></script>
<script src="{{ url_for('cms_static', path='admin/js/content-page-edit.js') }}"></script>
{% endblock %}

View File

@@ -1,4 +1,4 @@
{# app/templates/admin/content-pages.html #}
{# app/modules/cms/templates/cms/admin/content-pages.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
@@ -178,5 +178,5 @@
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/content-pages.js') }}"></script>
<script src="{{ url_for('cms_static', path='admin/js/content-pages.js') }}"></script>
{% endblock %}

View File

@@ -1,4 +1,4 @@
{# app/templates/vendor/content-page-edit.html #}
{# app/modules/cms/templates/cms/vendor/content-page-edit.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state, alert_dynamic %}
{% from 'shared/macros/headers.html' import back_button %}
@@ -322,5 +322,5 @@
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='vendor/js/content-page-edit.js') }}"></script>
<script src="{{ url_for('cms_static', path='vendor/js/content-page-edit.js') }}"></script>
{% endblock %}

View File

@@ -1,4 +1,4 @@
{# app/templates/vendor/content-pages.html #}
{# app/modules/cms/templates/cms/vendor/content-pages.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
@@ -323,5 +323,5 @@
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='vendor/js/content-pages.js') }}"></script>
<script src="{{ url_for('cms_static', path='vendor/js/content-pages.js') }}"></script>
{% endblock %}

View File

@@ -44,7 +44,6 @@ Routes:
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.api.deps import (
@@ -53,11 +52,11 @@ from app.api.deps import (
require_menu_access,
)
from app.core.config import settings
from app.templates_config import templates
from models.database.admin_menu_config import FrontendType
from models.database.user import User
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# ============================================================================
@@ -1278,88 +1277,8 @@ async def admin_platform_modules(
# ============================================================================
# CONTENT MANAGEMENT SYSTEM (CMS) ROUTES
# ============================================================================
@router.get("/platform-homepage", include_in_schema=False)
async def admin_platform_homepage_manager(
request: Request,
current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Deprecated: Redirects to platforms page.
Platform homepages are now managed via:
- /admin/platforms → Select platform → Homepage button
- Or directly: /admin/content-pages?platform_code={code}&slug=home
"""
return RedirectResponse(url="/admin/platforms", status_code=302)
@router.get("/content-pages", response_class=HTMLResponse, include_in_schema=False)
async def admin_content_pages_list(
request: Request,
current_user: User = Depends(require_menu_access("content-pages", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render content pages list.
Shows all platform defaults and vendor overrides with filtering.
"""
return templates.TemplateResponse(
"admin/content-pages.html",
{
"request": request,
"user": current_user,
},
)
@router.get(
"/content-pages/create", response_class=HTMLResponse, include_in_schema=False
)
async def admin_content_page_create(
request: Request,
current_user: User = Depends(require_menu_access("content-pages", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render create content page form.
Allows creating new platform defaults or vendor-specific pages.
"""
return templates.TemplateResponse(
"admin/content-page-edit.html",
{
"request": request,
"user": current_user,
"page_id": None, # Indicates this is a create operation
},
)
@router.get(
"/content-pages/{page_id}/edit",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_content_page_edit(
request: Request,
page_id: int = Path(..., description="Content page ID"),
current_user: User = Depends(require_menu_access("content-pages", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render edit content page form.
Allows editing existing platform or vendor content pages.
"""
return templates.TemplateResponse(
"admin/content-page-edit.html",
{
"request": request,
"user": current_user,
"page_id": page_id,
},
)
# NOTE: CMS routes moved to self-contained module: app.modules.cms.routes.pages.admin
# Routes are registered directly in main.py from the CMS module
# ============================================================================

View File

@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.services.content_page_service import content_page_service
from app.modules.cms.services import content_page_service
from app.utils.i18n import get_jinja2_globals
router = APIRouter()

View File

@@ -34,16 +34,15 @@ import logging
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_from_cookie_or_header, get_db
from app.services.content_page_service import content_page_service
from app.modules.cms.services import content_page_service
from app.services.platform_settings_service import platform_settings_service
from app.templates_config import templates
from models.database.customer import Customer
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
logger = logging.getLogger(__name__)

View File

@@ -26,7 +26,6 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.api.deps import (
@@ -34,16 +33,15 @@ from app.api.deps import (
get_current_vendor_optional,
get_db,
)
from app.services.content_page_service import content_page_service
from app.services.onboarding_service import OnboardingService
from app.services.platform_settings_service import platform_settings_service
from app.templates_config import templates
from models.database.user import User
from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# ============================================================================
@@ -697,154 +695,12 @@ async def vendor_analytics_page(
# ============================================================================
# CONTENT PAGES MANAGEMENT
# CONTENT PAGES MANAGEMENT & CMS
# ============================================================================
@router.get(
"/{vendor_code}/content-pages", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_content_pages_list(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render content pages management page.
Shows platform defaults (can be overridden) and vendor custom pages.
"""
return templates.TemplateResponse(
"vendor/content-pages.html",
get_vendor_context(request, db, current_user, vendor_code),
)
@router.get(
"/{vendor_code}/content-pages/create",
response_class=HTMLResponse,
include_in_schema=False,
)
async def vendor_content_page_create(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render content page creation form.
"""
return templates.TemplateResponse(
"vendor/content-page-edit.html",
get_vendor_context(request, db, current_user, vendor_code, page_id=None),
)
@router.get(
"/{vendor_code}/content-pages/{page_id}/edit",
response_class=HTMLResponse,
include_in_schema=False,
)
async def vendor_content_page_edit(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
page_id: int = Path(..., description="Content page ID"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render content page edit form.
"""
return templates.TemplateResponse(
"vendor/content-page-edit.html",
get_vendor_context(request, db, current_user, vendor_code, page_id=page_id),
)
# ============================================================================
# DYNAMIC CONTENT PAGES (CMS)
# ============================================================================
@router.get(
"/{vendor_code}/{slug}", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_content_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
slug: str = Path(..., description="Content page slug"),
db: Session = Depends(get_db),
):
"""
Generic content page handler for vendor shop (CMS).
Handles dynamic content pages like:
- /vendors/wizamart/about, /vendors/wizamart/faq, /vendors/wizamart/contact, etc.
Features:
- Two-tier system: Vendor overrides take priority, fallback to platform defaults
- Only shows published pages
- Returns 404 if page not found or unpublished
NOTE: This is a catch-all route and must be registered LAST to avoid
shadowing other specific routes.
"""
logger.debug(
"[VENDOR_HANDLER] vendor_content_page REACHED",
extra={
"path": request.url.path,
"vendor_code": vendor_code,
"slug": slug,
"vendor": getattr(request.state, "vendor", "NOT SET"),
"context": getattr(request.state, "context_type", "NOT SET"),
},
)
vendor = getattr(request.state, "vendor", None)
vendor_id = vendor.id if vendor else None
# Load content page from database (vendor override → platform default)
page = content_page_service.get_page_for_vendor(
db, slug=slug, vendor_id=vendor_id, include_unpublished=False
)
if not page:
logger.info(
f"[VENDOR_HANDLER] Content page not found: {slug}",
extra={
"slug": slug,
"vendor_code": vendor_code,
"vendor_id": vendor_id,
},
)
raise HTTPException(status_code=404, detail="Page not found")
logger.info(
f"[VENDOR_HANDLER] Rendering CMS page: {page.title}",
extra={
"slug": slug,
"page_id": page.id,
"is_vendor_override": page.vendor_id is not None,
"vendor_id": vendor_id,
},
)
# Resolve locale for shop template (uses same resolution chain as shop routes)
platform_config = platform_settings_service.get_storefront_config(db)
storefront_locale = platform_config["locale"]
storefront_currency = platform_config["currency"]
if vendor and vendor.storefront_locale:
storefront_locale = vendor.storefront_locale
return templates.TemplateResponse(
"shop/content-page.html",
{
"request": request,
"page": page,
"vendor": vendor,
"vendor_code": vendor_code,
"storefront_locale": storefront_locale,
"storefront_currency": storefront_currency,
},
)
# NOTE: CMS routes moved to self-contained module: app.modules.cms.routes.pages.vendor
# Routes are registered directly in main.py from the CMS module
# This includes:
# - /{vendor_code}/content-pages (list)
# - /{vendor_code}/content-pages/create
# - /{vendor_code}/content-pages/{page_id}/edit
# - /{vendor_code}/{slug} (catch-all CMS page viewer)

View File

@@ -1,30 +0,0 @@
# app/services/content_page_service.py
"""
DEPRECATED: This module has moved to app.modules.cms.services.content_page_service
Please update your imports:
# Old (deprecated):
from app.services.content_page_service import content_page_service
# New (preferred):
from app.modules.cms.services import content_page_service
This shim re-exports from the new location for backwards compatibility.
"""
import warnings
warnings.warn(
"Import from app.modules.cms.services.content_page_service instead of "
"app.services.content_page_service. This shim will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
# Re-export everything from the new location
from app.modules.cms.services.content_page_service import ( # noqa: E402, F401
ContentPageService,
content_page_service,
)
__all__ = ["ContentPageService", "content_page_service"]