Files
orion/app/modules/cms/static/admin/js/content-page-edit.js
Samir Boulahtit b5bb9415f6
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
feat(cms): Phase A — page type selector, translation UI, SEO cleanup
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>
2026-04-16 22:30:55 +02:00

568 lines
21 KiB
JavaScript

// noqa: js-006 - async init pattern is safe, loadData has try/catch
// static/admin/js/content-page-edit.js
// Use centralized logger
const contentPageEditLog = window.LogConfig.loggers.contentPageEdit || window.LogConfig.createLogger('contentPageEdit');
// ============================================
// CONTENT PAGE EDITOR FUNCTION
// ============================================
function contentPageEditor(pageId) {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Page identifier for sidebar active state
currentPage: 'content-pages',
// Editor state
pageId: pageId,
form: {
slug: '',
title: '',
title_translations: {},
content: '',
content_translations: {},
content_format: 'html',
template: 'default',
meta_description: '',
meta_description_translations: {},
is_published: false,
show_in_header: false,
show_in_footer: true,
show_in_legal: false,
display_order: 0,
platform_id: null,
store_id: null
},
platforms: [],
stores: [],
loading: false,
loadingPlatforms: false,
loadingStores: false,
saving: false,
error: null,
successMessage: null,
// Page type: 'content' or 'landing'
pageType: 'content',
// Translation language for title/content
titleContentLang: 'fr',
// ========================================
// HOMEPAGE SECTIONS STATE
// ========================================
supportedLanguages: ['fr', 'de', 'en'],
defaultLanguage: 'fr',
currentLang: 'fr',
openSection: null,
sectionsLoaded: false,
languageNames: {
en: 'English',
fr: 'Français',
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,
badge_text: { translations: {} },
title: { translations: {} },
subtitle: { translations: {} },
background_type: 'gradient',
buttons: []
},
features: {
enabled: true,
title: { translations: {} },
subtitle: { translations: {} },
features: [],
layout: 'grid'
},
pricing: {
enabled: true,
title: { translations: {} },
subtitle: { translations: {} },
use_subscription_tiers: true
},
cta: {
enabled: true,
title: { translations: {} },
subtitle: { translations: {} },
buttons: [],
background_type: 'gradient'
}
},
// Initialize
async init() {
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZING ===');
contentPageEditLog.info('Page ID:', this.pageId);
// Prevent multiple initializations
if (window._contentPageEditInitialized) {
contentPageEditLog.warn('Content page editor already initialized, skipping...');
return;
}
window._contentPageEditInitialized = true;
// Load platforms and stores for dropdowns
await Promise.all([this.loadPlatforms(), this.loadStores()]);
if (this.pageId) {
// Edit mode - load existing page
contentPageEditLog.group('Loading page for editing');
await this.loadPage();
contentPageEditLog.groupEnd();
// Load sections if this is a landing page
if (this.pageType === 'landing') {
await this.loadSections();
}
} else {
// Create mode - use default values
contentPageEditLog.info('Create mode - using default form values');
}
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
},
// Check if we should show section editor
isHomepage: false,
// 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;
try {
contentPageEditLog.info('Loading platforms...');
const response = await apiClient.get('/admin/platforms?is_active=true');
const data = response.data || response;
this.platforms = data.platforms || data.items || data || [];
contentPageEditLog.info(`Loaded ${this.platforms.length} platforms`);
// Set default platform if not editing and no platform selected
if (!this.pageId && !this.form.platform_id && this.platforms.length > 0) {
this.form.platform_id = this.platforms[0].id;
}
} catch (err) {
contentPageEditLog.error('Error loading platforms:', err);
this.platforms = [];
} finally {
this.loadingPlatforms = false;
}
},
// Load stores for dropdown
async loadStores() {
this.loadingStores = true;
try {
contentPageEditLog.info('Loading stores...');
const response = await apiClient.get('/admin/stores?is_active=true&limit=100');
const data = response.data || response;
this.stores = data.stores || data.items || data || [];
contentPageEditLog.info(`Loaded ${this.stores.length} stores`);
} catch (err) {
contentPageEditLog.error('Error loading stores:', err);
this.stores = [];
} finally {
this.loadingStores = false;
}
},
// Load existing page
async loadPage() {
this.loading = true;
this.error = null;
try {
contentPageEditLog.info(`Fetching page ${this.pageId}...`);
const response = await apiClient.get(`/admin/content-pages/${this.pageId}`);
contentPageEditLog.debug('API Response:', response);
if (!response) {
throw new Error('Invalid API response');
}
// Handle response - API returns object directly
const page = response.data || response;
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_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,
show_in_legal: page.show_in_legal || false,
display_order: page.display_order || 0,
platform_id: page.platform_id,
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
this.updateIsHomepage();
// Re-initialize Quill editor content after page data is loaded
// (Quill may have initialized before loadPage completed)
this.syncQuillContent();
} catch (err) {
contentPageEditLog.error('Error loading page:', err);
this.error = err.message || 'Failed to load page';
} finally {
this.loading = false;
}
},
// Sync Quill editor content after page data loads
// Quill may initialize before loadPage completes, leaving editor empty
syncQuillContent(retries = 5) {
const quillContainer = document.getElementById('content-editor');
if (!quillContainer || !quillContainer.__quill) {
// Quill not ready yet, retry
if (retries > 0) {
setTimeout(() => this.syncQuillContent(retries - 1), 100);
}
return;
}
const quill = quillContainer.__quill;
if (this.form.content && quill.root.innerHTML !== this.form.content) {
quill.root.innerHTML = this.form.content; // # noqa: SEC-015
contentPageEditLog.debug('Synced Quill content after page load');
}
},
// ========================================
// SECTIONS METHODS
// ========================================
// Load sections for landing pages
async loadSections() {
if (!this.pageId || this.pageType !== 'landing') {
contentPageEditLog.debug('Skipping section load - not a landing page');
return;
}
try {
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);
contentPageEditLog.info('Sections loaded:', Object.keys(data.sections));
} else {
this.initializeEmptySections();
contentPageEditLog.info('No sections found - initialized empty structure');
}
this.sectionsLoaded = true;
} catch (err) {
contentPageEditLog.error('Error loading sections:', err);
}
},
// Merge loaded sections with default structure
mergeWithDefaults(loadedSections) {
const defaults = this.getDefaultSectionStructure();
// 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;
},
// Get default section structure
getDefaultSectionStructure() {
const emptyTranslations = () => {
const t = {};
this.supportedLanguages.forEach(lang => t[lang] = '');
return { translations: t };
};
return {
hero: {
enabled: true,
badge_text: emptyTranslations(),
title: emptyTranslations(),
subtitle: emptyTranslations(),
background_type: 'gradient',
buttons: []
},
features: {
enabled: true,
title: emptyTranslations(),
subtitle: emptyTranslations(),
features: [],
layout: 'grid'
},
pricing: {
enabled: true,
title: emptyTranslations(),
subtitle: emptyTranslations(),
use_subscription_tiers: true
},
cta: {
enabled: true,
title: emptyTranslations(),
subtitle: emptyTranslations(),
buttons: [],
background_type: 'gradient'
}
};
},
// Initialize empty sections for all languages
initializeEmptySections() {
this.sections = this.getDefaultSectionStructure();
},
// Add a button to hero or cta section
addButton(sectionName) {
const newButton = {
text: { translations: {} },
url: '',
style: 'primary'
};
this.supportedLanguages.forEach(lang => {
newButton.text.translations[lang] = '';
});
this.sections[sectionName].buttons.push(newButton);
contentPageEditLog.debug(`Added button to ${sectionName}`);
},
// Remove a button from hero or cta section
removeButton(sectionName, index) {
this.sections[sectionName].buttons.splice(index, 1);
contentPageEditLog.debug(`Removed button ${index} from ${sectionName}`);
},
// Add a feature card
addFeature() {
const newFeature = {
icon: '',
title: { translations: {} },
description: { translations: {} }
};
this.supportedLanguages.forEach(lang => {
newFeature.title.translations[lang] = '';
newFeature.description.translations[lang] = '';
});
this.sections.features.features.push(newFeature);
contentPageEditLog.debug('Added feature card');
},
// Remove a feature card
removeFeature(index) {
this.sections.features.features.splice(index, 1);
contentPageEditLog.debug(`Removed feature ${index}`);
},
// Save sections
async saveSections() {
if (!this.pageId || this.pageType !== 'landing') return;
try {
contentPageEditLog.info('Saving sections...');
await apiClient.put(`/admin/content-pages/${this.pageId}/sections`, this.sections);
contentPageEditLog.info('Sections saved successfully');
} catch (err) {
contentPageEditLog.error('Error saving sections:', err);
throw err;
}
},
// Save page (create or update)
async savePage() {
if (this.saving) return;
this.saving = true;
this.error = null;
this.successMessage = null;
try {
contentPageEditLog.info(this.pageId ? 'Updating page...' : 'Creating page...');
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_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,
show_in_legal: this.form.show_in_legal,
display_order: this.form.display_order,
platform_id: this.form.platform_id,
store_id: this.form.store_id
};
contentPageEditLog.debug('Payload:', payload);
let response;
if (this.pageId) {
// Update existing page
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
// Also save sections if this is a landing page
if (this.pageType === 'landing' && this.sectionsLoaded) {
await this.saveSections();
}
this.successMessage = 'Page updated successfully!';
contentPageEditLog.info('Page updated');
} else {
// Create new page - use store or platform endpoint based on selection
const endpoint = this.form.store_id
? '/admin/content-pages/store'
: '/admin/content-pages/platform';
response = await apiClient.post(endpoint, payload);
this.successMessage = 'Page created successfully!';
contentPageEditLog.info('Page created', { endpoint, store_id: this.form.store_id });
// Redirect to edit page after creation
const pageData = response.data || response;
if (pageData && pageData.id) {
setTimeout(() => {
window.location.href = `/admin/content-pages/${pageData.id}/edit`;
}, 1500);
}
}
// Clear success message after 3 seconds
setTimeout(() => {
this.successMessage = null;
}, 3000);
} catch (err) {
contentPageEditLog.error('Error saving page:', err);
this.error = err.message || 'Failed to save page';
// Scroll to top to show error
window.scrollTo({ top: 0, behavior: 'smooth' });
} finally {
this.saving = false;
}
}
};
}