diff --git a/app/api/v1/vendor/content_pages.py b/app/api/v1/vendor/content_pages.py index fff88f3f..52596aa7 100644 --- a/app/api/v1/vendor/content_pages.py +++ b/app/api/v1/vendor/content_pages.py @@ -48,6 +48,9 @@ class VendorContentPageCreate(BaseModel): 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") + show_in_legal: bool = Field( + default=False, description="Show in legal/bottom bar (next to copyright)" + ) display_order: int = Field(default=0, description="Display order (lower = first)") @@ -62,6 +65,7 @@ class VendorContentPageUpdate(BaseModel): is_published: bool | None = None show_in_footer: bool | None = None show_in_header: bool | None = None + show_in_legal: bool | None = None display_order: int | None = None @@ -82,6 +86,7 @@ class ContentPageResponse(BaseModel): display_order: int show_in_footer: bool show_in_header: bool + show_in_legal: bool is_platform_default: bool is_vendor_override: bool created_at: str @@ -188,6 +193,7 @@ def create_vendor_page( is_published=page_data.is_published, show_in_footer=page_data.show_in_footer, show_in_header=page_data.show_in_header, + show_in_legal=page_data.show_in_legal, display_order=page_data.display_order, created_by=current_user.id, ) @@ -224,6 +230,7 @@ def update_vendor_page( is_published=page_data.is_published, show_in_footer=page_data.show_in_footer, show_in_header=page_data.show_in_header, + show_in_legal=page_data.show_in_legal, display_order=page_data.display_order, updated_by=current_user.id, ) diff --git a/app/routes/vendor_pages.py b/app/routes/vendor_pages.py index e56727d4..09005187 100644 --- a/app/routes/vendor_pages.py +++ b/app/routes/vendor_pages.py @@ -527,6 +527,82 @@ async def vendor_billing_page( ) +# ============================================================================ +# CONTENT PAGES MANAGEMENT +# ============================================================================ + + +@router.get( + "/{vendor_code}/content-pages", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_content_pages_list( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), +): + """ + Render content pages management page. + Shows platform defaults (can be overridden) and vendor custom pages. + """ + return templates.TemplateResponse( + "vendor/content-pages.html", + { + "request": request, + "user": current_user, + "vendor_code": vendor_code, + }, + ) + + +@router.get( + "/{vendor_code}/content-pages/create", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_content_page_create( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), +): + """ + Render content page creation form. + """ + return templates.TemplateResponse( + "vendor/content-page-edit.html", + { + "request": request, + "user": current_user, + "vendor_code": vendor_code, + "page_id": None, + }, + ) + + +@router.get( + "/{vendor_code}/content-pages/{page_id}/edit", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_content_page_edit( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + page_id: int = Path(..., description="Content page ID"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), +): + """ + Render content page edit form. + """ + return templates.TemplateResponse( + "vendor/content-page-edit.html", + { + "request": request, + "user": current_user, + "vendor_code": vendor_code, + "page_id": page_id, + }, + ) + + # ============================================================================ # DYNAMIC CONTENT PAGES (CMS) # ============================================================================ diff --git a/app/templates/vendor/content-page-edit.html b/app/templates/vendor/content-page-edit.html new file mode 100644 index 00000000..399c3dd7 --- /dev/null +++ b/app/templates/vendor/content-page-edit.html @@ -0,0 +1,294 @@ +{# app/templates/vendor/content-page-edit.html #} +{% extends "vendor/base.html" %} +{% from 'shared/macros/alerts.html' import loading_state, error_state, alert_dynamic %} +{% from 'shared/macros/headers.html' import back_button %} +{% from 'shared/macros/inputs.html' import number_stepper %} + +{% block title %}{% if page_id %}Edit{% else %}Create{% endif %} Content Page{% endblock %} + +{% block alpine_data %}vendorContentPageEditor({{ page_id if page_id else 'null' }}){% endblock %} + +{% block content %} +{# Dynamic title/subtitle and save button text based on create vs edit mode #} +
+
+

+

+ Create a new custom page for your shop + Customize this platform default page + Edit your custom page +

+
+
+ {{ back_button('/vendor/' + vendor_code + '/content-pages', 'Back to List') }} + +
+
+ +{{ loading_state('Loading page...') }} + +{{ error_state('Error', show_condition='error && !loading') }} + +{{ alert_dynamic(type='success', message_var='successMessage', show_condition='successMessage') }} + + +
+
+ +
+

Overriding Platform Default

+

+ You're customizing the "" page. Your version will be shown to customers instead of the platform default. + +

+
+
+
+ + +
+
+ +
+

+ Basic Information +

+ +
+ +
+ + +
+ + +
+ +
+ / + +
+

+ URL-safe identifier (lowercase, numbers, hyphens, underscores only) + Slug cannot be changed for override pages +

+
+
+
+ + +
+

+ Page Content +

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

+ Enter HTML content. Basic HTML tags are supported. + Enter Markdown content. Will be converted to HTML. +

+
+
+ + +
+

+ SEO & Metadata +

+ +
+ +
+ + +

+ /300 characters (150-160 recommended) +

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

+ Navigation & Display +

+ +
+ +
+ + {{ number_stepper(model='form.display_order', min=0, max=100, step=1, label='Display Order') }} +

Lower = first

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

+ Make this page visible to your customers +

+
+ +
+ + Cancel + + +
+
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/vendor/content-pages.html b/app/templates/vendor/content-pages.html new file mode 100644 index 00000000..e5e6d623 --- /dev/null +++ b/app/templates/vendor/content-pages.html @@ -0,0 +1,283 @@ +{# app/templates/vendor/content-pages.html #} +{% extends "vendor/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tabs.html' import tabs_inline, tab_button %} + +{% block title %}Content Pages{% endblock %} + +{% block alpine_data %}vendorContentPagesManager(){% endblock %} + +{% block content %} +{{ page_header('Content Pages', subtitle='Customize your shop pages or create new ones', action_label='Create Page', action_url='/vendor/' + vendor_code + '/content-pages/create') }} + +{{ loading_state('Loading pages...') }} + +{{ error_state('Error loading pages') }} + + +
+
+ + {% call tabs_inline() %} + {{ tab_button('platform', 'Platform Defaults', count_var='platformPages.length') }} + {{ tab_button('custom', 'My Pages', count_var='customPages.length') }} + {% endcall %} + + +
+ + + + +
+
+
+ + +
+ +
+
+ +
+

Platform Default Pages

+

+ These pages are provided by the platform. You can override any of them with your own custom content. + Your overridden version will be shown to your customers instead of the default. +

+
+
+
+ + +
+
+ + + + + + + + + + + + + +
PageURLNavigationStatusActions
+
+
+ + +
+ +

No pages found

+

+ No platform pages match "" +

+
+
+ + +
+ +
+
+ +
+

Your Custom Pages

+

+ Create unique pages for your shop like promotions, brand story, or special information. + These pages are exclusive to your store. +

+
+
+
+ + +
+
+ + + + + + + + + + + + + + +
PageURLNavigationStatusUpdatedActions
+
+
+ + +
+ +

+

+ No custom pages match "" +

+

+ Create your first custom page or override a platform default. +

+ + + Create Page + +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/vendor/partials/sidebar.html b/app/templates/vendor/partials/sidebar.html index 160f0a0e..c0006203 100644 --- a/app/templates/vendor/partials/sidebar.html +++ b/app/templates/vendor/partials/sidebar.html @@ -141,6 +141,29 @@ Follows same pattern as admin sidebar + +
+
+ + + Shop + +
+
+ +
diff --git a/static/vendor/js/content-page-edit.js b/static/vendor/js/content-page-edit.js new file mode 100644 index 00000000..58e608db --- /dev/null +++ b/static/vendor/js/content-page-edit.js @@ -0,0 +1,196 @@ +// static/vendor/js/content-page-edit.js + +// Use centralized logger +const contentPageEditLog = window.LogConfig.loggers.contentPageEdit || window.LogConfig.createLogger('contentPageEdit'); + +// ============================================ +// VENDOR CONTENT PAGE EDITOR +// ============================================ +function vendorContentPageEditor(pageId) { + return { + // Inherit base layout functionality from init-alpine.js + ...data(), + + // Page identifier for sidebar active state + currentPage: 'content-pages', + + // Editor state + pageId: pageId, + isOverride: false, + form: { + slug: '', + title: '', + content: '', + content_format: 'html', + meta_description: '', + meta_keywords: '', + is_published: false, + show_in_header: false, + show_in_footer: true, + show_in_legal: false, + display_order: 0 + }, + loading: false, + saving: false, + error: null, + successMessage: null, + + // Initialize + async init() { + contentPageEditLog.info('=== VENDOR CONTENT PAGE EDITOR INITIALIZING ==='); + contentPageEditLog.info('Page ID:', this.pageId); + + // Prevent multiple initializations + if (window._vendorContentPageEditInitialized) { + contentPageEditLog.warn('Content page editor already initialized, skipping...'); + return; + } + window._vendorContentPageEditInitialized = true; + + if (this.pageId) { + // Edit mode - load existing page + contentPageEditLog.group('Loading page for editing'); + await this.loadPage(); + contentPageEditLog.groupEnd(); + } else { + // Create mode - use default values + contentPageEditLog.info('Create mode - using default form values'); + } + + contentPageEditLog.info('=== VENDOR CONTENT PAGE EDITOR INITIALIZATION COMPLETE ==='); + }, + + // Load existing page + async loadPage() { + this.loading = true; + this.error = null; + + try { + contentPageEditLog.info(`Fetching page ${this.pageId}...`); + + // Use the vendor API to get page by ID + // We need to get the page details - use overrides endpoint and find by ID + const response = await apiClient.get('/vendor/content-pages/overrides'); + const pages = response.data || response || []; + const page = pages.find(p => p.id === this.pageId); + + if (!page) { + throw new Error('Page not found or you do not have access to it'); + } + + contentPageEditLog.debug('Page data:', page); + + this.isOverride = page.is_vendor_override || false; + this.form = { + slug: page.slug || '', + title: page.title || '', + content: page.content || '', + content_format: page.content_format || 'html', + meta_description: page.meta_description || '', + meta_keywords: page.meta_keywords || '', + 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 + }; + + contentPageEditLog.info('Page loaded successfully'); + + } catch (err) { + contentPageEditLog.error('Error loading page:', err); + this.error = err.message || 'Failed to load page'; + } finally { + this.loading = false; + } + }, + + // 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, + content: this.form.content, + content_format: this.form.content_format, + meta_description: this.form.meta_description, + meta_keywords: this.form.meta_keywords, + 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 + }; + + contentPageEditLog.debug('Payload:', payload); + + let response; + if (this.pageId) { + // Update existing page + response = await apiClient.put(`/vendor/content-pages/${this.pageId}`, payload); + this.successMessage = 'Page updated successfully!'; + contentPageEditLog.info('Page updated'); + } else { + // Create new page + response = await apiClient.post('/vendor/content-pages/', payload); + this.successMessage = 'Page created successfully!'; + contentPageEditLog.info('Page created'); + + // Redirect to edit page after creation + const pageData = response.data || response; + if (pageData && pageData.id) { + setTimeout(() => { + window.location.href = `/vendor/${this.vendorCode}/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; + } + }, + + // Delete page (revert to default for overrides) + async deletePage() { + const message = this.isOverride + ? 'Are you sure you want to revert to the platform default? Your customizations will be lost.' + : 'Are you sure you want to delete this page? This cannot be undone.'; + + if (!confirm(message)) { + return; + } + + try { + contentPageEditLog.info('Deleting page:', this.pageId); + + await apiClient.delete(`/vendor/content-pages/${this.pageId}`); + + // Redirect back to list + window.location.href = `/vendor/${this.vendorCode}/content-pages`; + + } catch (err) { + contentPageEditLog.error('Error deleting page:', err); + this.error = err.message || 'Failed to delete page'; + } + } + }; +} diff --git a/static/vendor/js/content-pages.js b/static/vendor/js/content-pages.js new file mode 100644 index 00000000..6eb093a0 --- /dev/null +++ b/static/vendor/js/content-pages.js @@ -0,0 +1,192 @@ +// static/vendor/js/content-pages.js + +// Use centralized logger +const contentPagesLog = window.LogConfig.loggers.contentPages || window.LogConfig.createLogger('contentPages'); + +// ============================================ +// VENDOR CONTENT PAGES MANAGER +// ============================================ +function vendorContentPagesManager() { + return { + // Inherit base layout functionality from init-alpine.js + ...data(), + + // Page identifier for sidebar active state + currentPage: 'content-pages', + + // State + loading: false, + error: null, + activeTab: 'platform', + searchQuery: '', + + // Data + platformPages: [], // Platform default pages + customPages: [], // Vendor's own pages (overrides + custom) + overrideMap: {}, // Map of slug -> page id for quick lookup + + // Initialize + async init() { + contentPagesLog.info('=== VENDOR CONTENT PAGES MANAGER INITIALIZING ==='); + + // Prevent multiple initializations + if (window._vendorContentPagesInitialized) { + contentPagesLog.warn('Content pages manager already initialized, skipping...'); + return; + } + window._vendorContentPagesInitialized = true; + + await this.loadPages(); + + contentPagesLog.info('=== VENDOR CONTENT PAGES MANAGER INITIALIZATION COMPLETE ==='); + }, + + // Load all pages + async loadPages() { + this.loading = true; + this.error = null; + + try { + contentPagesLog.info('Loading content pages...'); + + // Load platform defaults and vendor pages in parallel + const [platformResponse, vendorResponse] = await Promise.all([ + apiClient.get('/vendor/content-pages/'), + apiClient.get('/vendor/content-pages/overrides') + ]); + + // Platform pages - filter to only show actual platform defaults + const allPages = platformResponse.data || platformResponse || []; + this.platformPages = allPages.filter(p => p.is_platform_default); + + // Vendor's custom pages (includes overrides) + this.customPages = vendorResponse.data || vendorResponse || []; + + // Build override map for quick lookups + this.overrideMap = {}; + this.customPages.forEach(page => { + if (page.is_vendor_override) { + this.overrideMap[page.slug] = page.id; + } + }); + + contentPagesLog.info(`Loaded ${this.platformPages.length} platform pages, ${this.customPages.length} vendor pages`); + + } catch (err) { + contentPagesLog.error('Error loading pages:', err); + this.error = err.message || 'Failed to load pages'; + } finally { + this.loading = false; + } + }, + + // Check if vendor has overridden a platform page + hasOverride(slug) { + return slug in this.overrideMap; + }, + + // Get override page ID + getOverrideId(slug) { + return this.overrideMap[slug]; + }, + + // Create an override for a platform page + async createOverride(platformPage) { + contentPagesLog.info('Creating override for:', platformPage.slug); + + try { + // Create a new vendor page with the same slug as the platform page + const payload = { + slug: platformPage.slug, + title: platformPage.title, + content: platformPage.content, + content_format: platformPage.content_format || 'html', + meta_description: platformPage.meta_description, + meta_keywords: platformPage.meta_keywords, + is_published: true, + show_in_header: platformPage.show_in_header, + show_in_footer: platformPage.show_in_footer, + display_order: platformPage.display_order + }; + + const response = await apiClient.post('/vendor/content-pages/', payload); + const newPage = response.data || response; + + contentPagesLog.info('Override created:', newPage.id); + + // Redirect to edit the new page + window.location.href = `/vendor/${this.vendorCode}/content-pages/${newPage.id}/edit`; + + } catch (err) { + contentPagesLog.error('Error creating override:', err); + this.error = err.message || 'Failed to create override'; + } + }, + + // Delete a page + async deletePage(page) { + const message = page.is_vendor_override + ? `Are you sure you want to delete your override for "${page.title}"? The platform default will be shown instead.` + : `Are you sure you want to delete "${page.title}"? This cannot be undone.`; + + if (!confirm(message)) { + return; + } + + try { + contentPagesLog.info('Deleting page:', page.id); + + await apiClient.delete(`/vendor/content-pages/${page.id}`); + + // Remove from local state + this.customPages = this.customPages.filter(p => p.id !== page.id); + + // Update override map + if (page.is_vendor_override) { + delete this.overrideMap[page.slug]; + } + + contentPagesLog.info('Page deleted successfully'); + + } catch (err) { + contentPagesLog.error('Error deleting page:', err); + this.error = err.message || 'Failed to delete page'; + } + }, + + // Filtered platform pages based on search + get filteredPlatformPages() { + if (!this.searchQuery) { + return this.platformPages; + } + const query = this.searchQuery.toLowerCase(); + return this.platformPages.filter(page => + page.title.toLowerCase().includes(query) || + page.slug.toLowerCase().includes(query) + ); + }, + + // Filtered custom pages based on search + get filteredCustomPages() { + if (!this.searchQuery) { + return this.customPages; + } + const query = this.searchQuery.toLowerCase(); + return this.customPages.filter(page => + page.title.toLowerCase().includes(query) || + page.slug.toLowerCase().includes(query) + ); + }, + + // Format date for display + formatDate(dateStr) { + if (!dateStr) return '—'; + const date = new Date(dateStr); + return date.toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric' + }); + } + }; +}