/* /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 = `