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:
2026-01-23 14:08:02 +01:00
parent d70a9f38d4
commit 3d3b8cae22
25 changed files with 3233 additions and 95 deletions

View File

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

View File

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

View File

@@ -69,7 +69,6 @@ class BasePlatformConfig(ABC):
"""
return [
"home",
"platform_homepage",
"pricing",
"about",
"contact",

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 %}

View 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 %}

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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}/`;
},
};
}

View 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',
});
},
};
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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