refactor: migrate templates and static files to self-contained modules

Templates Migration:
- Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.)
- Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.)
- Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms)
- Migrate public templates to modules (billing, marketplace, cms)
- Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/)
- Migrate letzshop partials to marketplace module

Static Files Migration:
- Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file)
- Migrate vendor JS to modules: tenancy (4 files), core (2 files)
- Migrate shared JS: vendor-selector.js to core, media-picker.js to cms
- Migrate storefront JS: storefront-layout.js to core
- Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/)
- Update all template references to use module_static paths

Naming Consistency:
- Rename static/platform/ to static/public/
- Rename app/templates/platform/ to app/templates/public/
- Update all extends and static references

Documentation:
- Update module-system.md with shared templates documentation
- Update frontend-structure.md with new module JS organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -0,0 +1,191 @@
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// static/admin/js/dashboard.js
// ✅ Use centralized logger - ONE LINE!
const dashLog = window.LogConfig.loggers.dashboard;
function adminDashboard() {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Dashboard-specific state
currentPage: 'dashboard',
stats: {
totalVendors: 0,
activeUsers: 0,
verifiedVendors: 0,
importJobs: 0
},
recentVendors: [],
loading: true,
error: null,
/**
* Initialize dashboard
*/
async init() {
// Guard against multiple initialization
if (window._dashboardInitialized) {
dashLog.warn('Dashboard already initialized, skipping...');
return;
}
window._dashboardInitialized = true;
dashLog.info('=== DASHBOARD INITIALIZING ===');
dashLog.debug('Current URL:', window.location.href);
dashLog.debug('Current pathname:', window.location.pathname);
const token = localStorage.getItem('admin_token');
dashLog.debug('Has admin_token?', !!token);
if (token) {
dashLog.debug('Token preview:', token.substring(0, 20) + '...');
}
dashLog.debug('Dashboard initialization flag set');
const startTime = performance.now();
await this.loadDashboard();
const duration = performance.now() - startTime;
window.LogConfig.logPerformance('Dashboard Init', duration);
dashLog.info('=== DASHBOARD INITIALIZATION COMPLETE ===');
},
/**
* Load all dashboard data
*/
async loadDashboard() {
dashLog.info('Loading dashboard data...');
this.loading = true;
this.error = null;
dashLog.debug('Dashboard state: loading=true, error=null');
try {
dashLog.group('Loading dashboard data');
const startTime = performance.now();
// Load stats and vendors in parallel
await Promise.all([
this.loadStats(),
this.loadRecentVendors()
]);
const duration = performance.now() - startTime;
dashLog.groupEnd();
window.LogConfig.logPerformance('Load Dashboard Data', duration);
dashLog.info(`Dashboard data loaded successfully in ${duration}ms`);
} catch (error) {
window.LogConfig.logError(error, 'Dashboard Load');
this.error = error.message;
Utils.showToast('Failed to load dashboard data', 'error');
} finally {
this.loading = false;
dashLog.debug('Dashboard state: loading=false');
dashLog.info('Dashboard load attempt finished');
}
},
/**
* Load platform statistics
*/
async loadStats() {
dashLog.info('Loading platform statistics...');
const url = '/admin/dashboard/stats/platform';
window.LogConfig.logApiCall('GET', url, null, 'request');
try {
const startTime = performance.now();
const data = await apiClient.get(url);
const duration = performance.now() - startTime;
window.LogConfig.logApiCall('GET', url, data, 'response');
window.LogConfig.logPerformance('Load Stats', duration);
// Map API response to stats cards
this.stats = {
totalVendors: data.vendors?.total_vendors || 0,
activeUsers: data.users?.active_users || 0,
verifiedVendors: data.vendors?.verified_vendors || 0,
importJobs: data.imports?.total_imports || 0
};
dashLog.info('Stats mapped:', this.stats);
} catch (error) {
dashLog.error('Failed to load stats:', error);
throw error;
}
},
/**
* Load recent vendors
*/
async loadRecentVendors() {
dashLog.info('Loading recent vendors...');
const url = '/admin/dashboard';
window.LogConfig.logApiCall('GET', url, null, 'request');
try {
const startTime = performance.now();
const data = await apiClient.get(url);
const duration = performance.now() - startTime;
window.LogConfig.logApiCall('GET', url, data, 'response');
window.LogConfig.logPerformance('Load Recent Vendors', duration);
this.recentVendors = data.recent_vendors || [];
if (this.recentVendors.length > 0) {
dashLog.info(`Loaded ${this.recentVendors.length} recent vendors`);
dashLog.debug('First vendor:', this.recentVendors[0]);
} else {
dashLog.warn('No recent vendors found');
}
} catch (error) {
dashLog.error('Failed to load recent vendors:', error);
throw error;
}
},
/**
* Format date for display
*/
formatDate(dateString) {
if (!dateString) {
dashLog.debug('formatDate called with empty dateString');
return '-';
}
const formatted = Utils.formatDate(dateString);
dashLog.debug(`Date formatted: ${dateString} -> ${formatted}`);
return formatted;
},
/**
* Navigate to vendor detail page
*/
viewVendor(vendorCode) {
dashLog.info('Navigating to vendor:', vendorCode);
const url = `/admin/vendors?code=${vendorCode}`;
dashLog.debug('Navigation URL:', url);
window.location.href = url;
},
/**
* Refresh dashboard data
*/
async refresh() {
dashLog.info('=== DASHBOARD REFRESH TRIGGERED ===');
await this.loadDashboard();
Utils.showToast('Dashboard refreshed', 'success');
dashLog.info('=== DASHBOARD REFRESH COMPLETE ===');
}
};
}
dashLog.info('Dashboard module loaded');

View File

@@ -0,0 +1,428 @@
/**
* Alpine.js v3 global data initialization
* Provides theme toggle, menu controls, sidebar sections, and page state
*/
function data() {
// ─────────────────────────────────────────────────────────────────
// Theme (dark mode) persistence
// ─────────────────────────────────────────────────────────────────
function getThemeFromLocalStorage() {
if (window.localStorage.getItem('dark')) {
return JSON.parse(window.localStorage.getItem('dark'))
}
return (
!!window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
)
}
function setThemeToLocalStorage(value) {
window.localStorage.setItem('dark', value)
}
// ─────────────────────────────────────────────────────────────────
// Sidebar sections persistence
// ─────────────────────────────────────────────────────────────────
const SIDEBAR_STORAGE_KEY = 'admin_sidebar_sections';
// Default state: Platform Administration open, others closed
const defaultSections = {
superAdmin: true, // Super admin section (only visible to super admins)
platformAdmin: true,
vendorOps: false,
marketplace: false,
billing: false,
contentMgmt: false,
devTools: false,
platformHealth: false,
monitoring: false,
settingsSection: false
};
function getSidebarSectionsFromStorage() {
try {
const stored = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
if (stored) {
return { ...defaultSections, ...JSON.parse(stored) };
}
} catch (e) {
console.warn('Failed to parse sidebar sections from localStorage:', e);
}
return { ...defaultSections };
}
function saveSidebarSectionsToStorage(sections) {
try {
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, JSON.stringify(sections));
} catch (e) {
console.warn('Failed to save sidebar sections to localStorage:', e);
}
}
// ─────────────────────────────────────────────────────────────────
// Last visited page tracking (for redirect after login)
// ─────────────────────────────────────────────────────────────────
const LAST_PAGE_KEY = 'admin_last_visited_page';
const currentPath = window.location.pathname;
// Save current page (exclude login, logout, error pages)
if (currentPath.startsWith('/admin/') &&
!currentPath.includes('/login') &&
!currentPath.includes('/logout') &&
!currentPath.includes('/errors/')) {
try {
window.localStorage.setItem(LAST_PAGE_KEY, currentPath);
} catch (e) {
// Ignore storage errors
}
}
// Helper to get admin profile from localStorage
function getAdminProfileFromStorage() {
try {
// Check admin_user first (set by login), then adminProfile (legacy)
const stored = window.localStorage.getItem('admin_user') ||
window.localStorage.getItem('adminProfile');
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to parse admin profile from localStorage:', e);
}
return null;
}
// Map pages to their parent sections
const pageSectionMap = {
// Super Admin section
'admin-users': 'superAdmin',
// Platform Administration
companies: 'platformAdmin',
vendors: 'platformAdmin',
messages: 'platformAdmin',
// Vendor Operations (Products, Customers, Inventory, Orders, Shipping)
'marketplace-products': 'vendorOps',
'vendor-products': 'vendorOps',
customers: 'vendorOps',
inventory: 'vendorOps',
orders: 'vendorOps',
// Future: shipping will map to 'vendorOps'
// Marketplace
'marketplace-letzshop': 'marketplace',
// Content Management
'platform-homepage': 'contentMgmt',
'content-pages': 'contentMgmt',
'vendor-theme': 'contentMgmt',
// Developer Tools
components: 'devTools',
icons: 'devTools',
// Platform Health
testing: 'platformHealth',
'code-quality': 'platformHealth',
// Platform Monitoring
imports: 'monitoring',
'background-tasks': 'monitoring',
logs: 'monitoring',
'notifications-settings': 'monitoring',
// Platform Settings
settings: 'settingsSection',
profile: 'settingsSection',
'api-keys': 'settingsSection'
};
return {
// ─────────────────────────────────────────────────────────────────
// Theme
// ─────────────────────────────────────────────────────────────────
dark: getThemeFromLocalStorage(),
toggleTheme() {
this.dark = !this.dark
setThemeToLocalStorage(this.dark)
},
// ─────────────────────────────────────────────────────────────────
// Mobile side menu
// ─────────────────────────────────────────────────────────────────
isSideMenuOpen: false,
toggleSideMenu() {
this.isSideMenuOpen = !this.isSideMenuOpen
},
closeSideMenu() {
this.isSideMenuOpen = false
},
// ─────────────────────────────────────────────────────────────────
// Notifications menu
// ─────────────────────────────────────────────────────────────────
isNotificationsMenuOpen: false,
toggleNotificationsMenu() {
this.isNotificationsMenuOpen = !this.isNotificationsMenuOpen
},
closeNotificationsMenu() {
this.isNotificationsMenuOpen = false
},
// ─────────────────────────────────────────────────────────────────
// Profile menu
// ─────────────────────────────────────────────────────────────────
isProfileMenuOpen: false,
toggleProfileMenu() {
this.isProfileMenuOpen = !this.isProfileMenuOpen
},
closeProfileMenu() {
this.isProfileMenuOpen = false
},
// ─────────────────────────────────────────────────────────────────
// Pages menu (legacy)
// ─────────────────────────────────────────────────────────────────
isPagesMenuOpen: false,
togglePagesMenu() {
this.isPagesMenuOpen = !this.isPagesMenuOpen
},
// ─────────────────────────────────────────────────────────────────
// Collapsible sidebar sections
// ─────────────────────────────────────────────────────────────────
openSections: getSidebarSectionsFromStorage(),
toggleSection(section) {
this.openSections[section] = !this.openSections[section];
saveSidebarSectionsToStorage(this.openSections);
},
// Auto-expand section containing current page
expandSectionForCurrentPage() {
const section = pageSectionMap[this.currentPage];
if (section && !this.openSections[section]) {
this.openSections[section] = true;
saveSidebarSectionsToStorage(this.openSections);
}
},
// ─────────────────────────────────────────────────────────────────
// Page identifier - will be set by individual pages
// ─────────────────────────────────────────────────────────────────
currentPage: '',
// ─────────────────────────────────────────────────────────────────
// Admin profile and super admin flag
// ─────────────────────────────────────────────────────────────────
adminProfile: getAdminProfileFromStorage(),
get isSuperAdmin() {
return this.adminProfile?.is_super_admin === true;
},
// ─────────────────────────────────────────────────────────────────
// Dynamic menu visibility (loaded from API)
// ─────────────────────────────────────────────────────────────────
menuData: null,
menuLoading: false,
visibleMenuItems: new Set(),
async loadMenuConfig(forceReload = false) {
// Don't reload if already loaded (unless forced)
if (!forceReload && (this.menuData || this.menuLoading)) return;
// Skip if apiClient is not available (e.g., on login page)
if (typeof apiClient === 'undefined') {
console.debug('Menu config: apiClient not available');
return;
}
// Skip if not authenticated
if (!localStorage.getItem('admin_token')) {
console.debug('Menu config: no admin_token, skipping');
return;
}
this.menuLoading = true;
try {
this.menuData = await apiClient.get('/admin/menu-config/render/admin');
// Build a set of visible menu item IDs for quick lookup
this.visibleMenuItems = new Set();
for (const section of (this.menuData?.sections || [])) {
for (const item of (section.items || [])) {
this.visibleMenuItems.add(item.id);
}
}
console.debug('Menu config loaded:', this.visibleMenuItems.size, 'items');
} catch (e) {
// Silently fail - menu will show all items as fallback
console.debug('Menu config not loaded, using defaults:', e?.message || e);
} finally {
this.menuLoading = false;
}
},
async reloadSidebarMenu() {
// Force reload the sidebar menu config
this.menuData = null;
this.visibleMenuItems = new Set();
await this.loadMenuConfig(true);
},
isMenuItemVisible(menuItemId) {
// If menu not loaded yet, show all items (fallback to hardcoded)
if (!this.menuData) return true;
return this.visibleMenuItems.has(menuItemId);
},
isSectionVisible(sectionId) {
// If menu not loaded yet, show all sections
if (!this.menuData) return true;
// Check if any item in this section is visible
const section = this.menuData?.sections?.find(s => s.id === sectionId);
return section && section.items && section.items.length > 0;
}
}
}
/**
* Language selector component for i18n support
* Used by language_selector macros in templates
*
* @param {string} currentLang - Current language code (e.g., 'fr')
* @param {Array} enabledLanguages - Array of enabled language codes
* @returns {Object} Alpine.js component data
*/
function languageSelector(currentLang, enabledLanguages) {
return {
isLangOpen: false,
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['fr', 'de', 'en'],
languageNames: {
'en': 'English',
'fr': 'Français',
'de': 'Deutsch',
'lb': 'Lëtzebuergesch'
},
languageFlags: {
'en': 'gb',
'fr': 'fr',
'de': 'de',
'lb': 'lu'
},
async setLanguage(lang) {
if (lang === this.currentLang) {
this.isLangOpen = false;
return;
}
try {
const response = await fetch('/api/v1/language/set', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ language: lang })
});
if (response.ok) {
this.currentLang = lang;
window.location.reload();
}
} catch (error) {
console.error('Failed to set language:', error);
}
this.isLangOpen = false;
}
};
}
// Export to window for use in templates
window.languageSelector = languageSelector;
/**
* Header messages badge component
* Shows unread message count in header
*/
function headerMessages() {
return {
unreadCount: 0,
pollInterval: null,
async init() {
await this.fetchUnreadCount();
// Poll every 30 seconds
this.pollInterval = setInterval(() => this.fetchUnreadCount(), 30000);
},
destroy() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
},
async fetchUnreadCount() {
try {
const response = await apiClient.get('/admin/messages/unread-count');
this.unreadCount = response.total_unread || 0;
} catch (error) {
// Silently fail - don't spam console for auth issues on login page
}
}
};
}
// Export to window
window.headerMessages = headerMessages;
/**
* Platform Settings Utility
* Provides cached access to platform-wide settings
*/
const PlatformSettings = {
// Cache key and TTL
CACHE_KEY: 'platform_settings_cache',
CACHE_TTL: 5 * 60 * 1000, // 5 minutes
/**
* Get cached settings or fetch from API
*/
async get() {
try {
const cached = localStorage.getItem(this.CACHE_KEY);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp < this.CACHE_TTL) {
return data;
}
}
// Fetch from API
const response = await apiClient.get('/admin/settings/display/public');
const settings = {
rows_per_page: response.rows_per_page || 20
};
// Cache the result
localStorage.setItem(this.CACHE_KEY, JSON.stringify({
data: settings,
timestamp: Date.now()
}));
return settings;
} catch (error) {
console.warn('Failed to load platform settings, using defaults:', error);
return { rows_per_page: 20 };
}
},
/**
* Get rows per page setting
*/
async getRowsPerPage() {
const settings = await this.get();
return settings.rows_per_page;
},
/**
* Clear the cache (call after saving settings)
*/
clearCache() {
localStorage.removeItem(this.CACHE_KEY);
}
};
// Export to window
window.PlatformSettings = PlatformSettings;

View File

@@ -0,0 +1,246 @@
// static/admin/js/login.js
// noqa: js-003 - Standalone login page, doesn't use base layout
// noqa: js-004 - No sidebar on login page, doesn't need currentPage
// ✅ Use centralized logger - ONE LINE!
// Create custom logger for login page
const loginLog = window.LogConfig.createLogger('LOGIN');
function adminLogin() {
return {
dark: false,
credentials: {
username: '',
password: ''
},
loading: false,
error: null,
success: null,
errors: {},
init() {
// Guard against multiple initialization
if (window._adminLoginInitialized) return;
window._adminLoginInitialized = true;
loginLog.info('=== LOGIN PAGE INITIALIZING ===');
loginLog.debug('Current pathname:', window.location.pathname);
loginLog.debug('Current URL:', window.location.href);
// Just set theme - NO auth checking, NO token clearing!
this.dark = localStorage.getItem('theme') === 'dark';
loginLog.debug('Dark mode:', this.dark);
// DON'T clear tokens on init!
// If user lands here with a valid token, they might be navigating manually
// or got redirected. Let them try to login or navigate away.
const token = localStorage.getItem('admin_token');
if (token) {
loginLog.warn('Found existing token on login page');
loginLog.debug('Token preview:', token.substring(0, 20) + '...');
loginLog.info('Not clearing token - user may have navigated here manually');
} else {
loginLog.debug('No existing token found');
}
loginLog.info('=== LOGIN PAGE INITIALIZATION COMPLETE ===');
},
clearTokens() {
loginLog.debug('Clearing all auth tokens...');
const tokensBefore = {
admin_token: !!localStorage.getItem('admin_token'),
admin_user: !!localStorage.getItem('admin_user'),
token: !!localStorage.getItem('token')
};
loginLog.debug('Tokens before clear:', tokensBefore);
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
localStorage.removeItem('token');
const tokensAfter = {
admin_token: !!localStorage.getItem('admin_token'),
admin_user: !!localStorage.getItem('admin_user'),
token: !!localStorage.getItem('token')
};
loginLog.debug('Tokens after clear:', tokensAfter);
},
clearErrors() {
loginLog.debug('Clearing form errors');
this.error = null;
this.success = null;
this.errors = {};
},
validateForm() {
loginLog.debug('Validating login form...');
this.clearErrors();
let isValid = true;
if (!this.credentials.username.trim()) {
this.errors.username = 'Username is required';
loginLog.warn('Validation failed: Username is required');
isValid = false;
}
if (!this.credentials.password) {
this.errors.password = 'Password is required';
loginLog.warn('Validation failed: Password is required');
isValid = false;
} else if (this.credentials.password.length < 6) {
this.errors.password = 'Password must be at least 6 characters';
loginLog.warn('Validation failed: Password too short');
isValid = false;
}
loginLog.info('Form validation result:', isValid ? 'VALID' : 'INVALID');
return isValid;
},
async handleLogin() {
loginLog.info('=== LOGIN ATTEMPT STARTED ===');
if (!this.validateForm()) {
loginLog.warn('Form validation failed, aborting login');
return;
}
this.loading = true;
this.clearErrors();
loginLog.debug('Login state set to loading');
try {
loginLog.info('Calling login API endpoint...');
loginLog.debug('Username:', this.credentials.username);
const url = '/admin/auth/login';
const payload = {
email_or_username: this.credentials.username.trim(),
password: this.credentials.password
};
window.LogConfig.logApiCall('POST', url, { username: payload.username }, 'request');
const startTime = performance.now();
const response = await apiClient.post(url, payload);
const duration = performance.now() - startTime;
window.LogConfig.logApiCall('POST', url, {
hasToken: !!response.access_token,
user: response.user?.username
}, 'response');
window.LogConfig.logPerformance('Login', duration);
loginLog.info(`Login API response received in ${duration}ms`);
loginLog.debug('Response structure:', {
hasToken: !!response.access_token,
hasUser: !!response.user,
userRole: response.user?.role,
userName: response.user?.username
});
// Validate response
if (!response.access_token) {
loginLog.error('Invalid response: No access token');
throw new Error('Invalid response from server - no token');
}
if (response.user && response.user.role !== 'admin') {
loginLog.error('Authorization failed: User is not admin', {
actualRole: response.user.role
});
throw new Error('Access denied. Admin privileges required.');
}
loginLog.info('Login successful, storing authentication data...');
// Store authentication data
localStorage.setItem('admin_token', response.access_token);
localStorage.setItem('token', response.access_token);
loginLog.debug('Token stored, length:', response.access_token.length);
if (response.user) {
localStorage.setItem('admin_user', JSON.stringify(response.user));
loginLog.debug('User data stored:', {
username: response.user.username,
role: response.user.role,
id: response.user.id,
is_super_admin: response.user.is_super_admin
});
}
// Verify storage
const storedToken = localStorage.getItem('admin_token');
const storedUser = localStorage.getItem('admin_user');
loginLog.info('Storage verification:', {
tokenStored: !!storedToken,
userStored: !!storedUser,
tokenLength: storedToken?.length
});
// Show success message
this.success = 'Login successful! Checking platform access...';
loginLog.info('Success message displayed to user');
// Check if platform selection is required
try {
loginLog.info('Checking accessible platforms...');
const platformsResponse = await apiClient.get('/admin/auth/accessible-platforms');
loginLog.debug('Accessible platforms response:', platformsResponse);
if (platformsResponse.requires_platform_selection) {
// Platform admin needs to select a platform
loginLog.info('Platform selection required, redirecting...');
this.success = 'Login successful! Please select a platform...';
window.location.href = '/admin/select-platform';
return;
}
} catch (platformError) {
loginLog.warn('Could not check platforms, proceeding to dashboard:', platformError);
}
// Super admin or single platform - proceed to dashboard
this.success = 'Login successful! Redirecting...';
// Check for last visited page (saved before logout)
const lastPage = localStorage.getItem('admin_last_visited_page');
const redirectTo = (lastPage && lastPage.startsWith('/admin/') && !lastPage.includes('/login') && !lastPage.includes('/select-platform'))
? lastPage
: '/admin/dashboard';
loginLog.info('=== EXECUTING REDIRECT ===');
loginLog.debug('Last visited page:', lastPage);
loginLog.debug('Target URL:', redirectTo);
// Use href instead of replace to allow back button
window.location.href = redirectTo;
} catch (error) {
window.LogConfig.logError(error, 'Login');
this.error = error.message || 'Invalid username or password. Please try again.';
loginLog.info('Error message displayed to user:', this.error);
// Only clear tokens on login FAILURE
this.clearTokens();
loginLog.info('Tokens cleared after error');
} finally {
this.loading = false;
loginLog.debug('Login state set to not loading');
loginLog.info('=== LOGIN ATTEMPT FINISHED ===');
}
},
toggleDarkMode() {
loginLog.debug('Toggling dark mode...');
this.dark = !this.dark;
localStorage.setItem('theme', this.dark ? 'dark' : 'light');
loginLog.info('Dark mode:', this.dark ? 'ON' : 'OFF');
}
}
}
loginLog.info('Login module loaded');

View File

@@ -0,0 +1,187 @@
// static/admin/js/my-menu-config.js
// Personal menu configuration for super admins
//
// TODO: BUG - Sidebar menu doesn't update immediately after changes.
// User must navigate to another page to see the updated menu.
// The issue is that Alpine.js doesn't properly track reactivity for the
// visibleMenuItems Set in init-alpine.js. Attempted fixes with reloadSidebarMenu()
// and window.location.reload() didn't work reliably.
// Possible solutions:
// 1. Convert visibleMenuItems from Set to plain object for better Alpine reactivity
// 2. Use Alpine.store() for shared state between components
// 3. Dispatch a custom event that the sidebar listens for
// 4. Force re-render of sidebar component after changes
const myMenuConfigLog = window.LogConfig?.loggers?.myMenuConfig || window.LogConfig?.createLogger?.('myMenuConfig') || console;
function adminMyMenuConfig() {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Page-specific state
currentPage: 'my-menu',
loading: true,
error: null,
successMessage: null,
saving: false,
// Data
menuConfig: null,
// Computed grouped items
get groupedItems() {
if (!this.menuConfig?.items) return [];
// Group items by section
const sections = {};
for (const item of this.menuConfig.items) {
const sectionId = item.section_id;
if (!sections[sectionId]) {
sections[sectionId] = {
id: sectionId,
label: item.section_label,
isSuperAdminOnly: item.is_super_admin_only,
items: [],
visibleCount: 0
};
}
sections[sectionId].items.push(item);
if (item.is_visible) {
sections[sectionId].visibleCount++;
}
}
// Convert to array and maintain order
return Object.values(sections);
},
async init() {
// Guard against multiple initialization
if (window._adminMyMenuConfigInitialized) {
myMenuConfigLog.warn('Already initialized, skipping');
return;
}
window._adminMyMenuConfigInitialized = true;
myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZING ===');
try {
await this.loadMenuConfig();
myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZED ===');
} catch (error) {
myMenuConfigLog.error('Failed to initialize my menu config page:', error);
this.error = 'Failed to load page data. Please refresh.';
}
},
async refresh() {
this.error = null;
this.successMessage = null;
await this.loadMenuConfig();
},
async loadMenuConfig() {
this.loading = true;
this.error = null;
try {
this.menuConfig = await apiClient.get('/admin/menu-config/user');
myMenuConfigLog.info('Loaded menu config:', {
totalItems: this.menuConfig?.total_items,
visibleItems: this.menuConfig?.visible_items
});
} catch (error) {
myMenuConfigLog.error('Failed to load menu config:', error);
this.error = error.message || 'Failed to load menu configuration';
} finally {
this.loading = false;
}
},
async toggleVisibility(item) {
if (item.is_mandatory) {
myMenuConfigLog.warn('Cannot toggle mandatory item:', item.id);
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
const newVisibility = !item.is_visible;
try {
await apiClient.put('/admin/menu-config/user', {
menu_item_id: item.id,
is_visible: newVisibility
});
// Update local state
item.is_visible = newVisibility;
// Update counts
if (newVisibility) {
this.menuConfig.visible_items++;
this.menuConfig.hidden_items--;
} else {
this.menuConfig.visible_items--;
this.menuConfig.hidden_items++;
}
myMenuConfigLog.info('Toggled visibility:', item.id, newVisibility);
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to toggle visibility:', error);
this.error = error.message || 'Failed to update menu visibility';
this.saving = false;
}
},
async showAll() {
if (!confirm('This will show all menu items. Continue?')) {
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
try {
await apiClient.post('/admin/menu-config/user/show-all');
myMenuConfigLog.info('Showed all menu items');
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to show all menu items:', error);
this.error = error.message || 'Failed to show all menu items';
this.saving = false;
}
},
async resetToDefaults() {
if (!confirm('This will hide all menu items (except mandatory ones). You can then enable the ones you want. Continue?')) {
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
try {
await apiClient.post('/admin/menu-config/user/reset');
myMenuConfigLog.info('Reset menu config to defaults');
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to reset menu config:', error);
this.error = error.message || 'Failed to reset menu configuration';
this.saving = false;
}
}
};
}

View File

@@ -0,0 +1,509 @@
// static/admin/js/settings.js
// noqa: JS-003 - Uses ...baseData which is data() with safety check
const settingsLog = window.LogConfig?.loggers?.settings || console;
function adminSettings() {
// Get base data with safety check for standalone usage
const baseData = typeof data === 'function' ? data() : {};
return {
// Inherit base layout functionality from init-alpine.js
...baseData,
// Settings-specific state
currentPage: 'settings',
loading: true,
saving: false,
error: null,
successMessage: null,
activeTab: 'display',
displaySettings: {
rows_per_page: 20
},
logSettings: {
log_level: 'INFO',
log_file_max_size_mb: 10,
log_file_backup_count: 5,
db_log_retention_days: 30,
file_logging_enabled: true,
db_logging_enabled: true
},
notificationSettings: {
email_enabled: true,
in_app_enabled: true,
critical_only: false
},
shippingSettings: {
carrier_greco_label_url: 'https://dispatchweb.fr/Tracky/Home/',
carrier_colissimo_label_url: '',
carrier_xpresslogistics_label_url: ''
},
emailSettings: {
provider: 'smtp',
from_email: '',
from_name: '',
reply_to: '',
smtp_host: '',
smtp_port: 587,
smtp_user: '',
mailgun_domain: '',
aws_region: '',
debug: false,
enabled: true,
is_configured: false,
has_db_overrides: false
},
// Email editing form (separate from display to track changes)
emailForm: {
provider: 'smtp',
from_email: '',
from_name: '',
reply_to: '',
smtp_host: '',
smtp_port: 587,
smtp_user: '',
smtp_password: '',
smtp_use_tls: true,
smtp_use_ssl: false,
sendgrid_api_key: '',
mailgun_api_key: '',
mailgun_domain: '',
aws_access_key_id: '',
aws_secret_access_key: '',
aws_region: 'eu-west-1',
enabled: true,
debug: false
},
emailEditMode: false,
testEmailAddress: '',
sendingTestEmail: false,
testEmailError: null,
testEmailSuccess: null,
async init() {
// Guard against multiple initialization
if (window._adminSettingsInitialized) return;
window._adminSettingsInitialized = true;
try {
settingsLog.info('=== SETTINGS PAGE INITIALIZING ===');
await Promise.all([
this.loadDisplaySettings(),
this.loadLogSettings(),
this.loadShippingSettings(),
this.loadEmailSettings()
]);
} catch (error) {
settingsLog.error('Init failed:', error);
this.error = 'Failed to initialize settings page';
}
},
async refresh() {
this.error = null;
this.successMessage = null;
await Promise.all([
this.loadDisplaySettings(),
this.loadLogSettings(),
this.loadShippingSettings(),
this.loadEmailSettings()
]);
},
async loadDisplaySettings() {
try {
const data = await apiClient.get('/admin/settings/display/rows-per-page');
this.displaySettings.rows_per_page = data.rows_per_page || 20;
settingsLog.info('Display settings loaded:', this.displaySettings);
} catch (error) {
settingsLog.error('Failed to load display settings:', error);
// Use default value on error
this.displaySettings.rows_per_page = 20;
}
},
async saveDisplaySettings() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
const data = await apiClient.put(`/admin/settings/display/rows-per-page?rows=${this.displaySettings.rows_per_page}`);
this.successMessage = data.message || 'Display settings saved successfully';
// Clear the cached platform settings so pages pick up the new value
if (window.PlatformSettings) {
window.PlatformSettings.clearCache();
}
// Auto-hide success message after 5 seconds
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Display settings saved successfully');
} catch (error) {
settingsLog.error('Failed to save display settings:', error);
this.error = error.response?.data?.detail || 'Failed to save display settings';
} finally {
this.saving = false;
}
},
async loadLogSettings() {
this.loading = true;
this.error = null;
try {
const data = await apiClient.get('/admin/logs/settings');
this.logSettings = data;
settingsLog.info('Log settings loaded:', this.logSettings);
} catch (error) {
settingsLog.error('Failed to load log settings:', error);
this.error = error.response?.data?.detail || 'Failed to load log settings';
} finally {
this.loading = false;
}
},
async saveLogSettings() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
const data = await apiClient.put('/admin/logs/settings', this.logSettings);
this.successMessage = data.message || 'Log settings saved successfully';
// Auto-hide success message after 5 seconds
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Log settings saved successfully');
} catch (error) {
settingsLog.error('Failed to save log settings:', error);
this.error = error.response?.data?.detail || 'Failed to save log settings';
} finally {
this.saving = false;
}
},
async cleanupOldLogs() {
if (!confirm(`This will delete all logs older than ${this.logSettings.db_log_retention_days} days. Continue?`)) {
return;
}
this.error = null;
this.successMessage = null;
try {
const data = await apiClient.delete(
`/admin/logs/database/cleanup?retention_days=${this.logSettings.db_log_retention_days}&confirm=true`
);
this.successMessage = data.message || 'Old logs cleaned up successfully';
// Auto-hide success message after 5 seconds
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Old logs cleaned up successfully');
} catch (error) {
settingsLog.error('Failed to cleanup logs:', error);
this.error = error.response?.data?.detail || 'Failed to cleanup old logs';
}
},
async saveNotificationSettings() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
// TODO: Implement API endpoint for notification settings
// const data = await apiClient.put('/admin/notifications/settings', this.notificationSettings);
// For now, just show success (settings are client-side only)
this.successMessage = 'Notification settings saved successfully';
// Auto-hide success message after 5 seconds
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Notification settings saved:', this.notificationSettings);
} catch (error) {
settingsLog.error('Failed to save notification settings:', error);
this.error = error.response?.data?.detail || 'Failed to save notification settings';
} finally {
this.saving = false;
}
},
async loadShippingSettings() {
try {
// Load each carrier setting with defaults to avoid 404 errors
const carriers = [
{ name: 'greco', default: 'https://dispatchweb.fr/Tracky/Home/' },
{ name: 'colissimo', default: '' },
{ name: 'xpresslogistics', default: '' }
];
for (const carrier of carriers) {
const key = `carrier_${carrier.name}_label_url`;
// Use default query param to avoid 404 for non-existent settings
const data = await apiClient.get(`/admin/settings/${key}?default=${encodeURIComponent(carrier.default)}`);
if (data && data.value !== undefined) {
this.shippingSettings[key] = data.value;
}
}
settingsLog.info('Shipping settings loaded:', this.shippingSettings);
} catch (error) {
settingsLog.error('Failed to load shipping settings:', error);
// On error, keep existing defaults
}
},
async saveShippingSettings() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
// Save each carrier setting using upsert
const carriers = [
{ key: 'carrier_greco_label_url', name: 'Greco' },
{ key: 'carrier_colissimo_label_url', name: 'Colissimo' },
{ key: 'carrier_xpresslogistics_label_url', name: 'XpressLogistics' }
];
for (const carrier of carriers) {
await apiClient.post('/admin/settings/upsert', {
key: carrier.key,
value: this.shippingSettings[carrier.key] || '',
category: 'shipping',
value_type: 'string',
description: `Label URL prefix for ${carrier.name} carrier`
});
}
this.successMessage = 'Shipping settings saved successfully';
// Auto-hide success message after 5 seconds
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Shipping settings saved:', this.shippingSettings);
} catch (error) {
settingsLog.error('Failed to save shipping settings:', error);
this.error = error.response?.data?.detail || 'Failed to save shipping settings';
} finally {
this.saving = false;
}
},
getShippingLabelUrl(carrier, shipmentNumber) {
// Helper to generate full label URL
const prefix = this.shippingSettings[`carrier_${carrier}_label_url`] || '';
if (!prefix || !shipmentNumber) return null;
return prefix + shipmentNumber;
},
// =====================================================================
// EMAIL SETTINGS
// =====================================================================
async loadEmailSettings() {
try {
const data = await apiClient.get('/admin/settings/email/status');
this.emailSettings = {
provider: data.provider || 'smtp',
from_email: data.from_email || '',
from_name: data.from_name || '',
reply_to: data.reply_to || '',
smtp_host: data.smtp_host || '',
smtp_port: data.smtp_port || 587,
smtp_user: data.smtp_user || '',
mailgun_domain: data.mailgun_domain || '',
aws_region: data.aws_region || '',
debug: data.debug || false,
enabled: data.enabled !== false,
is_configured: data.is_configured || false,
has_db_overrides: data.has_db_overrides || false
};
// Populate edit form with current values
this.populateEmailForm();
settingsLog.info('Email settings loaded:', this.emailSettings);
} catch (error) {
settingsLog.error('Failed to load email settings:', error);
// Use defaults on error
}
},
populateEmailForm() {
// Copy current settings to form (passwords are not loaded from API)
this.emailForm = {
provider: this.emailSettings.provider,
from_email: this.emailSettings.from_email,
from_name: this.emailSettings.from_name,
reply_to: this.emailSettings.reply_to || '',
smtp_host: this.emailSettings.smtp_host || '',
smtp_port: this.emailSettings.smtp_port || 587,
smtp_user: this.emailSettings.smtp_user || '',
smtp_password: '', // Never populated from API
smtp_use_tls: true,
smtp_use_ssl: false,
sendgrid_api_key: '',
mailgun_api_key: '',
mailgun_domain: this.emailSettings.mailgun_domain || '',
aws_access_key_id: '',
aws_secret_access_key: '',
aws_region: this.emailSettings.aws_region || 'eu-west-1',
enabled: this.emailSettings.enabled,
debug: this.emailSettings.debug
};
},
enableEmailEditing() {
this.emailEditMode = true;
this.populateEmailForm();
},
cancelEmailEditing() {
this.emailEditMode = false;
this.populateEmailForm();
},
async saveEmailSettings() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
// Only send non-empty values to update
const payload = {};
// Always send these core fields
if (this.emailForm.provider) payload.provider = this.emailForm.provider;
if (this.emailForm.from_email) payload.from_email = this.emailForm.from_email;
if (this.emailForm.from_name) payload.from_name = this.emailForm.from_name;
if (this.emailForm.reply_to) payload.reply_to = this.emailForm.reply_to;
payload.enabled = this.emailForm.enabled;
payload.debug = this.emailForm.debug;
// Provider-specific fields
if (this.emailForm.provider === 'smtp') {
if (this.emailForm.smtp_host) payload.smtp_host = this.emailForm.smtp_host;
if (this.emailForm.smtp_port) payload.smtp_port = this.emailForm.smtp_port;
if (this.emailForm.smtp_user) payload.smtp_user = this.emailForm.smtp_user;
if (this.emailForm.smtp_password) payload.smtp_password = this.emailForm.smtp_password;
payload.smtp_use_tls = this.emailForm.smtp_use_tls;
payload.smtp_use_ssl = this.emailForm.smtp_use_ssl;
} else if (this.emailForm.provider === 'sendgrid') {
if (this.emailForm.sendgrid_api_key) payload.sendgrid_api_key = this.emailForm.sendgrid_api_key;
} else if (this.emailForm.provider === 'mailgun') {
if (this.emailForm.mailgun_api_key) payload.mailgun_api_key = this.emailForm.mailgun_api_key;
if (this.emailForm.mailgun_domain) payload.mailgun_domain = this.emailForm.mailgun_domain;
} else if (this.emailForm.provider === 'ses') {
if (this.emailForm.aws_access_key_id) payload.aws_access_key_id = this.emailForm.aws_access_key_id;
if (this.emailForm.aws_secret_access_key) payload.aws_secret_access_key = this.emailForm.aws_secret_access_key;
if (this.emailForm.aws_region) payload.aws_region = this.emailForm.aws_region;
}
const data = await apiClient.put('/admin/settings/email/settings', payload);
this.successMessage = data.message || 'Email settings saved successfully';
this.emailEditMode = false;
// Reload to get updated status
await this.loadEmailSettings();
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Email settings saved successfully');
} catch (error) {
settingsLog.error('Failed to save email settings:', error);
this.error = error.message || 'Failed to save email settings';
} finally {
this.saving = false;
}
},
async resetEmailSettings() {
if (!confirm('This will reset all email settings to use .env defaults. Continue?')) {
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
try {
const data = await apiClient.delete('/admin/settings/email/settings');
this.successMessage = data.message || 'Email settings reset to defaults';
this.emailEditMode = false;
// Reload to get .env values
await this.loadEmailSettings();
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Email settings reset successfully');
} catch (error) {
settingsLog.error('Failed to reset email settings:', error);
this.error = error.message || 'Failed to reset email settings';
} finally {
this.saving = false;
}
},
async sendTestEmail() {
if (!this.testEmailAddress) {
this.testEmailError = 'Please enter a test email address';
return;
}
this.sendingTestEmail = true;
this.testEmailError = null;
this.testEmailSuccess = null;
try {
settingsLog.info('Sending test email to:', this.testEmailAddress);
const data = await apiClient.post('/admin/settings/email/test', {
to_email: this.testEmailAddress
});
settingsLog.info('Test email response:', data);
if (data.success) {
this.testEmailSuccess = `Test email sent to ${this.testEmailAddress}`;
setTimeout(() => {
this.testEmailSuccess = null;
}, 5000);
} else {
settingsLog.error('Test email failed:', data.message);
// Extract the first line of error for cleaner display
let errorMsg = data.message || 'Failed to send test email';
if (errorMsg.includes('\n')) {
errorMsg = errorMsg.split('\n')[0];
}
this.testEmailError = errorMsg;
}
} catch (error) {
settingsLog.error('Failed to send test email (exception):', error);
this.testEmailError = error.message || 'Failed to send test email';
} finally {
this.sendingTestEmail = false;
settingsLog.info('sendingTestEmail set to false, testEmailError:', this.testEmailError);
}
}
};
}
settingsLog.info('Settings module loaded');

View File

@@ -0,0 +1,243 @@
// static/shared/js/vendor-selector.js
/**
* Shared Vendor Selector Module
* =============================
* Provides a reusable Tom Select-based vendor autocomplete component.
*
* Features:
* - Async search with debouncing (150ms)
* - Searches by vendor name and code
* - Dark mode support
* - Caches recent searches
* - Graceful fallback if Tom Select not available
*
* Usage:
* // In Alpine.js component init():
* this.$nextTick(() => {
* this.vendorSelector = initVendorSelector(this.$refs.vendorSelect, {
* onSelect: (vendor) => this.handleVendorSelect(vendor),
* onClear: () => this.handleVendorClear(),
* minChars: 2,
* maxOptions: 50
* });
* });
*
* // To programmatically set a value:
* this.vendorSelector.setValue(vendorId);
*
* // To clear:
* this.vendorSelector.clear();
*/
const vendorSelectorLog = window.LogConfig?.loggers?.vendorSelector ||
window.LogConfig?.createLogger?.('vendorSelector', false) ||
{ info: console.log, warn: console.warn, error: console.error }; // noqa: js-001 - fallback if logger not ready
/**
* Check if Tom Select is available, with retry logic
* @param {Function} callback - Called when Tom Select is available
* @param {number} maxRetries - Maximum retry attempts
* @param {number} retryDelay - Delay between retries in ms
*/
function waitForTomSelect(callback, maxRetries = 20, retryDelay = 100) {
let retries = 0;
function check() {
if (typeof TomSelect !== 'undefined') {
callback();
} else if (retries < maxRetries) {
retries++;
vendorSelectorLog.info(`Waiting for TomSelect... (attempt ${retries}/${maxRetries})`);
setTimeout(check, retryDelay);
} else {
vendorSelectorLog.error('TomSelect not available after maximum retries');
}
}
check();
}
/**
* Initialize a vendor selector on the given element
* @param {HTMLElement} selectElement - The select element to enhance
* @param {Object} options - Configuration options
* @param {Function} options.onSelect - Callback when vendor is selected (receives vendor object)
* @param {Function} options.onClear - Callback when selection is cleared
* @param {number} options.minChars - Minimum characters before search (default: 2)
* @param {number} options.maxOptions - Maximum options to show (default: 50)
* @param {string} options.placeholder - Placeholder text
* @param {string} options.apiEndpoint - API endpoint for search (default: '/admin/vendors')
* @returns {Object} Controller object with setValue() and clear() methods
*/
function initVendorSelector(selectElement, options = {}) {
if (!selectElement) {
vendorSelectorLog.error('Vendor selector element not provided');
return null;
}
const config = {
minChars: options.minChars || 2,
maxOptions: options.maxOptions || 50,
placeholder: options.placeholder || selectElement.getAttribute('placeholder') || 'Search vendor by name or code...',
apiEndpoint: options.apiEndpoint || '/admin/vendors', // Note: apiClient adds /api/v1 prefix
onSelect: options.onSelect || (() => {}),
onClear: options.onClear || (() => {})
};
let tomSelectInstance = null;
// Controller object returned to caller
const controller = {
/**
* Set the selected vendor by ID
* @param {number} vendorId - Vendor ID to select
* @param {Object} vendorData - Optional vendor data to avoid API call
*/
setValue: async function(vendorId, vendorData = null) {
if (!tomSelectInstance) return;
if (vendorData) {
// Add option and set value
tomSelectInstance.addOption({
id: vendorData.id,
name: vendorData.name,
vendor_code: vendorData.vendor_code
});
tomSelectInstance.setValue(vendorData.id, true);
} else if (vendorId) {
// Fetch vendor data and set
try {
const response = await apiClient.get(`${config.apiEndpoint}/${vendorId}`);
tomSelectInstance.addOption({
id: response.id,
name: response.name,
vendor_code: response.vendor_code
});
tomSelectInstance.setValue(response.id, true);
} catch (error) {
vendorSelectorLog.error('Failed to load vendor:', error);
}
}
},
/**
* Clear the selection
*/
clear: function() {
if (tomSelectInstance) {
tomSelectInstance.clear();
}
},
/**
* Get the Tom Select instance
*/
getInstance: function() {
return tomSelectInstance;
},
/**
* Destroy the Tom Select instance
*/
destroy: function() {
if (tomSelectInstance) {
tomSelectInstance.destroy();
tomSelectInstance = null;
}
}
};
// Initialize Tom Select when available
waitForTomSelect(() => {
vendorSelectorLog.info('Initializing vendor selector');
tomSelectInstance = new TomSelect(selectElement, {
valueField: 'id',
labelField: 'name',
searchField: ['name', 'vendor_code'],
maxOptions: config.maxOptions,
placeholder: config.placeholder,
// Async search with debouncing
load: async function(query, callback) {
if (query.length < config.minChars) {
callback([]);
return;
}
try {
const response = await apiClient.get(
`${config.apiEndpoint}?search=${encodeURIComponent(query)}&limit=${config.maxOptions}`
);
const vendors = (response.vendors || []).map(v => ({
id: v.id,
name: v.name,
vendor_code: v.vendor_code
}));
vendorSelectorLog.info(`Found ${vendors.length} vendors for "${query}"`);
callback(vendors);
} catch (error) {
vendorSelectorLog.error('Vendor search failed:', error);
callback([]);
}
},
// Custom rendering
render: {
option: function(data, escape) {
return `<div class="flex justify-between items-center py-1">
<span class="font-medium">${escape(data.name)}</span>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2 font-mono">${escape(data.vendor_code)}</span>
</div>`;
},
item: function(data, escape) {
return `<div class="flex items-center gap-2">
<span>${escape(data.name)}</span>
<span class="text-xs text-gray-400 font-mono">(${escape(data.vendor_code)})</span>
</div>`;
},
no_results: function() {
return '<div class="no-results py-2 px-3 text-gray-500 dark:text-gray-400">No vendors found</div>';
},
loading: function() {
return '<div class="loading py-2 px-3 text-gray-500 dark:text-gray-400">Searching...</div>';
}
},
// Event handlers
onChange: function(value) {
if (value) {
const selectedOption = this.options[value];
if (selectedOption) {
vendorSelectorLog.info('Vendor selected:', selectedOption);
config.onSelect({
id: parseInt(value),
name: selectedOption.name,
vendor_code: selectedOption.vendor_code
});
}
} else {
vendorSelectorLog.info('Vendor selection cleared');
config.onClear();
}
},
// Performance settings
loadThrottle: 150, // Debounce search requests
closeAfterSelect: true,
hideSelected: false,
persist: true, // Cache options
createOnBlur: false,
create: false
});
vendorSelectorLog.info('Vendor selector initialized');
});
return controller;
}
// Export to window for global access
window.initVendorSelector = initVendorSelector;

View File

@@ -0,0 +1,295 @@
// static/storefront/js/storefront-layout.js
/**
* Shop Layout Component
* Provides base functionality for vendor shop pages
* Works with vendor-specific themes
*/
const shopLog = {
info: (...args) => console.info('🛒 [SHOP]', ...args),
warn: (...args) => console.warn('⚠️ [SHOP]', ...args),
error: (...args) => console.error('❌ [SHOP]', ...args),
debug: (...args) => console.log('🔍 [SHOP]', ...args)
};
/**
* Shop Layout Data
* Base Alpine.js component for shop pages
*/
function shopLayoutData() {
return {
// Theme state
dark: localStorage.getItem('shop-theme') === 'dark',
// UI state
mobileMenuOpen: false,
searchOpen: false,
loading: false,
cartCount: 0,
// Cart state
cart: [],
sessionId: null,
// Initialize
init() {
shopLog.info('Shop layout initializing...');
// Get or create session ID
this.sessionId = this.getOrCreateSessionId();
shopLog.debug('Session ID:', this.sessionId);
// Load cart from localStorage
this.loadCart();
// Listen for cart updates
window.addEventListener('cart-updated', () => {
this.loadCart();
});
shopLog.info('Shop layout initialized');
},
// Get or create session ID
getOrCreateSessionId() {
let sessionId = localStorage.getItem('cart_session_id');
if (!sessionId) {
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('cart_session_id', sessionId);
shopLog.info('Created new session ID:', sessionId);
}
return sessionId;
},
// Theme management
toggleTheme() {
this.dark = !this.dark;
localStorage.setItem('shop-theme', this.dark ? 'dark' : 'light');
shopLog.debug('Theme toggled:', this.dark ? 'dark' : 'light');
},
// Mobile menu
toggleMobileMenu() {
this.mobileMenuOpen = !this.mobileMenuOpen;
if (this.mobileMenuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
},
closeMobileMenu() {
this.mobileMenuOpen = false;
document.body.style.overflow = '';
},
// Search
openSearch() {
this.searchOpen = true;
shopLog.debug('Search opened');
// Focus search input after a short delay
setTimeout(() => {
const input = document.querySelector('#search-input');
if (input) input.focus();
}, 100);
},
closeSearch() {
this.searchOpen = false;
},
// Cart management
loadCart() {
try {
const cartData = localStorage.getItem('shop-cart');
if (cartData) {
this.cart = JSON.parse(cartData);
this.cartCount = this.cart.reduce((sum, item) => sum + item.quantity, 0);
}
} catch (error) {
shopLog.error('Failed to load cart:', error);
this.cart = [];
this.cartCount = 0;
}
},
addToCart(product, quantity = 1) {
shopLog.info('Adding to cart:', product.name, 'x', quantity);
// Find existing item
const existingIndex = this.cart.findIndex(item => item.id === product.id);
if (existingIndex !== -1) {
// Update quantity
this.cart[existingIndex].quantity += quantity;
} else {
// Add new item
this.cart.push({
id: product.id,
name: product.name,
price: product.price,
image: product.image,
quantity: quantity
});
}
// Save and update
this.saveCart();
this.showToast(`${product.name} added to cart`, 'success');
},
updateCartItem(productId, quantity) {
const index = this.cart.findIndex(item => item.id === productId);
if (index !== -1) {
if (quantity <= 0) {
this.cart.splice(index, 1);
} else {
this.cart[index].quantity = quantity;
}
this.saveCart();
}
},
removeFromCart(productId) {
this.cart = this.cart.filter(item => item.id !== productId);
this.saveCart();
this.showToast('Item removed from cart', 'info');
},
clearCart() {
this.cart = [];
this.saveCart();
this.showToast('Cart cleared', 'info');
},
saveCart() {
try {
localStorage.setItem('shop-cart', JSON.stringify(this.cart));
this.cartCount = this.cart.reduce((sum, item) => sum + item.quantity, 0);
// Dispatch custom event
window.dispatchEvent(new CustomEvent('cart-updated'));
shopLog.debug('Cart saved:', this.cart.length, 'items');
} catch (error) {
shopLog.error('Failed to save cart:', error);
}
},
// Get cart total
get cartTotal() {
return this.cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
},
// Toast notifications
showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast toast-${type} transform transition-all duration-300 mb-2`;
// Color based on type
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
};
// noqa: SEC-015 - message is application-controlled
toast.innerHTML = `
<div class="${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-3">
<span>${message}</span>
<button onclick="this.parentElement.parentElement.remove()"
class="ml-4 hover:opacity-75">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
`;
container.appendChild(toast);
// Auto-remove after 3 seconds
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
},
// Format currency using configured locale
formatPrice(amount) {
if (!amount && amount !== 0) return '';
const locale = window.SHOP_CONFIG?.locale || 'fr-LU';
const currency = window.SHOP_CONFIG?.currency || 'EUR';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(amount);
},
// Format date
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
};
}
// Make available globally
window.shopLayoutData = shopLayoutData;
/**
* Language Selector Component
* Alpine.js component for language switching
*/
function languageSelector(currentLang, enabledLanguages) {
return {
isLangOpen: false,
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['fr', 'de', 'en'],
languageNames: {
'en': 'English',
'fr': 'Français',
'de': 'Deutsch',
'lb': 'Lëtzebuergesch'
},
languageFlags: {
'en': 'gb',
'fr': 'fr',
'de': 'de',
'lb': 'lu'
},
async setLanguage(lang) {
if (lang === this.currentLang) {
this.isLangOpen = false;
return;
}
try {
const response = await fetch('/api/v1/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang })
});
if (response.ok) {
this.currentLang = lang;
window.location.reload();
}
} catch (error) {
console.error('Failed to set language:', error);
}
this.isLangOpen = false;
}
};
}
window.languageSelector = languageSelector;
shopLog.info('Shop layout module loaded');

View File

@@ -0,0 +1,129 @@
// app/static/vendor/js/dashboard.js
/**
* Vendor dashboard page logic
*/
// ✅ Use centralized logger (with safe fallback)
const vendorDashLog = window.LogConfig.loggers.dashboard ||
window.LogConfig.createLogger('dashboard', false);
vendorDashLog.info('Loading...');
vendorDashLog.info('[VENDOR DASHBOARD] data function exists?', typeof data);
function vendorDashboard() {
vendorDashLog.info('[VENDOR DASHBOARD] vendorDashboard() called');
vendorDashLog.info('[VENDOR DASHBOARD] data function exists inside?', typeof data);
return {
// ✅ Inherit base layout state (includes vendorCode, dark mode, menu states)
...data(),
// ✅ Set page identifier
currentPage: 'dashboard',
loading: false,
error: '',
stats: {
products_count: 0,
orders_count: 0,
customers_count: 0,
revenue: 0
},
recentOrders: [],
recentProducts: [],
async init() {
// Guard against multiple initialization
if (window._vendorDashboardInitialized) {
return;
}
window._vendorDashboardInitialized = true;
try {
// IMPORTANT: Call parent init first to set vendorCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
await this.loadDashboardData();
} catch (error) {
vendorDashLog.error('Failed to initialize dashboard:', error);
}
},
async loadDashboardData() {
this.loading = true;
this.error = '';
try {
// Load stats
// NOTE: apiClient prepends /api/v1, and vendor context middleware handles vendor detection
// So we just call /vendor/dashboard/stats → becomes /api/v1/vendor/dashboard/stats
const statsResponse = await apiClient.get(
`/vendor/dashboard/stats`
);
// Map API response to stats (similar to admin dashboard pattern)
this.stats = {
products_count: statsResponse.products?.total || 0,
orders_count: statsResponse.orders?.total || 0,
customers_count: statsResponse.customers?.total || 0,
revenue: statsResponse.revenue?.total || 0
};
// Load recent orders
const ordersResponse = await apiClient.get(
`/vendor/orders?limit=5&sort=created_at:desc`
);
this.recentOrders = ordersResponse.items || [];
// Load recent products
const productsResponse = await apiClient.get(
`/vendor/products?limit=5&sort=created_at:desc`
);
this.recentProducts = productsResponse.items || [];
vendorDashLog.info('Dashboard data loaded', {
stats: this.stats,
orders: this.recentOrders.length,
products: this.recentProducts.length
});
} catch (error) {
vendorDashLog.error('Failed to load dashboard data', error);
this.error = 'Failed to load dashboard data. Please try refreshing the page.';
} finally {
this.loading = false;
}
},
async refresh() {
try {
await this.loadDashboardData();
} catch (error) {
vendorDashLog.error('Failed to refresh dashboard:', error);
}
},
formatCurrency(amount) {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
const currency = window.VENDOR_CONFIG?.currency || 'EUR';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(amount || 0);
},
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return date.toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
};
}

View File

@@ -0,0 +1,270 @@
// app/static/vendor/js/init-alpine.js
/**
* Alpine.js initialization for vendor pages
* Provides common data and methods for all vendor pages
*/
// ✅ Use centralized logger
const vendorLog = window.LogConfig.log;
console.log('[VENDOR INIT-ALPINE] Loading...');
// Sidebar section state persistence
const VENDOR_SIDEBAR_STORAGE_KEY = 'vendor_sidebar_sections';
function getVendorSidebarSectionsFromStorage() {
try {
const stored = localStorage.getItem(VENDOR_SIDEBAR_STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('[VENDOR INIT-ALPINE] Failed to load sidebar state from localStorage:', e);
}
// Default: all sections open
return {
products: true,
sales: true,
customers: true,
shop: true,
account: true
};
}
function saveVendorSidebarSectionsToStorage(sections) {
try {
localStorage.setItem(VENDOR_SIDEBAR_STORAGE_KEY, JSON.stringify(sections));
} catch (e) {
console.warn('[VENDOR INIT-ALPINE] Failed to save sidebar state to localStorage:', e);
}
}
function data() {
console.log('[VENDOR INIT-ALPINE] data() function called');
return {
dark: false,
isSideMenuOpen: false,
isNotificationsMenuOpen: false,
isProfileMenuOpen: false,
currentPage: '',
currentUser: {},
vendor: null,
vendorCode: null,
// Sidebar collapsible sections state
openSections: getVendorSidebarSectionsFromStorage(),
init() {
// Set current page from URL
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
this.currentPage = segments[segments.length - 1] || 'dashboard';
// Get vendor code from URL
if (segments[0] === 'vendor' && segments[1]) {
this.vendorCode = segments[1];
}
// Load user from localStorage
const user = localStorage.getItem('currentUser');
if (user) {
this.currentUser = JSON.parse(user);
}
// Load theme preference
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
this.dark = true;
}
// Load vendor info
this.loadVendorInfo();
// Save last visited page (for redirect after login)
// Exclude login, logout, onboarding, error pages
if (!path.includes('/login') &&
!path.includes('/logout') &&
!path.includes('/onboarding') &&
!path.includes('/errors/')) {
try {
localStorage.setItem('vendor_last_visited_page', path);
} catch (e) {
// Ignore storage errors
}
}
},
async loadVendorInfo() {
if (!this.vendorCode) return;
try {
// apiClient prepends /api/v1, so /vendor/info/{code} → /api/v1/vendor/info/{code}
const response = await apiClient.get(`/vendor/info/${this.vendorCode}`);
this.vendor = response;
vendorLog.debug('Vendor info loaded', this.vendor);
} catch (error) {
vendorLog.error('Failed to load vendor info', error);
}
},
toggleSideMenu() {
this.isSideMenuOpen = !this.isSideMenuOpen;
},
closeSideMenu() {
this.isSideMenuOpen = false;
},
toggleNotificationsMenu() {
this.isNotificationsMenuOpen = !this.isNotificationsMenuOpen;
if (this.isNotificationsMenuOpen) {
this.isProfileMenuOpen = false;
}
},
closeNotificationsMenu() {
this.isNotificationsMenuOpen = false;
},
toggleProfileMenu() {
this.isProfileMenuOpen = !this.isProfileMenuOpen;
if (this.isProfileMenuOpen) {
this.isNotificationsMenuOpen = false;
}
},
closeProfileMenu() {
this.isProfileMenuOpen = false;
},
toggleTheme() {
this.dark = !this.dark;
localStorage.setItem('theme', this.dark ? 'dark' : 'light');
},
// Sidebar section toggle with persistence
toggleSection(section) {
this.openSections[section] = !this.openSections[section];
saveVendorSidebarSectionsToStorage(this.openSections);
},
async handleLogout() {
console.log('🚪 Logging out vendor user...');
try {
// Call logout API
await apiClient.post('/vendor/auth/logout');
console.log('✅ Logout API called successfully');
} catch (error) {
console.error('⚠️ Logout API error (continuing anyway):', error);
} finally {
// Clear vendor tokens only (not admin or customer tokens)
// Keep vendor_last_visited_page so user returns to same page after login
console.log('🧹 Clearing vendor tokens...');
localStorage.removeItem('vendor_token');
localStorage.removeItem('vendor_user');
localStorage.removeItem('currentUser');
localStorage.removeItem('vendorCode');
// Note: Do NOT use localStorage.clear() - it would clear admin/customer tokens too
console.log('🔄 Redirecting to login...');
window.location.href = `/vendor/${this.vendorCode}/login`;
}
}
};
}
/**
* Language Selector Component
* Alpine.js component for language switching in vendor dashboard
*/
function languageSelector(currentLang, enabledLanguages) {
return {
isLangOpen: false,
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
languageNames: {
'en': 'English',
'fr': 'Français',
'de': 'Deutsch',
'lb': 'Lëtzebuergesch'
},
languageFlags: {
'en': 'gb',
'fr': 'fr',
'de': 'de',
'lb': 'lu'
},
async setLanguage(lang) {
if (lang === this.currentLang) {
this.isLangOpen = false;
return;
}
try {
const response = await fetch('/api/v1/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang })
});
if (response.ok) {
this.currentLang = lang;
window.location.reload();
}
} catch (error) {
console.error('Failed to set language:', error);
}
this.isLangOpen = false;
}
};
}
window.languageSelector = languageSelector;
/**
* Email Settings Warning Component
* Shows warning banner when vendor email settings are not configured
*
* Usage in template:
* <div x-data="emailSettingsWarning()" x-show="showWarning">...</div>
*/
function emailSettingsWarning() {
return {
showWarning: false,
loading: true,
vendorCode: null,
async init() {
// Get vendor code from URL
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
if (segments[0] === 'vendor' && segments[1]) {
this.vendorCode = segments[1];
}
// Skip if we're on the settings page (to avoid showing banner on config page)
if (path.includes('/settings')) {
this.loading = false;
return;
}
// Check email settings status
await this.checkEmailStatus();
},
async checkEmailStatus() {
try {
const response = await apiClient.get('/vendor/email-settings/status');
// Show warning if not configured
this.showWarning = !response.is_configured;
} catch (error) {
// Don't show warning on error (might be 401, etc.)
console.debug('[EmailWarning] Failed to check email status:', error);
this.showWarning = false;
} finally {
this.loading = false;
}
}
};
}
window.emailSettingsWarning = emailSettingsWarning;