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:
@@ -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),
|
||||
)
|
||||
|
||||
332
app/modules/cms/static/admin/js/store-theme.js
Normal file
332
app/modules/cms/static/admin/js/store-theme.js
Normal 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');
|
||||
171
app/modules/cms/static/admin/js/store-themes.js
Normal file
171
app/modules/cms/static/admin/js/store-themes.js
Normal 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');
|
||||
463
app/modules/cms/templates/cms/admin/store-theme.html
Normal file
463
app/modules/cms/templates/cms/admin/store-theme.html
Normal 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 %}
|
||||
129
app/modules/cms/templates/cms/admin/store-themes.html
Normal file
129
app/modules/cms/templates/cms/admin/store-themes.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user