Preview token propagation: - JavaScript in storefront base.html appends _preview query param to all internal links when in preview mode, so clicking nav items (Services, Contact, etc.) preserves the preview bypass Scraped content enrichment: - POC builder now appends first 5 scraped paragraphs to about/services/ projects pages, so the POC shows actual content from the prospect's site instead of just generic template text - Extracts tagline from second scraped heading Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
430 lines
24 KiB
HTML
430 lines
24 KiB
HTML
{# app/templates/storefront/base.html #}
|
|
{# Base template for store shop frontend with theme support #}
|
|
<!DOCTYPE html>
|
|
<html lang="en" x-data="{% block alpine_data %}storefrontLayoutData(){% endblock %}" x-bind:class="{ 'dark': dark }">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
{# Dynamic title with store branding #}
|
|
<title>
|
|
{% block title %}{{ store.name }}{% endblock %}
|
|
{% if store.tagline %} - {{ store.tagline }}{% endif %}
|
|
</title>
|
|
|
|
{# SEO Meta Tags #}
|
|
<meta name="description" content="{% block meta_description %}{{ store.description or 'Shop at ' + store.name }}{% endblock %}">
|
|
<meta name="keywords" content="{% block meta_keywords %}{{ store.name }}, online shop{% endblock %}">
|
|
|
|
{# Favicon - store-specific or default #}
|
|
{% if theme.branding.favicon %}
|
|
<link rel="icon" type="image/x-icon" href="{{ theme.branding.favicon }}">
|
|
{% else %}
|
|
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='favicon.ico') }}">
|
|
{% endif %}
|
|
|
|
{# CRITICAL: Inject theme CSS variables #}
|
|
<style id="store-theme-variables">
|
|
:root {
|
|
{% for key, value in theme.css_variables.items() %}
|
|
{{ key }}: {{ value }};
|
|
{% endfor %}
|
|
}
|
|
|
|
{# Custom CSS from store theme #}
|
|
{% if theme.custom_css %}
|
|
{{ theme.custom_css | safe }}{# sanitized: admin-controlled #}
|
|
{% endif %}
|
|
</style>
|
|
|
|
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
|
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/tailwind.output.css') }}">
|
|
|
|
{# Flag Icons for Language Selector with local fallback #}
|
|
<link
|
|
rel="stylesheet"
|
|
href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css"
|
|
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/store/flag-icons.min.css') }}';"
|
|
/>
|
|
|
|
{# Base Shop Styles #}
|
|
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/storefront.css') }}">
|
|
|
|
{# Dev debug toolbar (dev only — auto-hides in production, must load early for interceptors) #}
|
|
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
|
|
|
|
{% block extra_head %}{% endblock %}
|
|
</head>
|
|
|
|
<body class="bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
|
|
|
|
{# Header - Theme-aware #}
|
|
<header class="{% if theme.layout.header == 'fixed' %}sticky top-0 z-50{% endif %}
|
|
bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div class="flex justify-between items-center h-16">
|
|
|
|
{# Store Logo #}
|
|
<div class="flex items-center">
|
|
<a href="{{ base_url }}" class="flex items-center space-x-3">
|
|
{% if theme.branding.logo %}
|
|
{# Show light logo in light mode, dark logo in dark mode #}
|
|
<img x-show="!dark"
|
|
src="{{ theme.branding.logo }}"
|
|
alt="{{ store.name }}"
|
|
class="h-8 w-auto">
|
|
{% if theme.branding.logo_dark %}
|
|
<img x-show="dark"
|
|
src="{{ theme.branding.logo_dark }}"
|
|
alt="{{ store.name }}"
|
|
class="h-8 w-auto">
|
|
{% endif %}
|
|
{% else %}
|
|
<span class="text-xl font-bold" style="color: var(--color-primary)">
|
|
{{ store.name }}
|
|
</span>
|
|
{% endif %}
|
|
</a>
|
|
</div>
|
|
|
|
{# Navigation — Home is always shown, module items are dynamic #}
|
|
<nav class="hidden md:flex space-x-8">
|
|
<a href="{{ base_url }}" class="text-gray-700 dark:text-gray-300 hover:text-primary">
|
|
Home
|
|
</a>
|
|
{% for item in storefront_nav.get('nav', []) %}
|
|
<a href="{{ base_url }}{{ item.route }}" class="text-gray-700 dark:text-gray-300 hover:text-primary">
|
|
{{ _(item.label_key) }}
|
|
</a>
|
|
{% endfor %}
|
|
{# CMS pages (About, Contact) are already dynamic via header_pages #}
|
|
{% for page in header_pages|default([]) %}
|
|
<a href="{{ base_url }}{{ page.slug }}" class="text-gray-700 dark:text-gray-300 hover:text-primary">
|
|
{{ page.title }}
|
|
</a>
|
|
{% endfor %}
|
|
</nav>
|
|
|
|
{# Right side actions #}
|
|
<div class="flex items-center space-x-4">
|
|
|
|
{% if 'catalog' in enabled_modules|default([]) %}
|
|
{# Search #}
|
|
<button @click="openSearch()" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
|
</svg>
|
|
</button>
|
|
{% endif %}
|
|
|
|
{% if 'cart' in enabled_modules|default([]) %}
|
|
{# Cart #}
|
|
<a href="{{ base_url }}cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
|
</svg>
|
|
<span x-show="cartCount > 0"
|
|
x-text="cartCount"
|
|
class="absolute -top-1 -right-1 bg-accent text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"
|
|
style="background-color: var(--color-accent)">
|
|
</span>
|
|
</a>
|
|
{% endif %}
|
|
|
|
{# Theme toggle #}
|
|
<button @click="toggleTheme()"
|
|
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
<svg x-show="!dark" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
|
|
</svg>
|
|
<svg x-show="dark" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
|
</svg>
|
|
</button>
|
|
|
|
{# Language Selector #}
|
|
{% set enabled_langs = store.storefront_languages if store and store.storefront_languages else ['fr', 'de', 'en'] %}
|
|
{% if enabled_langs|length > 1 %}
|
|
<div class="relative" x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ enabled_langs|tojson }})'>
|
|
<button
|
|
@click="isLangOpen = !isLangOpen"
|
|
@click.outside="isLangOpen = false"
|
|
class="inline-flex items-center gap-1 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
aria-label="Change language"
|
|
>
|
|
<span x-html="$icon('globe-alt', 'w-5 h-5')"></span>
|
|
<span class="text-xs font-semibold uppercase" x-text="currentLang"></span>
|
|
</button>
|
|
<div
|
|
x-show="isLangOpen"
|
|
x-cloak
|
|
x-transition:enter="transition ease-out duration-100"
|
|
x-transition:enter-start="transform opacity-0 scale-95"
|
|
x-transition:enter-end="transform opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-75"
|
|
x-transition:leave-start="transform opacity-100 scale-100"
|
|
x-transition:leave-end="transform opacity-0 scale-95"
|
|
class="absolute right-0 w-40 mt-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50"
|
|
>
|
|
<template x-for="lang in languages" :key="lang">
|
|
<button
|
|
@click="setLanguage(lang)"
|
|
type="button"
|
|
class="flex items-center gap-3 w-full px-4 py-2 text-sm font-medium transition-colors"
|
|
:class="currentLang === lang
|
|
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
|
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'"
|
|
>
|
|
<span class="fi" :class="'fi-' + languageFlags[lang]"></span>
|
|
<span x-text="languageNames[lang]"></span>
|
|
<svg x-show="currentLang === lang" class="w-4 h-4 ml-auto" style="color: var(--color-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
|
</svg>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Account #}
|
|
<a href="{{ base_url }}account" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
|
</svg>
|
|
</a>
|
|
|
|
{# Mobile menu toggle #}
|
|
<button @click="toggleMobileMenu()" class="md:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M4 6h16M4 12h16M4 18h16"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{# Preview mode banner (POC site previews via signed URL) #}
|
|
{% if request.state.is_preview|default(false) %}
|
|
<div style="position:fixed;top:0;left:0;right:0;z-index:9999;background:linear-gradient(135deg,#0D9488,#14B8A6);color:white;padding:10px 20px;display:flex;align-items:center;justify-content:space-between;font-family:system-ui;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,0.15);">
|
|
<div style="display:flex;align-items:center;gap:12px;">
|
|
<span style="font-weight:700;font-size:16px;">HostWizard</span>
|
|
<span style="opacity:0.9;">Preview Mode</span>
|
|
</div>
|
|
<a href="https://hostwizard.lu" style="color:white;text-decoration:none;padding:6px 16px;border:1px solid rgba(255,255,255,0.4);border-radius:6px;font-size:13px;" target="_blank">hostwizard.lu</a>
|
|
</div>
|
|
<style>header { margin-top: 48px !important; }</style>
|
|
<script>
|
|
// Propagate preview token to all internal links
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
var params = new URLSearchParams(window.location.search);
|
|
var token = params.get('_preview');
|
|
if (!token) return;
|
|
document.querySelectorAll('a[href]').forEach(function(a) {
|
|
var href = a.getAttribute('href');
|
|
if (href && (href.startsWith('/') || href.startsWith(window.location.origin)) && !href.startsWith('//')) {
|
|
var url = new URL(href, window.location.origin);
|
|
url.searchParams.set('_preview', token);
|
|
a.setAttribute('href', url.pathname + url.search);
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
{% endif %}
|
|
|
|
{# Mobile menu panel #}
|
|
<div x-show="mobileMenuOpen"
|
|
x-cloak
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0 -translate-y-2"
|
|
x-transition:enter-end="opacity-100 translate-y-0"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100 translate-y-0"
|
|
x-transition:leave-end="opacity-0 -translate-y-2"
|
|
class="md:hidden bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 shadow-lg">
|
|
<nav class="max-w-7xl mx-auto px-4 py-4 space-y-1">
|
|
<a href="{{ base_url }}" @click="closeMobileMenu()"
|
|
class="block px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 font-medium">
|
|
Home
|
|
</a>
|
|
{% for item in storefront_nav.get('nav', []) %}
|
|
<a href="{{ base_url }}{{ item.route }}" @click="closeMobileMenu()"
|
|
class="block px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
{{ _(item.label_key) }}
|
|
</a>
|
|
{% endfor %}
|
|
{% for page in header_pages|default([]) %}
|
|
<a href="{{ base_url }}{{ page.slug }}" @click="closeMobileMenu()"
|
|
class="block px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
{{ page.title }}
|
|
</a>
|
|
{% endfor %}
|
|
</nav>
|
|
</div>
|
|
|
|
{# Main Content Area #}
|
|
<main class="min-h-screen">
|
|
{% block content %}
|
|
{# Page-specific content goes here #}
|
|
{% endblock %}
|
|
</main>
|
|
|
|
{# Footer with store info and social links #}
|
|
<footer class="bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-12">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
|
|
|
{# Store Info #}
|
|
<div class="col-span-1 md:col-span-2">
|
|
<h3 class="text-lg font-semibold mb-4" style="color: var(--color-primary)">
|
|
{{ store.name }}
|
|
</h3>
|
|
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
|
{{ store.description }}
|
|
</p>
|
|
|
|
{# Social Links from theme #}
|
|
{% if theme.social_links %}
|
|
<div class="flex space-x-4">
|
|
{% if theme.social_links.facebook %}
|
|
<a href="{{ theme.social_links.facebook }}" target="_blank"
|
|
class="text-gray-600 hover:text-primary dark:text-gray-400">
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
|
</svg>
|
|
</a>
|
|
{% endif %}
|
|
{% if theme.social_links.instagram %}
|
|
<a href="{{ theme.social_links.instagram }}" target="_blank"
|
|
class="text-gray-600 hover:text-primary dark:text-gray-400">
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
|
</svg>
|
|
</a>
|
|
{% endif %}
|
|
{# Add more social networks as needed #}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# Dynamic CMS Pages - Footer Navigation #}
|
|
{% if footer_pages %}
|
|
{# Split footer pages into two columns if there are many #}
|
|
{% set half = (footer_pages | length / 2) | round(0, 'ceil') | int %}
|
|
{% set col1_pages = footer_pages[:half] %}
|
|
{% set col2_pages = footer_pages[half:] %}
|
|
|
|
{# Column 1 #}
|
|
<div>
|
|
<h4 class="font-semibold mb-4">Quick Links</h4>
|
|
<ul class="space-y-2">
|
|
{% if 'catalog' in enabled_modules|default([]) %}
|
|
<li><a href="{{ base_url }}products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
|
|
{% endif %}
|
|
{% for page in col1_pages %}
|
|
<li><a href="{{ base_url }}{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
|
|
{# Column 2 #}
|
|
{% if col2_pages %}
|
|
<div>
|
|
<h4 class="font-semibold mb-4">Information</h4>
|
|
<ul class="space-y-2">
|
|
{% for page in col2_pages %}
|
|
<li><a href="{{ base_url }}{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
{% else %}
|
|
{# Fallback: Static links if no CMS pages configured #}
|
|
<div>
|
|
<h4 class="font-semibold mb-4">Quick Links</h4>
|
|
<ul class="space-y-2">
|
|
{% if 'catalog' in enabled_modules|default([]) %}
|
|
<li><a href="{{ base_url }}products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
|
|
{% endif %}
|
|
<li><a href="{{ base_url }}about" class="text-gray-600 hover:text-primary dark:text-gray-400">About Us</a></li>
|
|
<li><a href="{{ base_url }}contact" class="text-gray-600 hover:text-primary dark:text-gray-400">Contact</a></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 class="font-semibold mb-4">Information</h4>
|
|
<ul class="space-y-2">
|
|
<li><a href="{{ base_url }}faq" class="text-gray-600 hover:text-primary dark:text-gray-400">FAQ</a></li>
|
|
<li><a href="{{ base_url }}shipping" class="text-gray-600 hover:text-primary dark:text-gray-400">Shipping</a></li>
|
|
<li><a href="{{ base_url }}returns" class="text-gray-600 hover:text-primary dark:text-gray-400">Returns</a></li>
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# Copyright #}
|
|
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700 text-center text-gray-600 dark:text-gray-400">
|
|
<p>© <span x-text="new Date().getFullYear()"></span> {{ store.name }}. All rights reserved.</p>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
|
|
{# JavaScript Loading Order (CRITICAL - must be in this order) #}
|
|
|
|
{# 0. Frontend type (server-injected, used by log-config and dev-toolbar) #}
|
|
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("storefront") }}';</script>
|
|
|
|
{# 1. Log Configuration (must load first) #}
|
|
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
|
|
|
|
{# 2. Global Shop Configuration (resolved via PlatformSettingsService) #}
|
|
<script>
|
|
window.SHOP_CONFIG = {
|
|
locale: '{{ storefront_locale }}',
|
|
currency: '{{ storefront_currency }}',
|
|
language: '{{ request.state.language|default("fr") }}'
|
|
};
|
|
</script>
|
|
|
|
{# 3. Icon System #}
|
|
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
|
|
|
|
{# 4. Base Shop Layout (Alpine.js component - must load before Alpine) #}
|
|
<script defer src="{{ url_for('core_static', path='storefront/js/storefront-layout.js') }}"></script>
|
|
|
|
{# 5. Utilities #}
|
|
<script defer src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
|
|
|
|
{# 5b. i18n Support #}
|
|
<script defer src="{{ url_for('static', path='shared/js/i18n.js') }}"></script>
|
|
<script>
|
|
// Initialize i18n with storefront language and preload modules
|
|
// Wrapped in DOMContentLoaded so deferred i18n.js has loaded
|
|
document.addEventListener('DOMContentLoaded', async function() {
|
|
const modules = {% block i18n_modules %}[]{% endblock %};
|
|
await I18n.init('{{ current_language | default("en") }}', modules);
|
|
});
|
|
</script>
|
|
|
|
{# 6. API Client #}
|
|
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
|
|
|
|
{# 7. Page-specific JavaScript (MUST load before Alpine.js) #}
|
|
{% block extra_scripts %}{% endblock %}
|
|
|
|
{# 8. LAST: Alpine.js (must be last defer script — auto-initializes on load) #}
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
|
|
onerror="var s=document.createElement('script');s.defer=true;s.src='{{ url_for('static', path='shared/js/lib/alpine.min.js') }}';document.head.appendChild(s);"></script>
|
|
|
|
{# Toast notification container #}
|
|
<div id="toast-container" class="fixed bottom-4 right-4 z-50"></div>
|
|
|
|
</body>
|
|
</html>
|