/* /assets/salon-cart.js?v=18 ✅ FIX: Service currency now reads from data-service-currency (and falls back safely) ✅ FIX: Script works even if loaded in (delegated change handlers + DOMContentLoaded init) ✅ FIX: Product add supports extra attribute fallbacks (data-price, data-currency, data-image, etc.) ✅ Appointment modal time dropdown shows DEFAULT slots and disables unavailable ones ✅ Robust slot loading states + errors (stale-request safe, timeout) ✅ Cart + checkout token flow preserved ✅ NEW (MONEY FORMAT FIX): - Uses correct decimals per currency (TZS/UGX/RWF=0, USD/EUR/GBP=2, etc.) - Prevents “TZS divided by 100” bug */ (() => { 'use strict'; /* ------------------ Config ------------------ */ const APP_NAME = window.SALON_APP_NAME || 'Salon'; const TODAY_STR = window.SALON_TODAY || new Date().toISOString().slice(0, 10); const SLOTS_ENDPOINT = window.SALON_SLOTS_ENDPOINT || '/booking.php?ajax=day_slots'; const CHECKOUT_ENDPOINT = window.SALON_CHECKOUT_ENDPOINT || '/api/checkout_cart.php'; const CHECKOUT_URL = window.SALON_CHECKOUT_URL || '/checkout.php'; const CSRF = window.SALON_CSRF || ''; const TIERS_BY_SERVICE = window.SALON_SERVICE_TIERS || {}; const DEFAULT_CURRENCY = (window.SALON_DEFAULT_CURRENCY || 'USD').toUpperCase(); // Optional override from PHP: // window.SALON_DEFAULT_SLOTS = ["08:30","10:00",...] const DEFAULT_SLOTS = Array.isArray(window.SALON_DEFAULT_SLOTS) && window.SALON_DEFAULT_SLOTS.length ? window.SALON_DEFAULT_SLOTS.map(String) : [ '08:00','08:30','09:00','09:30', '10:00','10:30','11:00','11:30', '12:00','12:30','13:00','13:30', '14:00','14:30','15:00','15:30', '16:00','16:30','17:00','17:30', '18:00','18:30' ]; const CART_KEY = 'salon_cart_v1'; /* ------------------ Currency decimals (NEW) ------------------ */ // You can override/extend from PHP: // window.SALON_CURRENCY_DECIMALS_MAP = { "TZS": 0, "USD": 2, ... } const CURRENCY_DECIMALS_MAP = Object.assign( { TZS:0, UGX:0, RWF:0, JPY:0, KRW:0 }, (window.SALON_CURRENCY_DECIMALS_MAP || {}) ); function currencyDecimals(cur) { cur = String(cur || DEFAULT_CURRENCY).trim().toUpperCase(); const v = CURRENCY_DECIMALS_MAP[cur]; if (Number.isFinite(v)) return Math.max(0, Math.min(6, v)); return 2; } function currencyFactor(cur) { return Math.pow(10, currencyDecimals(cur)); } /* ------------------ DOM (lazy refreshed) ------------------ */ function dom() { return { cartFab: document.getElementById('shopFloatingCart'), cartCountEl: document.getElementById('shopCartCount'), cartBackdrop: document.getElementById('shopCartBackdrop'), cartItemsEl: document.getElementById('shopCartItems'), cartTotalEl: document.getElementById('shopCartTotal'), toastEl: document.getElementById('shopToast'), // Appointment modal apptBackdrop: document.getElementById('apptBackdrop'), apptTitle: document.getElementById('apptModalTitle'), apptSub: document.getElementById('apptModalSub'), apptBranch: document.getElementById('apptBranch'), apptDate: document.getElementById('apptDate'), apptTime: document.getElementById('apptTime'), apptTierWrap: document.getElementById('apptTierWrap'), apptTier: document.getElementById('apptTier'), apptTierPreview: document.getElementById('apptTierPreview'), apptTierImg: document.getElementById('apptTierImg'), apptTierName: document.getElementById('apptTierName'), apptTierDesc: document.getElementById('apptTierDesc'), apptPriceEl: document.getElementById('apptPrice'), apptDurEl: document.getElementById('apptDur'), apptHint: document.getElementById('apptHint'), apptPriceTypeWrap: document.getElementById('apptPriceTypeWrap'), apptPriceType: document.getElementById('apptPriceType'), }; } /* ------------------ Utils ------------------ */ function $(sel, root=document) { return root.querySelector(sel); } function $$(sel, root=document) { return Array.from(root.querySelectorAll(sel)); } function showToast(msg) { const { toastEl } = dom(); if (!toastEl) return; toastEl.textContent = msg; toastEl.style.display = 'block'; clearTimeout(showToast._t); showToast._t = setTimeout(() => { toastEl.style.display = 'none'; }, 2400); } // ✅ UPDATED: uses correct minor units by currency function eMoney(minorUnits, currency) { currency = (currency || DEFAULT_CURRENCY).toUpperCase(); const d = currencyDecimals(currency); const factor = currencyFactor(currency); const amount = factor ? (Number(minorUnits || 0) / factor) : Number(minorUnits || 0); const hasSymbol = (currency === 'USD' || currency === 'EUR' || currency === 'GBP'); const sym = currency === 'USD' ? '$' : currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : currency; const txt = amount.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d }); return hasSymbol ? `${sym}${txt}` : `${sym} ${txt}`; } function formatTimeLabel(timeStr) { const parts = String(timeStr).split(':'); if (parts.length < 2) return String(timeStr); let h = parseInt(parts[0], 10); const m = parts[1]; if (isNaN(h)) return String(timeStr); const suffix = h >= 12 ? 'PM' : 'AM'; if (h === 0) h = 12; else if (h > 12) h -= 12; return `${h}:${m} ${suffix}`; } function isSameDay(a, b) { return String(a) === String(b); } function nowMinutes() { const d = new Date(); return d.getHours() * 60 + d.getMinutes(); } function timeToMinutes(t) { const p = String(t).split(':'); const hh = parseInt(p[0] || '0', 10) || 0; const mm = parseInt(p[1] || '0', 10) || 0; return hh * 60 + mm; } function safeJsonParse(text) { try { return JSON.parse(text); } catch { return null; } } async function fetchJson(url, opts={}) { const controller = new AbortController(); const t = setTimeout(() => controller.abort(), 15000); try { const res = await fetch(url, { ...opts, signal: controller.signal }); const text = await res.text(); const json = safeJsonParse(text); return { ok: res.ok, status: res.status, json, text }; } catch (err) { return { ok: false, status: 0, json: null, text: '', error: err }; } finally { clearTimeout(t); } } function buildUrlWithParams(base, params) { const hasQ = base.includes('?'); const usp = new URLSearchParams(); Object.entries(params || {}).forEach(([k, v]) => { if (v === undefined || v === null || v === '') return; usp.set(k, String(v)); }); const qs = usp.toString(); if (!qs) return base; return base + (hasQ ? '&' : '?') + qs; } function normalizeRateType(val) { const v = String(val || '').trim().toLowerCase(); if (v === 'nonresident' || v === 'non-resident' || v === 'non_resident') return 'non_resident'; if (v === 'resident') return 'resident'; return 'standard'; } function toUpperCur(v) { const s = String(v || '').trim(); return (s ? s : DEFAULT_CURRENCY).toUpperCase(); } function parseIntSafe(v, fallback=0) { const n = parseInt(String(v || '').replace(/[^\d\-]/g, ''), 10); return Number.isFinite(n) ? n : fallback; } /* ------------------ Cart state ------------------ */ function loadCart() { try { const raw = localStorage.getItem(CART_KEY); const data = raw ? JSON.parse(raw) : null; if (!data || !Array.isArray(data.items)) return { items: [] }; return data; } catch { return { items: [] }; } } function saveCart(cart) { localStorage.setItem(CART_KEY, JSON.stringify(cart)); renderCartUI(); } function cartCount(cart) { return (cart.items || []).reduce((n, it) => n + (it.type === 'product' ? (it.qty || 1) : 1), 0); } function removeCartItem(index) { const cart = loadCart(); cart.items.splice(index, 1); saveCart(cart); } function clearCart() { saveCart({ items: [] }); } /* ------------------ Cart UI ------------------ */ function openCart() { const { cartBackdrop } = dom(); if (!cartBackdrop) return; cartBackdrop.style.display = 'flex'; cartBackdrop.setAttribute('aria-hidden', 'false'); } function closeCart() { const { cartBackdrop } = dom(); if (!cartBackdrop) return; cartBackdrop.style.display = 'none'; cartBackdrop.setAttribute('aria-hidden', 'true'); } function renderCartUI() { const { cartFab, cartCountEl, cartItemsEl, cartTotalEl } = dom(); const cart = loadCart(); const count = cartCount(cart); if (cartFab) cartFab.style.display = count > 0 ? 'flex' : 'none'; if (cartCountEl) cartCountEl.textContent = String(count); if (!cartItemsEl || !cartTotalEl) return; cartItemsEl.innerHTML = ''; if (!cart.items.length) { cartItemsEl.innerHTML = `
Your cart is empty.
`; cartTotalEl.textContent = eMoney(0, DEFAULT_CURRENCY); return; } const totals = new Map(); cart.items.forEach((it, idx) => { const currency = toUpperCur(it.currency || DEFAULT_CURRENCY); const qty = it.type === 'product' ? (Number(it.qty || 1) || 1) : 1; const unit = Number(it.price_cents || 0) || 0; const lineCents = unit * qty; totals.set(currency, (totals.get(currency) || 0) + lineCents); const img = it.image_url ? `
` : `
${(it.name||'S').slice(0,1)}
`; const subLines = []; if (it.type === 'appointment') { if (it.branch_name) subLines.push(`Branch: ${it.branch_name}`); if (it.date && it.time) subLines.push(`When: ${it.date} • ${it.time}`); const rt = normalizeRateType(it.rate_type || it.price_type || it.customer_type || ''); if (rt === 'resident') subLines.push(`Type: Resident`); else if (rt === 'non_resident') subLines.push(`Type: Non-resident`); if (it.tier_label) subLines.push(`Option: ${it.tier_label}`); if (it.duration_minutes) subLines.push(`Duration: ${it.duration_minutes} min`); } else { subLines.push(`Qty: ${qty}`); } cartItemsEl.insertAdjacentHTML('beforeend', `
${img}
${it.name || 'Item'}
${subLines.join('
')}
${it.type === 'product' && qty > 1 ? `${eMoney(unit, currency)} × ${qty} = ${eMoney(lineCents, currency)}` : `${eMoney(unit, currency)}` }
`); }); const currencies = Array.from(totals.keys()); if (currencies.length === 1) { const cur = currencies[0]; cartTotalEl.textContent = eMoney(totals.get(cur), cur); } else { cartTotalEl.textContent = 'Multiple'; cartItemsEl.insertAdjacentHTML('beforeend', `
${currencies.map(c => `
${c}: ${eMoney(totals.get(c), c)}
`).join('')}
`); } } /* ------------------ Slots / Time dropdown ------------------ */ let slotReqId = 0; function setApptHint(msg) { const { apptHint } = dom(); if (apptHint) apptHint.textContent = msg || ''; } function setTimeSelectLoading() { const { apptTime } = dom(); if (!apptTime) return; apptTime.disabled = true; apptTime.innerHTML = ``; } function renderTimeSelect(slots, messageIfEmpty) { const { apptTime, apptDate } = dom(); if (!apptTime) return; const available = Array.isArray(slots) ? slots.map(String) : []; const set = new Set(available); const baseSlots = DEFAULT_SLOTS; const isToday = isSameDay(apptDate?.value || '', TODAY_STR); const nm = nowMinutes(); const chosen = apptTime.value || ''; const hasApi = available.length > 0; const options = []; options.push(``); baseSlots.forEach(t => { const mins = timeToMinutes(t); const past = isToday && mins <= nm; const apiOk = set.has(String(t)); const disabled = past || !apiOk; let suffix = ''; if (disabled) { if (past) suffix = ' — Unavailable'; else suffix = hasApi ? ' — Booked' : ' — Unavailable'; } options.push( `` ); }); apptTime.innerHTML = options.join(''); if (chosen && set.has(chosen) && !(isToday && timeToMinutes(chosen) <= nm)) { apptTime.value = chosen; } else { apptTime.value = ''; } if (!available.length) { apptTime.disabled = true; setApptHint(messageIfEmpty || 'No times available for this day. Please choose another date.'); } else { apptTime.disabled = false; setApptHint('Tip: change date/branch to refresh times.'); } } function normalizeSlots(any) { const out = []; if (!Array.isArray(any)) return out; any.forEach(x => { let t = ''; if (typeof x === 'string') t = x; else if (x && typeof x === 'object') t = x.time || x.start || x.slot || x.value || ''; t = String(t || '').trim(); if (!t) return; const m = t.match(/^(\d{1,2}):(\d{2})/); if (!m) return; const hh = String(m[1]).padStart(2, '0'); const mm = String(m[2]).padStart(2, '0'); out.push(`${hh}:${mm}`); }); return Array.from(new Set(out)); } async function loadDaySlots({ service_id, date, branch_id, duration_minutes, tier_id, price_type }) { slotReqId++; const myId = slotReqId; setTimeSelectLoading(); setApptHint('Loading availability…'); const url = buildUrlWithParams(SLOTS_ENDPOINT, { service_id, date, branch_id, duration_minutes, tier_id, price_type }); const r = await fetchJson(url, { method: 'GET', headers: { 'Accept': 'application/json' } }); if (myId !== slotReqId) return; if (!r.ok || !r.json) { renderTimeSelect([], 'Could not load times. Please try another date/branch.'); return; } const j = r.json; const rawSlots = (Array.isArray(j) ? j : Array.isArray(j.slots) ? j.slots : Array.isArray(j.times) ? j.times : (j.data && Array.isArray(j.data.slots)) ? j.data.slots : (j.data && Array.isArray(j.data.times)) ? j.data.times : []); const slots = normalizeSlots(rawSlots); if (!slots.length) { const msg = (j.message || j.error || '').toString().trim(); renderTimeSelect([], msg || 'No times available for this day. Please choose another date.'); return; } renderTimeSelect(slots); } /* ------------------ Appointment Modal ------------------ */ let currentAppt = null; function getTiers(serviceId) { const arr = TIERS_BY_SERVICE && serviceId ? TIERS_BY_SERVICE[String(serviceId)] : null; return Array.isArray(arr) ? arr : []; } function tierById(serviceId, tierId) { const tiers = getTiers(serviceId); return tiers.find(t => String(t.id) === String(tierId)) || null; } function pickPriceForAppt(appt) { const hasDual = appt.has_dual === true; const priceTypeUi = String(appt.price_type || 'resident').toLowerCase(); const rt = normalizeRateType(priceTypeUi); if (appt.tier_id) { const t = tierById(appt.service_id, appt.tier_id); if (t) { const curRes = toUpperCur(t.resident_currency || appt.currency_resident || appt.currency || DEFAULT_CURRENCY); const curNon = toUpperCur(t.non_resident_currency || appt.currency_nonresident || appt.currency || DEFAULT_CURRENCY); const common = Number(t.price_cents || 0) || 0; const res = Number(t.resident_price_cents || 0) || common; const non = Number(t.non_resident_price_cents || 0) || common; if (hasDual) { return (rt === 'non_resident') ? { cents: non, currency: curNon } : { cents: res, currency: curRes }; } return { cents: common || res || non, currency: toUpperCur(appt.currency || DEFAULT_CURRENCY) }; } } if (hasDual) { const resCents = Number(appt.price_resident || 0) || 0; const nonCents = Number(appt.price_nonresident || 0) || 0; const curRes = toUpperCur(appt.currency_resident || appt.currency || DEFAULT_CURRENCY); const curNon = toUpperCur(appt.currency_nonresident || appt.currency || DEFAULT_CURRENCY); return (rt === 'non_resident') ? { cents: nonCents, currency: curNon } : { cents: resCents, currency: curRes }; } return { cents: Number(appt.price_cents || 0) || 0, currency: toUpperCur(appt.currency || DEFAULT_CURRENCY) }; } function updateApptSummary() { const { apptTierWrap, apptTier, apptTierPreview, apptTierImg, apptTierName, apptTierDesc, apptPriceTypeWrap, apptPriceType, apptDurEl, apptPriceEl } = dom(); if (!currentAppt) return; const tierId = apptTierWrap && apptTierWrap.style.display !== 'none' ? (apptTier.value || '') : ''; currentAppt.tier_id = tierId || ''; if (tierId) { const t = tierById(currentAppt.service_id, tierId); currentAppt.tier_label = t ? (t.label || t.name || t.title || 'Option') : ''; if (apptTierPreview && t) { const img = (t.image_url || t.image || t.cover || ''); const desc = (t.description || t.desc || t.details || ''); if (img) { apptTierImg.src = img; apptTierImg.style.display = ''; } else { apptTierImg.removeAttribute('src'); apptTierImg.style.display = 'none'; } apptTierName.textContent = currentAppt.tier_label || ''; apptTierDesc.textContent = desc || ''; apptTierPreview.style.display = (img || desc) ? 'flex' : 'none'; } } else { currentAppt.tier_label = ''; if (apptTierPreview) apptTierPreview.style.display = 'none'; } if (apptPriceTypeWrap && apptPriceTypeWrap.style.display !== 'none') { currentAppt.price_type = apptPriceType.value || 'resident'; } else { currentAppt.price_type = ''; } if (apptDurEl) { apptDurEl.textContent = currentAppt.duration_minutes ? `${currentAppt.duration_minutes} min` : '—'; } const p = pickPriceForAppt(currentAppt); currentAppt._picked_price_cents = p.cents; currentAppt._picked_currency = p.currency; if (apptPriceEl) { apptPriceEl.textContent = p.cents > 0 ? eMoney(p.cents, p.currency) : 'Price on request'; } } function fillTierSelect(serviceId) { const { apptTierWrap, apptTier, apptTierPreview } = dom(); const tiers = getTiers(serviceId); if (!apptTierWrap || !apptTier) return; if (!tiers.length) { apptTierWrap.style.display = 'none'; apptTier.innerHTML = ''; if (apptTierPreview) apptTierPreview.style.display = 'none'; return; } apptTierWrap.style.display = ''; apptTier.innerHTML = `` + tiers.map(t => { const id = String(t.id || ''); const label = (t.label || t.name || t.title || 'Option'); return ``; }).join(''); } function openApptModal(appt) { const { apptBackdrop, apptTitle, apptSub, apptDate, apptBranch, apptPriceTypeWrap, apptPriceType, apptTime } = dom(); currentAppt = appt; if (!apptBackdrop) return; apptBackdrop.style.display = 'flex'; apptBackdrop.setAttribute('aria-hidden', 'false'); if (apptTitle) apptTitle.textContent = appt.name || 'Service'; if (apptSub) apptSub.textContent = 'Pick your date & time'; if (apptDate) { apptDate.min = TODAY_STR; apptDate.value = TODAY_STR; } if (apptBranch && appt.branch_id) { apptBranch.value = appt.branch_id; } if (apptPriceTypeWrap && apptPriceType) { if (appt.has_dual) { apptPriceTypeWrap.style.display = ''; apptPriceType.value = 'resident'; } else { apptPriceTypeWrap.style.display = 'none'; } } fillTierSelect(appt.service_id); if (apptTime) { apptTime.disabled = true; apptTime.innerHTML = ``; } updateApptSummary(); refreshApptSlots(); } function closeApptModal() { const { apptBackdrop } = dom(); if (!apptBackdrop) return; apptBackdrop.style.display = 'none'; apptBackdrop.setAttribute('aria-hidden', 'true'); currentAppt = null; } function refreshApptSlots() { const { apptDate, apptBranch, apptTierWrap, apptTier, apptPriceTypeWrap, apptPriceType } = dom(); if (!currentAppt) return; const service_id = currentAppt.service_id; const date = apptDate ? apptDate.value : ''; const branch_id = apptBranch ? apptBranch.value : ''; const duration_minutes = currentAppt.duration_minutes || ''; const tier_id = apptTierWrap && apptTierWrap.style.display !== 'none' ? (apptTier.value || '') : ''; const price_type = apptPriceTypeWrap && apptPriceTypeWrap.style.display !== 'none' ? (apptPriceType.value || 'resident') : ''; if (!service_id || !date) { renderTimeSelect([], 'Select a date to see availability.'); return; } loadDaySlots({ service_id, date, branch_id, duration_minutes, tier_id, price_type }); } function computeEndAt(date, time, durationMinutes) { if (!date || !time || !durationMinutes) return ''; const dt = new Date(`${date}T${time}:00`); if (isNaN(dt.getTime())) return ''; dt.setMinutes(dt.getMinutes() + Number(durationMinutes)); const yyyyMmDd = dt.toISOString().slice(0, 10); const hhmm = dt.toTimeString().slice(0, 5); return `${yyyyMmDd} ${hhmm}:00`; } function addCurrentApptToCart() { const { apptDate, apptTime, apptBranch } = dom(); if (!currentAppt) return; const date = apptDate ? apptDate.value : ''; const time = apptTime ? apptTime.value : ''; const branch_id = apptBranch ? (apptBranch.value || '') : ''; const branch_name = apptBranch ? (apptBranch.options[apptBranch.selectedIndex]?.textContent || '') : ''; if (!date) { showToast('Please choose a date.'); return; } if (!time) { showToast('Please choose a time.'); return; } updateApptSummary(); const p = pickPriceForAppt(currentAppt); const rate_type = normalizeRateType(currentAppt.price_type || ''); const start_at = `${date} ${time}:00`; const end_at = computeEndAt(date, time, currentAppt.duration_minutes || 0); const item = { type: 'appointment', service_id: String(currentAppt.service_id), name: String(currentAppt.name || 'Service'), image_url: currentAppt.image_url || '', date, time, start_at, end_at, branch_id, branch_name: branch_id ? branch_name : '', tier_id: currentAppt.tier_id || '', tier_label: currentAppt.tier_label || '', price_type: (currentAppt.price_type || ''), rate_type, // standard | resident | non_resident customer_type: rate_type, // legacy duration_minutes: Number(currentAppt.duration_minutes || 0) || 0, price_cents: Number(p.cents || 0) || 0, currency: toUpperCur(p.currency || DEFAULT_CURRENCY) }; const cart = loadCart(); cart.items.push(item); saveCart(cart); closeApptModal(); showToast('Added to cart.'); openCart(); } /* ------------------ Product add ------------------ */ function getProductHost(btn) { return btn.closest('[data-product-id]') || btn.closest('.product-card') || btn; } function addProductFromHost(host) { // Primary attrs let id = host.getAttribute('data-product-id') || ''; let name = host.getAttribute('data-product-name') || 'Product'; let price = parseIntSafe(host.getAttribute('data-product-price'), 0); let currency = toUpperCur(host.getAttribute('data-product-currency') || DEFAULT_CURRENCY); let image = host.getAttribute('data-product-image') || ''; // Fallback attrs (some templates) if (!id) id = host.getAttribute('data-id') || host.getAttribute('data-product') || ''; if (!price) price = parseIntSafe(host.getAttribute('data-price'), 0); if (!currency || currency === DEFAULT_CURRENCY) currency = toUpperCur(host.getAttribute('data-currency') || currency); if (!image) image = host.getAttribute('data-image') || host.getAttribute('data-img') || host.getAttribute('data-image-url') || ''; if (!id || price <= 0) { showToast('This item cannot be added.'); return; } const cart = loadCart(); const existing = cart.items.find(it => it.type === 'product' && it.product_id === id && toUpperCur(it.currency || DEFAULT_CURRENCY) === currency ); if (existing) { existing.qty = (existing.qty || 1) + 1; } else { cart.items.push({ type: 'product', product_id: id, name, qty: 1, price_cents: price, currency, image_url: image }); } saveCart(cart); showToast('Added to cart.'); openCart(); } /* ------------------ Checkout ------------------ */ async function checkoutCart() { const cart = loadCart(); if (!cart.items.length) { showToast('Cart is empty.'); return; } const payload = { csrf: CSRF, items: cart.items, meta: { app: APP_NAME, source_url: location.href, created_at: new Date().toISOString(), v: 18 } }; const r = await fetchJson(CHECKOUT_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', ...(CSRF ? { 'X-CSRF-Token': CSRF } : {}) }, body: JSON.stringify(payload) }); if (!r.ok || !r.json) { showToast('Checkout failed. Try again.'); return; } const j = r.json; const token = (j.token || j.cart_token || j.session_token || j.checkout_token || '').toString(); const directUrl = (j.checkout_url || j.url || '').toString(); if (directUrl) { window.location.href = directUrl; return; } if (token) { window.location.href = `${CHECKOUT_URL}?token=${encodeURIComponent(token)}`; return; } if (j.checkout_id) { window.location.href = `/checkout.php?checkout_id=${encodeURIComponent(String(j.checkout_id))}`; return; } showToast('Checkout error. Please try again.'); } /* ------------------ Event wiring (delegated) ------------------ */ function bindEvents() { document.addEventListener('click', (e) => { const t = e.target; const fab = t.closest('#shopFloatingCart,[data-open-cart]'); if (fab) { e.preventDefault(); openCart(); return; } const closeBtn = t.closest('[data-shop-close]'); if (closeBtn) { e.preventDefault(); closeCart(); return; } const { cartBackdrop } = dom(); if (cartBackdrop && t === cartBackdrop) { closeCart(); return; } const checkoutBtn = t.closest('[data-shop-checkout]'); if (checkoutBtn) { e.preventDefault(); checkoutCart(); return; } const clearBtn = t.closest('[data-shop-clear]'); if (clearBtn) { e.preventDefault(); clearCart(); showToast('Cart cleared.'); closeCart(); return; } const removeBtn = t.closest('[data-remove]'); if (removeBtn) { e.preventDefault(); const i = parseIntSafe(removeBtn.getAttribute('data-remove'), NaN); if (!Number.isNaN(i)) removeCartItem(i); showToast('Removed.'); return; } const prodBtn = t.closest('[data-add-product],[data-add-to-cart]'); if (prodBtn) { e.preventDefault(); const host = getProductHost(prodBtn); addProductFromHost(host); return; } const apptBtn = t.closest('[data-add-appointment]'); if (apptBtn) { e.preventDefault(); const btn = apptBtn; const baseCur = toUpperCur(btn.getAttribute('data-service-currency') || DEFAULT_CURRENCY); const appt = { service_id: btn.getAttribute('data-service-id') || '', name: btn.getAttribute('data-service-name') || 'Service', price_cents: parseIntSafe(btn.getAttribute('data-service-price'), 0), price_resident: parseIntSafe(btn.getAttribute('data-service-price-resident'), 0), price_nonresident: parseIntSafe(btn.getAttribute('data-service-price-nonresident'), 0), currency: baseCur, currency_resident: toUpperCur(btn.getAttribute('data-service-currency-resident') || baseCur), currency_nonresident: toUpperCur(btn.getAttribute('data-service-currency-nonresident') || baseCur), has_dual: (btn.getAttribute('data-service-has-dual') === '1'), image_url: btn.getAttribute('data-service-image') || '', duration_minutes: parseIntSafe(btn.getAttribute('data-service-duration'), 0), branch_id: btn.getAttribute('data-branch-id') || '', branch_name: btn.getAttribute('data-branch-name') || '', tier_id: '', tier_label: '', price_type: '' }; openApptModal(appt); return; } const apptClose = t.closest('[data-appt-close],[data-appt-cancel]'); if (apptClose) { e.preventDefault(); closeApptModal(); return; } const apptConfirm = t.closest('[data-appt-confirm]'); if (apptConfirm) { e.preventDefault(); addCurrentApptToCart(); return; } const { apptBackdrop } = dom(); if (apptBackdrop && t === apptBackdrop) { closeApptModal(); return; } }); document.addEventListener('change', (e) => { const el = e.target; if (!el) return; if (el.id === 'apptBranch' || el.matches?.('#apptBranch')) { refreshApptSlots(); return; } if (el.id === 'apptDate' || el.matches?.('#apptDate')) { refreshApptSlots(); return; } if (el.id === 'apptPriceType' || el.matches?.('#apptPriceType')) { updateApptSummary(); refreshApptSlots(); return; } if (el.id === 'apptTier' || el.matches?.('#apptTier')) { updateApptSummary(); refreshApptSlots(); return; } if (el.id === 'apptTime' || el.matches?.('#apptTime')) { if (el.value) setApptHint('Time selected.'); else setApptHint('Tip: change date/branch to refresh times.'); return; } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeApptModal(); closeCart(); } }); window.addEventListener('storage', (e) => { if (e.key === CART_KEY) renderCartUI(); }); } /* ------------------ Init ------------------ */ let didInit = false; function init() { if (didInit) return; didInit = true; bindEvents(); renderCartUI(); window.SalonCart = { loadCart, saveCart, clearCart, checkoutCart }; } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();