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 +