feat(admin.scss): Améliore le style global et ajoute le thème TomSelect.

 feat(admin.js): Regroupe l'enregistrement des custom elements et améliore l'UI.
 feat(app.js): Initialise Sentry et ajoute une gestion des erreurs Turbo.
 feat(reserve.js): Optimise le chargement des images et améliore l'UI.
This commit is contained in:
Serreau Jovann
2026-01-28 12:39:30 +01:00
parent 48d0c00f64
commit b80238eee0
5 changed files with 382 additions and 613 deletions

View File

@@ -2,113 +2,101 @@ import './reserve.scss';
import { UtmEvent, UtmAccount } from "./tools/UtmEvent.js";
import { CookieBanner } from "./tools/CookieBanner.js";
import * as Turbo from "@hotwired/turbo";
import {onLCP, onINP, onCLS} from 'web-vitals';
import { onLCP, onINP, onCLS } from 'web-vitals';
// --- DÉTECTION BOT / PERFORMANCE ---
const isLighthouse = () => {
if (typeof navigator === 'undefined' || !navigator.userAgent) return false;
const userAgent = navigator.userAgent.toLowerCase();
const patterns = ['chrome-lighthouse', 'google/lighthouse', 'lighthouse', 'pagespeed', 'headless', 'webdriver'];
return patterns.some(pattern => userAgent.includes(pattern));
// --- CONFIGURATION & ÉTAT ---
const CONFIG = {
sentryDsn: "https://803814be6540031b1c37bf92ba9c0f79@sentry.esy-web.dev/24",
vitalsUrl: '/reservation/web-vitals'
};
// --- GESTION DYNAMIQUE DE SENTRY ---
// --- UTILS ---
const isLighthouse = () => {
if (typeof navigator === 'undefined' || !navigator.userAgent) return false;
return ['lighthouse', 'pagespeed', 'headless', 'webdriver'].some(p =>
navigator.userAgent.toLowerCase().includes(p)
);
};
// --- PERFORMANCE (Web Vitals) ---
const sendToAnalytics = (metric) => {
if (isLighthouse()) return;
const body = JSON.stringify({ ...metric, path: window.location.pathname });
if (navigator.sendBeacon) {
navigator.sendBeacon(CONFIG.vitalsUrl, body);
} else {
fetch(CONFIG.vitalsUrl, { body, method: 'POST', keepalive: true });
}
};
const initVitals = () => {
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
};
// --- SERVICES (Sentry) ---
const toggleSentry = async (status) => {
if (isLighthouse()) return;
try {
const Sentry = await import("@sentry/browser");
if (status === 'accepted') {
if (!window.SentryInitialized) {
window.sentryClient = Sentry.init({
dsn: "https://803814be6540031b1c37bf92ba9c0f79@sentry.esy-web.dev/24",
dsn: CONFIG.sentryDsn,
tunnel: "/sentry-tunnel",
integrations: [Sentry.browserTracingIntegration()],
tracesSampleRate: 1.0,
});
window.SentryInitialized = true;
console.log("✔️ Sentry initialisé et activé");
} else {
// Réactivation si déjà chargé
if (window.sentryClient) window.sentryClient.getOptions().enabled = true;
console.log("✔️ Sentry ré-activé");
} else if (window.sentryClient) {
window.sentryClient.getOptions().enabled = true;
}
}
if (status === 'refused' && window.SentryInitialized) {
// Désactivation sans décharger le script
} else if (status === 'refused' && window.SentryInitialized) {
if (window.sentryClient) window.sentryClient.getOptions().enabled = false;
console.log("🛑 Sentry désactivé (Client muet)");
}
} catch (e) {
console.warn("Sentry toggle failed", e);
}
} catch (e) { console.warn("Sentry error", e); }
};
// --- UI : LOADERS & IMAGES ---
const initImageLoader = () => {
// On cible uniquement les images à l'intérieur de la balise <main>
const mainContainer = document.querySelector('main');
if (!mainContainer) return;
const images = mainContainer.querySelectorAll('img:not(.loaded)');
const images = document.querySelectorAll('main img:not(.loaded)');
images.forEach(img => {
// Sécurité : si l'image est déjà chargée (cache), on marque et on skip
if (img.complete) {
img.classList.add('loaded');
img.style.opacity = '1';
return;
}
// 1. Préparation du parent (doit être relatif pour le loader absolu)
const parent = img.parentElement;
if (!parent) return;
parent.classList.add('relative', 'overflow-hidden', 'bg-gray-50');
parent.classList.add('relative', 'overflow-hidden', 'bg-slate-50');
// 2. Création du Loader (Spinner Tailwind)
const loader = document.createElement('div');
loader.id = `loader-${Math.random().toString(36).substr(2, 9)}`;
loader.className = 'absolute inset-0 flex items-center justify-center z-10 bg-gray-50 transition-opacity duration-500';
loader.innerHTML = `
<div class="flex flex-col items-center gap-2">
<div class="w-7 h-7 border-3 border-slate-200 border-t-slate-800 rounded-full animate-spin"></div>
</div>
`;
loader.className = 'absolute inset-0 flex items-center justify-center z-10 bg-slate-50 transition-opacity duration-500';
loader.innerHTML = `<div class="w-7 h-7 border-3 border-slate-200 border-t-[#f39e36] rounded-full animate-spin"></div>`;
parent.insertBefore(loader, img);
// 3. État initial de l'image (invisible)
img.classList.add('opacity-0', 'transition-opacity', 'duration-700');
// 4. Gestionnaire de fin de chargement
img.onload = () => {
img.classList.replace('opacity-0', 'opacity-100');
img.classList.add('loaded');
loader.classList.add('opacity-0');
setTimeout(() => loader.remove(), 500);
};
// Gestion de l'erreur
img.onerror = () => {
loader.innerHTML = '<span class="text-[10px] text-gray-400 font-medium uppercase">Erreur</span>';
};
img.onerror = () => { loader.innerHTML = '⚠️'; };
});
};
// --- LOGIQUE DU LOADER TURBO ---
const initLoader = () => {
if (document.getElementById('turbo-loader')) return;
const initTurboLoader = () => {
if (document.getElementById('turbo-loader')) return;
const loaderEl = document.createElement('div');
loaderEl.id = 'turbo-loader';
loaderEl.className = 'fixed inset-0 z-[9999] flex items-center justify-center bg-white transition-opacity duration-300 opacity-0 pointer-events-none';
loaderEl.className = 'fixed inset-0 z-[9999] flex items-center justify-center bg-white/80 backdrop-blur-sm transition-opacity duration-300 opacity-0 pointer-events-none';
loaderEl.innerHTML = `
<div class="relative flex items-center justify-center">
<div class="absolute w-24 h-24 border-4 border-[#f39e36] border-t-transparent rounded-full animate-spin"></div>
<img src="/provider/images/favicon.webp" class="w-12 h-12 relative z-10 animate-pulse" alt="Logo">
</div>
`;
<div class="relative flex flex-col items-center gap-4">
<div class="w-16 h-16 border-4 border-[#f39e36] border-t-[#fc0e50] rounded-full animate-spin"></div>
<img src="/provider/images/favicon.webp" class="w-8 h-8 absolute top-4 animate-pulse" alt="LDK">
</div>`;
document.body.appendChild(loaderEl);
document.addEventListener("turbo:click", () => {
@@ -116,141 +104,145 @@ const initLoader = () => {
loaderEl.classList.remove('pointer-events-none');
});
const hideLoader = () => {
setTimeout(() => {
loaderEl.classList.replace('opacity-100', 'opacity-0');
loaderEl.classList.add('pointer-events-none');
}, 300);
const hide = () => {
loaderEl.classList.replace('opacity-100', 'opacity-0');
loaderEl.classList.add('pointer-events-none');
};
document.addEventListener("turbo:load", hideLoader);
document.addEventListener("turbo:render", hideLoader);
document.addEventListener("turbo:load", hide);
document.addEventListener("turbo:render", hide);
};
// --- LOGIQUE INTERFACE (Menu, Filtres, Redirect, Register) ---
// --- UI : BACK TO TOP ---
const initBackToTop = () => {
if (document.getElementById('back-to-top')) return;
const btn = document.createElement('button');
btn.id = 'back-to-top';
// Modification :
// 1. bottom-24 au lieu de bottom-8 pour laisser de la place aux cookies
// 2. bg-[#f39e36] pour respecter ta couleur orange
// 3. hover:bg-[#fc0e50] pour le rappel rose au survol
btn.className = `
fixed bottom-24 right-8 z-50 p-4
bg-[#f39e36] text-white rounded-2xl shadow-[0_10px_25px_-5px_rgba(243,158,54,0.4)]
border-2 border-white/20 opacity-0 translate-y-10
pointer-events-none transition-all duration-500
hover:bg-[#fc0e50] hover:-translate-y-2 group
`;
btn.innerHTML = `
<svg class="w-6 h-6 transform group-hover:animate-bounce" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
</svg>
`;
document.body.appendChild(btn);
const handleScroll = () => {
const isVisible = window.scrollY > 400;
if (isVisible) {
btn.classList.replace('opacity-0', 'opacity-100');
btn.classList.replace('translate-y-10', 'translate-y-0');
btn.classList.remove('pointer-events-none');
} else {
btn.classList.replace('opacity-100', 'opacity-0');
btn.classList.replace('translate-y-0', 'translate-y-10');
btn.classList.add('pointer-events-none');
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
btn.onclick = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
};
// --- LOGIQUE MÉTIER ---
const initMobileMenu = () => {
const btn = document.getElementById('menu-button');
const menu = document.getElementById('mobile-menu');
if (btn && menu) {
btn.onclick = null;
btn.addEventListener('click', () => {
const isExpanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', !isExpanded);
menu.classList.toggle('hidden');
});
}
if (!btn || !menu) return;
btn.onclick = () => {
const open = menu.classList.toggle('hidden');
btn.setAttribute('aria-expanded', !open);
};
};
const initCatalogueSearch = () => {
const filters = document.querySelectorAll('.filter-btn');
const products = document.querySelectorAll('.product-item');
const emptyMsg = document.getElementById('empty-msg');
if (filters.length === 0) return;
filters.forEach(btn => {
btn.onclick = () => {
const category = btn.getAttribute('data-filter').toLowerCase();
const category = btn.dataset.filter.toLowerCase();
let count = 0;
filters.forEach(f => {
f.classList.replace('bg-slate-900', 'bg-white');
f.classList.replace('text-white', 'text-slate-500');
});
btn.classList.replace('bg-white', 'bg-slate-900');
btn.classList.replace('text-slate-500', 'text-white');
filters.forEach(f => f.classList.remove('bg-slate-900', 'text-white'));
btn.classList.add('bg-slate-900', 'text-white');
products.forEach(item => {
const itemCat = (item.getAttribute('data-category') || '').toLowerCase();
const isVisible = category === 'all' || itemCat.includes(category);
item.style.display = isVisible ? 'block' : 'none';
const isVisible = category === 'all' || (item.dataset.category || '').toLowerCase().includes(category);
item.classList.toggle('hidden', !isVisible);
if (isVisible) count++;
});
if (emptyMsg) count === 0 ? emptyMsg.classList.remove('hidden') : emptyMsg.classList.add('hidden');
emptyMsg?.classList.toggle('hidden', count > 0);
};
});
};
const initAutoRedirect = () => {
const container = document.getElementById('payment-check-container');
if (container && container.dataset.autoRedirect) {
const url = container.dataset.autoRedirect;
setTimeout(() => {
if (document.getElementById('payment-check-container')) Turbo.visit(url);
}, 10000);
}
};
const initRegisterLogic = () => {
const siretContainer = document.getElementById('siret-container');
const typeRadios = document.querySelectorAll('input[name="type"]');
if (!siretContainer || typeRadios.length === 0) return;
const updateSiretVisibility = () => {
const selectedType = document.querySelector('input[name="type"]:checked')?.value;
if (selectedType === 'buisness') {
siretContainer.classList.remove('hidden');
siretContainer.querySelector('input')?.setAttribute('required', 'required');
} else {
siretContainer.classList.add('hidden');
siretContainer.querySelector('input')?.removeAttribute('required');
}
if (!siretContainer) return;
const toggle = () => {
const isBiz = document.querySelector('input[name="type"]:checked')?.value === 'buisness';
siretContainer.classList.toggle('hidden', !isBiz);
siretContainer.querySelector('input')?.toggleAttribute('required', isBiz);
};
typeRadios.forEach(radio => radio.addEventListener('change', updateSiretVisibility));
updateSiretVisibility();
typeRadios.forEach(r => r.addEventListener('change', toggle));
toggle();
};
const sendToAnalytics = ({ name, delta, id }) => {
// On ne veut pas polluer les stats avec les tests Lighthouse
if (isLighthouse()) return;
const body = JSON.stringify({
name, // 'LCP', 'INP', ou 'CLS'
value: delta, // La valeur de la mesure
id, // ID unique de la session de page (pour éviter les doublons)
path: window.location.pathname // Pour savoir quelle page est lente
});
const url = '/reservation/web-vitals';
// sendBeacon est idéal pour les stats car il ne bloque pas le thread principal
if (navigator.sendBeacon) {
navigator.sendBeacon(url, body);
} else {
fetch(url, { body, method: 'POST', keepalive: true });
}
// --- INITIALISATION ---
const registerComponents = () => {
const comps = [['utm-event', UtmEvent], ['utm-account', UtmAccount], ['cookie-banner', CookieBanner]];
comps.forEach(([name, cl]) => { if (!customElements.get(name)) customElements.define(name, cl); });
};
// --- INITIALISATION GLOBALE ---
document.addEventListener('DOMContentLoaded', () => {
initVitals();
initTurboLoader();
registerComponents();
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
initLoader();
initImageLoader();
// Enregistrement Custom Elements
if (!customElements.get('utm-event')) customElements.define('utm-event', UtmEvent);
if (!customElements.get('utm-account')) customElements.define('utm-account', UtmAccount);
if (!customElements.get('cookie-banner')) customElements.define('cookie-banner', CookieBanner);
const consent = sessionStorage.getItem('ldk_cookie');
if (consent) toggleSentry(consent);
// Initialisation Sentry basée sur le choix existant
const currentConsent = sessionStorage.getItem('ldk_cookie');
if (currentConsent) toggleSentry(currentConsent);
// Écouteurs pour changements de choix cookies
window.addEventListener('cookieAccepted', () => toggleSentry('accepted'));
window.addEventListener('cookieRefused', () => toggleSentry('refused'));
});
document.addEventListener('turbo:load', () => {
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
initMobileMenu();
initCatalogueSearch();
initAutoRedirect();
initRegisterLogic();
initVitals();
initImageLoader();
initBackToTop();
initMobileMenu();
initRegisterLogic();
initCatalogueSearch();
const payContainer = document.getElementById('payment-check-container');
if (payContainer?.dataset.autoRedirect) {
setTimeout(() => {
if (document.getElementById('payment-check-container')) {
Turbo.visit(payContainer.dataset.autoRedirect);
}
}, 10000);
}
});
document.addEventListener("turbo:before-cache", () => {
document.querySelectorAll('.product-item').forEach(i => i.style.display = 'block');
const emptyMsg = document.getElementById('empty-msg');
if (emptyMsg) emptyMsg.classList.add('hidden');
document.querySelectorAll('.product-item').forEach(i => i.classList.remove('hidden'));
document.getElementById('empty-msg')?.classList.add('hidden');
});