feat(cms): Phase A — page type selector, translation UI, SEO cleanup
Some checks failed
Some checks failed
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:
@@ -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")
|
||||||
@@ -135,7 +135,12 @@ class ContentPage(Base):
|
|||||||
|
|
||||||
# SEO
|
# SEO
|
||||||
meta_description = Column(String(300), nullable=True)
|
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
|
# Publishing
|
||||||
is_published = Column(Boolean, default=False, nullable=False)
|
is_published = Column(Boolean, default=False, nullable=False)
|
||||||
@@ -230,6 +235,16 @@ class ContentPage(Base):
|
|||||||
)
|
)
|
||||||
return self.content
|
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):
|
def to_dict(self):
|
||||||
"""Convert to dictionary for API responses."""
|
"""Convert to dictionary for API responses."""
|
||||||
return {
|
return {
|
||||||
@@ -248,7 +263,7 @@ class ContentPage(Base):
|
|||||||
"template": self.template,
|
"template": self.template,
|
||||||
"sections": self.sections,
|
"sections": self.sections,
|
||||||
"meta_description": self.meta_description,
|
"meta_description": self.meta_description,
|
||||||
"meta_keywords": self.meta_keywords,
|
"meta_description_translations": self.meta_description_translations,
|
||||||
"is_published": self.is_published,
|
"is_published": self.is_published,
|
||||||
"published_at": (
|
"published_at": (
|
||||||
self.published_at.isoformat() if self.published_at else None
|
self.published_at.isoformat() if self.published_at else None
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def create_platform_page(
|
|||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
template=page_data.template,
|
template=page_data.template,
|
||||||
meta_description=page_data.meta_description,
|
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,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
@@ -117,7 +117,7 @@ def create_store_page(
|
|||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
template=page_data.template,
|
template=page_data.template,
|
||||||
meta_description=page_data.meta_description,
|
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,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
@@ -177,11 +177,13 @@ def update_page(
|
|||||||
db,
|
db,
|
||||||
page_id=page_id,
|
page_id=page_id,
|
||||||
title=page_data.title,
|
title=page_data.title,
|
||||||
|
title_translations=page_data.title_translations,
|
||||||
content=page_data.content,
|
content=page_data.content,
|
||||||
|
content_translations=page_data.content_translations,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
template=page_data.template,
|
template=page_data.template,
|
||||||
meta_description=page_data.meta_description,
|
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,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ def create_store_page(
|
|||||||
store_id=current_user.token_store_id,
|
store_id=current_user.token_store_id,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
meta_description=page_data.meta_description,
|
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,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
@@ -241,7 +241,7 @@ def update_store_page(
|
|||||||
content=page_data.content,
|
content=page_data.content,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
meta_description=page_data.meta_description,
|
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,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
|
|||||||
@@ -24,19 +24,27 @@ class ContentPageCreate(BaseModel):
|
|||||||
description="URL-safe identifier (about, faq, contact, etc.)",
|
description="URL-safe identifier (about, faq, contact, etc.)",
|
||||||
)
|
)
|
||||||
title: str = Field(..., max_length=200, description="Page title")
|
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: 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(
|
content_format: str = Field(
|
||||||
default="html", description="Content format: html or markdown"
|
default="html", description="Content format: html or markdown"
|
||||||
)
|
)
|
||||||
template: str = Field(
|
template: str = Field(
|
||||||
default="default",
|
default="default",
|
||||||
max_length=50,
|
max_length=50,
|
||||||
description="Template name (default, minimal, modern)",
|
description="Template name (default, minimal, modern, full)",
|
||||||
)
|
)
|
||||||
meta_description: str | None = Field(
|
meta_description: str | None = Field(
|
||||||
None, max_length=300, description="SEO meta description"
|
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")
|
is_published: bool = Field(default=False, description="Publish immediately")
|
||||||
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
||||||
show_in_header: bool = Field(default=False, description="Show in header 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)."""
|
"""Schema for updating a content page (admin)."""
|
||||||
|
|
||||||
title: str | None = Field(None, max_length=200)
|
title: str | None = Field(None, max_length=200)
|
||||||
|
title_translations: dict[str, str] | None = None
|
||||||
content: str | None = None
|
content: str | None = None
|
||||||
|
content_translations: dict[str, str] | None = None
|
||||||
content_format: str | None = None
|
content_format: str | None = None
|
||||||
template: str | None = Field(None, max_length=50)
|
template: str | None = Field(None, max_length=50)
|
||||||
meta_description: str | None = Field(None, max_length=300)
|
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
|
is_published: bool | None = None
|
||||||
show_in_footer: bool | None = None
|
show_in_footer: bool | None = None
|
||||||
show_in_header: bool | None = None
|
show_in_header: bool | None = None
|
||||||
@@ -78,11 +88,13 @@ class ContentPageResponse(BaseModel):
|
|||||||
store_name: str | None
|
store_name: str | None
|
||||||
slug: str
|
slug: str
|
||||||
title: str
|
title: str
|
||||||
|
title_translations: dict[str, str] | None = None
|
||||||
content: str
|
content: str
|
||||||
|
content_translations: dict[str, str] | None = None
|
||||||
content_format: str
|
content_format: str
|
||||||
template: str | None = None
|
template: str | None = None
|
||||||
meta_description: str | None
|
meta_description: str | None
|
||||||
meta_keywords: str | None
|
meta_description_translations: dict[str, str] | None = None
|
||||||
is_published: bool
|
is_published: bool
|
||||||
published_at: str | None
|
published_at: str | None
|
||||||
display_order: int
|
display_order: int
|
||||||
@@ -135,7 +147,6 @@ class StoreContentPageCreate(BaseModel):
|
|||||||
meta_description: str | None = Field(
|
meta_description: str | None = Field(
|
||||||
None, max_length=300, description="SEO meta description"
|
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")
|
is_published: bool = Field(default=False, description="Publish immediately")
|
||||||
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
||||||
show_in_header: bool = Field(default=False, description="Show in header 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: str | None = None
|
||||||
content_format: str | None = None
|
content_format: str | None = None
|
||||||
meta_description: str | None = Field(None, max_length=300)
|
meta_description: str | None = Field(None, max_length=300)
|
||||||
meta_keywords: str | None = Field(None, max_length=300)
|
|
||||||
is_published: bool | None = None
|
is_published: bool | None = None
|
||||||
show_in_footer: bool | None = None
|
show_in_footer: bool | None = None
|
||||||
show_in_header: bool | None = None
|
show_in_header: bool | None = None
|
||||||
@@ -187,7 +197,6 @@ class PublicContentPageResponse(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
content_format: str
|
content_format: str
|
||||||
meta_description: str | None
|
meta_description: str | None
|
||||||
meta_keywords: str | None
|
|
||||||
published_at: str | None
|
published_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ class ContentPageService:
|
|||||||
content_format: str = "html",
|
content_format: str = "html",
|
||||||
template: str = "default",
|
template: str = "default",
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool = False,
|
is_published: bool = False,
|
||||||
show_in_footer: bool = True,
|
show_in_footer: bool = True,
|
||||||
show_in_header: bool = False,
|
show_in_header: bool = False,
|
||||||
@@ -495,7 +495,7 @@ class ContentPageService:
|
|||||||
content_format: "html" or "markdown"
|
content_format: "html" or "markdown"
|
||||||
template: Template name for landing pages
|
template: Template name for landing pages
|
||||||
meta_description: SEO description
|
meta_description: SEO description
|
||||||
meta_keywords: SEO keywords
|
meta_description_translations: Meta description translations dict
|
||||||
is_published: Publish immediately
|
is_published: Publish immediately
|
||||||
show_in_footer: Show in footer navigation
|
show_in_footer: Show in footer navigation
|
||||||
show_in_header: Show in header navigation
|
show_in_header: Show in header navigation
|
||||||
@@ -516,7 +516,7 @@ class ContentPageService:
|
|||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
template=template,
|
template=template,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
published_at=datetime.now(UTC) if is_published else None,
|
published_at=datetime.now(UTC) if is_published else None,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
@@ -542,11 +542,13 @@ class ContentPageService:
|
|||||||
db: Session,
|
db: Session,
|
||||||
page_id: int,
|
page_id: int,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
|
title_translations: dict[str, str] | None = None,
|
||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
|
content_translations: dict[str, str] | None = None,
|
||||||
content_format: str | None = None,
|
content_format: str | None = None,
|
||||||
template: str | None = None,
|
template: str | None = None,
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool | None = None,
|
is_published: bool | None = None,
|
||||||
show_in_footer: bool | None = None,
|
show_in_footer: bool | None = None,
|
||||||
show_in_header: bool | None = None,
|
show_in_header: bool | None = None,
|
||||||
@@ -574,16 +576,20 @@ class ContentPageService:
|
|||||||
# Update fields if provided
|
# Update fields if provided
|
||||||
if title is not None:
|
if title is not None:
|
||||||
page.title = title
|
page.title = title
|
||||||
|
if title_translations is not None:
|
||||||
|
page.title_translations = title_translations
|
||||||
if content is not None:
|
if content is not None:
|
||||||
page.content = content
|
page.content = content
|
||||||
|
if content_translations is not None:
|
||||||
|
page.content_translations = content_translations
|
||||||
if content_format is not None:
|
if content_format is not None:
|
||||||
page.content_format = content_format
|
page.content_format = content_format
|
||||||
if template is not None:
|
if template is not None:
|
||||||
page.template = template
|
page.template = template
|
||||||
if meta_description is not None:
|
if meta_description is not None:
|
||||||
page.meta_description = meta_description
|
page.meta_description = meta_description
|
||||||
if meta_keywords is not None:
|
if meta_description_translations is not None:
|
||||||
page.meta_keywords = meta_keywords
|
page.meta_description_translations = meta_description_translations
|
||||||
if is_published is not None:
|
if is_published is not None:
|
||||||
page.is_published = is_published
|
page.is_published = is_published
|
||||||
if is_published and not page.published_at:
|
if is_published and not page.published_at:
|
||||||
@@ -699,7 +705,7 @@ class ContentPageService:
|
|||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
content_format: str | None = None,
|
content_format: str | None = None,
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool | None = None,
|
is_published: bool | None = None,
|
||||||
show_in_footer: bool | None = None,
|
show_in_footer: bool | None = None,
|
||||||
show_in_header: bool | None = None,
|
show_in_header: bool | None = None,
|
||||||
@@ -726,7 +732,7 @@ class ContentPageService:
|
|||||||
content=content,
|
content=content,
|
||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
show_in_header=show_in_header,
|
show_in_header=show_in_header,
|
||||||
@@ -761,7 +767,7 @@ class ContentPageService:
|
|||||||
content: str,
|
content: str,
|
||||||
content_format: str = "html",
|
content_format: str = "html",
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool = False,
|
is_published: bool = False,
|
||||||
show_in_footer: bool = True,
|
show_in_footer: bool = True,
|
||||||
show_in_header: bool = False,
|
show_in_header: bool = False,
|
||||||
@@ -792,7 +798,7 @@ class ContentPageService:
|
|||||||
is_platform_page=False,
|
is_platform_page=False,
|
||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
show_in_header=show_in_header,
|
show_in_header=show_in_header,
|
||||||
@@ -914,11 +920,13 @@ class ContentPageService:
|
|||||||
db: Session,
|
db: Session,
|
||||||
page_id: int,
|
page_id: int,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
|
title_translations: dict[str, str] | None = None,
|
||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
|
content_translations: dict[str, str] | None = None,
|
||||||
content_format: str | None = None,
|
content_format: str | None = None,
|
||||||
template: str | None = None,
|
template: str | None = None,
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool | None = None,
|
is_published: bool | None = None,
|
||||||
show_in_footer: bool | None = None,
|
show_in_footer: bool | None = None,
|
||||||
show_in_header: bool | None = None,
|
show_in_header: bool | None = None,
|
||||||
@@ -936,11 +944,13 @@ class ContentPageService:
|
|||||||
db,
|
db,
|
||||||
page_id=page_id,
|
page_id=page_id,
|
||||||
title=title,
|
title=title,
|
||||||
|
title_translations=title_translations,
|
||||||
content=content,
|
content=content,
|
||||||
|
content_translations=content_translations,
|
||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
template=template,
|
template=template,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
show_in_header=show_in_header,
|
show_in_header=show_in_header,
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ function contentPageEditor(pageId) {
|
|||||||
form: {
|
form: {
|
||||||
slug: '',
|
slug: '',
|
||||||
title: '',
|
title: '',
|
||||||
|
title_translations: {},
|
||||||
content: '',
|
content: '',
|
||||||
|
content_translations: {},
|
||||||
content_format: 'html',
|
content_format: 'html',
|
||||||
template: 'default',
|
template: 'default',
|
||||||
meta_description: '',
|
meta_description: '',
|
||||||
meta_keywords: '',
|
meta_description_translations: {},
|
||||||
is_published: false,
|
is_published: false,
|
||||||
show_in_header: false,
|
show_in_header: false,
|
||||||
show_in_footer: true,
|
show_in_footer: true,
|
||||||
@@ -42,6 +44,12 @@ function contentPageEditor(pageId) {
|
|||||||
error: null,
|
error: null,
|
||||||
successMessage: null,
|
successMessage: null,
|
||||||
|
|
||||||
|
// Page type: 'content' or 'landing'
|
||||||
|
pageType: 'content',
|
||||||
|
|
||||||
|
// Translation language for title/content
|
||||||
|
titleContentLang: 'fr',
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// HOMEPAGE SECTIONS STATE
|
// HOMEPAGE SECTIONS STATE
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -56,6 +64,13 @@ function contentPageEditor(pageId) {
|
|||||||
de: 'Deutsch',
|
de: 'Deutsch',
|
||||||
lb: 'Lëtzebuergesch'
|
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: {
|
sections: {
|
||||||
hero: {
|
hero: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -108,8 +123,8 @@ function contentPageEditor(pageId) {
|
|||||||
await this.loadPage();
|
await this.loadPage();
|
||||||
contentPageEditLog.groupEnd();
|
contentPageEditLog.groupEnd();
|
||||||
|
|
||||||
// Load sections if this is a homepage
|
// Load sections if this is a landing page
|
||||||
if (this.form.slug === 'home') {
|
if (this.pageType === 'landing') {
|
||||||
await this.loadSections();
|
await this.loadSections();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -120,14 +135,86 @@ function contentPageEditor(pageId) {
|
|||||||
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
|
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,
|
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() {
|
updateIsHomepage() {
|
||||||
this.isHomepage = this.form.slug === 'home';
|
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
|
// Load platforms for dropdown
|
||||||
async loadPlatforms() {
|
async loadPlatforms() {
|
||||||
this.loadingPlatforms = true;
|
this.loadingPlatforms = true;
|
||||||
@@ -188,11 +275,13 @@ function contentPageEditor(pageId) {
|
|||||||
this.form = {
|
this.form = {
|
||||||
slug: page.slug || '',
|
slug: page.slug || '',
|
||||||
title: page.title || '',
|
title: page.title || '',
|
||||||
|
title_translations: page.title_translations || {},
|
||||||
content: page.content || '',
|
content: page.content || '',
|
||||||
|
content_translations: page.content_translations || {},
|
||||||
content_format: page.content_format || 'html',
|
content_format: page.content_format || 'html',
|
||||||
template: page.template || 'default',
|
template: page.template || 'default',
|
||||||
meta_description: page.meta_description || '',
|
meta_description: page.meta_description || '',
|
||||||
meta_keywords: page.meta_keywords || '',
|
meta_description_translations: page.meta_description_translations || {},
|
||||||
is_published: page.is_published || false,
|
is_published: page.is_published || false,
|
||||||
show_in_header: page.show_in_header || false,
|
show_in_header: page.show_in_header || false,
|
||||||
show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
|
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
|
store_id: page.store_id
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set page type from template
|
||||||
|
this.pageType = (this.form.template === 'full') ? 'landing' : 'content';
|
||||||
|
|
||||||
contentPageEditLog.info('Page loaded successfully');
|
contentPageEditLog.info('Page loaded successfully');
|
||||||
|
|
||||||
// Update computed properties after loading
|
// 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() {
|
async loadSections() {
|
||||||
if (!this.pageId || this.form.slug !== 'home') {
|
if (!this.pageId || this.pageType !== 'landing') {
|
||||||
contentPageEditLog.debug('Skipping section load - not a homepage');
|
contentPageEditLog.debug('Skipping section load - not a landing page');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
contentPageEditLog.info('Loading homepage sections...');
|
contentPageEditLog.info('Loading sections...');
|
||||||
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
|
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
|
||||||
const data = response.data || response;
|
const data = response.data || response;
|
||||||
|
|
||||||
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
|
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
|
||||||
this.defaultLanguage = data.default_language || 'fr';
|
this.defaultLanguage = data.default_language || 'fr';
|
||||||
this.currentLang = this.defaultLanguage;
|
this.currentLang = this.defaultLanguage;
|
||||||
|
this.titleContentLang = this.defaultLanguage;
|
||||||
|
|
||||||
if (data.sections) {
|
if (data.sections) {
|
||||||
this.sections = this.mergeWithDefaults(data.sections);
|
this.sections = this.mergeWithDefaults(data.sections);
|
||||||
@@ -277,12 +370,18 @@ function contentPageEditor(pageId) {
|
|||||||
mergeWithDefaults(loadedSections) {
|
mergeWithDefaults(loadedSections) {
|
||||||
const defaults = this.getDefaultSectionStructure();
|
const defaults = this.getDefaultSectionStructure();
|
||||||
|
|
||||||
// Deep merge each section
|
// Deep merge each section that exists in defaults
|
||||||
for (const key of ['hero', 'features', 'pricing', 'cta']) {
|
for (const key of Object.keys(defaults)) {
|
||||||
if (loadedSections[key]) {
|
if (loadedSections[key]) {
|
||||||
defaults[key] = { ...defaults[key], ...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;
|
return defaults;
|
||||||
},
|
},
|
||||||
@@ -375,7 +474,7 @@ function contentPageEditor(pageId) {
|
|||||||
|
|
||||||
// Save sections
|
// Save sections
|
||||||
async saveSections() {
|
async saveSections() {
|
||||||
if (!this.pageId || !this.isHomepage) return;
|
if (!this.pageId || this.pageType !== 'landing') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
contentPageEditLog.info('Saving sections...');
|
contentPageEditLog.info('Saving sections...');
|
||||||
@@ -401,11 +500,13 @@ function contentPageEditor(pageId) {
|
|||||||
const payload = {
|
const payload = {
|
||||||
slug: this.form.slug,
|
slug: this.form.slug,
|
||||||
title: this.form.title,
|
title: this.form.title,
|
||||||
|
title_translations: this.form.title_translations,
|
||||||
content: this.form.content,
|
content: this.form.content,
|
||||||
|
content_translations: this.form.content_translations,
|
||||||
content_format: this.form.content_format,
|
content_format: this.form.content_format,
|
||||||
template: this.form.template,
|
template: this.form.template,
|
||||||
meta_description: this.form.meta_description,
|
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,
|
is_published: this.form.is_published,
|
||||||
show_in_header: this.form.show_in_header,
|
show_in_header: this.form.show_in_header,
|
||||||
show_in_footer: this.form.show_in_footer,
|
show_in_footer: this.form.show_in_footer,
|
||||||
@@ -422,8 +523,8 @@ function contentPageEditor(pageId) {
|
|||||||
// Update existing page
|
// Update existing page
|
||||||
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
|
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
|
||||||
|
|
||||||
// Also save sections if this is a homepage
|
// Also save sections if this is a landing page
|
||||||
if (this.isHomepage && this.sectionsLoaded) {
|
if (this.pageType === 'landing' && this.sectionsLoaded) {
|
||||||
await this.saveSections();
|
await this.saveSections();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,19 +57,23 @@
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<!-- Page Title -->
|
<!-- Page Type -->
|
||||||
<div class="md:col-span-2">
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<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>
|
</label>
|
||||||
<input
|
<select
|
||||||
type="text"
|
x-model="pageType"
|
||||||
x-model="form.title"
|
@change="updatePageType()"
|
||||||
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"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Slug -->
|
<!-- Slug -->
|
||||||
@@ -133,10 +137,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Title with Language Tabs -->
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<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">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Page Content
|
Page Content
|
||||||
|
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Content Format -->
|
<!-- Content Format -->
|
||||||
@@ -219,9 +267,9 @@
|
|||||||
</div>
|
</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">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
Homepage Sections
|
Homepage Sections
|
||||||
@@ -258,7 +306,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- HERO SECTION -->
|
<!-- 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'hero' ? null : 'hero'"
|
@click="openSection = openSection === 'hero' ? null : 'hero'"
|
||||||
@@ -341,7 +389,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- FEATURES SECTION -->
|
<!-- 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'features' ? null : 'features'"
|
@click="openSection = openSection === 'features' ? null : 'features'"
|
||||||
@@ -410,7 +458,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- PRICING SECTION -->
|
<!-- 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
|
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
|
||||||
@@ -448,7 +496,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- CTA SECTION -->
|
<!-- 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'cta' ? null : 'cta'"
|
@click="openSection = openSection === 'cta' ? null : 'cta'"
|
||||||
@@ -525,6 +573,7 @@
|
|||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<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">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
SEO & Metadata
|
SEO & Metadata
|
||||||
|
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -534,30 +583,17 @@
|
|||||||
Meta Description
|
Meta Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
x-model="form.meta_description"
|
:value="getTranslatedMetaDescription()"
|
||||||
|
@input="setTranslatedMetaDescription($event.target.value)"
|
||||||
rows="2"
|
rows="2"
|
||||||
maxlength="300"
|
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"
|
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>
|
></textarea>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<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>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
|
|
||||||
{# SEO Meta Tags #}
|
{# 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="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 #}
|
{# Favicon #}
|
||||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='favicon.ico') }}">
|
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='favicon.ico') }}">
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
|
|
||||||
{# SEO Meta Tags #}
|
{# SEO Meta Tags #}
|
||||||
<meta name="description" content="{% block meta_description %}{{ store.description or 'Shop at ' + store.name }}{% endblock %}">
|
<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 #}
|
{# Favicon - store-specific or default #}
|
||||||
{% if theme.branding.favicon %}
|
{% if theme.branding.favicon %}
|
||||||
|
|||||||
@@ -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]`
|
- 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
|
- 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 |
|
| Template | Available Sections |
|
||||||
|---------|------------------|----------------|
|
|----------|-------------------|
|
||||||
| Hero | Yes | Yes |
|
| `default` (platform homepage) | hero, features, products, pricing, testimonials, gallery, contact_info, cta |
|
||||||
| Features | Yes | Yes |
|
| `full` (store landing page) | hero, features, testimonials, gallery, contact_info, cta |
|
||||||
| Pricing | Yes | **No** |
|
|
||||||
| CTA | Yes | Yes |
|
|
||||||
| Testimonials | Yes | Yes |
|
|
||||||
| Gallery | Yes | Yes |
|
|
||||||
| Contact Info | Yes | Yes |
|
|
||||||
|
|
||||||
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)
|
### Change 4: Sections as ordered list (future)
|
||||||
|
|
||||||
@@ -193,7 +188,7 @@ New contract: `StorefrontSectionProviderProtocol`
|
|||||||
- [ ] Title + content translation UI (language tabs on edit page)
|
- [ ] Title + content translation UI (language tabs on edit page)
|
||||||
- [ ] Page type selector (Content Page / Landing Page dropdown)
|
- [ ] Page type selector (Content Page / Landing Page dropdown)
|
||||||
- [ ] Hide content field when Landing Page selected
|
- [ ] 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: FASHIONHUB about page — add translations
|
||||||
- [ ] Fix: store theme API bug (done — `get_store_by_code_or_subdomain`)
|
- [ ] Fix: store theme API bug (done — `get_store_by_code_or_subdomain`)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user