diff --git a/app/modules/cms/migrations/versions/cms_003_seo_meta_description_translations.py b/app/modules/cms/migrations/versions/cms_003_seo_meta_description_translations.py new file mode 100644 index 00000000..73265e73 --- /dev/null +++ b/app/modules/cms/migrations/versions/cms_003_seo_meta_description_translations.py @@ -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") diff --git a/app/modules/cms/models/content_page.py b/app/modules/cms/models/content_page.py index 3524494a..8e2d1d20 100644 --- a/app/modules/cms/models/content_page.py +++ b/app/modules/cms/models/content_page.py @@ -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 diff --git a/app/modules/cms/routes/api/admin_content_pages.py b/app/modules/cms/routes/api/admin_content_pages.py index 7c4a0c5f..bef1413d 100644 --- a/app/modules/cms/routes/api/admin_content_pages.py +++ b/app/modules/cms/routes/api/admin_content_pages.py @@ -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, diff --git a/app/modules/cms/routes/api/store_content_pages.py b/app/modules/cms/routes/api/store_content_pages.py index a3416e4a..8ae801ab 100644 --- a/app/modules/cms/routes/api/store_content_pages.py +++ b/app/modules/cms/routes/api/store_content_pages.py @@ -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, diff --git a/app/modules/cms/schemas/content_page.py b/app/modules/cms/schemas/content_page.py index 6f89fa60..c52603d2 100644 --- a/app/modules/cms/schemas/content_page.py +++ b/app/modules/cms/schemas/content_page.py @@ -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 diff --git a/app/modules/cms/services/content_page_service.py b/app/modules/cms/services/content_page_service.py index ac6c4f1d..19e383da 100644 --- a/app/modules/cms/services/content_page_service.py +++ b/app/modules/cms/services/content_page_service.py @@ -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, diff --git a/app/modules/cms/static/admin/js/content-page-edit.js b/app/modules/cms/static/admin/js/content-page-edit.js index a1cd5658..e232842f 100644 --- a/app/modules/cms/static/admin/js/content-page-edit.js +++ b/app/modules/cms/static/admin/js/content-page-edit.js @@ -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(); } diff --git a/app/modules/cms/templates/cms/admin/content-page-edit.html b/app/modules/cms/templates/cms/admin/content-page-edit.html index dabe531c..fc71fe9b 100644 --- a/app/modules/cms/templates/cms/admin/content-page-edit.html +++ b/app/modules/cms/templates/cms/admin/content-page-edit.html @@ -57,19 +57,23 @@
- -
+ +
- + + + +

+ Standard page with rich text content (About, FAQ, Privacy...) + Section-based page with hero, features, CTA blocks +

@@ -133,10 +137,54 @@
- +
+

+ Page Title + (Multi-language) +

+ + +
+
+ +
+
+ +
+ + +
+
+ + +

Page Content +

@@ -219,9 +267,9 @@
- + -
+

Homepage Sections @@ -258,7 +306,7 @@ -
+
diff --git a/app/templates/platform/base.html b/app/templates/platform/base.html index d789276e..5aab7c03 100644 --- a/app/templates/platform/base.html +++ b/app/templates/platform/base.html @@ -11,7 +11,6 @@ {# SEO Meta Tags #} - {# Favicon #} diff --git a/app/templates/storefront/base.html b/app/templates/storefront/base.html index 9b3ed4d7..94f1e46d 100644 --- a/app/templates/storefront/base.html +++ b/app/templates/storefront/base.html @@ -14,7 +14,6 @@ {# SEO Meta Tags #} - {# Favicon - store-specific or default #} {% if theme.branding.favicon %} diff --git a/docs/proposals/cms-redesign-alignment.md b/docs/proposals/cms-redesign-alignment.md index 6d2ac078..5a09cf73 100644 --- a/docs/proposals/cms-redesign-alignment.md +++ b/docs/proposals/cms-redesign-alignment.md @@ -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`)