Rename all "shop" directories and references to "storefront" to match the API and route naming convention already in use. Renamed directories: - app/templates/shop/ → app/templates/storefront/ - static/shop/ → static/storefront/ - app/templates/shared/macros/shop/ → .../macros/storefront/ - docs/frontend/shop/ → docs/frontend/storefront/ Renamed files: - shop.css → storefront.css - shop-layout.js → storefront-layout.js Updated references in: - app/routes/storefront_pages.py (21 template references) - app/modules/cms/routes/pages/vendor.py - app/templates/storefront/base.html (static paths) - All storefront templates (extends/includes) - docs/architecture/frontend-structure.md This aligns the template/static naming with: - Route file: storefront_pages.py - API directory: app/api/v1/storefront/ - Module routes: */routes/api/storefront.py - URL paths: /storefront/* Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
295 lines
9.3 KiB
JavaScript
295 lines
9.3 KiB
JavaScript
// 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'); |