refactor(cms): migrate store theme UI from tenancy to CMS module

Move store theme admin pages, templates, and JS from tenancy module
to CMS module where the data layer (model, service, API, schemas)
already lives. Eliminates split ownership.

Moved:
- Route handlers: GET /store-themes, GET /stores/{code}/theme
- Templates: store-theme.html, store-themes.html
- JS: store-theme.js, store-themes.js
- Updated static references: tenancy_static → cms_static

Deleted old tenancy files (no remaining references).
Menu item in CMS definition already pointed to correct route.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 10:30:09 +02:00
parent 21e4ac5124
commit 51a2114e02
7 changed files with 52 additions and 50 deletions

View File

@@ -2,7 +2,8 @@
"""
CMS Admin Page Routes (HTML rendering).
Admin pages for managing platform and store content pages.
Admin pages for managing platform and store content pages,
and store theme customization.
"""
from fastapi import APIRouter, Depends, Path, Request
@@ -10,6 +11,7 @@ from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_menu_access
from app.modules.core.utils.page_context import get_admin_context
from app.modules.enums import FrontendType
from app.modules.tenancy.models import User
from app.templates_config import templates
@@ -86,3 +88,49 @@ async def admin_content_page_edit(
"page_id": page_id,
},
)
# ============================================================================
# STORE THEMES
# ============================================================================
@router.get("/store-themes", response_class=HTMLResponse, include_in_schema=False)
async def admin_store_themes_page(
request: Request,
current_user: User = Depends(
require_menu_access("store-themes", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render store themes selection page.
Allows admins to select a store to customize their theme.
"""
return templates.TemplateResponse(
"cms/admin/store-themes.html",
get_admin_context(request, db, current_user),
)
@router.get(
"/stores/{store_code}/theme",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_store_theme_page(
request: Request,
store_code: str = Path(..., description="Store code"),
current_user: User = Depends(
require_menu_access("store-themes", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render store theme customization page.
Allows admins to customize colors, fonts, layout, and branding.
"""
return templates.TemplateResponse(
"cms/admin/store-theme.html",
get_admin_context(request, db, current_user, store_code=store_code),
)

View File

@@ -0,0 +1,332 @@
// static/admin/js/store-theme.js (FIXED VERSION)
/**
* Store Theme Editor - Alpine.js Component
* Manages theme customization for store shops
*
* REQUIRES: log-config.js to be loaded first
*/
// ============================================================================
// LOGGING CONFIGURATION (using centralized logger)
// ============================================================================
// Use the pre-configured theme logger from centralized log-config.js
const themeLog = window.LogConfig.loggers.storeTheme;
// ============================================================================
// ALPINE.JS COMPONENT
// ============================================================================
function adminStoreTheme() {
return {
// ✅ CRITICAL: Inherit base layout functionality
...data(),
// ✅ CRITICAL: Set page identifier
currentPage: 'store-theme',
// Page state
storeCode: null,
store: null,
loading: true,
saving: false,
error: null,
showResetThemeModal: false,
// Theme data structure matching StoreTheme model
themeData: {
theme_name: 'default',
colors: {
primary: '#6366f1',
secondary: '#8b5cf6',
accent: '#ec4899',
background: '#ffffff',
text: '#1f2937',
border: '#e5e7eb'
},
fonts: {
heading: 'Inter, sans-serif',
body: 'Inter, sans-serif',
size_base: '16px',
size_heading: '2rem'
},
layout: {
style: 'grid',
header_position: 'fixed',
product_card_style: 'card',
sidebar_position: 'left'
},
branding: {
logo_url: '',
favicon_url: '',
banner_url: ''
},
custom_css: '',
social_links: {
facebook: '',
instagram: '',
twitter: '',
linkedin: ''
}
},
// Available presets
presets: [],
selectedPreset: null,
// ====================================================================
// INITIALIZATION
// ====================================================================
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
// Guard against multiple initialization
if (window._adminStoreThemeInitialized) return;
window._adminStoreThemeInitialized = true;
themeLog.info('Initializing store theme editor');
// Start performance timer
const startTime = performance.now();
try {
// Extract store code from URL
const urlParts = window.location.pathname.split('/');
this.storeCode = urlParts[urlParts.indexOf('stores') + 1];
themeLog.debug('Store code from URL:', this.storeCode);
if (!this.storeCode) {
throw new Error('Store code not found in URL');
}
// Load data in parallel
themeLog.group('Loading theme data');
await Promise.all([
this.loadStore(),
this.loadTheme(),
this.loadPresets()
]);
themeLog.groupEnd();
// Log performance
const duration = performance.now() - startTime;
window.LogConfig.logPerformance('Theme Editor Init', duration);
themeLog.info('Theme editor initialized successfully');
} catch (error) {
// Use centralized error logger
window.LogConfig.logError(error, 'Theme Editor Init');
this.error = error.message || 'Failed to initialize theme editor';
Utils.showToast(this.error, 'error');
} finally {
this.loading = false;
}
},
// ====================================================================
// DATA LOADING
// ====================================================================
async loadStore() {
themeLog.info('Loading store data');
const url = `/admin/stores/${this.storeCode}`;
window.LogConfig.logApiCall('GET', url, null, 'request');
try {
// ✅ FIX: apiClient returns data directly, not response.data
const response = await apiClient.get(url);
// ✅ Direct assignment - response IS the data
this.store = response;
window.LogConfig.logApiCall('GET', url, this.store, 'response');
themeLog.debug('Store loaded:', this.store);
} catch (error) {
themeLog.error('Failed to load store:', error);
throw error;
}
},
async loadTheme() {
themeLog.info('Loading theme data');
const url = `/admin/store-themes/${this.storeCode}`;
window.LogConfig.logApiCall('GET', url, null, 'request');
try {
// ✅ FIX: apiClient returns data directly
const response = await apiClient.get(url);
// Merge with default theme data
this.themeData = {
...this.themeData,
...response
};
window.LogConfig.logApiCall('GET', url, this.themeData, 'response');
themeLog.debug('Theme loaded:', this.themeData);
} catch (error) {
themeLog.warn('Failed to load theme, using defaults:', error);
// Continue with default theme
}
},
async loadPresets() {
themeLog.info('Loading theme presets');
const url = '/admin/store-themes/presets';
window.LogConfig.logApiCall('GET', url, null, 'request');
try {
// ✅ FIX: apiClient returns data directly
const response = await apiClient.get(url);
// ✅ Access presets directly from response, not response.data.presets
this.presets = response.presets || [];
window.LogConfig.logApiCall('GET', url, response, 'response');
themeLog.debug(`Loaded ${this.presets.length} presets`);
} catch (error) {
themeLog.error('Failed to load presets:', error);
this.presets = [];
}
},
// ====================================================================
// THEME OPERATIONS
// ====================================================================
async saveTheme() {
if (this.saving) return;
themeLog.info('Saving theme changes');
this.saving = true;
this.error = null;
const startTime = performance.now();
try {
const url = `/admin/store-themes/${this.storeCode}`;
window.LogConfig.logApiCall('PUT', url, this.themeData, 'request');
// ✅ FIX: apiClient returns data directly
const response = await apiClient.put(url, this.themeData);
window.LogConfig.logApiCall('PUT', url, response, 'response');
const duration = performance.now() - startTime;
window.LogConfig.logPerformance('Save Theme', duration);
themeLog.info('Theme saved successfully');
Utils.showToast(I18n.t('tenancy.messages.theme_saved_successfully'), 'success');
} catch (error) {
window.LogConfig.logError(error, 'Save Theme');
this.error = 'Failed to save theme';
Utils.showToast(this.error, 'error');
} finally {
this.saving = false;
}
},
async applyPreset(presetName) {
themeLog.info(`Applying preset: ${presetName}`);
this.saving = true;
try {
const url = `/admin/store-themes/${this.storeCode}/preset/${presetName}`;
window.LogConfig.logApiCall('POST', url, null, 'request');
// ✅ FIX: apiClient returns data directly
const response = await apiClient.post(url);
window.LogConfig.logApiCall('POST', url, response, 'response');
// ✅ FIX: Access theme directly from response, not response.data.theme
if (response && response.theme) {
this.themeData = {
...this.themeData,
...response.theme
};
}
themeLog.info(`Preset '${presetName}' applied successfully`);
Utils.showToast(`Applied ${presetName} preset`, 'success');
} catch (error) {
window.LogConfig.logError(error, 'Apply Preset');
Utils.showToast(I18n.t('tenancy.messages.failed_to_apply_preset'), 'error');
} finally {
this.saving = false;
}
},
async resetTheme() {
themeLog.warn('Resetting theme to default');
this.saving = true;
try {
const url = `/admin/store-themes/${this.storeCode}`;
window.LogConfig.logApiCall('DELETE', url, null, 'request');
await apiClient.delete(url);
window.LogConfig.logApiCall('DELETE', url, null, 'response');
// Reload theme data
await this.loadTheme();
themeLog.info('Theme reset successfully');
Utils.showToast(I18n.t('tenancy.messages.theme_reset_to_default'), 'success');
} catch (error) {
window.LogConfig.logError(error, 'Reset Theme');
Utils.showToast(I18n.t('tenancy.messages.failed_to_reset_theme'), 'error');
} finally {
this.saving = false;
}
},
// ====================================================================
// UTILITY METHODS
// ====================================================================
previewTheme() {
themeLog.debug('Opening theme preview');
const previewUrl = `/store/${this.store?.subdomain || this.storeCode}`;
window.open(previewUrl, '_blank');
},
updateColor(key, value) {
themeLog.debug(`Color updated: ${key} = ${value}`);
this.themeData.colors[key] = value;
},
updateFont(type, value) {
themeLog.debug(`Font updated: ${type} = ${value}`);
this.themeData.fonts[type] = value;
},
updateLayout(key, value) {
themeLog.debug(`Layout updated: ${key} = ${value}`);
this.themeData.layout[key] = value;
}
};
}
// ============================================================================
// MODULE LOADED
// ============================================================================
themeLog.info('Store theme editor module loaded');

View File

@@ -0,0 +1,171 @@
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// static/admin/js/store-themes.js
/**
* Admin store themes selection page
*/
// ✅ Use centralized logger
const storeThemesLog = window.LogConfig.loggers.storeTheme;
storeThemesLog.info('Loading...');
function adminStoreThemes() {
storeThemesLog.debug('adminStoreThemes() called');
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'store-theme',
// State
loading: false,
error: '',
stores: [],
selectedStoreCode: '',
// Selected store for filter (Tom Select)
selectedStore: null,
storeSelector: null,
// Search/filter
searchQuery: '',
async init() {
// Guard against multiple initialization
if (window._adminStoreThemesInitialized) {
return;
}
window._adminStoreThemesInitialized = true;
storeThemesLog.info('Store Themes init() called');
// Initialize store selector (Tom Select)
this.$nextTick(() => {
this.initStoreSelector();
});
// Check localStorage for saved store
const savedStoreId = localStorage.getItem('store_themes_selected_store_id');
if (savedStoreId) {
storeThemesLog.info('Restoring saved store:', savedStoreId);
await this.loadStores();
// Restore store after stores are loaded
setTimeout(async () => {
await this.restoreSavedStore(parseInt(savedStoreId));
}, 200);
} else {
await this.loadStores();
}
storeThemesLog.info('Store Themes initialization complete');
},
/**
* Restore saved store from localStorage
*/
async restoreSavedStore(storeId) {
try {
const store = await apiClient.get(`/admin/stores/${storeId}`);
if (this.storeSelector && store) {
// Use the store selector's setValue method
this.storeSelector.setValue(store.id, store);
// Set the filter state
this.selectedStore = store;
storeThemesLog.info('Restored store:', store.name);
}
} catch (error) {
storeThemesLog.warn('Failed to restore saved store, clearing localStorage:', error);
localStorage.removeItem('store_themes_selected_store_id');
}
},
/**
* Initialize store selector with Tom Select
*/
initStoreSelector() {
if (!this.$refs.storeSelect) {
storeThemesLog.warn('Store select element not found');
return;
}
this.storeSelector = initStoreSelector(this.$refs.storeSelect, {
placeholder: 'Search store...',
onSelect: (store) => {
storeThemesLog.info('Store selected:', store);
this.selectedStore = store;
// Save to localStorage
localStorage.setItem('store_themes_selected_store_id', store.id.toString());
},
onClear: () => {
storeThemesLog.info('Store filter cleared');
this.selectedStore = null;
// Clear from localStorage
localStorage.removeItem('store_themes_selected_store_id');
}
});
},
/**
* Clear store filter
*/
clearStoreFilter() {
if (this.storeSelector) {
this.storeSelector.clear();
}
this.selectedStore = null;
// Clear from localStorage
localStorage.removeItem('store_themes_selected_store_id');
},
async loadStores() {
this.loading = true;
this.error = '';
try {
const response = await apiClient.get('/admin/stores?limit=1000');
this.stores = response.stores || [];
storeThemesLog.debug('Loaded stores:', this.stores.length);
} catch (error) {
storeThemesLog.error('Failed to load stores:', error);
this.error = error.message || 'Failed to load stores';
} finally {
this.loading = false;
}
},
/**
* Computed: Filtered stores based on search and selected store
*/
get filteredStores() {
let filtered = this.stores;
// If a store is selected via Tom Select, show only that store
if (this.selectedStore) {
filtered = this.stores.filter(v => v.id === this.selectedStore.id);
}
// Otherwise filter by search query
else if (this.searchQuery) {
const query = this.searchQuery.toLowerCase();
filtered = this.stores.filter(v =>
v.name.toLowerCase().includes(query) ||
(v.store_code && v.store_code.toLowerCase().includes(query))
);
}
return filtered;
},
navigateToTheme() {
if (!this.selectedStoreCode) {
return;
}
window.location.href = `/admin/stores/${this.selectedStoreCode}/theme`;
}
};
}
storeThemesLog.info('Module loaded');

View File

@@ -0,0 +1,463 @@
{# app/templates/admin/store-theme.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/modals.html' import confirm_modal %}
{% block title %}Theme Editor - {{ store_code }}{% endblock %}
{# ✅ CRITICAL: Binds to adminStoreTheme() function in store-theme.js #}
{% block alpine_data %}adminStoreTheme(){% endblock %}
{% block content %}
{% call page_header_flex(title='Theme Editor', subtitle_var="'Customize appearance for ' + (store?.name || '...')") %}
<a :href="`/admin/stores/${storeCode}`"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:shadow-outline-gray">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Store
</a>
{% endcall %}
{{ loading_state('Loading theme...') }}
{{ error_state('Error', show_condition='error && !loading') }}
<!-- Main Content -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-3">
<!-- Theme Configuration Form (2 columns) -->
<div class="md:col-span-2 space-y-6">
<!-- Theme Presets -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('palette', 'inline w-5 h-5 mr-2')"></span>
Choose a Preset
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Start with a pre-designed theme, then customize it to match your brand.
</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<!-- Default Preset -->
<button @click="applyPreset('default')"
:disabled="saving"
:class="themeData.theme_name === 'default' ? 'ring-2 ring-purple-500' : ''"
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-purple-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
<div class="flex items-center justify-center space-x-2 mb-2">
<div class="w-4 h-4 rounded bg-indigo-500"></div>
<div class="w-4 h-4 rounded bg-purple-500"></div>
<div class="w-4 h-4 rounded bg-pink-500"></div>
</div>
<p class="text-gray-700 dark:text-gray-300">Default</p>
</button>
<!-- Modern Preset -->
<button @click="applyPreset('modern')"
:disabled="saving"
:class="themeData.theme_name === 'modern' ? 'ring-2 ring-purple-500' : ''"
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-purple-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
<div class="flex items-center justify-center space-x-2 mb-2">
<div class="w-4 h-4 rounded bg-indigo-500"></div>
<div class="w-4 h-4 rounded bg-purple-600"></div>
<div class="w-4 h-4 rounded bg-pink-500"></div>
</div>
<p class="text-gray-700 dark:text-gray-300">Modern</p>
</button>
<!-- Classic Preset -->
<button @click="applyPreset('classic')"
:disabled="saving"
:class="themeData.theme_name === 'classic' ? 'ring-2 ring-purple-500' : ''"
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-blue-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
<div class="flex items-center justify-center space-x-2 mb-2">
<div class="w-4 h-4 rounded bg-blue-800"></div>
<div class="w-4 h-4 rounded bg-purple-700"></div>
<div class="w-4 h-4 rounded bg-red-600"></div>
</div>
<p class="text-gray-700 dark:text-gray-300">Classic</p>
</button>
<!-- Minimal Preset -->
<button @click="applyPreset('minimal')"
:disabled="saving"
:class="themeData.theme_name === 'minimal' ? 'ring-2 ring-purple-500' : ''"
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-gray-400 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
<div class="flex items-center justify-center space-x-2 mb-2">
<div class="w-4 h-4 rounded bg-black"></div>
<div class="w-4 h-4 rounded bg-gray-600"></div>
<div class="w-4 h-4 rounded bg-gray-400"></div>
</div>
<p class="text-gray-700 dark:text-gray-300">Minimal</p>
</button>
<!-- Vibrant Preset -->
<button @click="applyPreset('vibrant')"
:disabled="saving"
:class="themeData.theme_name === 'vibrant' ? 'ring-2 ring-purple-500' : ''"
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-orange-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
<div class="flex items-center justify-center space-x-2 mb-2">
<div class="w-4 h-4 rounded bg-orange-500"></div>
<div class="w-4 h-4 rounded bg-red-500"></div>
<div class="w-4 h-4 rounded bg-purple-600"></div>
</div>
<p class="text-gray-700 dark:text-gray-300">Vibrant</p>
</button>
<!-- Elegant Preset -->
<button @click="applyPreset('elegant')"
:disabled="saving"
:class="themeData.theme_name === 'elegant' ? 'ring-2 ring-purple-500' : ''"
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-gray-400 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
<div class="flex items-center justify-center space-x-2 mb-2">
<div class="w-4 h-4 rounded bg-gray-500"></div>
<div class="w-4 h-4 rounded bg-gray-700"></div>
<div class="w-4 h-4 rounded bg-amber-600"></div>
</div>
<p class="text-gray-700 dark:text-gray-300">Elegant</p>
</button>
<!-- Nature Preset -->
<button @click="applyPreset('nature')"
:disabled="saving"
:class="themeData.theme_name === 'nature' ? 'ring-2 ring-purple-500' : ''"
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-green-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
<div class="flex items-center justify-center space-x-2 mb-2">
<div class="w-4 h-4 rounded bg-green-600"></div>
<div class="w-4 h-4 rounded bg-emerald-500"></div>
<div class="w-4 h-4 rounded bg-amber-500"></div>
</div>
<p class="text-gray-700 dark:text-gray-300">Nature</p>
</button>
</div>
</div>
<!-- Colors Section -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('color-swatch', 'inline w-5 h-5 mr-2')"></span>
Colors
</h3>
<div class="grid gap-4 md:grid-cols-2">
<!-- Primary Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Primary Color</span>
<p class="text-xs text-gray-500 mb-2">Main brand color for buttons and links</p>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.primary"
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
<input type="text"
x-model="themeData.colors.primary"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
</div>
</label>
<!-- Secondary Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Secondary Color</span>
<p class="text-xs text-gray-500 mb-2">Supporting color for accents</p>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.secondary"
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
<input type="text"
x-model="themeData.colors.secondary"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
</div>
</label>
<!-- Accent Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Accent Color</span>
<p class="text-xs text-gray-500 mb-2">Call-to-action and highlights</p>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.accent"
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
<input type="text"
x-model="themeData.colors.accent"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
</div>
</label>
<!-- Background Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Background Color</span>
<p class="text-xs text-gray-500 mb-2">Page background</p>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.background"
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
<input type="text"
x-model="themeData.colors.background"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
</div>
</label>
<!-- Text Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Text Color</span>
<p class="text-xs text-gray-500 mb-2">Primary text color</p>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.text"
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
<input type="text"
x-model="themeData.colors.text"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
</div>
</label>
<!-- Border Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Border Color</span>
<p class="text-xs text-gray-500 mb-2">Borders and dividers</p>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.border"
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
<input type="text"
x-model="themeData.colors.border"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
</div>
</label>
</div>
</div>
<!-- Typography Section -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('document', 'inline w-5 h-5 mr-2')"></span>
Typography
</h3>
<div class="grid gap-4 md:grid-cols-2">
<!-- Heading Font -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Heading Font</span>
<p class="text-xs text-gray-500 mb-2">For titles and headings</p>
<select x-model="themeData.fonts.heading"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
<option value="Inter, sans-serif">Inter (Modern)</option>
<option value="Roboto, sans-serif">Roboto (Clean)</option>
<option value="Poppins, sans-serif">Poppins (Friendly)</option>
<option value="Playfair Display, serif">Playfair Display (Elegant)</option>
<option value="Merriweather, serif">Merriweather (Classic)</option>
<option value="Georgia, serif">Georgia (Traditional)</option>
<option value="Helvetica, sans-serif">Helvetica (Minimal)</option>
<option value="Montserrat, sans-serif">Montserrat (Bold)</option>
</select>
</label>
<!-- Body Font -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Body Font</span>
<p class="text-xs text-gray-500 mb-2">For body text and content</p>
<select x-model="themeData.fonts.body"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
<option value="Inter, sans-serif">Inter (Modern)</option>
<option value="Roboto, sans-serif">Roboto (Clean)</option>
<option value="Open Sans, sans-serif">Open Sans (Readable)</option>
<option value="Lato, sans-serif">Lato (Friendly)</option>
<option value="Arial, sans-serif">Arial (Universal)</option>
<option value="Georgia, serif">Georgia (Traditional)</option>
<option value="Helvetica, sans-serif">Helvetica (Minimal)</option>
</select>
</label>
</div>
</div>
<!-- Layout Section -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('view-grid', 'inline w-5 h-5 mr-2')"></span>
Layout
</h3>
<div class="grid gap-4 md:grid-cols-3">
<!-- Product Layout Style -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Product Layout</span>
<select x-model="themeData.layout.style"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
<option value="grid">Grid</option>
<option value="list">List</option>
<option value="masonry">Masonry</option>
</select>
</label>
<!-- Header Style -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Header Style</span>
<select x-model="themeData.layout.header"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
<option value="fixed">Fixed</option>
<option value="static">Static</option>
<option value="transparent">Transparent</option>
</select>
</label>
<!-- Product Card Style -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Product Card</span>
<select x-model="themeData.layout.product_card"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
<option value="modern">Modern</option>
<option value="classic">Classic</option>
<option value="minimal">Minimal</option>
</select>
</label>
</div>
</div>
<!-- Custom CSS Section -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('code', 'inline w-5 h-5 mr-2')"></span>
Advanced: Custom CSS
</h3>
<label class="block text-sm">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
Add custom CSS rules for advanced styling (use with caution)
</p>
<textarea x-model="themeData.custom_css"
rows="6"
placeholder=".my-custom-class {
color: red;
font-weight: bold;
}"
class="block w-full mt-1 text-sm font-mono dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-textarea rounded"></textarea>
</label>
</div>
<!-- Action Buttons -->
<div class="flex justify-between items-center">
<button @click="showResetThemeModal = true"
:disabled="saving"
class="px-4 py-2 text-sm font-medium leading-5 text-red-700 transition-colors duration-150 bg-white border border-red-300 rounded-lg hover:bg-red-50 focus:outline-none disabled:opacity-50 dark:bg-gray-800 dark:text-red-400 dark:border-red-600">
<span x-html="$icon('refresh', 'inline w-4 h-4 mr-2')"></span>
Reset to Default
</button>
<button @click="saveTheme()"
:disabled="saving"
class="flex items-center px-6 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50">
<span x-show="!saving">
<span x-html="$icon('save', 'inline w-4 h-4 mr-2')"></span>
Save Theme
</span>
<span x-show="saving" class="flex items-center">
<span x-html="$icon('spinner', 'inline w-4 h-4 mr-2 animate-spin')"></span>
Saving...
</span>
</button>
</div>
</div>
<!-- Preview Panel (1 column) -->
<div class="md:col-span-1">
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 sticky top-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('eye', 'inline w-5 h-5 mr-2')"></span>
Preview
</h3>
<!-- Theme Preview -->
<div class="space-y-4">
<!-- Current Theme Name -->
<div class="p-3 bg-purple-50 dark:bg-purple-900 dark:bg-opacity-20 rounded-lg">
<p class="text-xs font-semibold text-purple-800 dark:text-purple-200 mb-1">ACTIVE THEME</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 capitalize" x-text="themeData.theme_name"></p>
</div>
<!-- Colors Preview -->
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">COLORS</p>
<div class="grid grid-cols-3 gap-2">
<div class="text-center">
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm"
:style="`background-color: ${themeData.colors.primary}`"></div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Primary</p>
</div>
<div class="text-center">
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm"
:style="`background-color: ${themeData.colors.secondary}`"></div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Secondary</p>
</div>
<div class="text-center">
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm"
:style="`background-color: ${themeData.colors.accent}`"></div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Accent</p>
</div>
</div>
</div>
<!-- Typography Preview -->
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">TYPOGRAPHY</p>
<div class="space-y-2 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p class="text-lg font-bold" :style="`font-family: ${themeData.fonts.heading}`">
Heading Font
</p>
<p class="text-sm" :style="`font-family: ${themeData.fonts.body}`">
This is body text font example. It will be used for paragraphs and descriptions.
</p>
</div>
</div>
<!-- Button Preview -->
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">BUTTONS</p>
<button class="px-4 py-2 text-sm font-medium text-white rounded-lg w-full shadow-sm"
:style="`background-color: ${themeData.colors.primary}`">
Primary Button
</button>
</div>
<!-- Layout Preview -->
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">LAYOUT</p>
<div class="text-xs space-y-1 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p class="text-gray-700 dark:text-gray-300">
<span class="font-semibold">Product Layout:</span>
<span class="capitalize" x-text="themeData.layout.style"></span>
</p>
<p class="text-gray-700 dark:text-gray-300">
<span class="font-semibold">Header:</span>
<span class="capitalize" x-text="themeData.layout.header"></span>
</p>
<p class="text-gray-700 dark:text-gray-300">
<span class="font-semibold">Product Card:</span>
<span class="capitalize" x-text="themeData.layout.product_card"></span>
</p>
</div>
</div>
<!-- Preview Link -->
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<a :href="`http://${store?.subdomain}.localhost:8000`"
target="_blank"
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 border border-purple-300 rounded-lg hover:bg-purple-100 dark:bg-purple-900 dark:bg-opacity-20 dark:text-purple-300 dark:border-purple-700 transition-colors">
<span x-html="$icon('external-link', 'w-4 h-4 mr-2')"></span>
View Live Shop
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Reset Theme Confirmation Modal -->
{{ confirm_modal(
'resetThemeModal',
'Reset Theme',
'Are you sure you want to reset the theme to default? All customizations will be lost.',
'resetTheme()',
'showResetThemeModal',
'Reset',
'Cancel',
'warning'
) }}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('cms_static', path='admin/js/store-theme.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,129 @@
{# app/templates/admin/store-themes.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Store Themes{% endblock %}
{% block extra_head %}
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.css" rel="stylesheet">
<style>
.ts-wrapper { width: 100%; }
.ts-control {
background-color: rgb(249 250 251) !important;
border-color: rgb(209 213 219) !important;
border-radius: 0.5rem !important;
padding: 0.5rem 0.75rem !important;
}
.dark .ts-control {
background-color: rgb(55 65 81) !important;
border-color: rgb(75 85 99) !important;
color: rgb(229 231 235) !important;
}
.ts-dropdown {
border-radius: 0.5rem !important;
border-color: rgb(209 213 219) !important;
}
.dark .ts-dropdown {
background-color: rgb(55 65 81) !important;
border-color: rgb(75 85 99) !important;
}
.dark .ts-dropdown .option {
color: rgb(229 231 235) !important;
}
.dark .ts-dropdown .option.active {
background-color: rgb(75 85 99) !important;
}
</style>
{% endblock %}
{% block alpine_data %}adminStoreThemes(){% endblock %}
{% block content %}
{{ page_header('Store Themes', subtitle='Customize store theme colors and branding') }}
<!-- Selected Store Display (when filtered) -->
<div x-show="selectedStore" x-cloak class="mb-6">
<div class="px-4 py-3 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span x-html="$icon('color-swatch', 'w-6 h-6 text-purple-600')"></span>
<div>
<p class="text-sm font-medium text-purple-700 dark:text-purple-300">Filtered by Store</p>
<p class="text-lg font-semibold text-purple-900 dark:text-purple-100" x-text="selectedStore?.name"></p>
</div>
</div>
<button
@click="clearStoreFilter()"
class="flex items-center gap-1 px-3 py-1.5 text-sm text-purple-700 dark:text-purple-300 bg-purple-100 dark:bg-purple-800 rounded-md hover:bg-purple-200 dark:hover:bg-purple-700 transition-colors"
>
<span x-html="$icon('x-mark', 'w-4 h-4')"></span>
Clear Filter
</button>
</div>
</div>
</div>
<!-- Store Search/Filter -->
<div class="px-4 py-6 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Search Store
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Search for a store to customize their theme
</p>
<div class="max-w-md">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Store
</label>
<select x-ref="storeSelect" placeholder="Search store by name or code..."></select>
</div>
</div>
{{ loading_state('Loading stores...') }}
{{ error_state('Error loading stores') }}
<!-- Stores List -->
<div x-show="!loading && filteredStores.length > 0">
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-text="selectedStore ? 'Selected Store' : 'All Stores'"></span>
<span class="text-sm font-normal text-gray-500 dark:text-gray-400" x-text="`(${filteredStores.length})`"></span>
</h3>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<template x-for="store in filteredStores" :key="store.store_code">
<a
:href="`/admin/stores/${store.store_code}/theme`"
class="block p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-purple-500 dark:hover:border-purple-500 hover:shadow-md transition-all"
>
<div class="flex items-center justify-between mb-2">
<h4 class="font-semibold text-gray-700 dark:text-gray-200" x-text="store.name"></h4>
<span x-html="$icon('color-swatch', 'w-5 h-5 text-purple-600')"></span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400" x-text="store.store_code"></p>
<div class="mt-3 flex items-center text-xs text-purple-600 dark:text-purple-400">
<span>Customize theme</span>
<span x-html="$icon('chevron-right', 'w-4 h-4 ml-1')"></span>
</div>
</a>
</template>
</div>
</div>
</div>
<!-- Empty State -->
<div x-show="!loading && filteredStores.length === 0" class="text-center py-12">
<span x-html="$icon('shopping-bag', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
<p class="text-gray-600 dark:text-gray-400">No stores found</p>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
<script defer src="{{ url_for('cms_static', path='admin/js/store-themes.js') }}"></script>
{% endblock %}