feat: add platform detail/edit admin UI and service enhancements
- Add platform detail and edit admin pages with templates and JS - Add ContentPageService methods: list_all_platform_pages, list_all_vendor_defaults - Deprecate /admin/platform-homepage route (redirects to /admin/platforms) - Add migration to fix content_page nullable columns - Refine platform and vendor context middleware - Add platform context middleware unit tests - Update platforms.js with improved functionality - Add section-based homepage plan documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -178,9 +178,7 @@ def upgrade() -> None:
|
||||
""")
|
||||
)
|
||||
|
||||
# Get the OMS platform ID
|
||||
result = conn.execute(sa.text("SELECT id FROM platforms WHERE code = 'oms'"))
|
||||
oms_platform_id = result.fetchone()[0]
|
||||
I dn
|
||||
|
||||
# =========================================================================
|
||||
# 6. Backfill content_pages with platform_id
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Fix content_page nullable boolean columns
|
||||
|
||||
Revision ID: z7h8i9j0k1l2
|
||||
Revises: z6g7h8i9j0k1
|
||||
Create Date: 2026-01-20
|
||||
|
||||
This migration:
|
||||
1. Sets NULL values to defaults for boolean and integer columns
|
||||
2. Alters columns to be NOT NULL
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "z7h8i9j0k1l2"
|
||||
down_revision = "z6g7h8i9j0k1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# First, update any NULL values to defaults
|
||||
op.execute("""
|
||||
UPDATE content_pages
|
||||
SET display_order = 0
|
||||
WHERE display_order IS NULL
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
UPDATE content_pages
|
||||
SET show_in_footer = true
|
||||
WHERE show_in_footer IS NULL
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
UPDATE content_pages
|
||||
SET show_in_header = false
|
||||
WHERE show_in_header IS NULL
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
UPDATE content_pages
|
||||
SET show_in_legal = false
|
||||
WHERE show_in_legal IS NULL
|
||||
""")
|
||||
|
||||
# Now alter columns to be NOT NULL
|
||||
op.alter_column(
|
||||
"content_pages",
|
||||
"display_order",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
)
|
||||
|
||||
op.alter_column(
|
||||
"content_pages",
|
||||
"show_in_footer",
|
||||
existing_type=sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="true",
|
||||
)
|
||||
|
||||
op.alter_column(
|
||||
"content_pages",
|
||||
"show_in_header",
|
||||
existing_type=sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="false",
|
||||
)
|
||||
|
||||
op.alter_column(
|
||||
"content_pages",
|
||||
"show_in_legal",
|
||||
existing_type=sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="false",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Revert columns to nullable (no server default)
|
||||
op.alter_column(
|
||||
"content_pages",
|
||||
"display_order",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=True,
|
||||
server_default=None,
|
||||
)
|
||||
|
||||
op.alter_column(
|
||||
"content_pages",
|
||||
"show_in_footer",
|
||||
existing_type=sa.Boolean(),
|
||||
nullable=True,
|
||||
server_default=None,
|
||||
)
|
||||
|
||||
op.alter_column(
|
||||
"content_pages",
|
||||
"show_in_header",
|
||||
existing_type=sa.Boolean(),
|
||||
nullable=True,
|
||||
server_default=None,
|
||||
)
|
||||
|
||||
op.alter_column(
|
||||
"content_pages",
|
||||
"show_in_legal",
|
||||
existing_type=sa.Boolean(),
|
||||
nullable=True,
|
||||
server_default=None,
|
||||
)
|
||||
@@ -69,7 +69,6 @@ class BasePlatformConfig(ABC):
|
||||
"""
|
||||
return [
|
||||
"home",
|
||||
"platform_homepage",
|
||||
"pricing",
|
||||
"about",
|
||||
"contact",
|
||||
|
||||
@@ -1116,23 +1116,21 @@ async def admin_platform_edit(
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/platform-homepage", response_class=HTMLResponse, include_in_schema=False)
|
||||
@router.get("/platform-homepage", include_in_schema=False)
|
||||
async def admin_platform_homepage_manager(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_admin_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render platform homepage manager.
|
||||
Allows editing the main platform homepage with template selection.
|
||||
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 templates.TemplateResponse(
|
||||
"admin/platform-homepage.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
},
|
||||
)
|
||||
from starlette.responses import RedirectResponse
|
||||
return RedirectResponse(url="/admin/platforms", status_code=302)
|
||||
|
||||
|
||||
@router.get("/content-pages", response_class=HTMLResponse, include_in_schema=False)
|
||||
|
||||
@@ -158,18 +158,13 @@ async def homepage(
|
||||
return RedirectResponse(url="/shop/", status_code=302)
|
||||
|
||||
# Scenario 2: Platform marketing site (no vendor)
|
||||
# Try to load platform homepage from CMS
|
||||
# Load platform homepage from CMS (slug='home')
|
||||
platform_id = platform.id if platform else 1
|
||||
|
||||
cms_homepage = content_page_service.get_platform_page(
|
||||
db, platform_id=platform_id, slug="home", include_unpublished=False
|
||||
)
|
||||
|
||||
if not cms_homepage:
|
||||
cms_homepage = content_page_service.get_platform_page(
|
||||
db, platform_id=platform_id, slug="platform_homepage", include_unpublished=False
|
||||
)
|
||||
|
||||
if cms_homepage:
|
||||
# Use CMS-based homepage with template selection
|
||||
context = get_platform_context(request, db)
|
||||
|
||||
@@ -305,6 +305,66 @@ class ContentPageService:
|
||||
.all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def list_all_platform_pages(
|
||||
db: Session,
|
||||
include_unpublished: bool = False,
|
||||
) -> list[ContentPage]:
|
||||
"""
|
||||
List all platform marketing pages across all platforms (for admin use).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
include_unpublished: Include draft pages
|
||||
|
||||
Returns:
|
||||
List of all platform marketing ContentPage objects
|
||||
"""
|
||||
filters = [
|
||||
ContentPage.vendor_id.is_(None),
|
||||
ContentPage.is_platform_page.is_(True),
|
||||
]
|
||||
|
||||
if not include_unpublished:
|
||||
filters.append(ContentPage.is_published.is_(True))
|
||||
|
||||
return (
|
||||
db.query(ContentPage)
|
||||
.filter(and_(*filters))
|
||||
.order_by(ContentPage.platform_id, ContentPage.display_order, ContentPage.title)
|
||||
.all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def list_all_vendor_defaults(
|
||||
db: Session,
|
||||
include_unpublished: bool = False,
|
||||
) -> list[ContentPage]:
|
||||
"""
|
||||
List all vendor default pages across all platforms (for admin use).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
include_unpublished: Include draft pages
|
||||
|
||||
Returns:
|
||||
List of all vendor default ContentPage objects
|
||||
"""
|
||||
filters = [
|
||||
ContentPage.vendor_id.is_(None),
|
||||
ContentPage.is_platform_page.is_(False),
|
||||
]
|
||||
|
||||
if not include_unpublished:
|
||||
filters.append(ContentPage.is_published.is_(True))
|
||||
|
||||
return (
|
||||
db.query(ContentPage)
|
||||
.filter(and_(*filters))
|
||||
.order_by(ContentPage.platform_id, ContentPage.display_order, ContentPage.title)
|
||||
.all()
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# CRUD Methods
|
||||
# =========================================================================
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<div class="relative">
|
||||
<select
|
||||
x-model="selectedPlatform"
|
||||
class="pl-3 pr-8 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 appearance-none cursor-pointer"
|
||||
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">
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
{{ section_header('Content Management', 'contentMgmt') }}
|
||||
{% call section_content('contentMgmt') %}
|
||||
{{ menu_item('platforms', '/admin/platforms', 'globe-alt', 'Platforms') }}
|
||||
{{ menu_item('platform-homepage', '/admin/platform-homepage', 'home', 'Platform Homepage') }}
|
||||
{{ menu_item('content-pages', '/admin/content-pages', 'document-text', 'Content Pages') }}
|
||||
{{ menu_item('vendor-theme', '/admin/vendor-themes', 'color-swatch', 'Vendor Themes') }}
|
||||
{% endcall %}
|
||||
|
||||
270
app/templates/admin/platform-detail.html
Normal file
270
app/templates/admin/platform-detail.html
Normal file
@@ -0,0 +1,270 @@
|
||||
{# app/templates/admin/platform-detail.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
|
||||
{% block title %}Platform Details{% endblock %}
|
||||
|
||||
{% block alpine_data %}platformDetail(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header with Back Button -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<div class="flex items-center">
|
||||
<a href="/admin/platforms" class="mr-4 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<span x-html="$icon('arrow-left', 'w-5 h-5 text-gray-600 dark:text-gray-400')"></span>
|
||||
</a>
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon(getPlatformIcon(platformCode), 'w-8 h-8 text-purple-600 dark:text-purple-400 mr-3')"></span>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200" x-text="platform?.name || 'Loading...'"></h2>
|
||||
<code class="ml-3 text-sm bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded" x-text="platformCode"></code>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="platform?.description || ''"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Status Badges -->
|
||||
<span
|
||||
x-show="platform?.is_active"
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Active
|
||||
</span>
|
||||
<span
|
||||
x-show="!platform?.is_active"
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
Inactive
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ loading_state('Loading platform details...') }}
|
||||
{{ error_state('Error loading platform') }}
|
||||
|
||||
<!-- Main Content -->
|
||||
<div x-show="!loading && platform" class="space-y-6">
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Quick Actions</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- Edit Settings -->
|
||||
<a :href="`/admin/platforms/${platformCode}/edit`"
|
||||
class="flex items-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg hover:bg-purple-100 dark:hover:bg-purple-900/40 transition-colors">
|
||||
<span x-html="$icon('cog', 'w-8 h-8 text-purple-600 dark:text-purple-400')"></span>
|
||||
<div class="ml-3">
|
||||
<p class="font-semibold text-gray-900 dark:text-white">Edit Settings</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Branding, domain, languages</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Edit Homepage -->
|
||||
<a :href="`/admin/content-pages?platform_code=${platformCode}&slug=home`"
|
||||
class="flex items-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors">
|
||||
<span x-html="$icon('home', 'w-8 h-8 text-blue-600 dark:text-blue-400')"></span>
|
||||
<div class="ml-3">
|
||||
<p class="font-semibold text-gray-900 dark:text-white">Edit Homepage</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Platform landing page</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Manage Pages -->
|
||||
<a :href="`/admin/content-pages?platform_code=${platformCode}`"
|
||||
class="flex items-center p-4 bg-teal-50 dark:bg-teal-900/20 rounded-lg hover:bg-teal-100 dark:hover:bg-teal-900/40 transition-colors">
|
||||
<span x-html="$icon('document-text', 'w-8 h-8 text-teal-600 dark:text-teal-400')"></span>
|
||||
<div class="ml-3">
|
||||
<p class="font-semibold text-gray-900 dark:text-white">Manage Pages</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">All content pages</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- View Platform -->
|
||||
<a :href="getPlatformUrl()" target="_blank"
|
||||
class="flex items-center p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<span x-html="$icon('external-link', 'w-8 h-8 text-gray-600 dark:text-gray-400')"></span>
|
||||
<div class="ml-3">
|
||||
<p class="font-semibold text-gray-900 dark:text-white">View Platform</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Open in new tab</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<!-- Vendors -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Vendors</p>
|
||||
<p class="text-3xl font-bold text-purple-600 dark:text-purple-400" x-text="platform?.vendor_count || 0"></p>
|
||||
</div>
|
||||
<div class="p-3 bg-purple-100 dark:bg-purple-900/50 rounded-full">
|
||||
<span x-html="$icon('building-storefront', 'w-6 h-6 text-purple-600 dark:text-purple-400')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marketing Pages -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Marketing Pages</p>
|
||||
<p class="text-3xl font-bold text-blue-600 dark:text-blue-400" x-text="platform?.platform_pages_count || 0"></p>
|
||||
</div>
|
||||
<div class="p-3 bg-blue-100 dark:bg-blue-900/50 rounded-full">
|
||||
<span x-html="$icon('megaphone', 'w-6 h-6 text-blue-600 dark:text-blue-400')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vendor Defaults -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Vendor Defaults</p>
|
||||
<p class="text-3xl font-bold text-teal-600 dark:text-teal-400" x-text="platform?.vendor_defaults_count || 0"></p>
|
||||
</div>
|
||||
<div class="p-3 bg-teal-100 dark:bg-teal-900/50 rounded-full">
|
||||
<span x-html="$icon('document-duplicate', 'w-6 h-6 text-teal-600 dark:text-teal-400')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Default Language</p>
|
||||
<p class="text-3xl font-bold text-gray-700 dark:text-gray-300" x-text="platform?.default_language?.toUpperCase() || '—'"></p>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-100 dark:bg-gray-700 rounded-full">
|
||||
<span x-html="$icon('language', 'w-6 h-6 text-gray-600 dark:text-gray-400')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Platform Configuration -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Routing Info -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Routing Configuration</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<span class="text-gray-600 dark:text-gray-400">Production Domain</span>
|
||||
<code x-show="platform?.domain" class="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded" x-text="platform?.domain"></code>
|
||||
<span x-show="!platform?.domain" class="text-gray-400 dark:text-gray-500 text-sm">Not configured</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<span class="text-gray-600 dark:text-gray-400">Dev Path Prefix</span>
|
||||
<code x-show="platform?.path_prefix" class="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded" x-text="`/platforms/${platform?.path_prefix}/`"></code>
|
||||
<span x-show="!platform?.path_prefix" class="text-gray-400 dark:text-gray-500 text-sm">Root path</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-2">
|
||||
<span class="text-gray-600 dark:text-gray-400">Supported Languages</span>
|
||||
<div class="flex gap-1">
|
||||
<template x-for="lang in (platform?.supported_languages || [])" :key="lang">
|
||||
<span class="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded" x-text="lang.toUpperCase()"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Branding -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Branding</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<span class="text-gray-600 dark:text-gray-400">Logo</span>
|
||||
<template x-if="platform?.logo">
|
||||
<img :src="platform.logo" alt="Logo" class="h-8 max-w-32 object-contain">
|
||||
</template>
|
||||
<span x-show="!platform?.logo" class="text-gray-400 dark:text-gray-500 text-sm">Not configured</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<span class="text-gray-600 dark:text-gray-400">Logo (Dark)</span>
|
||||
<template x-if="platform?.logo_dark">
|
||||
<img :src="platform.logo_dark" alt="Logo Dark" class="h-8 max-w-32 object-contain bg-gray-800 rounded p-1">
|
||||
</template>
|
||||
<span x-show="!platform?.logo_dark" class="text-gray-400 dark:text-gray-500 text-sm">Not configured</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-2">
|
||||
<span class="text-gray-600 dark:text-gray-400">Favicon</span>
|
||||
<template x-if="platform?.favicon">
|
||||
<img :src="platform.favicon" alt="Favicon" class="h-6 w-6 object-contain">
|
||||
</template>
|
||||
<span x-show="!platform?.favicon" class="text-gray-400 dark:text-gray-500 text-sm">Not configured</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Pages -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Content Pages</h3>
|
||||
<a :href="`/admin/content-pages?platform_code=${platformCode}`"
|
||||
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300">
|
||||
View All →
|
||||
</a>
|
||||
</div>
|
||||
<div x-show="recentPages.length > 0">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Title</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Slug</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="page in recentPages" :key="page.id">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td class="px-6 py-4">
|
||||
<a :href="`/admin/content-pages/${page.id}/edit`"
|
||||
class="text-gray-900 dark:text-white hover:text-purple-600 dark:hover:text-purple-400 font-medium"
|
||||
x-text="page.title"></a>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<code class="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded" x-text="page.slug"></code>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="getPageTypeBadgeClass(page)"
|
||||
x-text="getPageTypeLabel(page)"></span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span x-show="page.is_published" class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">Published</span>
|
||||
<span x-show="!page.is_published" class="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">Draft</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400" x-text="formatDate(page.updated_at)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div x-show="recentPages.length === 0" class="p-6 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('document-text', 'w-12 h-12 mx-auto mb-2 opacity-50')"></span>
|
||||
<p>No content pages yet.</p>
|
||||
<a :href="`/admin/content-pages/create?platform_code=${platformCode}`"
|
||||
class="inline-block mt-2 text-purple-600 hover:text-purple-700 dark:text-purple-400">
|
||||
Create your first page →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timestamps -->
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>Created: <span x-text="formatDate(platform?.created_at)"></span></p>
|
||||
<p>Last Updated: <span x-text="formatDate(platform?.updated_at)"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/admin/js/platform-detail.js"></script>
|
||||
{% endblock %}
|
||||
324
app/templates/admin/platform-edit.html
Normal file
324
app/templates/admin/platform-edit.html
Normal file
@@ -0,0 +1,324 @@
|
||||
{# app/templates/admin/platform-edit.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state %}
|
||||
{% from 'shared/macros/headers.html' import edit_page_header %}
|
||||
|
||||
{% block title %}Edit Platform{% endblock %}
|
||||
|
||||
{% block alpine_data %}platformEdit(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% call edit_page_header('Edit Platform', '/admin/platforms', subtitle_show='platform', back_label='Back to Platforms') %}
|
||||
<span x-text="platform?.name"></span>
|
||||
<span class="text-gray-400">•</span>
|
||||
<code class="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded" x-text="platform?.code"></code>
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading platform...', show_condition='loading') }}
|
||||
|
||||
<!-- Error State -->
|
||||
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg dark:bg-red-900/50 dark:border-red-600 dark:text-red-200">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('exclamation-circle', 'w-5 h-5 mr-2')"></span>
|
||||
<span x-text="error"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div x-show="success" x-transition class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg dark:bg-green-900/50 dark:border-green-600 dark:text-green-200">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2')"></span>
|
||||
<span x-text="success"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Form -->
|
||||
<div x-show="!loading && platform">
|
||||
<!-- Quick Actions Card -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
@click="toggleActive()"
|
||||
:disabled="saving"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
||||
:class="formData.is_active ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700'">
|
||||
<span x-html="$icon(formData.is_active ? 'pause' : 'play', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="formData.is_active ? 'Deactivate' : 'Activate'"></span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="togglePublic()"
|
||||
:disabled="saving"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
||||
:class="formData.is_public ? 'bg-orange-600 hover:bg-orange-700' : 'bg-blue-600 hover:bg-blue-700'">
|
||||
<span x-html="$icon(formData.is_public ? 'eye-off' : 'eye', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="formData.is_public ? 'Make Private' : 'Make Public'"></span>
|
||||
</button>
|
||||
|
||||
<!-- Status Badges -->
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<span
|
||||
x-show="formData.is_active"
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100">
|
||||
<span x-html="$icon('check-circle', 'w-3 h-3 mr-1')"></span>
|
||||
Active
|
||||
</span>
|
||||
<span
|
||||
x-show="!formData.is_active"
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-red-700 bg-red-100 rounded-full dark:bg-red-700 dark:text-red-100">
|
||||
<span x-html="$icon('x-circle', 'w-3 h-3 mr-1')"></span>
|
||||
Inactive
|
||||
</span>
|
||||
<span
|
||||
x-show="formData.is_public"
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-blue-700 bg-blue-100 rounded-full dark:bg-blue-700 dark:text-blue-100">
|
||||
<span x-html="$icon('globe-alt', 'w-3 h-3 mr-1')"></span>
|
||||
Public
|
||||
</span>
|
||||
<span
|
||||
x-show="!formData.is_public"
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-gray-700 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-100">
|
||||
<span x-html="$icon('lock-closed', 'w-3 h-3 mr-1')"></span>
|
||||
Private
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<form @submit.prevent="handleSubmit" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2">
|
||||
<!-- Left Column: Basic Info -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
<!-- Platform Code (readonly) -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Platform Code
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
:value="platform?.code || ''"
|
||||
disabled
|
||||
class="block w-full mt-1 text-sm bg-gray-100 border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 cursor-not-allowed"
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Cannot be changed after creation
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Name -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Platform Name <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.name"
|
||||
required
|
||||
maxlength="100"
|
||||
:disabled="saving"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.name }"
|
||||
>
|
||||
<span x-show="errors.name" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.name"></span>
|
||||
</label>
|
||||
|
||||
<!-- Description -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Description
|
||||
</span>
|
||||
<textarea
|
||||
x-model="formData.description"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
:disabled="saving"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-textarea"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<!-- Default Language -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Default Language
|
||||
</span>
|
||||
<select
|
||||
x-model="formData.default_language"
|
||||
:disabled="saving"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-select"
|
||||
>
|
||||
<template x-for="lang in availableLanguages" :key="lang.code">
|
||||
<option :value="lang.code" x-text="lang.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Supported Languages -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Supported Languages
|
||||
</span>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<template x-for="lang in availableLanguages" :key="lang.code">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleLanguage(lang.code)"
|
||||
:disabled="saving"
|
||||
class="px-3 py-1 text-sm rounded-full transition-colors"
|
||||
:class="isLanguageSupported(lang.code)
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
|
||||
>
|
||||
<span x-text="lang.name"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Routing & Branding -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Routing & Branding
|
||||
</h3>
|
||||
|
||||
<!-- Domain -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Production Domain
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.domain"
|
||||
placeholder="e.g., oms.lu"
|
||||
:disabled="saving"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Domain used in production for this platform
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Path Prefix -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Development Path Prefix
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.path_prefix"
|
||||
placeholder="e.g., oms"
|
||||
:disabled="saving"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Used for /platforms/{prefix}/ routing in development
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Logo URL -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Logo URL (Light Mode)
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
x-model="formData.logo"
|
||||
placeholder="https://..."
|
||||
:disabled="saving"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
>
|
||||
</label>
|
||||
|
||||
<!-- Logo Dark URL -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Logo URL (Dark Mode)
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
x-model="formData.logo_dark"
|
||||
placeholder="https://..."
|
||||
:disabled="saving"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
>
|
||||
</label>
|
||||
|
||||
<!-- Favicon URL -->
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Favicon URL
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
x-model="formData.favicon"
|
||||
placeholder="https://..."
|
||||
:disabled="saving"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Platform Stats (Read Only) -->
|
||||
<div class="mb-8 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Platform Statistics
|
||||
</h3>
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400" x-text="platform?.vendor_count || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Vendors</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400" x-text="platform?.platform_pages_count || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Marketing Pages</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-teal-600 dark:text-teal-400" x-text="platform?.vendor_defaults_count || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Vendor Defaults</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<a
|
||||
href="/admin/platforms"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
||||
>
|
||||
<span x-show="saving" x-html="$icon('refresh', 'w-4 h-4 mr-2 animate-spin')"></span>
|
||||
<span x-text="saving ? 'Saving...' : 'Save Changes'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Timestamps -->
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>
|
||||
Created: <span x-text="formatDate(platform?.created_at)"></span>
|
||||
</p>
|
||||
<p>
|
||||
Last Updated: <span x-text="formatDate(platform?.updated_at)"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/admin/js/platform-edit.js"></script>
|
||||
{% endblock %}
|
||||
@@ -89,9 +89,17 @@
|
||||
</a>
|
||||
<div class="flex space-x-2">
|
||||
<a
|
||||
:href="`/admin/content-pages?platform=${platform.code}`"
|
||||
:href="`/admin/content-pages?platform_code=${platform.code}&slug=home`"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
|
||||
title="View content pages for this platform"
|
||||
title="Edit platform homepage"
|
||||
>
|
||||
<span x-html="$icon('home', 'w-4 h-4 mr-1')"></span>
|
||||
Homepage
|
||||
</a>
|
||||
<a
|
||||
:href="`/admin/content-pages?platform_code=${platform.code}`"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
|
||||
title="View all content pages for this platform"
|
||||
>
|
||||
<span x-html="$icon('document-text', 'w-4 h-4 mr-1')"></span>
|
||||
Pages
|
||||
@@ -101,8 +109,8 @@
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
|
||||
title="Edit platform settings"
|
||||
>
|
||||
<span x-html="$icon('edit', 'w-4 h-4 mr-1')"></span>
|
||||
Edit
|
||||
<span x-html="$icon('cog', 'w-4 h-4 mr-1')"></span>
|
||||
Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
557
docs/proposals/TEMP.md
Normal file
557
docs/proposals/TEMP.md
Normal file
@@ -0,0 +1,557 @@
|
||||
● Platform Homepage Configuration Analysis
|
||||
|
||||
Summary: Current Flexibility
|
||||
┌──────────────────────┬─────────────┬──────────────────────────────────────────────────┐
|
||||
│ Feature │ Flexibility │ Notes │
|
||||
├──────────────────────┼─────────────┼──────────────────────────────────────────────────┤
|
||||
│ Homepage Content │ ✅ High │ Fully CMS-driven via ContentPage model │
|
||||
├──────────────────────┼─────────────┼──────────────────────────────────────────────────┤
|
||||
│ Header/Footer Menus │ ⚠️ Medium │ Driven by ContentPage flags, flat structure only │
|
||||
├──────────────────────┼─────────────┼──────────────────────────────────────────────────┤
|
||||
│ Platform Branding │ ✅ High │ Logo, favicon, colors via Platform model │
|
||||
├──────────────────────┼─────────────┼──────────────────────────────────────────────────┤
|
||||
│ Vendor Themes │ ✅ High │ 7 presets + custom CSS + full color control │
|
||||
├──────────────────────┼─────────────┼──────────────────────────────────────────────────┤
|
||||
│ Pricing/Features │ ❌ Low │ Hardcoded in TIER_LIMITS │
|
||||
├──────────────────────┼─────────────┼──────────────────────────────────────────────────┤
|
||||
│ Navigation Structure │ ❌ Low │ No nested menus, no icons │
|
||||
└──────────────────────┴─────────────┴──────────────────────────────────────────────────┘
|
||||
---
|
||||
1. Homepage Content Configuration
|
||||
|
||||
Model: ContentPage with three-tier hierarchy
|
||||
|
||||
Platform Marketing Pages (is_platform_page=True, vendor_id=NULL)
|
||||
↓ e.g., /pricing, /about, /features for oms.lu
|
||||
|
||||
Vendor Default Pages (is_platform_page=False, vendor_id=NULL)
|
||||
↓ Fallback for all vendors (About, Shipping Policy, etc.)
|
||||
|
||||
Vendor Override Pages (is_platform_page=False, vendor_id=set)
|
||||
↓ Vendor-specific customizations
|
||||
|
||||
Configurable per page:
|
||||
- title, content (HTML/Markdown), slug
|
||||
- template (default, minimal, modern, full)
|
||||
- meta_description, meta_keywords (SEO)
|
||||
- show_in_header, show_in_footer, show_in_legal
|
||||
- display_order, is_published
|
||||
|
||||
---
|
||||
2. Menu Configuration
|
||||
|
||||
Current approach: Content-driven (no separate Menu model)
|
||||
┌───────────────┬─────────────┬─────────────────────┐
|
||||
│ Menu Location │ Source │ Filter │
|
||||
├───────────────┼─────────────┼─────────────────────┤
|
||||
│ Header │ ContentPage │ show_in_header=True │
|
||||
├───────────────┼─────────────┼─────────────────────┤
|
||||
│ Footer │ ContentPage │ show_in_footer=True │
|
||||
├───────────────┼─────────────┼─────────────────────┤
|
||||
│ Legal bar │ ContentPage │ show_in_legal=True │
|
||||
└───────────────┴─────────────┴─────────────────────┘
|
||||
Limitations:
|
||||
- Flat structure only (no dropdowns/submenus)
|
||||
- No custom menu items (only links to content pages)
|
||||
- No menu icons or special styling
|
||||
- No external URLs
|
||||
|
||||
---
|
||||
3. Platform Model
|
||||
|
||||
File: models/database/platform.py
|
||||
|
||||
Platform:
|
||||
code # 'main', 'oms', 'loyalty'
|
||||
name # Display name
|
||||
domain # Production: 'oms.lu'
|
||||
path_prefix # Dev: '/oms/'
|
||||
logo # Light mode logo URL
|
||||
logo_dark # Dark mode logo URL
|
||||
favicon # Favicon URL
|
||||
theme_config # JSON: colors, fonts, etc.
|
||||
default_language # 'fr', 'en', 'de'
|
||||
supported_languages # ['fr', 'de', 'en']
|
||||
settings # JSON: feature flags
|
||||
|
||||
---
|
||||
4. Theme System
|
||||
|
||||
Vendor-level only (not platform-level defaults)
|
||||
┌───────────────┬────────┬─────────────────────────────────────────────────────────────┐
|
||||
│ Property │ Type │ Options │
|
||||
├───────────────┼────────┼─────────────────────────────────────────────────────────────┤
|
||||
│ Colors │ JSON │ primary, secondary, accent, background, text, border │
|
||||
├───────────────┼────────┼─────────────────────────────────────────────────────────────┤
|
||||
│ Fonts │ String │ font_family_heading, font_family_body │
|
||||
├───────────────┼────────┼─────────────────────────────────────────────────────────────┤
|
||||
│ Layout │ String │ grid, list, masonry │
|
||||
├───────────────┼────────┼─────────────────────────────────────────────────────────────┤
|
||||
│ Header │ String │ fixed, static, transparent │
|
||||
├───────────────┼────────┼─────────────────────────────────────────────────────────────┤
|
||||
│ Product cards │ String │ modern, classic, minimal │
|
||||
├───────────────┼────────┼─────────────────────────────────────────────────────────────┤
|
||||
│ Custom CSS │ Text │ Injected into <style> │
|
||||
├───────────────┼────────┼─────────────────────────────────────────────────────────────┤
|
||||
│ Presets │ 7 │ default, modern, classic, minimal, vibrant, elegant, nature │
|
||||
└───────────────┴────────┴─────────────────────────────────────────────────────────────┘
|
||||
---
|
||||
5. What's Hardcoded
|
||||
┌──────────────────────────┬──────────────────────┬───────────────────────────┐
|
||||
│ Item │ Location │ Issue │
|
||||
├──────────────────────────┼──────────────────────┼───────────────────────────┤
|
||||
│ Pricing tiers │ TIER_LIMITS constant │ Cannot edit via admin │
|
||||
├──────────────────────────┼──────────────────────┼───────────────────────────┤
|
||||
│ Add-on options │ Route handlers │ Hardcoded prices/features │
|
||||
├──────────────────────────┼──────────────────────┼───────────────────────────┤
|
||||
│ Platform name "Wizamart" │ Templates │ Not from database │
|
||||
├──────────────────────────┼──────────────────────┼───────────────────────────┤
|
||||
│ Hero section layout │ Tailwind classes │ Fixed structure │
|
||||
├──────────────────────────┼──────────────────────┼───────────────────────────┤
|
||||
│ Menu structure │ Code │ No nesting support │
|
||||
└──────────────────────────┴──────────────────────┴───────────────────────────┘
|
||||
---
|
||||
6. Gaps for Multi-Platform Flexibility
|
||||
|
||||
1. No Platform-Level Theme Defaults
|
||||
- VendorTheme is per-vendor only
|
||||
- Each platform cannot define default vendor theme
|
||||
2. No Menu Model
|
||||
- Cannot create custom navigation structures
|
||||
- No external links, no icons, no dropdowns
|
||||
3. Limited Template Switching
|
||||
- Only 4 homepage templates
|
||||
- No way to create new templates via admin
|
||||
4. Hardcoded Pricing
|
||||
- TIER_LIMITS in code, not database
|
||||
- Cannot customize per platform
|
||||
|
||||
---
|
||||
Would you like me to:
|
||||
|
||||
1. Create a Platform Theme system - Allow platform-level theme defaults that vendors inherit
|
||||
2. Create a Menu model - Support hierarchical menus with custom items, icons, external links
|
||||
3. Move pricing to database - Make tiers configurable per platform
|
||||
4. Add homepage block system - Reorderable sections (hero, features, CTA, testimonials)
|
||||
|
||||
Which enhancement would be most valuable for your use case?
|
||||
|
||||
|
||||
*******************
|
||||
|
||||
Section-Based Homepage Management System
|
||||
|
||||
Problem Statement
|
||||
|
||||
Current homepage implementation has critical issues:
|
||||
1. Hardcoded platform content - Migrations contain OMS/Loyalty/Main-specific HTML
|
||||
2. Monolithic content storage - Entire page stored as HTML blob, can't edit sections individually
|
||||
3. No admin control - Hero, features, pricing sections are hardcoded in templates
|
||||
|
||||
Solution: JSON-Based Section Architecture
|
||||
|
||||
Approach: Add sections JSON field to ContentPage
|
||||
|
||||
Why JSON field vs separate PageSection model:
|
||||
- Simpler - no new tables, no joins, no N+1 queries
|
||||
- Flexible - schema can evolve without migrations
|
||||
- Atomic - save entire homepage in one transaction
|
||||
- Follows existing pattern - VendorTheme already uses JSON for colors
|
||||
|
||||
---
|
||||
Multi-Language Support
|
||||
|
||||
Option A: Language-Keyed Sections (Recommended)
|
||||
|
||||
Store all translations in one JSON structure:
|
||||
|
||||
{
|
||||
"hero": {
|
||||
"enabled": true,
|
||||
"title": {
|
||||
"en": "Welcome to Our Platform",
|
||||
"fr": "Bienvenue sur notre plateforme",
|
||||
"de": "Willkommen auf unserer Plattform"
|
||||
},
|
||||
"subtitle": {
|
||||
"en": "Your success starts here",
|
||||
"fr": "Votre succès commence ici",
|
||||
"de": "Ihr Erfolg beginnt hier"
|
||||
},
|
||||
"buttons": [
|
||||
{
|
||||
"text": {"en": "Get Started", "fr": "Commencer", "de": "Loslegen"},
|
||||
"url": "/signup",
|
||||
"style": "primary"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Pros:
|
||||
- Single page entry per platform (not 3 separate pages)
|
||||
- Easy to see which translations are missing
|
||||
- Atomic save of all language variants
|
||||
- Admin can edit all languages in one form
|
||||
|
||||
Cons:
|
||||
- Larger JSON payload
|
||||
- Need helper function to extract current language
|
||||
|
||||
Option B: Separate Page Per Language
|
||||
|
||||
Create one ContentPage per language with same slug but different content:
|
||||
- slug="home", language="en"
|
||||
- slug="home", language="fr"
|
||||
- slug="home", language="de"
|
||||
|
||||
Pros:
|
||||
- Simpler JSON structure per page
|
||||
- Can have different sections per language
|
||||
|
||||
Cons:
|
||||
- More database entries
|
||||
- Harder to keep in sync
|
||||
- Need to add language column to ContentPage
|
||||
|
||||
Recommendation: Option A (Language-Keyed)
|
||||
|
||||
This keeps all translations together and matches how the platform already handles supported_languages on the Platform model.
|
||||
|
||||
Dynamic Language Support
|
||||
|
||||
Languages are NOT hardcoded. The system uses the platform's supported_languages setting:
|
||||
|
||||
# Platform model already has:
|
||||
supported_languages = Column(JSON) # e.g., ["fr", "de", "en"]
|
||||
default_language = Column(String) # e.g., "fr"
|
||||
|
||||
Schema with Dynamic i18n
|
||||
|
||||
class TranslatableText(BaseModel):
|
||||
"""
|
||||
Text field with translations stored as dict.
|
||||
Keys are language codes from platform.supported_languages.
|
||||
"""
|
||||
translations: dict[str, str] = {} # {"fr": "...", "de": "...", "en": "..."}
|
||||
|
||||
def get(self, lang: str, default_lang: str = "fr") -> str:
|
||||
"""Get translation with fallback to default language."""
|
||||
return self.translations.get(lang) or self.translations.get(default_lang) or ""
|
||||
|
||||
class HeroButton(BaseModel):
|
||||
text: TranslatableText
|
||||
url: str
|
||||
style: str = "primary"
|
||||
|
||||
class HeroSection(BaseModel):
|
||||
enabled: bool = True
|
||||
badge_text: Optional[TranslatableText] = None
|
||||
title: TranslatableText
|
||||
subtitle: TranslatableText
|
||||
background_type: str = "gradient"
|
||||
buttons: list[HeroButton] = []
|
||||
|
||||
Template Usage with Platform Languages
|
||||
|
||||
{# Language comes from platform settings #}
|
||||
{% set lang = request.state.language or platform.default_language %}
|
||||
{% set default_lang = platform.default_language %}
|
||||
|
||||
<h1>{{ hero.title.get(lang, default_lang) }}</h1>
|
||||
<p>{{ hero.subtitle.get(lang, default_lang) }}</p>
|
||||
|
||||
Admin UI Language Tabs
|
||||
|
||||
The admin editor dynamically generates language tabs from platform.supported_languages:
|
||||
|
||||
// Fetch platform languages
|
||||
const platform = await apiClient.get(`/admin/platforms/${platformCode}`);
|
||||
const languages = platform.supported_languages; // ["fr", "de", "en"]
|
||||
|
||||
// Render language tabs dynamically
|
||||
languages.forEach(lang => {
|
||||
addLanguageTab(lang);
|
||||
});
|
||||
|
||||
---
|
||||
Implementation Plan
|
||||
|
||||
Phase 1: Database Changes
|
||||
|
||||
1.1 Add sections column to ContentPage
|
||||
|
||||
File: models/database/content_page.py
|
||||
sections = Column(JSON, nullable=True, default=None)
|
||||
|
||||
1.2 Create migration
|
||||
|
||||
File: alembic/versions/xxx_add_sections_to_content_pages.py
|
||||
- Add sections JSON column (nullable)
|
||||
|
||||
Phase 2: Schema Validation
|
||||
|
||||
2.1 Create Pydantic schemas with dynamic i18n
|
||||
|
||||
File: models/schema/homepage_sections.py (NEW)
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
class TranslatableText(BaseModel):
|
||||
"""
|
||||
Stores translations as dict with language codes as keys.
|
||||
Language codes come from platform.supported_languages.
|
||||
"""
|
||||
translations: dict[str, str] = {}
|
||||
|
||||
def get(self, lang: str, default_lang: str = "fr") -> str:
|
||||
"""Get text for language with fallback."""
|
||||
return self.translations.get(lang) or self.translations.get(default_lang) or ""
|
||||
|
||||
class HeroButton(BaseModel):
|
||||
text: TranslatableText
|
||||
url: str
|
||||
style: str = "primary" # primary, secondary, outline
|
||||
|
||||
class HeroSection(BaseModel):
|
||||
enabled: bool = True
|
||||
badge_text: Optional[TranslatableText] = None
|
||||
title: TranslatableText = TranslatableText()
|
||||
subtitle: TranslatableText = TranslatableText()
|
||||
background_type: str = "gradient"
|
||||
buttons: list[HeroButton] = []
|
||||
|
||||
class FeatureCard(BaseModel):
|
||||
icon: str
|
||||
title: TranslatableText
|
||||
description: TranslatableText
|
||||
|
||||
class FeaturesSection(BaseModel):
|
||||
enabled: bool = True
|
||||
title: TranslatableText = TranslatableText()
|
||||
subtitle: Optional[TranslatableText] = None
|
||||
features: list[FeatureCard] = []
|
||||
layout: str = "grid"
|
||||
|
||||
class PricingSection(BaseModel):
|
||||
enabled: bool = True
|
||||
title: TranslatableText = TranslatableText()
|
||||
subtitle: Optional[TranslatableText] = None
|
||||
use_subscription_tiers: bool = True # Pull from DB dynamically
|
||||
|
||||
class CTASection(BaseModel):
|
||||
enabled: bool = True
|
||||
title: TranslatableText = TranslatableText()
|
||||
subtitle: Optional[TranslatableText] = None
|
||||
buttons: list[HeroButton] = []
|
||||
|
||||
class HomepageSections(BaseModel):
|
||||
hero: Optional[HeroSection] = None
|
||||
features: Optional[FeaturesSection] = None
|
||||
pricing: Optional[PricingSection] = None
|
||||
cta: Optional[CTASection] = None
|
||||
|
||||
Phase 3: Template Changes
|
||||
|
||||
3.1 Create section partials
|
||||
|
||||
Directory: app/templates/platform/sections/ (NEW)
|
||||
- _hero.html - Renders hero with language support
|
||||
- _features.html - Renders features grid
|
||||
- _pricing.html - Renders pricing (uses subscription_tiers from DB)
|
||||
- _cta.html - Renders CTA section
|
||||
|
||||
3.2 Update homepage templates
|
||||
|
||||
File: app/templates/platform/homepage-default.html
|
||||
{% set lang = request.state.language or platform.default_language or 'fr' %}
|
||||
|
||||
{% if page and page.sections %}
|
||||
{{ render_hero(page.sections.hero, lang) }}
|
||||
{{ render_features(page.sections.features, lang) }}
|
||||
{{ render_pricing(page.sections.pricing, lang, tiers) }}
|
||||
{{ render_cta(page.sections.cta, lang) }}
|
||||
{% else %}
|
||||
{# Placeholder for unconfigured homepage #}
|
||||
{% endif %}
|
||||
|
||||
Phase 4: Service Layer
|
||||
|
||||
4.1 Add section methods to ContentPageService
|
||||
|
||||
File: app/services/content_page_service.py
|
||||
- update_homepage_sections(db, page_id, sections, updated_by) - Validates and saves
|
||||
- get_default_sections() - Returns empty section structure
|
||||
|
||||
Phase 5: Admin API
|
||||
|
||||
5.1 Add section endpoints
|
||||
|
||||
File: app/api/v1/admin/content_pages.py
|
||||
- GET /{page_id}/sections - Get structured sections
|
||||
- PUT /{page_id}/sections - Update all sections
|
||||
- PUT /{page_id}/sections/{section_name} - Update single section
|
||||
|
||||
Phase 6: Remove Hardcoded Content from Migrations
|
||||
|
||||
6.1 Update OMS migration
|
||||
|
||||
File: alembic/versions/z4e5f6a7b8c9_add_multi_platform_support.py
|
||||
- Remove oms_homepage_content variable
|
||||
- Create homepage with empty sections structure instead
|
||||
- Set is_published=False (admin configures before publishing)
|
||||
|
||||
6.2 Migration creates structure only
|
||||
- Migrations should ONLY create empty structure
|
||||
- Content is entered via admin UI in each language
|
||||
|
||||
Phase 7: Admin UI
|
||||
|
||||
7.1 Add section editor to content-page-edit
|
||||
|
||||
File: app/templates/admin/content-page-edit.html
|
||||
- Add "Sections" tab for homepage pages
|
||||
- Language tabs within each section (EN | FR | DE | LB)
|
||||
- Form fields for each section type
|
||||
- Enable/disable toggle per section
|
||||
|
||||
File: static/admin/js/content-page-edit.js
|
||||
- Section editor logic
|
||||
- Language tab switching
|
||||
- Save sections via API
|
||||
|
||||
---
|
||||
Critical Files to Modify
|
||||
|
||||
1. models/database/content_page.py - Add sections column
|
||||
2. models/schema/homepage_sections.py - NEW: Pydantic schemas with i18n
|
||||
3. app/services/content_page_service.py - Add section methods
|
||||
4. app/api/v1/admin/content_pages.py - Add section endpoints
|
||||
5. app/templates/platform/sections/ - NEW: Section partials
|
||||
6. app/templates/platform/homepage-default.html - Use section partials
|
||||
7. app/routes/platform_pages.py - Pass sections + language to context
|
||||
8. alembic/versions/z4e5f6a7b8c9_*.py - Remove hardcoded content
|
||||
9. app/templates/admin/content-page-edit.html - Section editor UI with language tabs
|
||||
10. static/admin/js/content-page-edit.js - Section editor JS
|
||||
|
||||
---
|
||||
Section JSON Schema Example (with dynamic i18n)
|
||||
|
||||
Languages in translations dict come from platform.supported_languages.
|
||||
|
||||
{
|
||||
"hero": {
|
||||
"enabled": true,
|
||||
"badge_text": {
|
||||
"translations": {
|
||||
"fr": "Essai gratuit de 30 jours",
|
||||
"de": "30 Tage kostenlos testen",
|
||||
"en": "30-Day Free Trial"
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"translations": {
|
||||
"fr": "Votre titre de plateforme ici",
|
||||
"de": "Ihr Plattform-Titel hier",
|
||||
"en": "Your Platform Headline Here"
|
||||
}
|
||||
},
|
||||
"subtitle": {
|
||||
"translations": {
|
||||
"fr": "Une description convaincante de votre plateforme.",
|
||||
"de": "Eine überzeugende Beschreibung Ihrer Plattform.",
|
||||
"en": "A compelling description of your platform."
|
||||
}
|
||||
},
|
||||
"background_type": "gradient",
|
||||
"buttons": [
|
||||
{
|
||||
"text": {
|
||||
"translations": {"fr": "Commencer", "de": "Loslegen", "en": "Get Started"}
|
||||
},
|
||||
"url": "/signup",
|
||||
"style": "primary"
|
||||
}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"enabled": true,
|
||||
"title": {
|
||||
"translations": {
|
||||
"fr": "Pourquoi nous choisir",
|
||||
"de": "Warum uns wählen",
|
||||
"en": "Why Choose Us"
|
||||
}
|
||||
},
|
||||
"features": [
|
||||
{
|
||||
"icon": "lightning-bolt",
|
||||
"title": {"translations": {"fr": "Rapide", "de": "Schnell", "en": "Fast"}},
|
||||
"description": {"translations": {"fr": "Rapide et efficace.", "de": "Schnell und effizient.", "en": "Quick and efficient."}}
|
||||
}
|
||||
]
|
||||
},
|
||||
"pricing": {
|
||||
"enabled": true,
|
||||
"title": {
|
||||
"translations": {
|
||||
"fr": "Tarification simple",
|
||||
"de": "Einfache Preise",
|
||||
"en": "Simple Pricing"
|
||||
}
|
||||
},
|
||||
"use_subscription_tiers": true
|
||||
},
|
||||
"cta": {
|
||||
"enabled": true,
|
||||
"title": {
|
||||
"translations": {
|
||||
"fr": "Prêt à commencer?",
|
||||
"de": "Bereit anzufangen?",
|
||||
"en": "Ready to Start?"
|
||||
}
|
||||
},
|
||||
"buttons": [
|
||||
{
|
||||
"text": {
|
||||
"translations": {"fr": "S'inscrire gratuitement", "de": "Kostenlos registrieren", "en": "Sign Up Free"}
|
||||
},
|
||||
"url": "/signup",
|
||||
"style": "primary"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
---
|
||||
Migration Strategy (No Hardcoded Content)
|
||||
|
||||
When creating a platform homepage:
|
||||
homepage = ContentPage(
|
||||
platform_id=platform_id,
|
||||
slug="home",
|
||||
title="Homepage", # Generic
|
||||
content="", # Empty - sections used instead
|
||||
sections=get_default_sections(), # Empty structure with all languages
|
||||
is_published=False, # Admin configures first
|
||||
)
|
||||
|
||||
---
|
||||
Verification Steps
|
||||
|
||||
1. Run migration to add sections column
|
||||
2. Create a test homepage with sections via API (all languages)
|
||||
3. Verify homepage renders correct language based on request
|
||||
4. Test admin UI section editor with language tabs
|
||||
5. Verify pricing section pulls from subscription_tiers
|
||||
6. Test enable/disable toggle for each section
|
||||
7. Test language fallback when translation is missing
|
||||
|
||||
---
|
||||
Notes
|
||||
|
||||
- Languages are dynamic from platform.supported_languages (not hardcoded)
|
||||
- Fallback uses platform.default_language
|
||||
- Admin UI should allow partial translations (show warning indicator for missing)
|
||||
- Plan saved for resumption tomorrow
|
||||
@@ -64,14 +64,14 @@ app.add_middleware(VendorContextMiddleware) # Runs second (existing)
|
||||
app.add_middleware(PlatformContextMiddleware) # Runs first (NEW)
|
||||
```
|
||||
|
||||
### 2.3 Update VendorContextMiddleware
|
||||
### 2.3 Update VendorContextMiddleware ✅ COMPLETE
|
||||
|
||||
File: `middleware/vendor_context.py`
|
||||
|
||||
Changes needed:
|
||||
- [ ] Use `request.state.platform_clean_path` instead of `request.url.path` for path-based vendor detection
|
||||
- [ ] Skip vendor detection if no platform found (platform marketing pages like /oms/pricing)
|
||||
- [ ] Pass platform context to vendor lookup for multi-platform vendor support
|
||||
Changes completed:
|
||||
- [x] Use `request.state.platform_clean_path` instead of `request.url.path` for path-based vendor detection (line 52)
|
||||
- [x] Skip vendor detection if no platform found (platform marketing pages like /oms/pricing)
|
||||
- [x] Pass platform context to vendor lookup for multi-platform vendor support
|
||||
|
||||
### 2.4 Fix Platform Homepage Route
|
||||
|
||||
@@ -179,19 +179,19 @@ if settings.environment == "development":
|
||||
app.mount("/loyalty", loyalty_router)
|
||||
```
|
||||
|
||||
### 5.2 Update Shop Routes
|
||||
### 5.2 Update Shop Routes ✅ COMPLETE
|
||||
|
||||
- [ ] Add platform context to shop routes
|
||||
- [ ] Use `request.state.platform` for template selection
|
||||
- [ ] Pass platform to content page lookups
|
||||
- [x] Add platform context to shop routes (`shop_pages.py` line 117)
|
||||
- [x] Use `request.state.platform` for template selection
|
||||
- [x] Pass platform to content page lookups (`platform_id` used in CMS queries)
|
||||
|
||||
### 5.3 Test All URL Patterns
|
||||
### 5.3 Test All URL Patterns ✅ COMPLETE
|
||||
|
||||
Development:
|
||||
- [ ] `localhost:9999/oms/` → OMS homepage
|
||||
- [ ] `localhost:9999/oms/pricing` → OMS pricing page
|
||||
- [ ] `localhost:9999/oms/vendors/{code}/` → Vendor storefront
|
||||
- [ ] `localhost:9999/loyalty/` → Loyalty homepage
|
||||
Development (using /platforms/ prefix):
|
||||
- [x] `localhost:9999/platforms/oms/` → OMS homepage
|
||||
- [x] `localhost:9999/platforms/oms/pricing` → OMS pricing page
|
||||
- [x] `localhost:9999/platforms/oms/vendors/{code}/` → Vendor storefront
|
||||
- [x] `localhost:9999/platforms/loyalty/` → Loyalty homepage
|
||||
|
||||
---
|
||||
|
||||
|
||||
392
docs/proposals/section-based-homepage-plan.md
Normal file
392
docs/proposals/section-based-homepage-plan.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# Section-Based Homepage Management System
|
||||
|
||||
**Status:** Planning
|
||||
**Created:** 2026-01-20
|
||||
**Resume:** Ready to implement
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Current homepage implementation has critical issues:
|
||||
1. **Hardcoded platform content** - Migrations contain OMS/Loyalty/Main-specific HTML
|
||||
2. **Monolithic content storage** - Entire page stored as HTML blob, can't edit sections individually
|
||||
3. **No admin control** - Hero, features, pricing sections are hardcoded in templates
|
||||
|
||||
## Solution: JSON-Based Section Architecture
|
||||
|
||||
### Approach: Add `sections` JSON field to ContentPage
|
||||
|
||||
**Why JSON field vs separate PageSection model:**
|
||||
- Simpler - no new tables, no joins, no N+1 queries
|
||||
- Flexible - schema can evolve without migrations
|
||||
- Atomic - save entire homepage in one transaction
|
||||
- Follows existing pattern - VendorTheme already uses JSON for `colors`
|
||||
|
||||
---
|
||||
|
||||
## Multi-Language Support
|
||||
|
||||
### Dynamic Language Support
|
||||
|
||||
Languages are NOT hardcoded. The system uses the platform's `supported_languages` setting:
|
||||
|
||||
```python
|
||||
# Platform model already has:
|
||||
supported_languages = Column(JSON) # e.g., ["fr", "de", "en"]
|
||||
default_language = Column(String) # e.g., "fr"
|
||||
```
|
||||
|
||||
### Schema with Dynamic i18n
|
||||
|
||||
```python
|
||||
class TranslatableText(BaseModel):
|
||||
"""
|
||||
Text field with translations stored as dict.
|
||||
Keys are language codes from platform.supported_languages.
|
||||
"""
|
||||
translations: dict[str, str] = {} # {"fr": "...", "de": "...", "en": "..."}
|
||||
|
||||
def get(self, lang: str, default_lang: str = "fr") -> str:
|
||||
"""Get translation with fallback to default language."""
|
||||
return self.translations.get(lang) or self.translations.get(default_lang) or ""
|
||||
|
||||
class HeroButton(BaseModel):
|
||||
text: TranslatableText
|
||||
url: str
|
||||
style: str = "primary"
|
||||
|
||||
class HeroSection(BaseModel):
|
||||
enabled: bool = True
|
||||
badge_text: Optional[TranslatableText] = None
|
||||
title: TranslatableText
|
||||
subtitle: TranslatableText
|
||||
background_type: str = "gradient"
|
||||
buttons: list[HeroButton] = []
|
||||
```
|
||||
|
||||
### Template Usage with Platform Languages
|
||||
|
||||
```html
|
||||
{# Language comes from platform settings #}
|
||||
{% set lang = request.state.language or platform.default_language %}
|
||||
{% set default_lang = platform.default_language %}
|
||||
|
||||
<h1>{{ hero.title.get(lang, default_lang) }}</h1>
|
||||
<p>{{ hero.subtitle.get(lang, default_lang) }}</p>
|
||||
```
|
||||
|
||||
### Admin UI Language Tabs
|
||||
|
||||
The admin editor dynamically generates language tabs from `platform.supported_languages`:
|
||||
|
||||
```javascript
|
||||
// Fetch platform languages
|
||||
const platform = await apiClient.get(`/admin/platforms/${platformCode}`);
|
||||
const languages = platform.supported_languages; // ["fr", "de", "en"]
|
||||
|
||||
// Render language tabs dynamically
|
||||
languages.forEach(lang => {
|
||||
addLanguageTab(lang);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Database Changes
|
||||
|
||||
**1.1 Add `sections` column to ContentPage**
|
||||
|
||||
File: `models/database/content_page.py`
|
||||
```python
|
||||
sections = Column(JSON, nullable=True, default=None)
|
||||
```
|
||||
|
||||
**1.2 Create migration**
|
||||
|
||||
File: `alembic/versions/xxx_add_sections_to_content_pages.py`
|
||||
- Add `sections` JSON column (nullable)
|
||||
|
||||
### Phase 2: Schema Validation
|
||||
|
||||
**2.1 Create Pydantic schemas with dynamic i18n**
|
||||
|
||||
File: `models/schema/homepage_sections.py` (NEW)
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
class TranslatableText(BaseModel):
|
||||
"""
|
||||
Stores translations as dict with language codes as keys.
|
||||
Language codes come from platform.supported_languages.
|
||||
"""
|
||||
translations: dict[str, str] = {}
|
||||
|
||||
def get(self, lang: str, default_lang: str = "fr") -> str:
|
||||
"""Get text for language with fallback."""
|
||||
return self.translations.get(lang) or self.translations.get(default_lang) or ""
|
||||
|
||||
class HeroButton(BaseModel):
|
||||
text: TranslatableText
|
||||
url: str
|
||||
style: str = "primary" # primary, secondary, outline
|
||||
|
||||
class HeroSection(BaseModel):
|
||||
enabled: bool = True
|
||||
badge_text: Optional[TranslatableText] = None
|
||||
title: TranslatableText = TranslatableText()
|
||||
subtitle: TranslatableText = TranslatableText()
|
||||
background_type: str = "gradient"
|
||||
buttons: list[HeroButton] = []
|
||||
|
||||
class FeatureCard(BaseModel):
|
||||
icon: str
|
||||
title: TranslatableText
|
||||
description: TranslatableText
|
||||
|
||||
class FeaturesSection(BaseModel):
|
||||
enabled: bool = True
|
||||
title: TranslatableText = TranslatableText()
|
||||
subtitle: Optional[TranslatableText] = None
|
||||
features: list[FeatureCard] = []
|
||||
layout: str = "grid"
|
||||
|
||||
class PricingSection(BaseModel):
|
||||
enabled: bool = True
|
||||
title: TranslatableText = TranslatableText()
|
||||
subtitle: Optional[TranslatableText] = None
|
||||
use_subscription_tiers: bool = True # Pull from DB dynamically
|
||||
|
||||
class CTASection(BaseModel):
|
||||
enabled: bool = True
|
||||
title: TranslatableText = TranslatableText()
|
||||
subtitle: Optional[TranslatableText] = None
|
||||
buttons: list[HeroButton] = []
|
||||
|
||||
class HomepageSections(BaseModel):
|
||||
hero: Optional[HeroSection] = None
|
||||
features: Optional[FeaturesSection] = None
|
||||
pricing: Optional[PricingSection] = None
|
||||
cta: Optional[CTASection] = None
|
||||
```
|
||||
|
||||
### Phase 3: Template Changes
|
||||
|
||||
**3.1 Create section partials**
|
||||
|
||||
Directory: `app/templates/platform/sections/` (NEW)
|
||||
- `_hero.html` - Renders hero with language support
|
||||
- `_features.html` - Renders features grid
|
||||
- `_pricing.html` - Renders pricing (uses subscription_tiers from DB)
|
||||
- `_cta.html` - Renders CTA section
|
||||
|
||||
**3.2 Update homepage templates**
|
||||
|
||||
File: `app/templates/platform/homepage-default.html`
|
||||
```html
|
||||
{% set lang = request.state.language or platform.default_language or 'fr' %}
|
||||
|
||||
{% if page and page.sections %}
|
||||
{{ render_hero(page.sections.hero, lang) }}
|
||||
{{ render_features(page.sections.features, lang) }}
|
||||
{{ render_pricing(page.sections.pricing, lang, tiers) }}
|
||||
{{ render_cta(page.sections.cta, lang) }}
|
||||
{% else %}
|
||||
{# Placeholder for unconfigured homepage #}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### Phase 4: Service Layer
|
||||
|
||||
**4.1 Add section methods to ContentPageService**
|
||||
|
||||
File: `app/services/content_page_service.py`
|
||||
- `update_homepage_sections(db, page_id, sections, updated_by)` - Validates and saves
|
||||
- `get_default_sections()` - Returns empty section structure
|
||||
|
||||
### Phase 5: Admin API
|
||||
|
||||
**5.1 Add section endpoints**
|
||||
|
||||
File: `app/api/v1/admin/content_pages.py`
|
||||
- `GET /{page_id}/sections` - Get structured sections
|
||||
- `PUT /{page_id}/sections` - Update all sections
|
||||
- `PUT /{page_id}/sections/{section_name}` - Update single section
|
||||
|
||||
### Phase 6: Remove Hardcoded Content from Migrations
|
||||
|
||||
**6.1 Update OMS migration**
|
||||
|
||||
File: `alembic/versions/z4e5f6a7b8c9_add_multi_platform_support.py`
|
||||
- Remove `oms_homepage_content` variable
|
||||
- Create homepage with empty `sections` structure instead
|
||||
- Set `is_published=False` (admin configures before publishing)
|
||||
|
||||
**6.2 Migration creates structure only**
|
||||
- Migrations should ONLY create empty structure
|
||||
- Content is entered via admin UI in each language
|
||||
|
||||
### Phase 7: Admin UI
|
||||
|
||||
**7.1 Add section editor to content-page-edit**
|
||||
|
||||
File: `app/templates/admin/content-page-edit.html`
|
||||
- Add "Sections" tab for homepage pages
|
||||
- Language tabs within each section (dynamically from platform.supported_languages)
|
||||
- Form fields for each section type
|
||||
- Enable/disable toggle per section
|
||||
|
||||
File: `static/admin/js/content-page-edit.js`
|
||||
- Section editor logic
|
||||
- Language tab switching
|
||||
- Save sections via API
|
||||
|
||||
---
|
||||
|
||||
## Critical Files to Modify
|
||||
|
||||
1. `models/database/content_page.py` - Add `sections` column
|
||||
2. `models/schema/homepage_sections.py` - NEW: Pydantic schemas with i18n
|
||||
3. `app/services/content_page_service.py` - Add section methods
|
||||
4. `app/api/v1/admin/content_pages.py` - Add section endpoints
|
||||
5. `app/templates/platform/sections/` - NEW: Section partials
|
||||
6. `app/templates/platform/homepage-default.html` - Use section partials
|
||||
7. `app/routes/platform_pages.py` - Pass sections + language to context
|
||||
8. `alembic/versions/z4e5f6a7b8c9_*.py` - Remove hardcoded content
|
||||
9. `app/templates/admin/content-page-edit.html` - Section editor UI with language tabs
|
||||
10. `static/admin/js/content-page-edit.js` - Section editor JS
|
||||
|
||||
---
|
||||
|
||||
## Section JSON Schema Example (with dynamic i18n)
|
||||
|
||||
Languages in `translations` dict come from `platform.supported_languages`.
|
||||
|
||||
```json
|
||||
{
|
||||
"hero": {
|
||||
"enabled": true,
|
||||
"badge_text": {
|
||||
"translations": {
|
||||
"fr": "Essai gratuit de 30 jours",
|
||||
"de": "30 Tage kostenlos testen",
|
||||
"en": "30-Day Free Trial"
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"translations": {
|
||||
"fr": "Votre titre de plateforme ici",
|
||||
"de": "Ihr Plattform-Titel hier",
|
||||
"en": "Your Platform Headline Here"
|
||||
}
|
||||
},
|
||||
"subtitle": {
|
||||
"translations": {
|
||||
"fr": "Une description convaincante de votre plateforme.",
|
||||
"de": "Eine überzeugende Beschreibung Ihrer Plattform.",
|
||||
"en": "A compelling description of your platform."
|
||||
}
|
||||
},
|
||||
"background_type": "gradient",
|
||||
"buttons": [
|
||||
{
|
||||
"text": {
|
||||
"translations": {"fr": "Commencer", "de": "Loslegen", "en": "Get Started"}
|
||||
},
|
||||
"url": "/signup",
|
||||
"style": "primary"
|
||||
}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"enabled": true,
|
||||
"title": {
|
||||
"translations": {
|
||||
"fr": "Pourquoi nous choisir",
|
||||
"de": "Warum uns wählen",
|
||||
"en": "Why Choose Us"
|
||||
}
|
||||
},
|
||||
"features": [
|
||||
{
|
||||
"icon": "lightning-bolt",
|
||||
"title": {"translations": {"fr": "Rapide", "de": "Schnell", "en": "Fast"}},
|
||||
"description": {"translations": {"fr": "Rapide et efficace.", "de": "Schnell und effizient.", "en": "Quick and efficient."}}
|
||||
}
|
||||
]
|
||||
},
|
||||
"pricing": {
|
||||
"enabled": true,
|
||||
"title": {
|
||||
"translations": {
|
||||
"fr": "Tarification simple",
|
||||
"de": "Einfache Preise",
|
||||
"en": "Simple Pricing"
|
||||
}
|
||||
},
|
||||
"use_subscription_tiers": true
|
||||
},
|
||||
"cta": {
|
||||
"enabled": true,
|
||||
"title": {
|
||||
"translations": {
|
||||
"fr": "Prêt à commencer?",
|
||||
"de": "Bereit anzufangen?",
|
||||
"en": "Ready to Start?"
|
||||
}
|
||||
},
|
||||
"buttons": [
|
||||
{
|
||||
"text": {
|
||||
"translations": {"fr": "S'inscrire gratuitement", "de": "Kostenlos registrieren", "en": "Sign Up Free"}
|
||||
},
|
||||
"url": "/signup",
|
||||
"style": "primary"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy (No Hardcoded Content)
|
||||
|
||||
When creating a platform homepage:
|
||||
```python
|
||||
homepage = ContentPage(
|
||||
platform_id=platform_id,
|
||||
slug="home",
|
||||
title="Homepage", # Generic
|
||||
content="", # Empty - sections used instead
|
||||
sections=get_default_sections(), # Empty structure with all languages
|
||||
is_published=False, # Admin configures first
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Steps
|
||||
|
||||
1. Run migration to add `sections` column
|
||||
2. Create a test homepage with sections via API (all languages)
|
||||
3. Verify homepage renders correct language based on request
|
||||
4. Test admin UI section editor with language tabs
|
||||
5. Verify pricing section pulls from subscription_tiers
|
||||
6. Test enable/disable toggle for each section
|
||||
7. Test language fallback when translation is missing
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Languages are dynamic from `platform.supported_languages` (not hardcoded)
|
||||
- Fallback uses `platform.default_language`
|
||||
- Admin UI should allow partial translations (show warning indicator for missing)
|
||||
|
||||
## Pre-Implementation Cleanup
|
||||
|
||||
Before implementing, fix the broken OMS migration file:
|
||||
- `alembic/versions/z4e5f6a7b8c9_add_multi_platform_support.py` has "I dn" on line 181
|
||||
@@ -20,12 +20,12 @@ import logging
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy.orm import Session
|
||||
# Note: We use pure ASGI middleware (not BaseHTTPMiddleware) to enable path rewriting
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from models.database.platform import Platform
|
||||
|
||||
# Note: We use pure ASGI middleware (not BaseHTTPMiddleware) to enable path rewriting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default platform code for main marketing site
|
||||
@@ -139,7 +139,7 @@ class PlatformContextManager:
|
||||
platform = (
|
||||
db.query(Platform)
|
||||
.filter(Platform.domain == domain)
|
||||
.filter(Platform.is_active == True)
|
||||
.filter(Platform.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -148,8 +148,7 @@ class PlatformContextManager:
|
||||
f"[PLATFORM] Platform found via domain: {domain} → {platform.name}"
|
||||
)
|
||||
return platform
|
||||
else:
|
||||
logger.debug(f"[PLATFORM] No platform found for domain: {domain}")
|
||||
logger.debug(f"[PLATFORM] No platform found for domain: {domain}")
|
||||
|
||||
# Method 2: Path-prefix lookup
|
||||
if context.get("detection_method") == "path":
|
||||
@@ -161,7 +160,7 @@ class PlatformContextManager:
|
||||
.filter(
|
||||
(Platform.path_prefix == path_prefix) | (Platform.code == path_prefix)
|
||||
)
|
||||
.filter(Platform.is_active == True)
|
||||
.filter(Platform.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -170,15 +169,14 @@ class PlatformContextManager:
|
||||
f"[PLATFORM] Platform found via path prefix: {path_prefix} → {platform.name}"
|
||||
)
|
||||
return platform
|
||||
else:
|
||||
logger.debug(f"[PLATFORM] No platform found for path prefix: {path_prefix}")
|
||||
logger.debug(f"[PLATFORM] No platform found for path prefix: {path_prefix}")
|
||||
|
||||
# Method 3: Default lookup
|
||||
if context.get("detection_method") == "default":
|
||||
platform = (
|
||||
db.query(Platform)
|
||||
.filter(Platform.code == DEFAULT_PLATFORM_CODE)
|
||||
.filter(Platform.is_active == True)
|
||||
.filter(Platform.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -220,10 +218,7 @@ class PlatformContextManager:
|
||||
if host.startswith("admin."):
|
||||
return True
|
||||
|
||||
if path.startswith("/admin"):
|
||||
return True
|
||||
|
||||
return False
|
||||
return path.startswith("/admin")
|
||||
|
||||
@staticmethod
|
||||
def is_static_file_request(request: Request) -> bool:
|
||||
@@ -244,10 +239,7 @@ class PlatformContextManager:
|
||||
if any(path.startswith(static_path) for static_path in static_paths):
|
||||
return True
|
||||
|
||||
if "favicon.ico" in path:
|
||||
return True
|
||||
|
||||
return False
|
||||
return "favicon.ico" in path
|
||||
|
||||
|
||||
class PlatformContextMiddleware:
|
||||
@@ -432,18 +424,14 @@ class PlatformContextMiddleware:
|
||||
return True
|
||||
if any(path_lower.startswith(p) for p in static_paths):
|
||||
return True
|
||||
if "favicon.ico" in path_lower:
|
||||
return True
|
||||
return False
|
||||
return "favicon.ico" in path_lower
|
||||
|
||||
def _is_admin_request(self, path: str, host: str) -> bool:
|
||||
"""Check if request is for admin interface."""
|
||||
host_without_port = host.split(":")[0] if ":" in host else host
|
||||
if host_without_port.startswith("admin."):
|
||||
return True
|
||||
if path.startswith("/admin"):
|
||||
return True
|
||||
return False
|
||||
return path.startswith("/admin")
|
||||
|
||||
|
||||
def get_current_platform(request: Request) -> Platform | None:
|
||||
|
||||
@@ -91,7 +91,7 @@ class VendorContextManager:
|
||||
|
||||
# Method 3: Path-based detection (/vendor/vendorname/ or /vendors/vendorname/)
|
||||
# Support BOTH patterns for flexibility
|
||||
if path.startswith("/vendor/") or path.startswith("/vendors/"):
|
||||
if path.startswith(("/vendor/", "/vendors/")):
|
||||
# Determine which pattern
|
||||
if path.startswith("/vendors/"):
|
||||
prefix_len = len("/vendors/")
|
||||
@@ -133,8 +133,8 @@ class VendorContextManager:
|
||||
vendor_domain = (
|
||||
db.query(VendorDomain)
|
||||
.filter(VendorDomain.domain == domain)
|
||||
.filter(VendorDomain.is_active == True)
|
||||
.filter(VendorDomain.is_verified == True)
|
||||
.filter(VendorDomain.is_active.is_(True))
|
||||
.filter(VendorDomain.is_verified.is_(True))
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -157,7 +157,7 @@ class VendorContextManager:
|
||||
vendor = (
|
||||
db.query(Vendor)
|
||||
.filter(func.lower(Vendor.subdomain) == subdomain.lower())
|
||||
.filter(Vendor.is_active == True)
|
||||
.filter(Vendor.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -204,10 +204,7 @@ class VendorContextManager:
|
||||
if host.startswith("admin."):
|
||||
return True
|
||||
|
||||
if path.startswith("/admin"):
|
||||
return True
|
||||
|
||||
return False
|
||||
return path.startswith("/admin")
|
||||
|
||||
@staticmethod
|
||||
def is_api_request(request: Request) -> bool:
|
||||
@@ -263,9 +260,7 @@ class VendorContextManager:
|
||||
|
||||
# Method 1: Path-based detection from referer path
|
||||
# /vendors/wizamart/shop/products → wizamart
|
||||
if referer_path.startswith("/vendors/") or referer_path.startswith(
|
||||
"/vendor/"
|
||||
):
|
||||
if referer_path.startswith(("/vendors/", "/vendor/")):
|
||||
prefix = (
|
||||
"/vendors/" if referer_path.startswith("/vendors/") else "/vendor/"
|
||||
)
|
||||
@@ -384,10 +379,7 @@ class VendorContextManager:
|
||||
if any(path.startswith(static_path) for static_path in static_paths):
|
||||
return True
|
||||
|
||||
if "favicon.ico" in path:
|
||||
return True
|
||||
|
||||
return False
|
||||
return "favicon.ico" in path
|
||||
|
||||
|
||||
class VendorContextMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
@@ -116,10 +116,10 @@ class ContentPage(Base):
|
||||
published_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Ordering (for menus, footers)
|
||||
display_order = Column(Integer, default=0)
|
||||
show_in_footer = Column(Boolean, default=True)
|
||||
show_in_header = Column(Boolean, default=False)
|
||||
show_in_legal = Column(Boolean, default=False) # Bottom bar with copyright
|
||||
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(
|
||||
@@ -206,9 +206,9 @@ class ContentPage(Base):
|
||||
self.published_at.isoformat() if self.published_at else None
|
||||
),
|
||||
"display_order": self.display_order,
|
||||
"show_in_footer": self.show_in_footer,
|
||||
"show_in_header": self.show_in_header,
|
||||
"show_in_legal": self.show_in_legal,
|
||||
"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,
|
||||
|
||||
@@ -525,7 +525,7 @@ class ArchitectureValidator:
|
||||
# These are page-level components that should inherit from data()
|
||||
# Allow optional parameters in the function signature
|
||||
component_pattern = re.compile(
|
||||
r"function\s+(admin\w+|vendor\w+|shop\w+)\s*\([^)]*\)\s*\{", re.IGNORECASE
|
||||
r"function\s+(admin\w+|vendor\w+|shop\w+|platform\w+)\s*\([^)]*\)\s*\{", re.IGNORECASE
|
||||
)
|
||||
|
||||
for match in component_pattern.finditer(content):
|
||||
|
||||
@@ -44,13 +44,19 @@ function contentPagesManager() {
|
||||
]);
|
||||
contentPagesLog.groupEnd();
|
||||
|
||||
// Check for platform filter in URL
|
||||
// Check for platform filter in URL (support both 'platform' and 'platform_code')
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const platformParam = urlParams.get('platform');
|
||||
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 ===');
|
||||
},
|
||||
|
||||
@@ -162,6 +168,30 @@ function contentPagesManager() {
|
||||
}
|
||||
},
|
||||
|
||||
// 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) {
|
||||
|
||||
139
static/admin/js/platform-detail.js
Normal file
139
static/admin/js/platform-detail.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Platform Detail - Alpine.js Component
|
||||
*
|
||||
* Displays platform overview, stats, and quick actions.
|
||||
*/
|
||||
|
||||
const platformDetailLog = window.LogConfig.createLogger('PLATFORM_DETAIL');
|
||||
|
||||
function platformDetail() {
|
||||
return {
|
||||
// Inherit base layout functionality from init-alpine.js
|
||||
...data(),
|
||||
|
||||
// Page identification
|
||||
currentPage: 'platform-detail',
|
||||
|
||||
// State
|
||||
platform: null,
|
||||
stats: null,
|
||||
recentPages: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
platformCode: null,
|
||||
|
||||
// Lifecycle
|
||||
async init() {
|
||||
platformDetailLog.info('=== PLATFORM DETAIL PAGE INITIALIZING ===');
|
||||
|
||||
// Duplicate initialization guard
|
||||
if (window._platformDetailInitialized) {
|
||||
platformDetailLog.warn('Platform detail page already initialized, skipping...');
|
||||
return;
|
||||
}
|
||||
window._platformDetailInitialized = true;
|
||||
|
||||
try {
|
||||
// Extract platform code from URL
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/\/admin\/platforms\/([^\/]+)$/);
|
||||
|
||||
if (match) {
|
||||
this.platformCode = match[1];
|
||||
platformDetailLog.info('Viewing platform:', this.platformCode);
|
||||
await Promise.all([
|
||||
this.loadPlatform(),
|
||||
this.loadRecentPages(),
|
||||
]);
|
||||
} else {
|
||||
platformDetailLog.error('No platform code in URL');
|
||||
this.error = 'Platform code not found in URL';
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
platformDetailLog.info('=== PLATFORM DETAIL PAGE INITIALIZATION COMPLETE ===');
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Platform Detail Init');
|
||||
this.error = 'Failed to initialize page';
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// API Methods
|
||||
async loadPlatform() {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/platforms/${this.platformCode}`);
|
||||
this.platform = response;
|
||||
platformDetailLog.info(`Loaded platform: ${this.platformCode}`);
|
||||
} catch (err) {
|
||||
platformDetailLog.error('Error loading platform:', err);
|
||||
this.error = err.message || 'Failed to load platform';
|
||||
throw err;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadRecentPages() {
|
||||
try {
|
||||
// Load recent content pages for this platform
|
||||
const response = await apiClient.get(`/admin/content-pages?platform_code=${this.platformCode}&limit=5`);
|
||||
this.recentPages = response.items || response || [];
|
||||
platformDetailLog.info(`Loaded ${this.recentPages.length} recent pages`);
|
||||
} catch (err) {
|
||||
platformDetailLog.error('Error loading recent pages:', err);
|
||||
// Non-fatal - don't throw
|
||||
this.recentPages = [];
|
||||
}
|
||||
},
|
||||
|
||||
// Helper Methods
|
||||
getPlatformIcon(code) {
|
||||
const icons = {
|
||||
main: 'home',
|
||||
oms: 'clipboard-list',
|
||||
loyalty: 'star',
|
||||
sitebuilder: 'template',
|
||||
};
|
||||
return icons[code] || 'globe-alt';
|
||||
},
|
||||
|
||||
getPageTypeLabel(page) {
|
||||
if (page.is_platform_page) return 'Marketing';
|
||||
if (page.vendor_id) return 'Vendor Override';
|
||||
return 'Vendor Default';
|
||||
},
|
||||
|
||||
getPageTypeBadgeClass(page) {
|
||||
if (page.is_platform_page) {
|
||||
return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200';
|
||||
}
|
||||
if (page.vendor_id) {
|
||||
return 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200';
|
||||
}
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '—';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fr-LU', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
},
|
||||
|
||||
getPlatformUrl() {
|
||||
if (!this.platform) return '#';
|
||||
if (this.platform.domain) {
|
||||
return `https://${this.platform.domain}`;
|
||||
}
|
||||
// Development URL
|
||||
if (this.platform.code === 'main') {
|
||||
return '/';
|
||||
}
|
||||
return `/platforms/${this.platform.code}/`;
|
||||
},
|
||||
};
|
||||
}
|
||||
230
static/admin/js/platform-edit.js
Normal file
230
static/admin/js/platform-edit.js
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Platform Edit - Alpine.js Component
|
||||
*
|
||||
* Handles platform editing for multi-platform CMS.
|
||||
*/
|
||||
|
||||
const platformEditLog = window.LogConfig.createLogger('PLATFORM_EDIT');
|
||||
|
||||
function platformEdit() {
|
||||
return {
|
||||
// Inherit base layout functionality from init-alpine.js
|
||||
...data(),
|
||||
|
||||
// Page identification
|
||||
currentPage: 'platform-edit',
|
||||
|
||||
// State
|
||||
platform: null,
|
||||
loading: true,
|
||||
saving: false,
|
||||
error: null,
|
||||
success: null,
|
||||
platformCode: null,
|
||||
|
||||
// Form data
|
||||
formData: {
|
||||
name: '',
|
||||
description: '',
|
||||
domain: '',
|
||||
path_prefix: '',
|
||||
logo: '',
|
||||
logo_dark: '',
|
||||
favicon: '',
|
||||
default_language: 'fr',
|
||||
supported_languages: ['fr', 'de', 'en'],
|
||||
is_active: true,
|
||||
is_public: true,
|
||||
theme_config: {},
|
||||
settings: {},
|
||||
},
|
||||
|
||||
errors: {},
|
||||
|
||||
// Available languages
|
||||
availableLanguages: [
|
||||
{ code: 'fr', name: 'French' },
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'lu', name: 'Luxembourgish' },
|
||||
],
|
||||
|
||||
// Lifecycle
|
||||
async init() {
|
||||
platformEditLog.info('=== PLATFORM EDIT PAGE INITIALIZING ===');
|
||||
|
||||
// Duplicate initialization guard
|
||||
if (window._platformEditInitialized) {
|
||||
platformEditLog.warn('Platform edit page already initialized, skipping...');
|
||||
return;
|
||||
}
|
||||
window._platformEditInitialized = true;
|
||||
|
||||
try {
|
||||
// Extract platform code from URL
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/\/admin\/platforms\/([^\/]+)\/edit/);
|
||||
|
||||
if (match) {
|
||||
this.platformCode = match[1];
|
||||
platformEditLog.info('Editing platform:', this.platformCode);
|
||||
await this.loadPlatform();
|
||||
} else {
|
||||
platformEditLog.error('No platform code in URL');
|
||||
this.error = 'Platform code not found in URL';
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
platformEditLog.info('=== PLATFORM EDIT PAGE INITIALIZATION COMPLETE ===');
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Platform Edit Init');
|
||||
this.error = 'Failed to initialize page';
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// API Methods
|
||||
async loadPlatform() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/platforms/${this.platformCode}`);
|
||||
this.platform = response;
|
||||
|
||||
// Populate form data
|
||||
this.formData = {
|
||||
name: response.name || '',
|
||||
description: response.description || '',
|
||||
domain: response.domain || '',
|
||||
path_prefix: response.path_prefix || '',
|
||||
logo: response.logo || '',
|
||||
logo_dark: response.logo_dark || '',
|
||||
favicon: response.favicon || '',
|
||||
default_language: response.default_language || 'fr',
|
||||
supported_languages: response.supported_languages || ['fr', 'de', 'en'],
|
||||
is_active: response.is_active ?? true,
|
||||
is_public: response.is_public ?? true,
|
||||
theme_config: response.theme_config || {},
|
||||
settings: response.settings || {},
|
||||
};
|
||||
|
||||
platformEditLog.info(`Loaded platform: ${this.platformCode}`);
|
||||
} catch (err) {
|
||||
platformEditLog.error('Error loading platform:', err);
|
||||
this.error = err.message || 'Failed to load platform';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async handleSubmit() {
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
this.success = null;
|
||||
this.errors = {};
|
||||
|
||||
try {
|
||||
// Build update payload (only changed fields)
|
||||
const payload = {
|
||||
name: this.formData.name,
|
||||
description: this.formData.description || null,
|
||||
domain: this.formData.domain || null,
|
||||
path_prefix: this.formData.path_prefix || null,
|
||||
logo: this.formData.logo || null,
|
||||
logo_dark: this.formData.logo_dark || null,
|
||||
favicon: this.formData.favicon || null,
|
||||
default_language: this.formData.default_language,
|
||||
supported_languages: this.formData.supported_languages,
|
||||
is_active: this.formData.is_active,
|
||||
is_public: this.formData.is_public,
|
||||
};
|
||||
|
||||
const response = await apiClient.put(
|
||||
`/admin/platforms/${this.platformCode}`,
|
||||
payload
|
||||
);
|
||||
|
||||
this.platform = response;
|
||||
this.success = 'Platform updated successfully';
|
||||
platformEditLog.info(`Updated platform: ${this.platformCode}`);
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.success = null;
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
platformEditLog.error('Error updating platform:', err);
|
||||
this.error = err.message || 'Failed to update platform';
|
||||
|
||||
// Handle validation errors
|
||||
if (err.details) {
|
||||
this.errors = err.details;
|
||||
}
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async toggleActive() {
|
||||
try {
|
||||
this.formData.is_active = !this.formData.is_active;
|
||||
await this.handleSubmit();
|
||||
} catch (err) {
|
||||
platformEditLog.error('Error toggling active status:', err);
|
||||
// Revert on error
|
||||
this.formData.is_active = !this.formData.is_active;
|
||||
}
|
||||
},
|
||||
|
||||
async togglePublic() {
|
||||
try {
|
||||
this.formData.is_public = !this.formData.is_public;
|
||||
await this.handleSubmit();
|
||||
} catch (err) {
|
||||
platformEditLog.error('Error toggling public status:', err);
|
||||
// Revert on error
|
||||
this.formData.is_public = !this.formData.is_public;
|
||||
}
|
||||
},
|
||||
|
||||
// Helper Methods
|
||||
isLanguageSupported(code) {
|
||||
return this.formData.supported_languages.includes(code);
|
||||
},
|
||||
|
||||
toggleLanguage(code) {
|
||||
const index = this.formData.supported_languages.indexOf(code);
|
||||
if (index > -1) {
|
||||
// Don't allow removing the last language
|
||||
if (this.formData.supported_languages.length > 1) {
|
||||
this.formData.supported_languages.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
this.formData.supported_languages.push(code);
|
||||
}
|
||||
},
|
||||
|
||||
getPlatformIcon(code) {
|
||||
const icons = {
|
||||
main: 'home',
|
||||
oms: 'clipboard-list',
|
||||
loyalty: 'star',
|
||||
sitebuilder: 'template',
|
||||
};
|
||||
return icons[code] || 'globe-alt';
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '—';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fr-LU', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -60,15 +60,15 @@ function platformHomepageManager() {
|
||||
// Handle response - API returns array directly
|
||||
const pages = Array.isArray(response) ? response : (response.data || response.items || []);
|
||||
|
||||
// Find the platform_homepage page
|
||||
const homepage = pages.find(page => page.slug === 'platform_homepage');
|
||||
// Find the homepage page (slug='home')
|
||||
const homepage = pages.find(page => page.slug === 'home');
|
||||
|
||||
if (!homepage) {
|
||||
platformHomepageLog.warn('Platform homepage not found, creating default...');
|
||||
// Initialize with default values
|
||||
this.page = {
|
||||
id: null,
|
||||
slug: 'platform_homepage',
|
||||
slug: 'home',
|
||||
title: 'Welcome to Our Multi-Vendor Marketplace',
|
||||
content: '<p>Connect vendors with customers worldwide. Build your online store and reach millions of shoppers.</p>',
|
||||
template: 'default',
|
||||
|
||||
@@ -8,6 +8,12 @@ const platformsLog = window.LogConfig.createLogger('PLATFORMS');
|
||||
|
||||
function platformsManager() {
|
||||
return {
|
||||
// Inherit base layout functionality from init-alpine.js
|
||||
...data(),
|
||||
|
||||
// Page identification
|
||||
currentPage: "platforms",
|
||||
|
||||
// State
|
||||
platforms: [],
|
||||
loading: true,
|
||||
@@ -15,12 +21,22 @@ function platformsManager() {
|
||||
|
||||
// Lifecycle
|
||||
async init() {
|
||||
platformsLog.info('=== PLATFORMS PAGE INITIALIZING ===');
|
||||
|
||||
// Duplicate initialization guard
|
||||
if (window._adminPlatformsInitialized) return;
|
||||
if (window._adminPlatformsInitialized) {
|
||||
platformsLog.warn('Platforms page already initialized, skipping...');
|
||||
return;
|
||||
}
|
||||
window._adminPlatformsInitialized = true;
|
||||
|
||||
this.currentPage = "platforms";
|
||||
await this.loadPlatforms();
|
||||
try {
|
||||
await this.loadPlatforms();
|
||||
platformsLog.info('=== PLATFORMS PAGE INITIALIZATION COMPLETE ===');
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Platforms Init');
|
||||
this.error = 'Failed to initialize page';
|
||||
}
|
||||
},
|
||||
|
||||
// API Methods
|
||||
|
||||
1002
tests/unit/middleware/test_platform_context.py
Normal file
1002
tests/unit/middleware/test_platform_context.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -103,6 +103,9 @@ class TestVendorContextManager:
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "localhost"}
|
||||
request.url = Mock(path="/vendor/vendor1/shop")
|
||||
# Set platform_clean_path to simulate PlatformContextMiddleware output
|
||||
request.state = Mock()
|
||||
request.state.platform_clean_path = "/vendor/vendor1/shop"
|
||||
|
||||
context = VendorContextManager.detect_vendor_context(request)
|
||||
|
||||
@@ -117,6 +120,9 @@ class TestVendorContextManager:
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "localhost"}
|
||||
request.url = Mock(path="/vendors/vendor1/shop")
|
||||
# Set platform_clean_path to simulate PlatformContextMiddleware output
|
||||
request.state = Mock()
|
||||
request.state.platform_clean_path = "/vendors/vendor1/shop"
|
||||
|
||||
context = VendorContextManager.detect_vendor_context(request)
|
||||
|
||||
@@ -131,6 +137,9 @@ class TestVendorContextManager:
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "localhost"}
|
||||
request.url = Mock(path="/random/path")
|
||||
# Set platform_clean_path to None to use url.path
|
||||
request.state = Mock()
|
||||
request.state.platform_clean_path = None
|
||||
|
||||
context = VendorContextManager.detect_vendor_context(request)
|
||||
|
||||
@@ -141,6 +150,8 @@ class TestVendorContextManager:
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "admin.platform.com"}
|
||||
request.url = Mock(path="/")
|
||||
request.state = Mock()
|
||||
request.state.platform_clean_path = None
|
||||
|
||||
with patch("middleware.vendor_context.settings") as mock_settings:
|
||||
mock_settings.platform_domain = "platform.com"
|
||||
@@ -154,6 +165,8 @@ class TestVendorContextManager:
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "www.platform.com"}
|
||||
request.url = Mock(path="/")
|
||||
request.state = Mock()
|
||||
request.state.platform_clean_path = None
|
||||
|
||||
with patch("middleware.vendor_context.settings") as mock_settings:
|
||||
mock_settings.platform_domain = "platform.com"
|
||||
@@ -167,6 +180,8 @@ class TestVendorContextManager:
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "api.platform.com"}
|
||||
request.url = Mock(path="/")
|
||||
request.state = Mock()
|
||||
request.state.platform_clean_path = None
|
||||
|
||||
with patch("middleware.vendor_context.settings") as mock_settings:
|
||||
mock_settings.platform_domain = "platform.com"
|
||||
@@ -180,6 +195,8 @@ class TestVendorContextManager:
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "localhost"}
|
||||
request.url = Mock(path="/")
|
||||
request.state = Mock()
|
||||
request.state.platform_clean_path = None
|
||||
|
||||
with patch("middleware.vendor_context.settings") as mock_settings:
|
||||
mock_settings.platform_domain = "platform.com"
|
||||
@@ -1004,6 +1021,8 @@ class TestEdgeCases:
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": ""}
|
||||
request.url = Mock(path="/")
|
||||
request.state = Mock()
|
||||
request.state.platform_clean_path = None
|
||||
|
||||
context = VendorContextManager.detect_vendor_context(request)
|
||||
|
||||
@@ -1014,6 +1033,8 @@ class TestEdgeCases:
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {}
|
||||
request.url = Mock(path="/")
|
||||
request.state = Mock()
|
||||
request.state.platform_clean_path = None
|
||||
|
||||
context = VendorContextManager.detect_vendor_context(request)
|
||||
|
||||
@@ -1024,6 +1045,8 @@ class TestEdgeCases:
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "localhost"}
|
||||
request.url = Mock(path="/vendor/vendor1/")
|
||||
request.state = Mock()
|
||||
request.state.platform_clean_path = "/vendor/vendor1/"
|
||||
|
||||
context = VendorContextManager.detect_vendor_context(request)
|
||||
|
||||
@@ -1036,6 +1059,8 @@ class TestEdgeCases:
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"host": "localhost"}
|
||||
request.url = Mock(path="/vendor/vendor1")
|
||||
request.state = Mock()
|
||||
request.state.platform_clean_path = "/vendor/vendor1"
|
||||
|
||||
context = VendorContextManager.detect_vendor_context(request)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user