// UI primitives for HOS const { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } = React; /* ───────────── Currency ───────────── */ const CURRENCY_META = { CAD: { symbol: '$', label: 'CAD', locale: 'en-CA', flagUrl: 'https://flagcdn.com/24x18/ca.png' }, USD: { symbol: '$', label: 'USD', locale: 'en-US', flagUrl: 'https://flagcdn.com/24x18/us.png' }, EUR: { symbol: '€', label: 'EUR', locale: 'fr-FR', flagUrl: 'https://flagcdn.com/24x18/eu.png' }, TND: { symbol: 'DT', label: 'TND', locale: 'fr-TN', flagUrl: 'https://flagcdn.com/24x18/tn.png' }, }; // Detect the visitor's currency from their timezone (offline, no API): // Canada → CAD, Tunisia → TND, Europe → EUR, USA & everywhere else → USD. const CANADA_TZ = ['America/Toronto', 'America/Montreal', 'America/Vancouver', 'America/Edmonton', 'America/Winnipeg', 'America/Halifax', 'America/St_Johns', 'America/Regina', 'America/Moncton', 'America/Glace_Bay', 'America/Goose_Bay', 'America/Whitehorse', 'America/Dawson', 'America/Iqaluit', 'America/Yellowknife', 'America/Blanc-Sablon', 'America/Rankin_Inlet', 'America/Cambridge_Bay']; function detectCurrency(enabled, base) { let cur = 'USD'; try { const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone) || ''; const lang = (typeof navigator !== 'undefined' && (navigator.language || '')).toUpperCase(); if (tz === 'Africa/Tunis' || lang.endsWith('-TN')) cur = 'TND'; else if (CANADA_TZ.includes(tz) || lang.endsWith('-CA')) cur = 'CAD'; else if (tz.startsWith('Europe/')) cur = 'EUR'; else cur = 'USD'; } catch { cur = base; } return enabled.includes(cur) ? cur : (enabled.includes('USD') ? 'USD' : base); } function CurrencyFlag({ code, size = 16 }) { const m = CURRENCY_META[code] || {}; if (m.flagUrl) return ; // USD = worldwide → globe icon (renders everywhere, unlike emoji flags) return ; } // Convert a BASE-currency amount into `currency` using rates (rate = units of `currency` per 1 base). function convertPrice(baseAmount, { currency, base = 'CAD', rates = {} } = {}) { const amt = Number(baseAmount || 0); if (!currency || currency === base) return amt; const rate = Number(rates && rates[currency]); return rate > 0 ? amt * rate : amt; } // Effective price in the selected currency: a pinned per-zone override wins, otherwise we // auto-convert the base amount. `overrides` is a map like { EUR: 55, TND: 199 } (only the // currencies the admin chose to fix); anything missing/zero falls back to conversion. function effectivePrice(baseAmount, overrides, opts = {}) { const currency = opts.currency || opts.base || 'CAD'; const ov = overrides && typeof overrides === 'object' ? Number(overrides[currency]) : NaN; if (ov > 0) return ov; return convertPrice(baseAmount, opts); } function formatPrice(baseAmount, opts = {}, overrides = null) { const currency = opts.currency || opts.base || 'CAD'; const meta = CURRENCY_META[currency] || { symbol: '$', label: currency, locale: 'en-US' }; const value = effectivePrice(baseAmount, overrides, opts); const rounded = Math.round(value); let num; try { num = new Intl.NumberFormat(meta.locale).format(rounded); } catch { num = String(rounded); } return meta.symbol === 'DT' ? `${num} DT` : `${meta.symbol}${num}`; } const CurrencyCtx = createContext({ currency: 'CAD', setCurrency: () => {}, base: 'CAD', rates: {}, enabled: ['CAD'] }); function CurrencyProvider({ children }) { const { siteData } = (typeof useSiteData === 'function' ? useSiteData() : { siteData: {} }); const s = (siteData && siteData.settings) || {}; const base = s.baseCurrency || 'CAD'; const rates = s.currencyRates || {}; const enabled = (s.enabledCurrencies && s.enabledCurrencies.length) ? s.enabledCurrencies : ['CAD', 'EUR', 'USD', 'TND']; const [currency, setCurrencyState] = useState(() => { // Honour a returning visitor's manual choice; otherwise auto-detect from their location. try { const saved = localStorage.getItem('hos.currency'); if (saved) return saved; } catch {} return detectCurrency(['CAD', 'EUR', 'USD', 'TND'], base); }); // Keep the selected currency valid against what the store enables. useEffect(() => { if (!enabled.includes(currency)) setCurrencyState(base); }, [enabled.join(','), base]); const setCurrency = useCallback((c) => { try { localStorage.setItem('hos.currency', c); } catch {} setCurrencyState(c); }, []); return {children}; } function useCurrency() { const ctx = useContext(CurrencyCtx); // fmt(amount, overrides?) — overrides is an optional per-zone price map for this item. const fmt = useCallback((baseAmount, overrides) => formatPrice(baseAmount, { currency: ctx.currency, base: ctx.base, rates: ctx.rates }, overrides), [ctx.currency, ctx.base, ctx.rates]); // priceOf(amount, overrides?) — the raw number in the selected currency (for maths/sums). const priceOf = useCallback((baseAmount, overrides) => effectivePrice(baseAmount, overrides, { currency: ctx.currency, base: ctx.base, rates: ctx.rates }), [ctx.currency, ctx.base, ctx.rates]); // fmtRaw(value) — format a number that is ALREADY in the selected currency (no conversion). // Use for line totals / subtotals after summing per-unit override-aware prices × qty. const fmtRaw = useCallback((value) => formatPrice(value, { currency: ctx.currency, base: ctx.currency, rates: ctx.rates }), [ctx.currency, ctx.rates]); return { ...ctx, fmt, priceOf, fmtRaw }; } // Drop-in price display — shows a base amount in the visitor's selected currency. // Pass `overrides` (a per-zone price map) to honour a pinned price for the active currency. function Price({ amount, overrides, className }) { const { fmt } = useCurrency(); return {fmt(amount, overrides)}; } function CurrencySwitcher({ tone = 'deep', className = '' }) { const { currency, setCurrency, enabled } = useCurrency(); const c = tone === 'ivory' ? 'text-ivory/85' : 'text-deep'; if (!enabled || enabled.length < 2) return null; const cur = CURRENCY_META[currency] || { label: currency }; return ( {cur.label} ); } /* ───────────── Language Context ───────────── */ const LangCtx = createContext({ lang: 'en', setLang: () => {} }); function LangProvider({ children }) { const [lang, setLangState] = useState(() => { try { return localStorage.getItem('hos.lang') || 'fr'; } catch { return 'fr'; } }); const setLang = useCallback((l) => { try { localStorage.setItem('hos.lang', l); } catch {} setLangState(l); }, []); useEffect(() => { document.documentElement.lang = lang; }, [lang]); return {children}; } function useLang() { const { lang, setLang } = useContext(LangCtx); const t = useCallback((en, fr) => (lang === 'fr' && fr ? fr : en), [lang]); return { lang, setLang, t }; } /* ───────────── Language Switcher ───────────── */ function LangSwitcher({ tone = 'deep', className = '' }) { const { lang, setLang } = useLang(); const c = tone === 'ivory' ? 'text-ivory/80' : 'text-deep'; return (
/
); } /* ───────────── Cart Context ───────────── */ const CartCtx = createContext(null); const CART_KEY = 'roudaina.cart.v1'; const WISH_KEY = 'roudaina.wish.v1'; function CartProvider({ children }) { const [items, setItems] = useState(() => { try { return JSON.parse(localStorage.getItem(CART_KEY) || '[]'); } catch { return []; } }); const [wishlist, setWishlist] = useState(() => { try { return new Set(JSON.parse(localStorage.getItem(WISH_KEY) || '[]')); } catch { return new Set(); } }); const [open, setOpen] = useState(false); const [toast, setToast] = useState(null); useEffect(() => { try { localStorage.setItem(CART_KEY, JSON.stringify(items)); } catch {} }, [items]); useEffect(() => { try { localStorage.setItem(WISH_KEY, JSON.stringify([...wishlist])); } catch {} }, [wishlist]); const add = useCallback((p) => { setItems(curr => { const ex = curr.find(x => x.id === p.id); if (ex) return curr.map(x => x.id === p.id ? {...x, qty: x.qty + 1} : x); return [...curr, { ...p, qty: 1 }]; }); setToast({ name: p.name, ts: Date.now() }); }, []); const remove = useCallback((id) => setItems(c => c.filter(x => x.id !== id)), []); const setQty = useCallback((id, qty) => setItems(c => c.map(x => x.id === id ? {...x, qty: Math.max(1, qty)} : x)), []); const toggleWish = useCallback((id) => { setWishlist(curr => { const next = new Set(curr); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const subtotal = useMemo(() => items.reduce((s,x) => s + x.price*x.qty, 0), [items]); const count = useMemo(() => items.reduce((s,x) => s + x.qty, 0), [items]); useEffect(() => { if (!toast) return; const t = setTimeout(() => setToast(null), 2200); return () => clearTimeout(t); }, [toast]); return ( {children} ); } const useCart = () => useContext(CartCtx); /* ───────────── Reveal on scroll ───────────── */ function useReveal(dep) { useEffect(() => { // Wait a tick so newly-mounted elements are in the DOM const t = setTimeout(() => { const els = document.querySelectorAll('.reveal:not(.in)'); const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } }); }, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' }); els.forEach(el => io.observe(el)); // Store on closure so cleanup can reach it useReveal._io = io; }, 40); return () => { clearTimeout(t); if (useReveal._io) { useReveal._io.disconnect(); useReveal._io = null; } }; }, [dep]); } /* ───────────── HOS Logo (SVG-like via type) ───────────── */ function Logo({ size = 'md', tone = 'rose', mark = true }) { const sizes = { sm: { mark: 'text-[22px]', sub: 'text-[8px]' }, md: { mark: 'text-[34px]', sub: 'text-[10px]' }, lg: { mark: 'text-[80px]', sub: 'text-[14px]' }, xl: { mark: 'text-[140px]', sub: 'text-[18px]' }, }; const s = sizes[size] || sizes.md; const color = tone === 'ivory' ? 'text-ivory' : tone === 'deep' ? 'text-deep' : 'text-rose'; return (
HOS {mark && ( <> HOUSE OF SHEYLAS
)}
); } function Diamond({ size = 8 }) { return ( ); } /* ───────────── Color swatch helpers ───────────── A color is { name, hex, hex2? }. With hex2 the swatch renders as a half / half split (the "two-tone bull"), e.g. half red, half blue. */ function _isLightHex(hex) { if (!hex || hex.length < 7) return false; const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16); return (0.2126*r + 0.7152*g + 0.0722*b) > 208; } function swatchBg(c) { if (!c) return 'transparent'; if (c.hex2) return `linear-gradient(135deg, ${c.hex} 0 50%, ${c.hex2} 50% 100%)`; return c.hex; } function swatchStyle(c) { const light = c && (_isLightHex(c.hex) || (c.hex2 && _isLightHex(c.hex2))); return { background: swatchBg(c), border: light ? '1px solid rgba(74,45,51,.28)' : 'none' }; } /* ───────────── Icons ───────────── */ const I = { search: (p) => , user: (p) => , bag: (p) => , heart: (props) => { const filled = !!(props && props.filled); return ; }, close: (p) => , menu: (p) => , plus: (p) => , minus: (p) => , arrowR: (p) => , arrowDown:(p)=> , star: (p) => , check: (p) => , insta: (p) => , tiktok: (p) => , pin: (p) => , facebook:(p) => , }; /* ───────────── Button ───────────── */ function Button({ children, variant = 'primary', size = 'md', className = '', as: As = 'button', ...rest }) { const base = 'btn-sweep inline-flex items-center justify-center gap-2 font-sans uppercase tracking-widest-2 transition-all duration-500 select-none'; const sizes = { sm: 'text-[11px] px-5 py-2.5', md: 'text-[12px] px-7 py-3.5', lg: 'text-[12px] px-9 py-4', }; const variants = { primary: 'bg-deep text-ivory hover:bg-rose', rose: 'bg-rose text-ivory hover:bg-deep', ivory: 'bg-ivory text-deep hover:bg-deep hover:text-ivory border border-deep/15', ghost: 'bg-transparent text-deep border border-deep/25 hover:bg-deep hover:text-ivory', ghostLight:'bg-transparent text-ivory border border-ivory/40 hover:bg-ivory hover:text-deep', link: 'text-deep border-b border-deep/30 hover:border-deep px-0 py-1 tracking-wider-2', }; return ( {children} ); } /* ───────────── SectionLabel (eyebrow) ───────────── */ function Eyebrow({ children, tone = 'rose', className = '' }) { const c = tone === 'ivory' ? 'text-ivory/80' : 'text-rose'; return (
{children}
); } /* ───────────── ProductCard ───────────── */ const TAG_FR = { 'New':'Nouveau', 'Just in':'Arrivage', 'Limited':'Édition limitée', 'Bestseller':'Best-seller', 'Most loved':'Plus aimé', 'Restocked':'Réassorti' }; function ProductCard({ p, idx = 0, reveal = true }) { const cart = useCart(); const { t, lang } = useLang(); const { fmt } = useCurrency(); const liked = cart.wishlist.has(p.id); const [activeColor, setActiveColor] = useState(0); const [imgLoaded, setImgLoaded] = useState(false); const tagLabel = p.tag ? (lang === 'fr' ? (TAG_FR[p.tag] || p.tag) : p.tag) : null; const soldOut = p.stock === 0 || p.stock === '0'; return (
{!imgLoaded && (
)} {p.name} setImgLoaded(true)} className={`img-zoom absolute inset-0 w-full h-full object-cover transition-opacity duration-700 ${imgLoaded?'opacity-100':'opacity-0'} ${soldOut ? 'grayscale-[0.35] opacity-80' : ''}`} loading="lazy" /> {/* Tag / Sold out */} {soldOut ? ( {t('Sold out', 'Épuisé')} ) : tagLabel && ( {tagLabel} )} {/* Wish */} {/* Quick add — slides up on hover (disabled when sold out) */}

{p.name}

{fmt(p.price, p.priceOverrides)} {p.colors && (
{p.colors.map((c, i) => (
)}
); } /* ───────────── Toast (added to bag) ───────────── */ function CartToast() { const cart = useCart(); const { t } = useLang(); if (!cart.toast) return null; return (
{t('Added to bag', 'Ajouté au panier')}
{cart.toast.name}
); } // Expose Object.assign(window, { React, ReactDOM, useState, useEffect, useRef, useCallback, useMemo, createContext, useContext, CartProvider, useCart, useReveal, LangProvider, useLang, LangSwitcher, CurrencyProvider, useCurrency, CurrencySwitcher, Price, formatPrice, convertPrice, effectivePrice, Logo, Diamond, I, Button, Eyebrow, ProductCard, CartToast, swatchBg, swatchStyle, TAG_FR, CAT_FR_GLOBAL: (typeof CAT_FR !== 'undefined' ? CAT_FR : null), });