feat(cms): Phase A — page type selector, translation UI, SEO cleanup
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

Content page editor improvements:
- Page type selector: Content Page / Landing Page dropdown (sets template)
- Title language tabs: translate page titles per language (same pattern as sections)
- Content language tabs: translate page content per language
- Meta description language tabs: translatable SEO descriptions
- Template-driven section palette: template defines which sections are available
  (store landing pages hide Pricing, platform homepages show all)
- Hide content editor when Landing Page selected, hide sections when Content Page

Schema changes (migration cms_003):
- Add meta_description_translations column (JSON) to content_pages
- Drop meta_keywords column (obsolete, ignored by all search engines since 2009)
- Remove meta keywords tag from storefront and platform base templates

API + service updates:
- title_translations, content_translations, meta_description_translations
  added to create/update schemas, route handlers, and service methods

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 22:30:55 +02:00
parent bb3d6f0012
commit b5bb9415f6
11 changed files with 293 additions and 91 deletions

View File

@@ -0,0 +1,36 @@
"""add meta_description_translations and drop meta_keywords from content_pages
Revision ID: cms_003
Revises: cms_002
Create Date: 2026-04-15
"""
import sqlalchemy as sa
from alembic import op
revision = "cms_003"
down_revision = "cms_002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"content_pages",
sa.Column(
"meta_description_translations",
sa.JSON(),
nullable=True,
comment="Language-keyed meta description dict for multi-language SEO",
),
)
op.drop_column("content_pages", "meta_keywords")
def downgrade() -> None:
op.add_column(
"content_pages",
sa.Column("meta_keywords", sa.String(300), nullable=True),
)
op.drop_column("content_pages", "meta_description_translations")

View File

@@ -135,7 +135,12 @@ class ContentPage(Base):
# SEO
meta_description = Column(String(300), nullable=True)
meta_keywords = Column(String(300), nullable=True)
meta_description_translations = Column(
JSON,
nullable=True,
default=None,
comment="Language-keyed meta description dict for multi-language SEO",
)
# Publishing
is_published = Column(Boolean, default=False, nullable=False)
@@ -230,6 +235,16 @@ class ContentPage(Base):
)
return self.content
def get_translated_meta_description(self, lang: str, default_lang: str = "fr") -> str:
"""Get meta description in the given language, falling back to default_lang then self.meta_description."""
if self.meta_description_translations:
return (
self.meta_description_translations.get(lang)
or self.meta_description_translations.get(default_lang)
or self.meta_description or ""
)
return self.meta_description or ""
def to_dict(self):
"""Convert to dictionary for API responses."""
return {
@@ -248,7 +263,7 @@ class ContentPage(Base):
"template": self.template,
"sections": self.sections,
"meta_description": self.meta_description,
"meta_keywords": self.meta_keywords,
"meta_description_translations": self.meta_description_translations,
"is_published": self.is_published,
"published_at": (
self.published_at.isoformat() if self.published_at else None

View File

@@ -73,7 +73,7 @@ def create_platform_page(
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=page_data.meta_description_translations,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
@@ -117,7 +117,7 @@ def create_store_page(
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=page_data.meta_description_translations,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
@@ -177,11 +177,13 @@ def update_page(
db,
page_id=page_id,
title=page_data.title,
title_translations=page_data.title_translations,
content=page_data.content,
content_translations=page_data.content_translations,
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=page_data.meta_description_translations,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,

View File

@@ -207,7 +207,7 @@ def create_store_page(
store_id=current_user.token_store_id,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=getattr(page_data, "meta_description_translations", None),
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
@@ -241,7 +241,7 @@ def update_store_page(
content=page_data.content,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=getattr(page_data, "meta_description_translations", None),
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,

View File

@@ -24,19 +24,27 @@ class ContentPageCreate(BaseModel):
description="URL-safe identifier (about, faq, contact, etc.)",
)
title: str = Field(..., max_length=200, description="Page title")
title_translations: dict[str, str] | None = Field(
None, description="Title translations keyed by language code"
)
content: str = Field(..., description="HTML or Markdown content")
content_translations: dict[str, str] | None = Field(
None, description="Content translations keyed by language code"
)
content_format: str = Field(
default="html", description="Content format: html or markdown"
)
template: str = Field(
default="default",
max_length=50,
description="Template name (default, minimal, modern)",
description="Template name (default, minimal, modern, full)",
)
meta_description: str | None = Field(
None, max_length=300, description="SEO meta description"
)
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
meta_description_translations: dict[str, str] | None = Field(
None, description="Meta description translations keyed by language code"
)
is_published: bool = Field(default=False, description="Publish immediately")
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
show_in_header: bool = Field(default=False, description="Show in header navigation")
@@ -53,11 +61,13 @@ class ContentPageUpdate(BaseModel):
"""Schema for updating a content page (admin)."""
title: str | None = Field(None, max_length=200)
title_translations: dict[str, str] | None = None
content: str | None = None
content_translations: dict[str, str] | None = None
content_format: str | None = None
template: str | None = Field(None, max_length=50)
meta_description: str | None = Field(None, max_length=300)
meta_keywords: str | None = Field(None, max_length=300)
meta_description_translations: dict[str, str] | None = None
is_published: bool | None = None
show_in_footer: bool | None = None
show_in_header: bool | None = None
@@ -78,11 +88,13 @@ class ContentPageResponse(BaseModel):
store_name: str | None
slug: str
title: str
title_translations: dict[str, str] | None = None
content: str
content_translations: dict[str, str] | None = None
content_format: str
template: str | None = None
meta_description: str | None
meta_keywords: str | None
meta_description_translations: dict[str, str] | None = None
is_published: bool
published_at: str | None
display_order: int
@@ -135,7 +147,6 @@ class StoreContentPageCreate(BaseModel):
meta_description: str | None = Field(
None, max_length=300, description="SEO meta description"
)
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
is_published: bool = Field(default=False, description="Publish immediately")
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
show_in_header: bool = Field(default=False, description="Show in header navigation")
@@ -152,7 +163,6 @@ class StoreContentPageUpdate(BaseModel):
content: str | None = None
content_format: str | None = None
meta_description: str | None = Field(None, max_length=300)
meta_keywords: str | None = Field(None, max_length=300)
is_published: bool | None = None
show_in_footer: bool | None = None
show_in_header: bool | None = None
@@ -187,7 +197,6 @@ class PublicContentPageResponse(BaseModel):
content: str
content_format: str
meta_description: str | None
meta_keywords: str | None
published_at: str | None

View File

@@ -473,7 +473,7 @@ class ContentPageService:
content_format: str = "html",
template: str = "default",
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool = False,
show_in_footer: bool = True,
show_in_header: bool = False,
@@ -495,7 +495,7 @@ class ContentPageService:
content_format: "html" or "markdown"
template: Template name for landing pages
meta_description: SEO description
meta_keywords: SEO keywords
meta_description_translations: Meta description translations dict
is_published: Publish immediately
show_in_footer: Show in footer navigation
show_in_header: Show in header navigation
@@ -516,7 +516,7 @@ class ContentPageService:
content_format=content_format,
template=template,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
published_at=datetime.now(UTC) if is_published else None,
show_in_footer=show_in_footer,
@@ -542,11 +542,13 @@ class ContentPageService:
db: Session,
page_id: int,
title: str | None = None,
title_translations: dict[str, str] | None = None,
content: str | None = None,
content_translations: dict[str, str] | None = None,
content_format: str | None = None,
template: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
@@ -574,16 +576,20 @@ class ContentPageService:
# Update fields if provided
if title is not None:
page.title = title
if title_translations is not None:
page.title_translations = title_translations
if content is not None:
page.content = content
if content_translations is not None:
page.content_translations = content_translations
if content_format is not None:
page.content_format = content_format
if template is not None:
page.template = template
if meta_description is not None:
page.meta_description = meta_description
if meta_keywords is not None:
page.meta_keywords = meta_keywords
if meta_description_translations is not None:
page.meta_description_translations = meta_description_translations
if is_published is not None:
page.is_published = is_published
if is_published and not page.published_at:
@@ -699,7 +705,7 @@ class ContentPageService:
content: str | None = None,
content_format: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
@@ -726,7 +732,7 @@ class ContentPageService:
content=content,
content_format=content_format,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
@@ -761,7 +767,7 @@ class ContentPageService:
content: str,
content_format: str = "html",
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool = False,
show_in_footer: bool = True,
show_in_header: bool = False,
@@ -792,7 +798,7 @@ class ContentPageService:
is_platform_page=False,
content_format=content_format,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
@@ -914,11 +920,13 @@ class ContentPageService:
db: Session,
page_id: int,
title: str | None = None,
title_translations: dict[str, str] | None = None,
content: str | None = None,
content_translations: dict[str, str] | None = None,
content_format: str | None = None,
template: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
@@ -936,11 +944,13 @@ class ContentPageService:
db,
page_id=page_id,
title=title,
title_translations=title_translations,
content=content,
content_translations=content_translations,
content_format=content_format,
template=template,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,

View File

@@ -20,11 +20,13 @@ function contentPageEditor(pageId) {
form: {
slug: '',
title: '',
title_translations: {},
content: '',
content_translations: {},
content_format: 'html',
template: 'default',
meta_description: '',
meta_keywords: '',
meta_description_translations: {},
is_published: false,
show_in_header: false,
show_in_footer: true,
@@ -42,6 +44,12 @@ function contentPageEditor(pageId) {
error: null,
successMessage: null,
// Page type: 'content' or 'landing'
pageType: 'content',
// Translation language for title/content
titleContentLang: 'fr',
// ========================================
// HOMEPAGE SECTIONS STATE
// ========================================
@@ -56,6 +64,13 @@ function contentPageEditor(pageId) {
de: 'Deutsch',
lb: 'Lëtzebuergesch'
},
// Template-driven section palette
sectionPalette: {
'default': ['hero', 'features', 'products', 'pricing', 'testimonials', 'gallery', 'contact_info', 'cta'],
'full': ['hero', 'features', 'testimonials', 'gallery', 'contact_info', 'cta'],
},
sections: {
hero: {
enabled: true,
@@ -108,8 +123,8 @@ function contentPageEditor(pageId) {
await this.loadPage();
contentPageEditLog.groupEnd();
// Load sections if this is a homepage
if (this.form.slug === 'home') {
// Load sections if this is a landing page
if (this.pageType === 'landing') {
await this.loadSections();
}
} else {
@@ -120,14 +135,86 @@ function contentPageEditor(pageId) {
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
},
// Check if we should show section editor (property, not getter for Alpine compatibility)
// Check if we should show section editor
isHomepage: false,
// Update isHomepage when slug changes
// Is a section available for the current template?
isSectionAvailable(sectionName) {
const palette = this.sectionPalette[this.form.template] || this.sectionPalette['full'];
return palette.includes(sectionName);
},
// Update homepage state
updateIsHomepage() {
this.isHomepage = this.form.slug === 'home';
},
// Update template when page type changes
updatePageType() {
if (this.pageType === 'landing') {
this.form.template = 'full';
// Load sections if editing and not yet loaded
if (this.pageId && !this.sectionsLoaded) {
this.loadSections();
}
} else {
this.form.template = 'default';
}
this.updateIsHomepage();
},
// ========================================
// TITLE/CONTENT TRANSLATION HELPERS
// ========================================
getTranslatedTitle() {
if (this.titleContentLang === this.defaultLanguage) {
return this.form.title;
}
return (this.form.title_translations || {})[this.titleContentLang] || '';
},
setTranslatedTitle(value) {
if (this.titleContentLang === this.defaultLanguage) {
this.form.title = value;
} else {
if (!this.form.title_translations) this.form.title_translations = {};
this.form.title_translations[this.titleContentLang] = value;
}
},
getTranslatedContent() {
if (this.titleContentLang === this.defaultLanguage) {
return this.form.content;
}
return (this.form.content_translations || {})[this.titleContentLang] || '';
},
setTranslatedContent(value) {
if (this.titleContentLang === this.defaultLanguage) {
this.form.content = value;
} else {
if (!this.form.content_translations) this.form.content_translations = {};
this.form.content_translations[this.titleContentLang] = value;
}
},
getTranslatedMetaDescription() {
if (this.titleContentLang === this.defaultLanguage) {
return this.form.meta_description;
}
return (this.form.meta_description_translations || {})[this.titleContentLang] || '';
},
setTranslatedMetaDescription(value) {
if (this.titleContentLang === this.defaultLanguage) {
this.form.meta_description = value;
} else {
if (!this.form.meta_description_translations) this.form.meta_description_translations = {};
this.form.meta_description_translations[this.titleContentLang] = value;
}
},
// Load platforms for dropdown
async loadPlatforms() {
this.loadingPlatforms = true;
@@ -188,11 +275,13 @@ function contentPageEditor(pageId) {
this.form = {
slug: page.slug || '',
title: page.title || '',
title_translations: page.title_translations || {},
content: page.content || '',
content_translations: page.content_translations || {},
content_format: page.content_format || 'html',
template: page.template || 'default',
meta_description: page.meta_description || '',
meta_keywords: page.meta_keywords || '',
meta_description_translations: page.meta_description_translations || {},
is_published: page.is_published || false,
show_in_header: page.show_in_header || false,
show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
@@ -202,6 +291,9 @@ function contentPageEditor(pageId) {
store_id: page.store_id
};
// Set page type from template
this.pageType = (this.form.template === 'full') ? 'landing' : 'content';
contentPageEditLog.info('Page loaded successfully');
// Update computed properties after loading
@@ -240,24 +332,25 @@ function contentPageEditor(pageId) {
},
// ========================================
// HOMEPAGE SECTIONS METHODS
// SECTIONS METHODS
// ========================================
// Load sections for homepage
// Load sections for landing pages
async loadSections() {
if (!this.pageId || this.form.slug !== 'home') {
contentPageEditLog.debug('Skipping section load - not a homepage');
if (!this.pageId || this.pageType !== 'landing') {
contentPageEditLog.debug('Skipping section load - not a landing page');
return;
}
try {
contentPageEditLog.info('Loading homepage sections...');
contentPageEditLog.info('Loading sections...');
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
const data = response.data || response;
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
this.defaultLanguage = data.default_language || 'fr';
this.currentLang = this.defaultLanguage;
this.titleContentLang = this.defaultLanguage;
if (data.sections) {
this.sections = this.mergeWithDefaults(data.sections);
@@ -277,12 +370,18 @@ function contentPageEditor(pageId) {
mergeWithDefaults(loadedSections) {
const defaults = this.getDefaultSectionStructure();
// Deep merge each section
for (const key of ['hero', 'features', 'pricing', 'cta']) {
// Deep merge each section that exists in defaults
for (const key of Object.keys(defaults)) {
if (loadedSections[key]) {
defaults[key] = { ...defaults[key], ...loadedSections[key] };
}
}
// Also preserve any extra sections from loaded data
for (const key of Object.keys(loadedSections)) {
if (!defaults[key]) {
defaults[key] = loadedSections[key];
}
}
return defaults;
},
@@ -375,7 +474,7 @@ function contentPageEditor(pageId) {
// Save sections
async saveSections() {
if (!this.pageId || !this.isHomepage) return;
if (!this.pageId || this.pageType !== 'landing') return;
try {
contentPageEditLog.info('Saving sections...');
@@ -401,11 +500,13 @@ function contentPageEditor(pageId) {
const payload = {
slug: this.form.slug,
title: this.form.title,
title_translations: this.form.title_translations,
content: this.form.content,
content_translations: this.form.content_translations,
content_format: this.form.content_format,
template: this.form.template,
meta_description: this.form.meta_description,
meta_keywords: this.form.meta_keywords,
meta_description_translations: this.form.meta_description_translations,
is_published: this.form.is_published,
show_in_header: this.form.show_in_header,
show_in_footer: this.form.show_in_footer,
@@ -422,8 +523,8 @@ function contentPageEditor(pageId) {
// Update existing page
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
// Also save sections if this is a homepage
if (this.isHomepage && this.sectionsLoaded) {
// Also save sections if this is a landing page
if (this.pageType === 'landing' && this.sectionsLoaded) {
await this.saveSections();
}

View File

@@ -57,19 +57,23 @@
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Page Title -->
<div class="md:col-span-2">
<!-- Page Type -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Page Title <span class="text-red-500">*</span>
Page Type
</label>
<input
type="text"
x-model="form.title"
required
maxlength="200"
<select
x-model="pageType"
@change="updatePageType()"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="About Us"
>
<option value="content">Content Page</option>
<option value="landing">Landing Page</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-show="pageType === 'content'">Standard page with rich text content (About, FAQ, Privacy...)</span>
<span x-show="pageType === 'landing'">Section-based page with hero, features, CTA blocks</span>
</p>
</div>
<!-- Slug -->
@@ -133,10 +137,54 @@
</div>
</div>
<!-- Content -->
<!-- Title with Language Tabs -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Page Title
<span class="text-sm font-normal text-gray-500 ml-2">(Multi-language)</span>
</h3>
<!-- Language Tabs for Title/Content -->
<div class="mb-4">
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex -mb-px space-x-4">
<template x-for="lang in supportedLanguages" :key="'tc-' + lang">
<button
type="button"
@click="titleContentLang = lang"
:class="titleContentLang === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="py-2 px-4 border-b-2 font-medium text-sm transition-colors"
>
<span x-text="languageNames[lang] || lang.toUpperCase()"></span>
<span x-show="lang === defaultLanguage" class="ml-1 text-xs text-gray-400">(default)</span>
</button>
</template>
</nav>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Title <span class="text-red-500">*</span>
<span class="font-normal text-gray-400 ml-1" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
</label>
<input
type="text"
:value="getTranslatedTitle()"
@input="setTranslatedTitle($event.target.value)"
required
maxlength="200"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
:placeholder="'Page title in ' + (languageNames[titleContentLang] || titleContentLang)"
>
</div>
</div>
<!-- Content (only for Content Page type) -->
<div x-show="pageType === 'content'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Page Content
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
</h3>
<!-- Content Format -->
@@ -219,9 +267,9 @@
</div>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- HOMEPAGE SECTIONS EDITOR (only for slug='home') -->
<!-- SECTIONS EDITOR (for Landing Page type) -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<div x-show="isHomepage" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
<div x-show="pageType === 'landing'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Homepage Sections
@@ -258,7 +306,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- HERO SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('hero')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'hero' ? null : 'hero'"
@@ -341,7 +389,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- FEATURES SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('features')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'features' ? null : 'features'"
@@ -410,7 +458,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- PRICING SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('pricing')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
@@ -448,7 +496,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- CTA SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('cta')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'cta' ? null : 'cta'"
@@ -525,6 +573,7 @@
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
SEO & Metadata
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
</h3>
<div class="space-y-4">
@@ -534,30 +583,17 @@
Meta Description
</label>
<textarea
x-model="form.meta_description"
:value="getTranslatedMetaDescription()"
@input="setTranslatedMetaDescription($event.target.value)"
rows="2"
maxlength="300"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="A brief description for search engines"
:placeholder="'Meta description in ' + (languageNames[titleContentLang] || titleContentLang)"
></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-text="(form.meta_description || '').length"></span>/300 characters (150-160 recommended)
150-160 characters recommended for search engines
</p>
</div>
<!-- Meta Keywords -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Meta Keywords
</label>
<input
type="text"
x-model="form.meta_keywords"
maxlength="300"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="keyword1, keyword2, keyword3"
>
</div>
</div>
</div>

View File

@@ -11,7 +11,6 @@
{# SEO Meta Tags #}
<meta name="description" content="{% block meta_description %}{{ platform.get_translated_description(current_language|default('fr'), platform.default_language|default('fr')) if platform else '' }}{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}letzshop, order management, oms, luxembourg, e-commerce, invoicing, inventory{% endblock %}">
{# Favicon #}
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='favicon.ico') }}">

View File

@@ -14,7 +14,6 @@
{# SEO Meta Tags #}
<meta name="description" content="{% block meta_description %}{{ store.description or 'Shop at ' + store.name }}{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}{{ store.name }}, online shop{% endblock %}">
{# Favicon - store-specific or default #}
{% if theme.branding.favicon %}

View File

@@ -129,21 +129,16 @@ Add **language tabs** to the title and content fields — same pattern the secti
- Other language tabs edit `form.title_translations[lang]` / `form.content_translations[lang]`
- When creating a store override from a default, pre-populate translations from the default
### Change 3: Context-aware section editor
### Change 3: Template-driven section palette
Hide irrelevant sections based on page context:
The **template** (page type) defines which sections are available — not a hardcoded list filtered by context. The admin section editor loads the available section types from a template config.
| Section | Platform Homepage | Store Homepage |
|---------|------------------|----------------|
| Hero | Yes | Yes |
| Features | Yes | Yes |
| Pricing | Yes | **No** |
| CTA | Yes | Yes |
| Testimonials | Yes | Yes |
| Gallery | Yes | Yes |
| Contact Info | Yes | Yes |
| Template | Available Sections |
|----------|-------------------|
| `default` (platform homepage) | hero, features, products, pricing, testimonials, gallery, contact_info, cta |
| `full` (store landing page) | hero, features, testimonials, gallery, contact_info, cta |
Implementation: pass `is_platform_page` to the JS component, conditionally show Pricing.
Implementation: a `TEMPLATE_SECTION_PALETTE` dict mapping template name → list of allowed section types. The route handler passes the palette to the editor JS, which only renders sections in the palette. This keeps the logic in one place and sets up Phase C/D — when sections become an ordered array with add/remove, the template defines the palette of available types, and modules can extend that palette.
### Change 4: Sections as ordered list (future)
@@ -193,7 +188,7 @@ New contract: `StorefrontSectionProviderProtocol`
- [ ] Title + content translation UI (language tabs on edit page)
- [ ] Page type selector (Content Page / Landing Page dropdown)
- [ ] Hide content field when Landing Page selected
- [ ] Hide Pricing section for non-platform pages
- [ ] Template-driven section palette (template defines which sections are available)
- [ ] Fix: FASHIONHUB about page — add translations
- [ ] Fix: store theme API bug (done — `get_store_by_code_or_subdomain`)