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

@@ -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

@@ -0,0 +1,302 @@
# app/modules/cms/routes/api/admin.py
"""
Admin Content Pages API
Platform administrators can:
- Create/edit/delete platform default content pages
- View all vendor content pages
- Override vendor content if needed
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, get_db
from app.exceptions import ValidationException
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__)
# ============================================================================
# PLATFORM DEFAULT PAGES (vendor_id=NULL)
# ============================================================================
@router.get("/platform", response_model=list[ContentPageResponse])
def list_platform_pages(
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
List all platform default content pages.
These are used as fallbacks when vendors haven't created custom pages.
"""
pages = content_page_service.list_all_platform_pages(
db, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@router.post("/platform", response_model=ContentPageResponse, status_code=201)
def create_platform_page(
page_data: ContentPageCreate,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Create a new platform default content page.
Platform defaults are shown to all vendors who haven't created their own version.
"""
# Force vendor_id to None for platform pages
page = content_page_service.create_page(
db,
slug=page_data.slug,
title=page_data.title,
content=page_data.content,
vendor_id=None, # Platform default
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
created_by=current_user.id,
)
db.commit()
return page.to_dict()
# ============================================================================
# VENDOR PAGES
# ============================================================================
@router.post("/vendor", response_model=ContentPageResponse, status_code=201)
def create_vendor_page(
page_data: ContentPageCreate,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Create a vendor-specific content page override.
Vendor pages override platform defaults for a specific vendor.
"""
if not page_data.vendor_id:
raise ValidationException(
message="vendor_id is required for vendor pages. Use /platform for platform defaults.",
field="vendor_id",
)
page = content_page_service.create_page(
db,
slug=page_data.slug,
title=page_data.title,
content=page_data.content,
vendor_id=page_data.vendor_id,
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
created_by=current_user.id,
)
db.commit()
return page.to_dict()
# ============================================================================
# ALL CONTENT PAGES (Platform + Vendors)
# ============================================================================
@router.get("/", response_model=list[ContentPageResponse])
def list_all_pages(
vendor_id: int | None = Query(None, description="Filter by vendor ID"),
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
List all content pages (platform defaults and vendor overrides).
Filter by vendor_id to see specific vendor pages.
"""
pages = content_page_service.list_all_pages(
db, vendor_id=vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@router.get("/{page_id}", response_model=ContentPageResponse)
def get_page(
page_id: int,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get a specific content page by ID."""
page = content_page_service.get_page_by_id_or_raise(db, page_id)
return page.to_dict()
@router.put("/{page_id}", response_model=ContentPageResponse)
def update_page(
page_id: int,
page_data: ContentPageUpdate,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Update a content page (platform or vendor)."""
page = content_page_service.update_page_or_raise(
db,
page_id=page_id,
title=page_data.title,
content=page_data.content,
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
updated_by=current_user.id,
)
db.commit()
return page.to_dict()
@router.delete("/{page_id}", status_code=204)
def delete_page(
page_id: int,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Delete a content page."""
content_page_service.delete_page_or_raise(db, page_id)
db.commit()
# ============================================================================
# HOMEPAGE SECTIONS MANAGEMENT
# ============================================================================
@router.get("/{page_id}/sections", response_model=HomepageSectionsResponse)
def get_page_sections(
page_id: int,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get homepage sections for a content page.
Returns sections along with platform language settings for the editor.
"""
page = content_page_service.get_page_by_id_or_raise(db, page_id)
# Get platform languages
platform = page.platform
supported_languages = (
platform.supported_languages if platform else ["fr", "de", "en"]
)
default_language = platform.default_language if platform else "fr"
return {
"sections": page.sections,
"supported_languages": supported_languages,
"default_language": default_language,
}
@router.put("/{page_id}/sections", response_model=SectionUpdateResponse)
def update_page_sections(
page_id: int,
sections: dict,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update all homepage sections at once.
Expected structure:
{
"hero": { ... },
"features": { ... },
"pricing": { ... },
"cta": { ... }
}
"""
page = content_page_service.update_homepage_sections(
db,
page_id=page_id,
sections=sections,
updated_by=current_user.id,
)
db.commit()
return {
"message": "Sections updated successfully",
"sections": page.sections,
}
@router.put("/{page_id}/sections/{section_name}", response_model=SectionUpdateResponse)
def update_single_section(
page_id: int,
section_name: str,
section_data: dict,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update a single section (hero, features, pricing, or cta).
section_name must be one of: hero, features, pricing, cta
"""
if section_name not in ["hero", "features", "pricing", "cta"]:
raise ValidationException(
message=f"Invalid section name: {section_name}. Must be one of: hero, features, pricing, cta",
field="section_name",
)
page = content_page_service.update_single_section(
db,
page_id=page_id,
section_name=section_name,
section_data=section_data,
updated_by=current_user.id,
)
db.commit()
return {
"message": f"Section '{section_name}' updated successfully",
"sections": page.sections,
}

View File

@@ -0,0 +1,84 @@
# app/modules/cms/routes/api/shop.py
"""
Shop Content Pages API (Public)
Public endpoints for retrieving content pages in shop frontend.
No authentication required.
"""
import logging
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.modules.cms.schemas import (
PublicContentPageResponse,
ContentPageListItem,
)
from app.modules.cms.services import content_page_service
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================================================
# PUBLIC ENDPOINTS
# ============================================================================
@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).
Uses vendor from request.state (set by middleware).
Returns vendor overrides + platform defaults.
"""
vendor = getattr(request.state, "vendor", None)
vendor_id = vendor.id if vendor else None
# Get all published pages for this vendor
pages = content_page_service.list_pages_for_vendor(
db, vendor_id=vendor_id, include_unpublished=False
)
return [
{
"slug": page.slug,
"title": page.title,
"show_in_footer": page.show_in_footer,
"show_in_header": page.show_in_header,
"display_order": page.display_order,
}
for page in pages
]
@router.get("/{slug}", response_model=PublicContentPageResponse)
def get_content_page(slug: str, request: Request, db: Session = Depends(get_db)):
"""
Get a specific content page by slug.
Uses vendor from request.state (set by middleware).
Returns vendor override if exists, otherwise platform default.
"""
vendor = getattr(request.state, "vendor", None)
vendor_id = vendor.id if vendor else None
page = content_page_service.get_page_for_vendor_or_raise(
db,
slug=slug,
vendor_id=vendor_id,
include_unpublished=False, # Only show published pages
)
return {
"slug": page.slug,
"title": page.title,
"content": page.content,
"content_format": page.content_format,
"meta_description": page.meta_description,
"meta_keywords": page.meta_keywords,
"published_at": page.published_at.isoformat() if page.published_at else None,
}

View File

@@ -0,0 +1,270 @@
# app/modules/cms/routes/api/vendor.py
"""
Vendor Content Pages API
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
Vendors can:
- View their content pages (includes platform defaults)
- Create/edit/delete their own content page overrides
- Preview pages before publishing
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, get_db
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()
logger = logging.getLogger(__name__)
# ============================================================================
# VENDOR CONTENT PAGES
# ============================================================================
@router.get("/", response_model=list[ContentPageResponse])
def list_vendor_pages(
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
List all content pages available for this vendor.
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
"""
pages = content_page_service.list_pages_for_vendor(
db, vendor_id=current_user.token_vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@router.get("/overrides", response_model=list[ContentPageResponse])
def list_vendor_overrides(
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
List only vendor-specific content pages (no platform defaults).
Shows what the vendor has customized.
"""
pages = content_page_service.list_all_vendor_pages(
db, vendor_id=current_user.token_vendor_id, include_unpublished=include_unpublished
)
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,
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get a specific content page by slug.
Returns vendor override if exists, otherwise platform default.
"""
page = content_page_service.get_page_for_vendor_or_raise(
db,
slug=slug,
vendor_id=current_user.token_vendor_id,
include_unpublished=include_unpublished,
)
return page.to_dict()
@router.post("/", response_model=ContentPageResponse, status_code=201)
def create_vendor_page(
page_data: VendorContentPageCreate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Create a vendor-specific content page override.
This will be shown instead of the platform default for this vendor.
"""
page = content_page_service.create_page(
db,
slug=page_data.slug,
title=page_data.title,
content=page_data.content,
vendor_id=current_user.token_vendor_id,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
created_by=current_user.id,
)
db.commit()
return page.to_dict()
@router.put("/{page_id}", response_model=ContentPageResponse)
def update_vendor_page(
page_id: int,
page_data: VendorContentPageUpdate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Update a vendor-specific content page.
Can only update pages owned by this vendor.
"""
# Update with ownership check in service layer
page = content_page_service.update_vendor_page(
db,
page_id=page_id,
vendor_id=current_user.token_vendor_id,
title=page_data.title,
content=page_data.content,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
updated_by=current_user.id,
)
db.commit()
return page.to_dict()
@router.delete("/{page_id}", status_code=204)
def delete_vendor_page(
page_id: int,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Delete a vendor-specific content page.
Can only delete pages owned by this vendor.
After deletion, platform default will be shown (if exists).
"""
# Delete with ownership check in service layer
content_page_service.delete_vendor_page(db, page_id, current_user.token_vendor_id)
db.commit()

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

@@ -0,0 +1,640 @@
{# 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 %}
{% from 'shared/macros/inputs.html' import number_stepper %}
{% from 'shared/macros/richtext.html' import quill_css, quill_js, quill_editor %}
{% block title %}{% if page_id %}Edit{% else %}Create{% endif %} Content Page{% endblock %}
{% block alpine_data %}contentPageEditor({{ page_id if page_id else 'null' }}){% endblock %}
{% block quill_css %}
{{ quill_css() }}
{% endblock %}
{% block quill_script %}
{{ quill_js() }}
{% endblock %}
{% block content %}
{# Dynamic title/subtitle and save button text based on create vs edit mode #}
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200" x-text="pageId ? 'Edit Content Page' : 'Create Content Page'"></h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
<span x-show="!pageId">Create a new platform default or vendor-specific page</span>
<span x-show="pageId">Modify an existing content page</span>
</p>
</div>
<div class="flex items-center space-x-3">
{{ back_button('/admin/content-pages', 'Back to List') }}
<button
@click="savePage()"
:disabled="saving"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 transition-colors duration-150 border rounded-lg focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed text-white bg-purple-600 border-transparent hover:bg-purple-700 focus:shadow-outline-purple"
>
<span x-show="!saving" x-html="$icon('check', 'w-4 h-4 mr-2')"></span>
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="saving ? 'Saving...' : (pageId ? 'Update Page' : 'Create Page')"></span>
</button>
</div>
</div>
{{ loading_state('Loading page...') }}
{{ error_state('Error', show_condition='error && !loading') }}
{{ alert_dynamic(type='success', message_var='successMessage', show_condition='successMessage') }}
<!-- Main Form -->
<div x-show="!loading" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<form @submit.prevent="savePage()">
<!-- Basic Information -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Basic Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Page Title -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Page Title <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="form.title"
required
maxlength="200"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="About Us"
>
</div>
<!-- Slug -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Slug <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="form.slug"
required
maxlength="100"
pattern="[a-z0-9\-_]+"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="about"
>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
URL-safe identifier (lowercase, numbers, hyphens, underscores only)
</p>
</div>
<!-- Platform Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Platform <span class="text-red-500">*</span>
</label>
<select
x-model="form.platform_id"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
:disabled="loadingPlatforms"
>
<template x-for="plat in (platforms || [])" :key="plat.id">
<option :value="plat.id" x-text="plat.name"></option>
</template>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Which platform this page belongs to (e.g., OMS, Loyalty+)
</p>
</div>
<!-- Vendor Override (optional) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Vendor Override
</label>
<select
x-model="form.vendor_id"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
:disabled="loadingVendors"
>
<option :value="null">None (Platform Default)</option>
<template x-for="vendor in (vendors || [])" :key="vendor.id">
<option :value="vendor.id" x-text="vendor.name"></option>
</template>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-show="!form.vendor_id">This is a platform-wide default page</span>
<span x-show="form.vendor_id">This page overrides the default for selected vendor only</span>
</p>
</div>
</div>
</div>
<!-- Content -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Page Content
</h3>
<!-- Content Format -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Content Format
</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input type="radio" x-model="form.content_format" value="html" class="mr-2">
<span class="text-sm">HTML</span>
</label>
<label class="flex items-center cursor-pointer">
<input type="radio" x-model="form.content_format" value="markdown" class="mr-2">
<span class="text-sm">Markdown</span>
</label>
</div>
</div>
<!-- Content Editor -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Content <span class="text-red-500">*</span>
</label>
<!-- Rich Text Editor for HTML format -->
<div x-show="form.content_format === 'html'" x-cloak>
{{ quill_editor(
id='content-editor',
model='form.content',
placeholder='Write your content here...',
min_height='300px',
toolbar='full',
help_text='Use the toolbar to format your content. Supports headings, lists, links, images, and more.'
) }}
</div>
<!-- Plain textarea for Markdown format -->
<div x-show="form.content_format === 'markdown'" x-cloak>
<textarea
x-model="form.content"
rows="12"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700 font-mono text-sm"
placeholder="# Your heading here&#10;&#10;Write your **markdown** content..."
></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Enter Markdown content. Will be converted to HTML when displayed.
</p>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- HOMEPAGE SECTIONS EDITOR (only for slug='home') -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<div x-show="isHomepage" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Homepage Sections
<span class="text-sm font-normal text-gray-500 ml-2">(Multi-language content)</span>
</h3>
<span x-show="!sectionsLoaded" class="text-sm text-gray-500">
<span x-html="$icon('spinner', 'w-4 h-4 inline mr-1')"></span>
Loading sections...
</span>
</div>
<!-- Language Tabs -->
<div class="mb-6" x-show="sectionsLoaded">
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex -mb-px space-x-4">
<template x-for="lang in supportedLanguages" :key="lang">
<button
type="button"
@click="currentLang = lang"
:class="currentLang === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="py-2 px-4 border-b-2 font-medium text-sm transition-colors"
>
<span x-text="languageNames[lang] || lang.toUpperCase()"></span>
<span x-show="lang === defaultLanguage" class="ml-1 text-xs text-gray-400">(default)</span>
</button>
</template>
</nav>
</div>
</div>
<!-- Section Accordions -->
<div class="space-y-4" x-show="sectionsLoaded">
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- HERO SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'hero' ? null : 'hero'"
class="w-full flex items-center justify-between p-4 text-left bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span class="font-medium text-gray-900 dark:text-white">Hero Section</span>
<div class="flex items-center space-x-3">
<label class="flex items-center" @click.stop>
<input type="checkbox" x-model="sections.hero.enabled" class="w-4 h-4 text-purple-600 rounded">
<span class="ml-2 text-sm text-gray-500">Enabled</span>
</label>
<span :class="openSection === 'hero' ? 'rotate-180' : ''" class="transition-transform" x-html="$icon('chevron-down', 'w-5 h-5 text-gray-400')"></span>
</div>
</button>
<div x-show="openSection === 'hero'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700">
<!-- Badge Text -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Badge Text</label>
<input
type="text"
x-model="sections.hero.badge_text.translations[currentLang]"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="'Badge text in ' + languageNames[currentLang]"
>
</div>
<!-- Title -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Title <span class="text-red-500">*</span></label>
<input
type="text"
x-model="sections.hero.title.translations[currentLang]"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="'Hero title in ' + languageNames[currentLang]"
>
</div>
<!-- Subtitle -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Subtitle</label>
<textarea
x-model="sections.hero.subtitle.translations[currentLang]"
rows="2"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="'Hero subtitle in ' + languageNames[currentLang]"
></textarea>
</div>
<!-- Buttons -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Buttons</label>
<template x-for="(button, idx) in sections.hero.buttons" :key="idx">
<div class="flex gap-2 mb-2">
<input
type="text"
x-model="button.text.translations[currentLang]"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm"
:placeholder="'Button text'"
>
<input
type="text"
x-model="button.url"
class="w-32 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm"
placeholder="/signup"
>
<select x-model="button.style" class="w-28 px-2 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm">
<option value="primary">Primary</option>
<option value="secondary">Secondary</option>
<option value="outline">Outline</option>
</select>
<button type="button" @click="removeButton('hero', idx)" class="px-3 py-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
</div>
</template>
<button type="button" @click="addButton('hero')" class="text-sm text-purple-600 hover:text-purple-700 font-medium">
+ Add Button
</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- FEATURES SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'features' ? null : 'features'"
class="w-full flex items-center justify-between p-4 text-left bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span class="font-medium text-gray-900 dark:text-white">Features Section</span>
<div class="flex items-center space-x-3">
<label class="flex items-center" @click.stop>
<input type="checkbox" x-model="sections.features.enabled" class="w-4 h-4 text-purple-600 rounded">
<span class="ml-2 text-sm text-gray-500">Enabled</span>
</label>
<span :class="openSection === 'features' ? 'rotate-180' : ''" class="transition-transform" x-html="$icon('chevron-down', 'w-5 h-5 text-gray-400')"></span>
</div>
</button>
<div x-show="openSection === 'features'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700">
<!-- Section Title -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Section Title</label>
<input
type="text"
x-model="sections.features.title.translations[currentLang]"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="'Features title in ' + languageNames[currentLang]"
>
</div>
<!-- Feature Cards -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Feature Cards</label>
<template x-for="(feature, idx) in sections.features.features" :key="idx">
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Feature <span x-text="idx + 1"></span></span>
<button type="button" @click="removeFeature(idx)" class="text-red-500 hover:text-red-700">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<input
type="text"
x-model="feature.icon"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm"
placeholder="Icon name (e.g., bolt)"
>
<input
type="text"
x-model="feature.title.translations[currentLang]"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm"
:placeholder="'Title'"
>
<input
type="text"
x-model="feature.description.translations[currentLang]"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm"
:placeholder="'Description'"
>
</div>
</div>
</template>
<button type="button" @click="addFeature()" class="text-sm text-purple-600 hover:text-purple-700 font-medium">
+ Add Feature Card
</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- PRICING SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
class="w-full flex items-center justify-between p-4 text-left bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span class="font-medium text-gray-900 dark:text-white">Pricing Section</span>
<div class="flex items-center space-x-3">
<label class="flex items-center" @click.stop>
<input type="checkbox" x-model="sections.pricing.enabled" class="w-4 h-4 text-purple-600 rounded">
<span class="ml-2 text-sm text-gray-500">Enabled</span>
</label>
<span :class="openSection === 'pricing' ? 'rotate-180' : ''" class="transition-transform" x-html="$icon('chevron-down', 'w-5 h-5 text-gray-400')"></span>
</div>
</button>
<div x-show="openSection === 'pricing'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700">
<!-- Section Title -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Section Title</label>
<input
type="text"
x-model="sections.pricing.title.translations[currentLang]"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="'Pricing title in ' + languageNames[currentLang]"
>
</div>
<!-- Use Subscription Tiers -->
<div class="flex items-center">
<input type="checkbox" x-model="sections.pricing.use_subscription_tiers" class="w-4 h-4 text-purple-600 rounded">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Use subscription tiers from database</span>
</div>
<p class="text-xs text-gray-500">When enabled, pricing cards are dynamically pulled from your subscription tier configuration.</p>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- CTA SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'cta' ? null : 'cta'"
class="w-full flex items-center justify-between p-4 text-left bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span class="font-medium text-gray-900 dark:text-white">Call to Action Section</span>
<div class="flex items-center space-x-3">
<label class="flex items-center" @click.stop>
<input type="checkbox" x-model="sections.cta.enabled" class="w-4 h-4 text-purple-600 rounded">
<span class="ml-2 text-sm text-gray-500">Enabled</span>
</label>
<span :class="openSection === 'cta' ? 'rotate-180' : ''" class="transition-transform" x-html="$icon('chevron-down', 'w-5 h-5 text-gray-400')"></span>
</div>
</button>
<div x-show="openSection === 'cta'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700">
<!-- Title -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Title</label>
<input
type="text"
x-model="sections.cta.title.translations[currentLang]"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="'CTA title in ' + languageNames[currentLang]"
>
</div>
<!-- Subtitle -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Subtitle</label>
<textarea
x-model="sections.cta.subtitle.translations[currentLang]"
rows="2"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="'CTA subtitle in ' + languageNames[currentLang]"
></textarea>
</div>
<!-- Buttons -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Buttons</label>
<template x-for="(button, idx) in sections.cta.buttons" :key="idx">
<div class="flex gap-2 mb-2">
<input
type="text"
x-model="button.text.translations[currentLang]"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm"
:placeholder="'Button text'"
>
<input
type="text"
x-model="button.url"
class="w-32 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm"
placeholder="/signup"
>
<select x-model="button.style" class="w-28 px-2 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm">
<option value="primary">Primary</option>
<option value="secondary">Secondary</option>
<option value="outline">Outline</option>
</select>
<button type="button" @click="removeButton('cta', idx)" class="px-3 py-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
</div>
</template>
<button type="button" @click="addButton('cta')" class="text-sm text-purple-600 hover:text-purple-700 font-medium">
+ Add Button
</button>
</div>
</div>
</div>
</div>
</div>
<!-- SEO Settings -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
SEO & Metadata
</h3>
<div class="space-y-4">
<!-- Meta Description -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Meta Description
</label>
<textarea
x-model="form.meta_description"
rows="2"
maxlength="300"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="A brief description for search engines"
></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-text="(form.meta_description || '').length"></span>/300 characters (150-160 recommended)
</p>
</div>
<!-- Meta Keywords -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Meta Keywords
</label>
<input
type="text"
x-model="form.meta_keywords"
maxlength="300"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="keyword1, keyword2, keyword3"
>
</div>
</div>
</div>
<!-- Navigation & Display Settings -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Navigation & Display
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Display Order -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Display Order
</label>
{{ number_stepper(model='form.display_order', min=0, max=100, step=1, label='Display Order') }}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Lower = first</p>
</div>
<!-- Show in Header -->
<div class="flex items-center">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="form.show_in_header"
class="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-white">
Show in Header
</span>
</label>
</div>
<!-- Show in Footer -->
<div class="flex items-center">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="form.show_in_footer"
class="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-white">
Show in Footer
</span>
</label>
</div>
<!-- Show in Legal (Bottom Bar) -->
<div class="flex items-center">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="form.show_in_legal"
class="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-white">
Show in Legal
</span>
</label>
<span class="ml-2 text-gray-400 dark:text-gray-500 cursor-help" title="Bottom bar next to copyright">
<span x-html="$icon('information-circle', 'w-4 h-4')"></span>
</span>
</div>
</div>
</div>
<!-- Publishing Settings -->
<div class="p-6 bg-gray-50 dark:bg-gray-700/50">
<div class="flex items-center justify-between">
<div>
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="form.is_published"
class="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-white">
Published
</span>
</label>
<p class="ml-8 text-xs text-gray-500 dark:text-gray-400">
Make this page visible to the public
</p>
</div>
<div class="flex gap-2">
<a
href="/admin/content-pages"
class="px-6 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-200 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:border-gray-400 dark:hover:border-gray-500"
>
Cancel
</a>
<button
type="submit"
:disabled="saving"
class="px-6 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-text="saving ? 'Saving...' : (pageId ? 'Update Page' : 'Create Page')"></span>
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('cms_static', path='admin/js/content-page-edit.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,182 @@
{# 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 %}
{% from 'shared/macros/tabs.html' import tabs_inline, tab_button %}
{% block title %}Content Pages{% endblock %}
{% block alpine_data %}contentPagesManager(){% endblock %}
{% block content %}
{{ page_header('Content Pages', subtitle='Manage platform defaults and vendor-specific content pages', action_label='Create Page', action_url='/admin/content-pages/create') }}
{{ loading_state('Loading pages...') }}
{{ error_state('Error loading pages') }}
<!-- Tabs and Filters -->
<div x-show="!loading" class="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-md p-4">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<!-- Tabs -->
{% call tabs_inline() %}
{{ tab_button('all', 'All Pages', count_var='allPages.length') }}
{{ tab_button('platform_marketing', 'Platform Marketing', count_var='platformMarketingPages.length') }}
{{ tab_button('vendor_defaults', 'Vendor Defaults', count_var='vendorDefaultPages.length') }}
{{ tab_button('vendor_overrides', 'Vendor Overrides', count_var='vendorOverridePages.length') }}
{% endcall %}
<!-- Filters Row -->
<div class="flex items-center gap-3">
<!-- Platform Filter -->
<div class="relative">
<select
x-model="selectedPlatform"
class="pl-3 pr-8 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 cursor-pointer"
style="appearance: none; -webkit-appearance: none; -moz-appearance: none; background-image: none;"
>
<option value="">All Platforms</option>
<template x-for="platform in platforms" :key="platform.id">
<option :value="platform.code" x-text="platform.name"></option>
</template>
</select>
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span x-html="$icon('chevron-down', 'w-4 h-4 text-gray-400')"></span>
</span>
</div>
<!-- Search -->
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input
type="text"
x-model="searchQuery"
placeholder="Search pages..."
class="pl-10 pr-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500"
>
</div>
</div>
</div>
</div>
<!-- Pages Table -->
<div x-show="!loading && filteredPages.length > 0" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 dark:text-gray-400">
<th class="px-4 py-3">Page</th>
<th class="px-4 py-3">Slug</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Navigation</th>
<th class="px-4 py-3">Updated</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y dark:divide-gray-700">
<template x-for="page in filteredPages" :key="page.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<!-- Page Title -->
<td class="px-4 py-3">
<div>
<p class="font-semibold text-gray-900 dark:text-white" x-text="page.title"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-show="page.vendor_name">
Vendor: <span x-text="page.vendor_name"></span>
</p>
</div>
</td>
<!-- Slug -->
<td class="px-4 py-3 text-sm">
<code class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="'/' + page.slug"></code>
</td>
<!-- Type (Three-Tier System) -->
<td class="px-4 py-3 text-sm">
<span
class="px-2 py-1 text-xs font-semibold rounded-full"
:class="getPageTierClass(page)"
x-text="getPageTierLabel(page)"
></span>
<!-- Platform badge -->
<span
x-show="page.platform_name"
class="ml-1 px-1.5 py-0.5 text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded"
x-text="page.platform_name"
></span>
</td>
<!-- Status -->
<td class="px-4 py-3 text-sm">
<span
class="px-2 py-1 text-xs font-semibold rounded-full"
:class="page.is_published ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'"
x-text="page.is_published ? 'Published' : 'Draft'"
></span>
</td>
<!-- Navigation -->
<td class="px-4 py-3 text-xs">
<div class="flex gap-1 flex-wrap">
<span x-show="page.show_in_header" class="px-2 py-0.5 font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-300 rounded-full">Header</span>
<span x-show="page.show_in_footer" class="px-2 py-0.5 font-medium bg-teal-100 text-teal-800 dark:bg-teal-900/50 dark:text-teal-300 rounded-full">Footer</span>
<span x-show="page.show_in_legal" class="px-2 py-0.5 font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300 rounded-full">Legal</span>
<span x-show="!page.show_in_header && !page.show_in_footer && !page.show_in_legal" class="text-gray-400"></span>
</div>
</td>
<!-- Updated -->
<td class="px-4 py-3 text-xs" x-text="formatDate(page.updated_at)"></td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<a
:href="`/admin/content-pages/${page.id}/edit`"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Edit"
>
<span x-html="$icon('edit', 'w-5 h-5')"></span>
</a>
<button
@click="deletePage(page)"
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Delete"
>
<span x-html="$icon('delete', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Empty State -->
<div x-show="!loading && filteredPages.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<span x-html="$icon('document-text', 'inline w-16 h-16 text-gray-400 mb-4')"></span>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">No pages found</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4" x-show="searchQuery">
No pages match your search: "<span x-text="searchQuery"></span>"
</p>
<p class="text-gray-500 dark:text-gray-400 mb-4" x-show="!searchQuery && activeTab === 'vendor'">
No vendor-specific pages have been created yet.
</p>
<a
href="/admin/content-pages/create"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
>
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create First Page
</a>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('cms_static', path='admin/js/content-pages.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,326 @@
{# 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 %}
{% from 'shared/macros/inputs.html' import number_stepper %}
{% from 'shared/macros/modals.html' import modal %}
{% block title %}{% if page_id %}Edit{% else %}Create{% endif %} Content Page{% endblock %}
{% block alpine_data %}vendorContentPageEditor({{ page_id if page_id else 'null' }}){% endblock %}
{% block content %}
{# Dynamic title/subtitle and save button text based on create vs edit mode #}
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200" x-text="pageId ? 'Edit Content Page' : 'Create Content Page'"></h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
<span x-show="!pageId">Create a new custom page for your shop</span>
<span x-show="pageId && isOverride">Customize this platform default page</span>
<span x-show="pageId && !isOverride">Edit your custom page</span>
</p>
</div>
<div class="flex items-center space-x-3">
{{ back_button('/vendor/' + vendor_code + '/content-pages', 'Back to List') }}
<button
@click="savePage()"
:disabled="saving"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 transition-colors duration-150 border rounded-lg focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed text-white bg-purple-600 border-transparent hover:bg-purple-700 focus:shadow-outline-purple"
>
<span x-show="!saving" x-html="$icon('check', 'w-4 h-4 mr-2')"></span>
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="saving ? 'Saving...' : (pageId ? 'Update Page' : 'Create Page')"></span>
</button>
</div>
</div>
{{ loading_state('Loading page...') }}
{{ error_state('Error', show_condition='error && !loading') }}
{{ alert_dynamic(type='success', message_var='successMessage', show_condition='successMessage') }}
<!-- Override Info Banner -->
<div x-show="!loading && isOverride" class="mb-6 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
<div class="flex items-start justify-between">
<div class="flex">
<span x-html="$icon('information-circle', 'w-5 h-5 text-purple-500 mr-3 flex-shrink-0 mt-0.5')"></span>
<div>
<h4 class="text-sm font-medium text-purple-800 dark:text-purple-200">Overriding Platform Default</h4>
<p class="text-sm text-purple-700 dark:text-purple-300 mt-1">
You're customizing the "<span x-text="form.title"></span>" page. Your version will be shown to customers instead of the platform default.
</p>
</div>
</div>
<div class="flex items-center gap-2 ml-4 flex-shrink-0">
<button
@click="showDefaultPreview()"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-purple-700 bg-white dark:bg-purple-900/50 dark:text-purple-300 border border-purple-300 dark:border-purple-700 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900 transition-colors"
>
<span x-html="$icon('eye', 'w-4 h-4 mr-1')"></span>
View Default
</button>
<button
@click="deletePage()"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-red-700 bg-white dark:bg-red-900/50 dark:text-red-300 border border-red-300 dark:border-red-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900 transition-colors"
>
<span x-html="$icon('arrow-uturn-left', 'w-4 h-4 mr-1')"></span>
Revert to Default
</button>
</div>
</div>
</div>
<!-- Default Content Preview Modal -->
{% call modal('defaultPreviewModal', 'Platform Default Content', 'showingDefaultPreview', size='lg', show_footer=false) %}
<div x-show="loadingDefault" class="text-center py-8">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-sm text-gray-500">Loading default content...</p>
</div>
<div x-show="!loadingDefault && defaultContent">
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Title</h4>
<p class="text-lg font-semibold text-gray-900 dark:text-white mb-4" x-text="defaultContent?.title"></p>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Content</h4>
<div class="prose dark:prose-invert max-w-none bg-gray-50 dark:bg-gray-700 rounded-lg p-4" x-html="defaultContent?.content"></div>
</div>
{% endcall %}
<!-- Main Form -->
<div x-show="!loading" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<form @submit.prevent="savePage()">
<!-- Basic Information -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Basic Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Page Title -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Page Title <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="form.title"
required
maxlength="200"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="About Our Store"
>
</div>
<!-- Slug -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
URL Slug <span class="text-red-500">*</span>
</label>
<div class="flex items-center">
<span class="text-sm text-gray-500 dark:text-gray-400 mr-2">/</span>
<input
type="text"
x-model="form.slug"
required
maxlength="100"
pattern="[a-z0-9\-_]+"
:disabled="isOverride"
class="flex-1 px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="about-us"
>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-show="!isOverride">URL-safe identifier (lowercase, numbers, hyphens, underscores only)</span>
<span x-show="isOverride">Slug cannot be changed for override pages</span>
</p>
</div>
</div>
</div>
<!-- Content -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Page Content
</h3>
<!-- Content Format -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Content Format
</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input type="radio" x-model="form.content_format" value="html" class="mr-2">
<span class="text-sm">HTML</span>
</label>
<label class="flex items-center cursor-pointer">
<input type="radio" x-model="form.content_format" value="markdown" class="mr-2">
<span class="text-sm">Markdown</span>
</label>
</div>
</div>
<!-- Content Editor -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Content <span class="text-red-500">*</span>
</label>
<textarea
x-model="form.content"
required
rows="12"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700 font-mono text-sm"
placeholder="<h2>Your content here...</h2>"
></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-show="form.content_format === 'html'">Enter HTML content. Basic HTML tags are supported.</span>
<span x-show="form.content_format === 'markdown'">Enter Markdown content. Will be converted to HTML.</span>
</p>
</div>
</div>
<!-- SEO Settings -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
SEO & Metadata
</h3>
<div class="space-y-4">
<!-- Meta Description -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Meta Description
</label>
<textarea
x-model="form.meta_description"
rows="2"
maxlength="300"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="A brief description for search engines"
></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-text="(form.meta_description || '').length"></span>/300 characters (150-160 recommended)
</p>
</div>
<!-- Meta Keywords -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Meta Keywords
</label>
<input
type="text"
x-model="form.meta_keywords"
maxlength="300"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="keyword1, keyword2, keyword3"
>
</div>
</div>
</div>
<!-- Navigation & Display Settings -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Navigation & Display
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Display Order -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Display Order
</label>
{{ number_stepper(model='form.display_order', min=0, max=100, step=1, label='Display Order') }}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Lower = first</p>
</div>
<!-- Show in Header -->
<div class="flex items-center">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="form.show_in_header"
class="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-white">
Show in Header
</span>
</label>
</div>
<!-- Show in Footer -->
<div class="flex items-center">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="form.show_in_footer"
class="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-white">
Show in Footer
</span>
</label>
</div>
<!-- Show in Legal -->
<div class="flex items-center">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="form.show_in_legal"
class="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-white">
Show in Legal
</span>
</label>
<span class="ml-2 text-gray-400 dark:text-gray-500 cursor-help" title="Bottom bar next to copyright">
<span x-html="$icon('information-circle', 'w-4 h-4')"></span>
</span>
</div>
</div>
</div>
<!-- Publishing Settings -->
<div class="p-6 bg-gray-50 dark:bg-gray-700/50">
<div class="flex items-center justify-between">
<div>
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="form.is_published"
class="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-white">
Published
</span>
</label>
<p class="ml-8 text-xs text-gray-500 dark:text-gray-400">
Make this page visible to your customers
</p>
</div>
<div class="flex gap-2">
<a
:href="`/vendor/${vendorCode}/content-pages`"
class="px-6 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-200 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:border-gray-400 dark:hover:border-gray-500"
>
Cancel
</a>
<button
type="submit"
:disabled="saving"
class="px-6 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-text="saving ? 'Saving...' : (pageId ? 'Update Page' : 'Create Page')"></span>
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('cms_static', path='vendor/js/content-page-edit.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,327 @@
{# 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 %}
{% from 'shared/macros/tabs.html' import tabs_inline, tab_button %}
{% block title %}Content Pages{% endblock %}
{% block alpine_data %}vendorContentPagesManager(){% endblock %}
{% block content %}
{{ page_header('Content Pages', subtitle='Customize your shop pages or create new ones', action_label='Create Page', action_url='/vendor/' + vendor_code + '/content-pages/create') }}
{{ loading_state('Loading pages...') }}
{{ error_state('Error loading pages') }}
<!-- CMS Usage Indicator -->
<div x-show="!loading && cmsUsage" class="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-md p-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex-1">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">CMS Usage</span>
<span class="text-sm text-gray-500 dark:text-gray-400">
<span x-text="cmsUsage.total_pages"></span>
<span x-show="cmsUsage.pages_limit"> / <span x-text="cmsUsage.pages_limit"></span></span>
<span x-show="!cmsUsage.pages_limit"> (unlimited)</span>
pages
</span>
</div>
<!-- Progress Bar -->
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
<div
class="h-2.5 rounded-full transition-all duration-300"
:class="{
'bg-green-500': cmsUsage.usage_percent < 70,
'bg-yellow-500': cmsUsage.usage_percent >= 70 && cmsUsage.usage_percent < 90,
'bg-red-500': cmsUsage.usage_percent >= 90
}"
:style="`width: ${cmsUsage.pages_limit ? cmsUsage.usage_percent : 0}%`"
></div>
</div>
<div class="flex justify-between mt-1 text-xs text-gray-500 dark:text-gray-400">
<span><span x-text="cmsUsage.override_pages"></span> overrides</span>
<span><span x-text="cmsUsage.custom_pages"></span> custom pages</span>
</div>
</div>
<!-- Upgrade Prompt (show when approaching limit) -->
<div x-show="cmsUsage.pages_limit && cmsUsage.usage_percent >= 80" class="flex-shrink-0">
<a
href="/vendor/{{ vendor_code }}/settings/billing"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-100 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors"
>
<span x-html="$icon('arrow-trending-up', 'w-4 h-4 mr-1')"></span>
Upgrade for more pages
</a>
</div>
</div>
</div>
<!-- Tabs and Info -->
<div x-show="!loading" class="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-md p-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<!-- Tabs -->
{% call tabs_inline() %}
{{ tab_button('platform', 'Platform Defaults', count_var='platformPages.length') }}
{{ tab_button('custom', 'My Pages', count_var='customPages.length') }}
{% endcall %}
<!-- Search -->
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input
type="text"
x-model="searchQuery"
placeholder="Search pages..."
class="pl-10 pr-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500"
>
</div>
</div>
</div>
<!-- Platform Defaults Tab -->
<div x-show="!loading && activeTab === 'platform'" class="space-y-4">
<!-- Info Banner -->
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div class="flex">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-500 mr-3 flex-shrink-0 mt-0.5')"></span>
<div>
<h4 class="text-sm font-medium text-blue-800 dark:text-blue-200">Platform Default Pages</h4>
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
These pages are provided by the platform. You can override any of them with your own custom content.
Your overridden version will be shown to your customers instead of the default.
</p>
</div>
</div>
</div>
<!-- Platform Pages Table -->
<div x-show="filteredPlatformPages.length > 0" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 dark:text-gray-400">
<th class="px-4 py-3">Page</th>
<th class="px-4 py-3">URL</th>
<th class="px-4 py-3">Navigation</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y dark:divide-gray-700">
<template x-for="page in filteredPlatformPages" :key="page.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<!-- Page Title -->
<td class="px-4 py-3">
<div>
<p class="font-semibold text-gray-900 dark:text-white" x-text="page.title"></p>
<p class="text-xs text-gray-500 dark:text-gray-400">Platform Default</p>
</div>
</td>
<!-- URL/Slug -->
<td class="px-4 py-3 text-sm">
<code class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="'/' + page.slug"></code>
</td>
<!-- Navigation -->
<td class="px-4 py-3 text-xs">
<div class="flex gap-1 flex-wrap">
<span x-show="page.show_in_header" class="px-2 py-0.5 font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-300 rounded-full">Header</span>
<span x-show="page.show_in_footer" class="px-2 py-0.5 font-medium bg-teal-100 text-teal-800 dark:bg-teal-900/50 dark:text-teal-300 rounded-full">Footer</span>
<span x-show="page.show_in_legal" class="px-2 py-0.5 font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300 rounded-full">Legal</span>
<span x-show="!page.show_in_header && !page.show_in_footer && !page.show_in_legal" class="text-gray-400"></span>
</div>
</td>
<!-- Status -->
<td class="px-4 py-3 text-sm">
<span x-show="hasOverride(page.slug)" class="px-2 py-1 text-xs font-semibold bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded-full">
Overridden
</span>
<span x-show="!hasOverride(page.slug)" class="px-2 py-1 text-xs font-semibold bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300 rounded-full">
Using Default
</span>
</td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<!-- Override / Edit Override button -->
<template x-if="hasOverride(page.slug)">
<a
:href="`/vendor/${vendorCode}/content-pages/${getOverrideId(page.slug)}/edit`"
class="flex items-center justify-center px-3 py-1.5 text-sm font-medium text-purple-600 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-400 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 transition-colors"
>
<span x-html="$icon('edit', 'w-4 h-4 mr-1')"></span>
Edit Override
</a>
</template>
<template x-if="!hasOverride(page.slug)">
<button
@click="createOverride(page)"
class="flex items-center justify-center px-3 py-1.5 text-sm font-medium text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 dark:text-blue-400 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 transition-colors"
>
<span x-html="$icon('plus', 'w-4 h-4 mr-1')"></span>
Override
</button>
</template>
<!-- Preview button -->
<a
:href="`/vendors/${vendorCode}/shop/${page.slug}`"
target="_blank"
class="flex items-center justify-center p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors"
title="Preview"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Empty State -->
<div x-show="filteredPlatformPages.length === 0 && searchQuery" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<span x-html="$icon('search', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">No pages found</h3>
<p class="text-gray-500 dark:text-gray-400">
No platform pages match "<span x-text="searchQuery"></span>"
</p>
</div>
</div>
<!-- Custom Pages Tab -->
<div x-show="!loading && activeTab === 'custom'" class="space-y-4">
<!-- Info Banner -->
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div class="flex">
<span x-html="$icon('plus-circle', 'w-5 h-5 text-green-500 mr-3 flex-shrink-0 mt-0.5')"></span>
<div>
<h4 class="text-sm font-medium text-green-800 dark:text-green-200">Your Custom Pages</h4>
<p class="text-sm text-green-700 dark:text-green-300 mt-1">
Create unique pages for your shop like promotions, brand story, or special information.
These pages are exclusive to your store.
</p>
</div>
</div>
</div>
<!-- Custom Pages Table -->
<div x-show="filteredCustomPages.length > 0" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 dark:text-gray-400">
<th class="px-4 py-3">Page</th>
<th class="px-4 py-3">URL</th>
<th class="px-4 py-3">Navigation</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Updated</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y dark:divide-gray-700">
<template x-for="page in filteredCustomPages" :key="page.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<!-- Page Title -->
<td class="px-4 py-3">
<div>
<p class="font-semibold text-gray-900 dark:text-white" x-text="page.title"></p>
<p x-show="page.is_vendor_override" class="text-xs text-purple-600 dark:text-purple-400">Override of platform default</p>
<p x-show="!page.is_vendor_override" class="text-xs text-green-600 dark:text-green-400">Custom page</p>
</div>
</td>
<!-- URL/Slug -->
<td class="px-4 py-3 text-sm">
<code class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="'/' + page.slug"></code>
</td>
<!-- Navigation -->
<td class="px-4 py-3 text-xs">
<div class="flex gap-1 flex-wrap">
<span x-show="page.show_in_header" class="px-2 py-0.5 font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-300 rounded-full">Header</span>
<span x-show="page.show_in_footer" class="px-2 py-0.5 font-medium bg-teal-100 text-teal-800 dark:bg-teal-900/50 dark:text-teal-300 rounded-full">Footer</span>
<span x-show="page.show_in_legal" class="px-2 py-0.5 font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300 rounded-full">Legal</span>
<span x-show="!page.show_in_header && !page.show_in_footer && !page.show_in_legal" class="text-gray-400"></span>
</div>
</td>
<!-- Status -->
<td class="px-4 py-3 text-sm">
<span
class="px-2 py-1 text-xs font-semibold rounded-full"
:class="page.is_published ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'"
x-text="page.is_published ? 'Published' : 'Draft'"
></span>
</td>
<!-- Updated -->
<td class="px-4 py-3 text-xs" x-text="formatDate(page.updated_at)"></td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<a
:href="`/vendor/${vendorCode}/content-pages/${page.id}/edit`"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Edit"
>
<span x-html="$icon('edit', 'w-5 h-5')"></span>
</a>
<a
:href="`/vendors/${vendorCode}/shop/${page.slug}`"
target="_blank"
class="flex items-center justify-center p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors"
title="Preview"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
<button
@click="deletePage(page)"
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Delete"
>
<span x-html="$icon('delete', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Empty State -->
<div x-show="filteredCustomPages.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<span x-html="$icon('document-text', 'inline w-16 h-16 text-gray-400 mb-4')"></span>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2" x-text="searchQuery ? 'No pages found' : 'No custom pages yet'"></h3>
<p class="text-gray-500 dark:text-gray-400 mb-4" x-show="searchQuery">
No custom pages match "<span x-text="searchQuery"></span>"
</p>
<p class="text-gray-500 dark:text-gray-400 mb-4" x-show="!searchQuery">
Create your first custom page or override a platform default.
</p>
<a
:href="`/vendor/${vendorCode}/content-pages/create`"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
>
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Page
</a>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('cms_static', path='vendor/js/content-pages.js') }}"></script>
{% endblock %}