// 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 && (
)}
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) */}
{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),
});