// ═══════════════════════════════════════════════════════════════ // Aurelle Bride — currency.jsx // Full replacement. // Load BEFORE components.jsx and app.jsx. // // Auto-detect only: // - No dropdown // - No manual selector // - Silent currency switching // - Read-only badge with location tooltip // // Exposes: // - window.CurrencyCtx // - window.CurrencyProvider // - window.useCurrency // - window.PriceDisplay // - window.CurrencyBadge // - window.ShippingNote // - window.InlinePrice // - window.convertPrice // ═══════════════════════════════════════════════════════════════ (function () { const ReactRef = typeof React !== 'undefined' ? React : null; if (!ReactRef) return; const { useState, useEffect, useMemo, useCallback, useContext, createContext, } = ReactRef; // ── Currency metadata ──────────────────────────────────────── // decimals = display precision for the target currency. window.CURRENCY_META = { PKR: { symbol: '₨', name: 'Pakistani Rupee', decimals: 0 }, USD: { symbol: '$', name: 'US Dollar', decimals: 0 }, GBP: { symbol: '£', name: 'British Pound', decimals: 0 }, EUR: { symbol: '€', name: 'Euro', decimals: 0 }, AED: { symbol: 'AED', name: 'UAE Dirham', decimals: 0 }, SAR: { symbol: 'SAR', name: 'Saudi Riyal', decimals: 0 }, INR: { symbol: '₹', name: 'Indian Rupee', decimals: 0 }, CAD: { symbol: 'CA$', name: 'Canadian Dollar', decimals: 0 }, AUD: { symbol: 'A$', name: 'Australian Dollar', decimals: 0 }, SGD: { symbol: 'S$', name: 'Singapore Dollar', decimals: 0 }, QAR: { symbol: 'QAR', name: 'Qatari Riyal', decimals: 0 }, KWD: { symbol: 'KWD', name: 'Kuwaiti Dinar', decimals: 3 }, BHD: { symbol: 'BHD', name: 'Bahraini Dinar', decimals: 3 }, OMR: { symbol: 'OMR', name: 'Omani Rial', decimals: 3 }, MYR: { symbol: 'RM', name: 'Malaysian Ringgit', decimals: 0 }, TRY: { symbol: '₺', name: 'Turkish Lira', decimals: 0 }, BDT: { symbol: '৳', name: 'Bangladeshi Taka', decimals: 0 }, JPY: { symbol: '¥', name: 'Japanese Yen', decimals: 0 }, KRW: { symbol: '₩', name: 'South Korean Won', decimals: 0 }, CNY: { symbol: '¥', name: 'Chinese Yuan', decimals: 0 }, HKD: { symbol: 'HK$', name: 'Hong Kong Dollar', decimals: 0 }, NZD: { symbol: 'NZ$', name: 'New Zealand Dollar', decimals: 0 }, ZAR: { symbol: 'R', name: 'South African Rand', decimals: 0 }, NGN: { symbol: '₦', name: 'Nigerian Naira', decimals: 0 }, EGP: { symbol: 'E£', name: 'Egyptian Pound', decimals: 0 }, ILS: { symbol: '₪', name: 'Israeli Shekel', decimals: 0 }, MXN: { symbol: 'MX$', name: 'Mexican Peso', decimals: 0 }, BRL: { symbol: 'R$', name: 'Brazilian Real', decimals: 0 }, PLN: { symbol: 'zł', name: 'Polish Złoty', decimals: 0 }, CZK: { symbol: 'Kč', name: 'Czech Koruna', decimals: 0 }, HUF: { symbol: 'Ft', name: 'Hungarian Forint', decimals: 0 }, }; // ── Country → currency map ────────────────────────────────── window.COUNTRY_CURRENCY = { PK: 'PKR', US: 'USD', GB: 'GBP', AE: 'AED', SA: 'SAR', IN: 'INR', CA: 'CAD', AU: 'AUD', SG: 'SGD', QA: 'QAR', KW: 'KWD', BH: 'BHD', OM: 'OMR', MY: 'MYR', TR: 'TRY', BD: 'BDT', IE: 'EUR', DE: 'EUR', FR: 'EUR', IT: 'EUR', ES: 'EUR', NL: 'EUR', BE: 'EUR', AT: 'EUR', PT: 'EUR', FI: 'EUR', GR: 'EUR', CH: 'CHF', SE: 'SEK', NO: 'NOK', DK: 'DKK', NZ: 'NZD', ZA: 'ZAR', NG: 'NGN', EG: 'EGP', IL: 'ILS', MX: 'MXN', BR: 'BRL', PL: 'PLN', CZ: 'CZK', HU: 'HUF', JP: 'JPY', KR: 'KRW', CN: 'CNY', HK: 'HKD', TH: 'USD', ID: 'USD', PH: 'USD', VN: 'USD', LK: 'USD', NP: 'INR', AF: 'USD', IR: 'USD', RU: 'USD', UA: 'USD', MY: 'MYR', CL: 'USD', AR: 'USD', CO: 'USD', PE: 'USD', }; const GEO_KEY = 'ab_geo_v2'; const RATES_KEY = 'ab_rates_v2'; const GEO_TTL = 1000 * 60 * 60 * 12; // 12 hours const RATES_TTL = 1000 * 60 * 60 * 6; // 6 hours const GEO_ENDPOINT = '/api/geo.php'; const PRIMARY_RATES_URL = 'https://open.er-api.com/v6/latest/PKR'; const FALLBACK_RATES_URL = 'https://api.exchangerate-api.com/v4/latest/PKR'; const DEFAULT_GEO = { currency: 'USD', country_code: 'US', country_name: 'Unknown', city: '', detected: false, source: 'fallback', }; const DEFAULT_RATES = { PKR: 1, USD: 0.0036, GBP: 0.0028, EUR: 0.0033, AED: 0.0131, SAR: 0.0134, INR: 0.30, CAD: 0.0049, AUD: 0.0055, SGD: 0.0048, QAR: 0.0130, KWD: 0.0011, BHD: 0.0013, OMR: 0.0014, MYR: 0.0167, TRY: 0.120, BDT: 0.39, JPY: 0.55, KRW: 4.8, CNY: 0.026, HKD: 0.028, NZD: 0.0060, ZAR: 0.064, NGN: 5.6, EGP: 0.17, ILS: 0.013, MXN: 0.066, BRL: 0.019, PLN: 0.014, CZK: 0.077, HUF: 1.25, }; function now() { return Date.now(); } function safeJsonParse(input, fallback = null) { try { return JSON.parse(input); } catch { return fallback; } } function safeStorageGet(key) { try { return sessionStorage.getItem(key); } catch { return null; } } function safeStorageSet(key, value) { try { sessionStorage.setItem(key, value); } catch { // ignore quota / privacy mode errors } } function timeoutSignal(ms) { if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') { return AbortSignal.timeout(ms); } const controller = new AbortController(); setTimeout(() => controller.abort(), ms); return controller.signal; } function isPlainObject(v) { return !!v && typeof v === 'object' && !Array.isArray(v); } function normalizeCode(code) { return String(code || '').trim().toUpperCase(); } function formatNumber(amount, decimals) { const n = Number(amount); if (!Number.isFinite(n)) return '0'; return n.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals, }); } function getCurrencyMeta(currencyCode) { return window.CURRENCY_META[normalizeCode(currencyCode)] || { symbol: normalizeCode(currencyCode) || '$', name: normalizeCode(currencyCode) || 'Currency', decimals: 0, }; } function roundAmount(amount, decimals) { const factor = Math.pow(10, decimals); return Math.round(Number(amount) * factor) / factor; } function getBrowserLocaleCurrency() { try { const locale = navigator.language || (navigator.languages && navigator.languages[0]) || 'en-US'; const region = locale.includes('-') ? locale.split('-')[1] : ''; const mapped = window.COUNTRY_CURRENCY[normalizeCode(region)]; return mapped || null; } catch { return null; } } function ipFromHeadersObject(value) { if (!value) return ''; return String(value).split(',')[0].trim(); } async function readTextWithTimeout(url, ms) { const res = await fetch(url, { signal: timeoutSignal(ms), credentials: 'omit', cache: 'no-store', }); return await res.text(); } async function readJsonWithTimeout(url, ms) { const res = await fetch(url, { signal: timeoutSignal(ms), credentials: 'omit', cache: 'no-store', }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } function buildGeoResult(sourceData) { const cc = normalizeCode(sourceData.country_code || sourceData.country || sourceData.loc || 'US') || 'US'; const currency = normalizeCode(sourceData.currency || window.COUNTRY_CURRENCY[cc] || getBrowserLocaleCurrency() || 'USD'); const countryName = sourceData.country_name || sourceData.country || cc || 'Unknown'; const city = sourceData.city || ''; return { currency: currency || 'USD', country_code: cc || 'US', country_name: countryName || 'Unknown', city, detected: true, source: sourceData.source || 'api', }; } async function detectCurrency() { // 1) session cache const cached = safeJsonParse(safeStorageGet(GEO_KEY), null); if (cached && isPlainObject(cached) && cached.currency) { if (!cached.ts || now() - cached.ts < GEO_TTL) return cached; } // 2) backend geo endpoint try { const data = await readJsonWithTimeout(GEO_ENDPOINT, 4500); if (data && data.currency) { const result = { ...buildGeoResult({ currency: data.currency, country_code: data.country_code, country_name: data.country_name, city: data.city, source: 'server', }), detected: data.detected !== false, }; safeStorageSet(GEO_KEY, JSON.stringify({ ...result, ts: now() })); return result; } } catch { // continue } // 3) Cloudflare trace fallback (browser-side) try { const text = await readTextWithTimeout('https://www.cloudflare.com/cdn-cgi/trace', 3500); const loc = (text.match(/(?:^|\n)loc=([A-Z]{2})(?:\n|$)/) || [])[1]; if (loc) { const cc = normalizeCode(loc); const result = { currency: window.COUNTRY_CURRENCY[cc] || 'USD', country_code: cc, country_name: cc, city: '', detected: true, source: 'cloudflare-trace', }; safeStorageSet(GEO_KEY, JSON.stringify({ ...result, ts: now() })); return result; } } catch { // continue } // 4) browser locale fallback const localeCurrency = getBrowserLocaleCurrency(); if (localeCurrency) { const result = { currency: localeCurrency, country_code: 'US', country_name: 'Unknown', city: '', detected: false, source: 'browser-locale', }; safeStorageSet(GEO_KEY, JSON.stringify({ ...result, ts: now() })); return result; } // 5) fallback const fallback = { ...DEFAULT_GEO }; safeStorageSet(GEO_KEY, JSON.stringify({ ...fallback, ts: now() })); return fallback; } async function fetchRates() { const cached = safeJsonParse(safeStorageGet(RATES_KEY), null); if (cached && isPlainObject(cached) && cached.rates && cached.ts && now() - cached.ts < RATES_TTL) { return cached.rates; } const tryEndpoint = async (url, timeoutMs) => { const data = await readJsonWithTimeout(url, timeoutMs); if (!data || !data.rates || typeof data.rates !== 'object') throw new Error('No rates'); return data.rates; }; try { const rates = await tryEndpoint(PRIMARY_RATES_URL, 5000); safeStorageSet(RATES_KEY, JSON.stringify({ ts: now(), rates })); return rates; } catch { // continue } try { const rates = await tryEndpoint(FALLBACK_RATES_URL, 5000); safeStorageSet(RATES_KEY, JSON.stringify({ ts: now(), rates })); return rates; } catch { // continue } safeStorageSet(RATES_KEY, JSON.stringify({ ts: now(), rates: DEFAULT_RATES })); return DEFAULT_RATES; } function currencyRateFor(rates, code) { if (!rates) return null; const cc = normalizeCode(code); if (cc === 'PKR') return 1; const rate = rates[cc]; if (typeof rate === 'number' && Number.isFinite(rate)) return rate; return null; } function convertPrice(pkrAmount, rate, currencyCode) { const value = Number(pkrAmount); if (!Number.isFinite(value)) return null; const code = normalizeCode(currencyCode || 'PKR'); const meta = getCurrencyMeta(code); const raw = value * Number(rate || 1); // For zero-decimal currencies, keep sensible luxury-friendly rounding. const decimals = Number.isFinite(meta.decimals) ? meta.decimals : 0; const converted = decimals === 0 ? Math.round(raw) : roundAmount(raw, decimals); const formatted = formatNumber(converted, decimals); return `${meta.symbol} ${formatted}`; } window.convertPrice = convertPrice; const CurrencyCtx = createContext({ currency: 'USD', country_code: 'US', country_name: 'Unknown', city: '', detected: false, loading: true, rate: null, rates: null, symbol: '$', convert: (pkr) => null, format: (pkr) => null, }); window.CurrencyCtx = CurrencyCtx; function CurrencyProvider({ children }) { const [state, setState] = useState({ currency: 'USD', country_code: 'US', country_name: 'Unknown', city: '', detected: false, loading: true, rate: null, rates: null, symbol: '$', source: 'fallback', }); useEffect(() => { let alive = true; (async () => { try { const [geo, rates] = await Promise.all([detectCurrency(), fetchRates()]); if (!alive) return; const currency = normalizeCode(geo.currency || 'USD'); const rate = currencyRateFor(rates, currency) ?? (currency === 'PKR' ? 1 : null) ?? currencyRateFor(rates, 'USD'); const symbol = getCurrencyMeta(currency).symbol || currency; setState({ currency, country_code: normalizeCode(geo.country_code || 'US') || 'US', country_name: geo.country_name || 'Unknown', city: geo.city || '', detected: !!geo.detected, loading: false, rate, rates, symbol, source: geo.source || 'api', }); } catch { if (!alive) return; setState({ currency: 'USD', country_code: 'US', country_name: 'Unknown', city: '', detected: false, loading: false, rate: currencyRateFor(DEFAULT_RATES, 'USD') || 0.0036, rates: DEFAULT_RATES, symbol: '$', source: 'fallback', }); } })(); return () => { alive = false; }; }, []); const convert = useCallback((pkrAmount) => { if (!Number.isFinite(Number(pkrAmount))) return null; if (!state.rate) return null; return convertPrice(pkrAmount, state.rate, state.currency); }, [state.rate, state.currency]); const format = useCallback((pkrAmount) => { const main = convert(pkrAmount); return main || `₨ ${formatNumber(pkrAmount, 0)}`; }, [convert]); const value = useMemo(() => ({ ...state, convert, format, }), [state, convert, format]); return React.createElement(CurrencyCtx.Provider, { value }, children); } window.CurrencyProvider = CurrencyProvider; function useCurrency() { return useContext(CurrencyCtx); } window.useCurrency = useCurrency; function InlinePrice({ pkr, className = '', style, showCurrency = true }) { const { loading, currency, rate, convert } = useCurrency(); const amount = Number(pkr); if (!Number.isFinite(amount)) { return React.createElement('span', { className, style }, '—'); } const base = `₨ ${formatNumber(amount, 0)}`; if (loading || !rate || currency === 'PKR') { return React.createElement('span', { className, style }, base); } const main = convert(amount) || base; return React.createElement('span', { className, style }, showCurrency ? main : main.replace(/^\S+\s*/, '')); } window.InlinePrice = InlinePrice; function ShippingNote({ compact = false, style, className = '' }) { const { currency } = useCurrency(); const note = compact ? 'Shipping is quoted after confirmation.' : 'Shipping is handled separately after confirmation. Please inquire for a custom shipping quote.'; return React.createElement( 'div', { className, style: { padding: compact ? '10px 12px' : '14px 16px', borderRadius: 12, border: '1px solid var(--line)', background: 'var(--surface-2)', color: 'var(--ink-2)', ...style, }, }, React.createElement('div', { style: { fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.18em', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 6, }, }, 'Shipping'), React.createElement('div', { style: { fontSize: compact ? 12 : 13, lineHeight: 1.6 } }, note), currency !== 'PKR' && !compact ? React.createElement('div', { style: { marginTop: 8, fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.12em', color: 'var(--ink-3)', }, }, `Displayed in ${currency} · billed in PKR`) : null ); } window.ShippingNote = ShippingNote; function PriceDisplay({ pkr, style, showPkrSub = true, className = '' }) { const { currency, convert, loading, rate } = useCurrency(); const amount = Number(pkr); if (!Number.isFinite(amount)) { return React.createElement('span', { style, className }, '—'); } const pkrText = `₨ ${formatNumber(amount, 0)}`; if (loading || !rate || currency === 'PKR') { return React.createElement('span', { style, className }, pkrText); } const main = convert(amount); if (!main) { return React.createElement('span', { style, className }, pkrText); } return React.createElement( 'span', { style, className }, React.createElement('span', null, main), showPkrSub ? React.createElement( 'span', { style: { display: 'block', fontSize: '0.65em', color: 'var(--ink-3)', fontStyle: 'normal', fontFamily: 'var(--mono)', letterSpacing: '.04em', marginTop: 2, }, }, `${pkrText} PKR` ) : null ); } window.PriceDisplay = PriceDisplay; function CurrencyBadge() { const { currency, city, country_name, country_code, loading, detected, source } = useCurrency(); const [open, setOpen] = useState(false); const meta = getCurrencyMeta(currency); const locationLabel = [city, country_name || country_code].filter(Boolean).join(', '); const badgeText = loading ? '···' : currency; return React.createElement( 'div', { style: { position: 'relative', display: 'inline-flex', alignItems: 'center', }, }, React.createElement( 'button', { type: 'button', onMouseEnter: () => setOpen(true), onMouseLeave: () => setOpen(false), onFocus: () => setOpen(true), onBlur: () => setOpen(false), 'aria-label': loading ? 'Detecting location' : `Detected currency ${currency}`, style: { display: 'inline-flex', alignItems: 'center', gap: 8, padding: '7px 12px', borderRadius: 999, border: '1px solid var(--line)', background: 'transparent', color: loading ? 'var(--ink-3)' : 'var(--ink)', fontFamily: 'var(--mono)', fontSize: 11, letterSpacing: '.18em', textTransform: 'uppercase', cursor: 'default', transition: 'border-color .2s, color .2s, background .2s', whiteSpace: 'nowrap', }, }, loading ? React.createElement('span', { style: { width: 6, height: 6, borderRadius: '50%', background: 'var(--gold)', animation: 'abPulse 1.2s ease-in-out infinite', }, }) : React.createElement('span', { style: { width: 6, height: 6, borderRadius: '50%', background: 'var(--gold)', }, }), badgeText ), open && !loading ? React.createElement( 'div', { role: 'tooltip', style: { position: 'absolute', top: 'calc(100% + 10px)', right: 0, minWidth: 240, zIndex: 1000, padding: '12px 14px', borderRadius: 14, border: '1px solid var(--line)', background: 'var(--surface)', boxShadow: '0 20px 40px -20px rgba(0,0,0,.45)', pointerEvents: 'none', }, }, React.createElement('div', { style: { fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '.2em', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 6, }, }, 'Detected location'), React.createElement('div', { style: { fontSize: 13, color: 'var(--ink)', lineHeight: 1.45, }, }, locationLabel || 'Unknown location', React.createElement('div', { style: { marginTop: 6, fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.12em', color: 'var(--ink-3)', }, }, `Currency: ${currency} · ${meta.name || currency}`), React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--ink-2)', }, }, detected ? 'Auto-detected' : `Fallback via ${source}`) ) ) : null ); } window.CurrencyBadge = CurrencyBadge; // Backward-compatibility aliases used by the existing app. window.CurrencyDisplay = CurrencyBadge; window.CurrencyChip = CurrencyBadge; // Global CSS keyframes for the loading dot, injected once. if (typeof document !== 'undefined' && !document.getElementById('ab-currency-style')) { const style = document.createElement('style'); style.id = 'ab-currency-style'; style.textContent = `@keyframes abPulse { 0%,100%{opacity:.35; transform:scale(.88);} 50%{opacity:1; transform:scale(1);} }`; document.head.appendChild(style); } })();