Compare commits
6 Commits
64fe58c171
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ade1b9354 | |||
| b5bb9415f6 | |||
| bb3d6f0012 | |||
| c92fe1261b | |||
| ca152cd544 | |||
| 914967edcc |
@@ -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
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -57,19 +57,23 @@
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Page Title -->
|
||||
<div class="md:col-span-2">
|
||||
<!-- Page Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Page Title <span class="text-red-500">*</span>
|
||||
Page Type
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.title"
|
||||
required
|
||||
maxlength="200"
|
||||
<select
|
||||
x-model="pageType"
|
||||
@change="updatePageType()"
|
||||
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
||||
placeholder="About Us"
|
||||
>
|
||||
<option value="content">Content Page</option>
|
||||
<option value="landing">Landing Page</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span x-show="pageType === 'content'">Standard page with rich text content (About, FAQ, Privacy...)</span>
|
||||
<span x-show="pageType === 'landing'">Section-based page with hero, features, CTA blocks</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Slug -->
|
||||
@@ -133,10 +137,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<!-- Title with Language Tabs -->
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Page Title
|
||||
<span class="text-sm font-normal text-gray-500 ml-2">(Multi-language)</span>
|
||||
</h3>
|
||||
|
||||
<!-- Language Tabs for Title/Content -->
|
||||
<div class="mb-4">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="flex -mb-px space-x-4">
|
||||
<template x-for="lang in supportedLanguages" :key="'tc-' + lang">
|
||||
<button
|
||||
type="button"
|
||||
@click="titleContentLang = lang"
|
||||
:class="titleContentLang === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
|
||||
class="py-2 px-4 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span x-text="languageNames[lang] || lang.toUpperCase()"></span>
|
||||
<span x-show="lang === defaultLanguage" class="ml-1 text-xs text-gray-400">(default)</span>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Title <span class="text-red-500">*</span>
|
||||
<span class="font-normal text-gray-400 ml-1" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
:value="getTranslatedTitle()"
|
||||
@input="setTranslatedTitle($event.target.value)"
|
||||
required
|
||||
maxlength="200"
|
||||
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
||||
:placeholder="'Page title in ' + (languageNames[titleContentLang] || titleContentLang)"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content (only for Content Page type) -->
|
||||
<div x-show="pageType === 'content'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Page Content
|
||||
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||
</h3>
|
||||
|
||||
<!-- Content Format -->
|
||||
@@ -219,9 +267,9 @@
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- HOMEPAGE SECTIONS EDITOR (only for slug='home') -->
|
||||
<!-- SECTIONS EDITOR (for Landing Page type) -->
|
||||
<!-- ══════════════════════════════════════════════════════════════════ -->
|
||||
<div x-show="isHomepage" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div x-show="pageType === 'landing'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Homepage Sections
|
||||
@@ -258,7 +306,7 @@
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- HERO SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div x-show="isSectionAvailable('hero')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
@click="openSection = openSection === 'hero' ? null : 'hero'"
|
||||
@@ -341,7 +389,7 @@
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- FEATURES SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div x-show="isSectionAvailable('features')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
@click="openSection = openSection === 'features' ? null : 'features'"
|
||||
@@ -410,7 +458,7 @@
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- PRICING SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div x-show="isSectionAvailable('pricing')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
|
||||
@@ -448,7 +496,7 @@
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- CTA SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div x-show="isSectionAvailable('cta')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
@click="openSection = openSection === 'cta' ? null : 'cta'"
|
||||
@@ -525,6 +573,7 @@
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
SEO & Metadata
|
||||
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -534,30 +583,17 @@
|
||||
Meta Description
|
||||
</label>
|
||||
<textarea
|
||||
x-model="form.meta_description"
|
||||
:value="getTranslatedMetaDescription()"
|
||||
@input="setTranslatedMetaDescription($event.target.value)"
|
||||
rows="2"
|
||||
maxlength="300"
|
||||
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
||||
placeholder="A brief description for search engines"
|
||||
:placeholder="'Meta description in ' + (languageNames[titleContentLang] || titleContentLang)"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span x-text="(form.meta_description || '').length"></span>/300 characters (150-160 recommended)
|
||||
150-160 characters recommended for search engines
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Meta Keywords -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Meta Keywords
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.meta_keywords"
|
||||
maxlength="300"
|
||||
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
||||
placeholder="keyword1, keyword2, keyword3"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -294,40 +294,106 @@ Tracked separately, not blocking launch.
|
||||
|
||||
---
|
||||
|
||||
## Critical Path
|
||||
## Development Status (as of 2026-04-16)
|
||||
|
||||
```
|
||||
Phase 0 (done) ──┬─► Phase 1 ──┬─► Phase 3 ──┐
|
||||
├─► Phase 2 ──┤ ├─► Phase 8 ──► LAUNCH
|
||||
└─► Phase 5 ──┘ │
|
||||
│
|
||||
Phase 4, 6, 7 (parallelizable) ───────────┘
|
||||
**All development phases (0-8) are COMPLETE.** 342 automated tests pass.
|
||||
|
||||
Phase 9 — post-launch
|
||||
```
|
||||
| Phase | Status | Completed |
|
||||
|---|---|---|
|
||||
| Phase 0 — Decisions | ✅ Done | 2026-04-09 |
|
||||
| Phase 1 — Config & Security | ✅ Done | 2026-04-09 |
|
||||
| Phase 1.x — Cross-store enrollment fix | ✅ Done | 2026-04-10 |
|
||||
| Phase 2A — Transactional notifications (5 templates) | ✅ Done | 2026-04-11 |
|
||||
| Phase 3 — Task reliability (batched expiration + wallet backoff) | ✅ Done | 2026-04-11 |
|
||||
| Phase 4.1 — T&C via CMS | ✅ Done | 2026-04-11 |
|
||||
| Phase 4.2 — Accessibility audit | ✅ Done | 2026-04-11 |
|
||||
| Phase 5 — Wallet UI flags | ✅ Done (already handled) | 2026-04-11 |
|
||||
| Phase 6 — GDPR, bulk ops, point restore, cascade restore | ✅ Done | 2026-04-11 |
|
||||
| Phase 7 — Analytics (cohort, churn, revenue + Chart.js) | ✅ Done | 2026-04-11 |
|
||||
| Phase 8 — Runbooks, monitoring docs, OpenAPI tags | ✅ Done | 2026-04-11 |
|
||||
|
||||
Phases 4, 6, 7 can run in parallel with 2/3/5 if multiple developers are available.
|
||||
**Additional bugfixes during manual testing (2026-04-15):**
|
||||
|
||||
## Effort Summary
|
||||
|
||||
| Phase | Days |
|
||||
|---|---|
|
||||
| 0 — Decisions | done |
|
||||
| 1 — Config & security | 2 |
|
||||
| 2 — Notifications | 4 |
|
||||
| 3 — Task reliability | 1.5 |
|
||||
| 4 — A11y + CMS T&C | 2 |
|
||||
| 5 — Google Wallet hardening | 1 |
|
||||
| 6 — Admin / GDPR / bulk | 3 |
|
||||
| 7 — Analytics | 2.5 |
|
||||
| 8 — Tests / docs / observability | 2 |
|
||||
| **Launch total** | **~18 days sequential, ~10 with 2 parallel tracks** |
|
||||
| 9 — Apple Wallet (post-launch) | 3 |
|
||||
- Terminal redeem: `card_id` → `id` normalization across schemas/JS
|
||||
- Card detail: enrolled store name resolution, copy buttons, paginated transactions
|
||||
- i18n flicker: server-rendered translations on success page
|
||||
- Icon fix: `device-mobile` → `phone`
|
||||
|
||||
---
|
||||
|
||||
## Open Items Needing Sign-off
|
||||
## Pre-Launch Checklist
|
||||
|
||||
1. ~~**Rate limit caps**~~ — confirmed.
|
||||
2. **Email copywriting** for the 7 templates × 4 locales (Phase 2.3) — flow: I draft EN, Samir reviews, then translate.
|
||||
3. ~~**`birth_date` column**~~ — confirmed missing; addressed in Phase 1.4. No backfill needed (not yet live).
|
||||
Everything below must be completed before going live. Items are ordered by dependency.
|
||||
|
||||
### Step 1: Seed email templates on prod DB
|
||||
- [ ] SSH into prod server
|
||||
- [ ] Run: `python scripts/seed/seed_email_templates_loyalty.py`
|
||||
- [ ] Verify: 20 rows created (5 templates × 4 locales)
|
||||
- [ ] Review EN email copy — adjust subject lines/body if needed via admin UI at `/admin/email-templates`
|
||||
|
||||
### Step 2: Deploy Google Wallet service account
|
||||
- [ ] Place service account JSON at `~/apps/orion/google-wallet-sa.json` (app user, mode 600)
|
||||
- [ ] Set `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=~/apps/orion/google-wallet-sa.json` in prod `.env`
|
||||
- [ ] Set `LOYALTY_GOOGLE_ISSUER_ID=<your issuer ID>` in prod `.env`
|
||||
- [ ] Restart app — verify no startup error (validator checks file exists)
|
||||
- [ ] Verify: `GET /api/v1/admin/loyalty/wallet-status` returns `google_configured: true`
|
||||
|
||||
### Step 3: Apply database migrations
|
||||
- [ ] Run: `alembic upgrade heads`
|
||||
- [ ] Verify migrations applied: `loyalty_003` through `loyalty_006`, `customers_003`
|
||||
|
||||
### Step 4: FR/DE/LB translations for new analytics i18n keys
|
||||
- [ ] Add translations for 7 keys in `app/modules/loyalty/locales/{fr,de,lb}.json`:
|
||||
- `store.analytics.revenue_title`
|
||||
- `store.analytics.at_risk_title`
|
||||
- `store.analytics.cards_at_risk`
|
||||
- `store.analytics.no_at_risk`
|
||||
- `store.analytics.cohort_title`
|
||||
- `store.analytics.cohort_month`
|
||||
- `store.analytics.cohort_enrolled`
|
||||
- `store.analytics.no_data_yet`
|
||||
|
||||
### Step 5: Investigate email template menu visibility
|
||||
- [ ] Check if `messaging.manage_templates` permission is assigned to `merchant_owner` role
|
||||
- [ ] If not, add it to permission discovery or default role assignments
|
||||
- [ ] Verify menu appears at `/store/{store_code}/email-templates`
|
||||
- [ ] Verify admin menu at `/admin/email-templates` shows loyalty templates
|
||||
|
||||
### Step 6: Manual E2E testing (user journeys)
|
||||
Follow the **Pre-Launch E2E Test Checklist** at the bottom of `user-journeys.md`:
|
||||
|
||||
- [ ] **Test 1:** Customer self-enrollment (with birthday)
|
||||
- [ ] **Test 2:** Cross-store re-enrollment (cross-location enabled)
|
||||
- [ ] **Test 3:** Staff operations — stamps/points via terminal
|
||||
- [ ] **Test 4:** Cross-store redemption (earn at store1, redeem at store2)
|
||||
- [ ] **Test 5:** Customer views dashboard + transaction history
|
||||
- [ ] **Test 6:** Void/return flow
|
||||
- [ ] **Test 7:** Admin oversight (programs, merchants, analytics)
|
||||
- [ ] **Test 8:** Cross-location disabled behavior (separate cards per store)
|
||||
|
||||
### Step 7: Google Wallet real-device test
|
||||
- [ ] Enroll a test customer on prod
|
||||
- [ ] Tap "Add to Google Wallet" on success page
|
||||
- [ ] Open Google Wallet on Android device — verify pass renders
|
||||
- [ ] Trigger a stamp/points transaction — verify pass auto-updates within 60s
|
||||
|
||||
### Step 8: Go live
|
||||
- [ ] Remove any test data from prod DB (test customers, test cards)
|
||||
- [ ] Verify Celery workers are running (`loyalty.expire_points`, `loyalty.sync_wallet_passes`)
|
||||
- [ ] Verify SMTP is configured and test email sends work
|
||||
- [ ] Enable the loyalty platform for production stores
|
||||
- [ ] Monitor first 24h: check email logs, wallet sync, expiration task
|
||||
|
||||
---
|
||||
|
||||
## Post-Launch Roadmap
|
||||
|
||||
| Item | Priority | Effort | Notes |
|
||||
|---|---|---|---|
|
||||
| **Phase 9 — Apple Wallet** | P1 | 3d | Requires Apple Developer certs. See `runbook-wallet-certs.md`. |
|
||||
| **Phase 2B — Marketing module** | P2 | 4d | Birthday + re-engagement emails. Cross-platform (OMS, loyalty, hosting). |
|
||||
| **Coverage to 80%** | P2 | 2d | Needs Celery task mocking infrastructure for task-level tests. |
|
||||
| **Admin trash UI** | P3 | 2d | Trash tab on programs/cards pages using existing `?only_deleted=true` API. The cascade restore API exists but has no UI. |
|
||||
| **Bulk PIN assignment** | P3 | 1d | Batch create staff PINs. API exists for single PIN; needs bulk endpoint + UI. |
|
||||
| **Cross-location enforcement** | P3 | 2d | `allow_cross_location_redemption` controls enrollment behavior but stamp/point operations don't enforce it yet. |
|
||||
| **Email template menu** | P2 | 0.5d | Investigate and fix `messaging.manage_templates` permission for store owners. |
|
||||
|
||||
@@ -761,7 +761,8 @@
|
||||
"col_location": "Location",
|
||||
"col_notes": "Notes",
|
||||
"no_transactions": "No transactions yet",
|
||||
"card_label": "Card"
|
||||
"card_label": "Card",
|
||||
"page_x_of_y": "Page {page} of {pages}"
|
||||
},
|
||||
"enroll": {
|
||||
"title": "Enroll Customer",
|
||||
|
||||
@@ -514,6 +514,17 @@ def get_card_detail(
|
||||
program = card.program
|
||||
customer = card.customer
|
||||
|
||||
# Resolve enrolled store name
|
||||
enrolled_store_name = None
|
||||
if card.enrolled_at_store_id:
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
enrolled_store = store_service.get_store_by_id_optional(
|
||||
db, card.enrolled_at_store_id
|
||||
)
|
||||
if enrolled_store:
|
||||
enrolled_store_name = enrolled_store.name
|
||||
|
||||
return CardDetailResponse(
|
||||
id=card.id,
|
||||
card_number=card.card_number,
|
||||
@@ -521,6 +532,7 @@ def get_card_detail(
|
||||
merchant_id=card.merchant_id,
|
||||
program_id=card.program_id,
|
||||
enrolled_at_store_id=card.enrolled_at_store_id,
|
||||
enrolled_at_store_name=enrolled_store_name,
|
||||
customer_name=customer.full_name if customer else None,
|
||||
customer_email=customer.email if customer else None,
|
||||
merchant_name=card.merchant.name if card.merchant else None,
|
||||
|
||||
@@ -100,6 +100,7 @@ class CardDetailResponse(CardResponse):
|
||||
|
||||
# Merchant info
|
||||
merchant_name: str | None = None
|
||||
enrolled_at_store_name: str | None = None
|
||||
|
||||
# Program info
|
||||
program_name: str
|
||||
|
||||
@@ -11,6 +11,7 @@ function storeLoyaltyCardDetail() {
|
||||
cardId: null,
|
||||
card: null,
|
||||
transactions: [],
|
||||
pagination: { page: 1, per_page: 20, total: 0 },
|
||||
|
||||
loading: false,
|
||||
error: null,
|
||||
@@ -38,6 +39,13 @@ function storeLoyaltyCardDetail() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use platform pagination setting if available
|
||||
if (window.PlatformSettings) {
|
||||
try {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
} catch (e) { /* use default */ }
|
||||
}
|
||||
|
||||
await this.loadData();
|
||||
loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZATION COMPLETE ===');
|
||||
},
|
||||
@@ -67,18 +75,49 @@ function storeLoyaltyCardDetail() {
|
||||
}
|
||||
},
|
||||
|
||||
async loadTransactions() {
|
||||
async loadTransactions(page = 1) {
|
||||
try {
|
||||
const response = await apiClient.get(`/store/loyalty/cards/${this.cardId}/transactions?limit=50`);
|
||||
const skip = (page - 1) * this.pagination.per_page;
|
||||
const response = await apiClient.get(
|
||||
`/store/loyalty/cards/${this.cardId}/transactions?skip=${skip}&limit=${this.pagination.per_page}`
|
||||
);
|
||||
if (response && response.transactions) {
|
||||
this.transactions = response.transactions;
|
||||
loyaltyCardDetailLog.info(`Loaded ${this.transactions.length} transactions`);
|
||||
this.pagination.total = response.total || 0;
|
||||
this.pagination.page = page;
|
||||
loyaltyCardDetailLog.info(`Loaded ${this.transactions.length} of ${this.pagination.total} transactions (page ${page})`);
|
||||
}
|
||||
} catch (error) {
|
||||
loyaltyCardDetailLog.warn('Failed to load transactions:', error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Standard pagination interface (matches shared pagination macro)
|
||||
get totalPages() {
|
||||
return Math.max(1, Math.ceil(this.pagination.total / this.pagination.per_page));
|
||||
},
|
||||
get startIndex() {
|
||||
if (this.pagination.total === 0) return 0;
|
||||
return (this.pagination.page - 1) * this.pagination.per_page + 1;
|
||||
},
|
||||
get endIndex() {
|
||||
return Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total);
|
||||
},
|
||||
get pageNumbers() {
|
||||
const pages = [];
|
||||
for (let i = 1; i <= this.totalPages; i++) {
|
||||
if (i === 1 || i === this.totalPages || Math.abs(i - this.pagination.page) <= 1) {
|
||||
pages.push(i);
|
||||
} else if (pages[pages.length - 1] !== '...') {
|
||||
pages.push('...');
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
},
|
||||
previousPage() { if (this.pagination.page > 1) this.loadTransactions(this.pagination.page - 1); },
|
||||
nextPage() { if (this.pagination.page < this.totalPages) this.loadTransactions(this.pagination.page + 1); },
|
||||
goToPage(p) { if (p >= 1 && p <= this.totalPages) this.loadTransactions(p); },
|
||||
|
||||
formatNumber(num) {
|
||||
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% from 'shared/macros/headers.html' import detail_page_header %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
|
||||
{% block title %}{{ _('loyalty.store.card_detail.title') }}{% endblock %}
|
||||
|
||||
@@ -69,15 +70,30 @@
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.name') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
|
||||
<button x-show="card?.customer_name" @click="Utils.copyToClipboard(card.customer_name)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.email') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
|
||||
<button x-show="card?.customer_email" @click="Utils.copyToClipboard(card.customer_email)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.phone') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
|
||||
<button x-show="card?.customer_phone" @click="Utils.copyToClipboard(card.customer_phone)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.birthday') }}</p>
|
||||
@@ -95,7 +111,12 @@
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.card_number') }}</p>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
|
||||
<button x-show="card?.card_number" @click="Utils.copyToClipboard(card.card_number)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.status') }}</p>
|
||||
@@ -151,6 +172,8 @@
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination() }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
{# SEO Meta Tags #}
|
||||
<meta name="description" content="{% block meta_description %}{{ platform.get_translated_description(current_language|default('fr'), platform.default_language|default('fr')) if platform else '' }}{% endblock %}">
|
||||
<meta name="keywords" content="{% block meta_keywords %}letzshop, order management, oms, luxembourg, e-commerce, invoicing, inventory{% endblock %}">
|
||||
|
||||
{# Favicon #}
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='favicon.ico') }}">
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
{# SEO Meta Tags #}
|
||||
<meta name="description" content="{% block meta_description %}{{ store.description or 'Shop at ' + store.name }}{% endblock %}">
|
||||
<meta name="keywords" content="{% block meta_keywords %}{{ store.name }}, online shop{% endblock %}">
|
||||
|
||||
{# Favicon - store-specific or default #}
|
||||
{% if theme.branding.favicon %}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user