Files
ludikevent_crm/assets/tools/FlowReserve.js
Serreau Jovann 900b55c07b ```
 feat(ReserverController): Gère les options de produits au panier et en session.

Ajoute la gestion des options de produits lors de l'ajout au panier et dans la session de réservation. Inclut des corrections pour les options orphelines.
```
2026-02-04 11:58:07 +01:00

479 lines
20 KiB
JavaScript

export class FlowReserve extends HTMLAnchorElement {
constructor() {
super();
this.sidebarId = 'flow-reserve-sidebar';
this.storageKey = 'pl_list';
this.apiUrl = '/basket/json';
this.checkAvailabilityUrl = '/produit/check/basket';
this.isOpen = false;
this.productsAvailable = true;
}
connectedCallback() {
this.addEventListener('click', (e) => {
e.preventDefault();
this.toggleSidebar();
});
// Listen for updates to the cart from other components
window.addEventListener('cart:updated', () => {
this.updateBadge();
this._checkProductAvailability(); // Re-check on cart update
});
// Initial badge update
this.updateBadge();
this._checkProductAvailability();
}
/**
* Updates the notification badge on the icon
*/
updateBadge() {
const list = this.getList();
const badge = this.querySelector('[data-count]');
if (badge) {
badge.innerText = list.length;
if (list.length > 0) {
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
}
getList() {
try {
return JSON.parse(localStorage.getItem(this.storageKey) || '[]');
} catch (e) {
return [];
}
}
getOptions() {
try {
return JSON.parse(localStorage.getItem('pl_options') || '{}');
} catch (e) {
return {};
}
}
removeFromList(id) {
let list = this.getList();
list = list.filter(itemId => itemId.toString() !== id.toString());
localStorage.setItem(this.storageKey, JSON.stringify(list));
// Remove options for this product
const options = this.getOptions();
if (options[id]) {
delete options[id];
localStorage.setItem('pl_options', JSON.stringify(options));
}
window.dispatchEvent(new CustomEvent('cart:updated'));
this.refreshContent(); // Re-fetch and render
}
toggleSidebar() {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
open() {
this.ensureSidebarExists();
const sidebar = document.getElementById(this.sidebarId);
const backdrop = sidebar.querySelector('.backdrop');
const panel = sidebar.querySelector('.panel');
sidebar.classList.remove('pointer-events-none');
backdrop.classList.remove('opacity-0');
panel.classList.remove('translate-x-full');
this.isOpen = true;
this.refreshContent();
}
close() {
const sidebar = document.getElementById(this.sidebarId);
if (!sidebar) return;
const backdrop = sidebar.querySelector('.backdrop');
const panel = sidebar.querySelector('.panel');
backdrop.classList.add('opacity-0');
panel.classList.add('translate-x-full');
setTimeout(() => {
sidebar.classList.add('pointer-events-none');
}, 300); // Match transition duration
this.isOpen = false;
}
async _checkProductAvailability() {
const ids = this.getList();
if (ids.length === 0) {
this.productsAvailable = true;
return;
}
let dates = { start: null, end: null };
try {
dates = JSON.parse(localStorage.getItem('reservation_dates') || '{}');
} catch (e) {
console.warn('Invalid reservation dates in localStorage for availability check');
}
try {
const response = await fetch(this.checkAvailabilityUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ids,
start: dates.start,
end: dates.end
})
});
if (!response.ok) throw new Error('Erreur réseau lors de la vérification de disponibilité');
const data = await response.json();
this.productsAvailable = data.available;
if (data.unavailable_products_ids && data.unavailable_products_ids.length > 0) {
let currentList = this.getList();
const initialLength = currentList.length;
// Filter out unavailable items
currentList = currentList.filter(id => !data.unavailable_products_ids.includes(parseInt(id)) && !data.unavailable_products_ids.includes(String(id)));
if (currentList.length !== initialLength) {
localStorage.setItem(this.storageKey, JSON.stringify(currentList));
window.dispatchEvent(new CustomEvent('cart:updated'));
console.warn('Produits indisponibles retirés du panier:', data.unavailable_products_ids);
// Force refresh to update UI immediately
this.refreshContent();
}
}
} catch (error) {
console.error('Erreur lors de la vérification de disponibilité:', error);
this.productsAvailable = false;
}
}
ensureSidebarExists() {
if (document.getElementById(this.sidebarId)) return;
const template = `
<div id="${this.sidebarId}" class="fixed inset-0 z-[100] flex justify-end pointer-events-none">
<!-- Backdrop -->
<div class="backdrop absolute inset-0 bg-slate-900/60 backdrop-blur-sm opacity-0 transition-opacity duration-300"></div>
<!-- Panel -->
<div class="panel w-full max-w-md bg-white shadow-2xl translate-x-full transition-transform duration-300 flex flex-col relative z-10">
<!-- Header -->
<div class="p-6 border-b border-gray-100 flex items-center justify-between bg-white">
<div>
<h2 class="text-2xl font-black text-slate-900 uppercase italic tracking-tighter">Ma Super <br><span class="text-[#f39e36]">Future Réservation</span></h2>
</div>
<button id="flow-reserve-close" class="p-2 hover:bg-gray-100 rounded-full transition-colors">
<svg class="w-6 h-6 text-slate-400" 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"/></svg>
</button>
</div>
<!-- Content (Loader/List/Empty) -->
<div id="flow-reserve-content" class="flex-1 overflow-y-auto p-6 space-y-6 bg-gray-50/50 relative">
<!-- Content injected via JS -->
</div>
<!-- Footer -->
<div id="flow-reserve-footer" class="p-6 border-t border-gray-100 bg-white">
<!-- Content injected via JS -->
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', template);
// Bind events
const sidebar = document.getElementById(this.sidebarId);
const closeHandler = (e) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
this.close();
};
sidebar.querySelector('.backdrop').addEventListener('click', closeHandler);
sidebar.querySelector('#flow-reserve-close').addEventListener('click', closeHandler);
}
async refreshContent() {
const container = document.getElementById('flow-reserve-content');
const footer = document.getElementById('flow-reserve-footer');
// Loader
container.innerHTML = `
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-10 h-10 border-4 border-[#f39e36] border-t-transparent rounded-full animate-spin"></div>
</div>
`;
footer.innerHTML = '';
const ids = this.getList();
const options = this.getOptions();
// Retrieve dates from localStorage
let dates = { start: null, end: null };
try {
dates = JSON.parse(localStorage.getItem('reservation_dates') || '{}');
} catch (e) {
console.warn('Invalid reservation dates in localStorage');
}
if (ids.length === 0) {
this.renderEmpty(container, footer);
this.productsAvailable = true;
return;
}
// Display warning if products are not available
if (!this.productsAvailable) {
container.innerHTML = `
<div class="text-center py-10 bg-red-100 border border-red-200 text-red-700 rounded-xl">
<p class="font-bold mb-2">Attention :</p>
<p>Certains produits de votre panier ne sont plus disponibles. Veuillez vérifier votre sélection.</p>
</div>
`;
footer.innerHTML = `
<button disabled class="block w-full py-4 bg-gray-300 text-gray-500 text-center rounded-2xl font-black uppercase italic tracking-widest cursor-not-allowed">
Valider ma demande (Produits indisponibles)
</button>
`;
return;
}
try {
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ids,
options,
start: dates.start,
end: dates.end
})
});
if (!response.ok) throw new Error('Erreur réseau');
const data = await response.json();
// Handle removed products (deleted from DB)
if (data.unavailable_products_ids && data.unavailable_products_ids.length > 0) {
let currentList = this.getList();
const initialLength = currentList.length;
currentList = currentList.filter(id => !data.unavailable_products_ids.includes(parseInt(id)) && !data.unavailable_products_ids.includes(String(id))); // Handle string/int types
if (currentList.length !== initialLength) {
localStorage.setItem(this.storageKey, JSON.stringify(currentList));
window.dispatchEvent(new CustomEvent('cart:updated'));
// We don't recurse here to avoid infinite loops, but the UI will update next time or we could just use the returned 'products' which already excludes them.
console.warn('Certains produits ont été retirés car ils n\'existent plus:', data.unavailable_products_ids);
}
}
// Merge client-side dates if server didn't return them (or to prioritize client choice)
if (!data.start_date && dates.start) data.start_date = this.formatDate(dates.start);
if (!data.end_date && dates.end) data.end_date = this.formatDate(dates.end);
// Fallback: if server returns dates in a format we can use directly, fine.
// If we just want to display what is in local storage:
if (dates.start) data.start_date = this.formatDate(dates.start);
if (dates.end) data.end_date = this.formatDate(dates.end);
this.renderList(container, footer, data);
} catch (error) {
console.error(error);
container.innerHTML = `
<div class="text-center py-10">
<p class="text-red-500 font-bold">Une erreur est survenue lors du chargement de votre panier.</p>
</div>
`;
}
}
renderEmpty(container, footer) {
container.innerHTML = `
<div class="h-full flex flex-col items-center justify-center text-center opacity-60">
<svg class="w-16 h-16 text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"/></svg>
<p class="text-lg font-bold text-slate-900 italic">Votre panier est vide</p>
<p class="text-sm text-slate-500">Ajoutez du bonheur à votre événement !</p>
<a href="/reservation/catalogue" id="flow-empty-catalog-link" class="mt-6 px-6 py-3 bg-[#f39e36] text-white rounded-xl font-bold text-sm uppercase tracking-widest hover:bg-slate-900 transition-colors">
Voir le catalogue
</a>
</div>
`;
footer.innerHTML = '';
container.querySelector('#flow-empty-catalog-link').addEventListener('click', () => this.close());
}
renderList(container, footer, data) {
// --- HEADER DATES ---
let datesHtml = '';
if (data.start_date && data.end_date) {
datesHtml = `
<div class="bg-blue-50 border border-blue-100 rounded-2xl p-4 mb-4 text-center">
<p class="text-[10px] uppercase tracking-widest text-slate-500 font-bold mb-1">Votre période</p>
<div class="flex items-center justify-center gap-2 text-sm font-black text-slate-900 uppercase italic">
<span>${data.start_date}</span>
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/></svg>
<span>${data.end_date}</span>
</div>
</div>
`;
}
const productsHtml = data.products.map(product => {
let optionsHtml = '';
if (product.options && product.options.length > 0) {
optionsHtml = '<div class="mt-1 space-y-1 bg-slate-50 p-2 rounded-lg">';
product.options.forEach(opt => {
optionsHtml += `
<div class="flex justify-between text-[9px] text-slate-500 font-medium">
<span>+ ${opt.name}</span>
<span>${this.formatPrice(opt.price)}</span>
</div>
`;
});
optionsHtml += '</div>';
}
return `
<div class="flex gap-4 bg-white p-3 rounded-2xl shadow-sm border border-gray-100">
<div class="w-20 h-20 bg-gray-100 rounded-xl flex-shrink-0 overflow-hidden">
<img src="${product.image || '/provider/images/favicon.png'}" class="w-full h-full object-cover" alt="${product.name}">
</div>
<div class="flex-1 flex flex-col justify-between">
<div>
<h4 class="font-black text-slate-900 leading-tight uppercase italic text-sm line-clamp-2">${product.name}</h4>
<div class="mt-1 flex flex-wrap gap-2 text-[10px] text-slate-500 font-bold uppercase">
<span>1J: ${this.formatPrice(product.priceHt1Day)} HT</span>
${product.priceHTSupDay ? `<span class="text-slate-300">|</span><span>Sup: ${this.formatPrice(product.priceHTSupDay)} HT</span>` : ''}
</div>
${optionsHtml}
</div>
<div class="flex justify-between items-end mt-2">
<span class="text-[#0782bc] font-black text-sm">${this.formatPrice(product.totalPriceTTC || product.totalPriceHT)} <span class="text-[9px] text-slate-400 font-bold">Total</span></span>
<button class="text-red-400 hover:text-red-600 p-1 remove-btn" data-remove-id="${product.id}">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</div>
</div>
`;
}).join('');
container.innerHTML = `<div class="space-y-3">${datesHtml}${productsHtml}</div>`;
// Attach event listeners
container.querySelectorAll('.remove-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.removeFromList(btn.dataset.removeId);
});
});
// --- RENDER FOOTER (TOTALS) ---
const total = data.total || {};
const hasTva = total.totalTva > 0;
footer.innerHTML = `
<div class="space-y-2 mb-6">
<div class="flex justify-between text-xs text-slate-500 font-bold uppercase">
<span>Total HT</span>
<span>${this.formatPrice(total.totalHT)}</span>
</div>
${hasTva ? `
<div class="flex justify-between text-xs text-slate-500 font-bold uppercase">
<span>TVA</span>
<span>${this.formatPrice(total.totalTva)}</span>
</div>
` : ''}
<div class="flex justify-between text-lg text-slate-900 font-black uppercase italic border-t border-dashed border-gray-200 pt-2">
<span>Total ${hasTva ? 'TTC' : 'HT'}</span>
<span class="text-[#f39e36]">${this.formatPrice(total.totalTTC || total.totalHT)}</span>
</div>
</div>
<a data-turbo="false" href="/reservation/devis" id="flow-validate-btn" class="block w-full py-4 bg-slate-900 text-white text-center rounded-2xl font-black uppercase italic tracking-widest hover:bg-[#fc0e50] transition-colors shadow-lg">
Valider ma demande
</a>
`;
const validateBtn = footer.querySelector('#flow-validate-btn');
if (validateBtn) {
validateBtn.addEventListener('click', (e) => {
this.validateBasket(e);
});
}
}
async validateBasket(e) {
e.preventDefault();
const ids = this.getList();
const options = this.getOptions();
let dates = { start: null, end: null };
try {
dates = JSON.parse(localStorage.getItem('reservation_dates') || '{}');
} catch (error) {
console.warn('Invalid reservation dates in localStorage');
}
try {
const response = await fetch('/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ids,
options,
start: dates.start,
end: dates.end
})
});
if (!response.ok) throw new Error('Erreur réseau');
const data = await response.json();
if (data.flowUrl) {
window.location.href = data.flowUrl;
}
} catch (error) {
console.error('Erreur lors de la validation du panier', error);
}
}
formatDate(dateString) {
if (!dateString) return '';
try {
const date = new Date(dateString);
return new Intl.DateTimeFormat('fr-FR').format(date);
} catch (e) {
return dateString;
}
}
formatPrice(amount) {
if (amount === undefined || amount === null) return '-';
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount);
}
}