// ═══════════════════════════════════════════════════════════════
// Aurelle Bride — components.jsx
// Shared utilities, hooks, and UI components
// Loaded first; assigns to window.* for cross-file access
// ═══════════════════════════════════════════════════════════════
window.Logo = function Logo({ size = 28, mark = false }) {
return (
Aurelle
{!mark && Bride}
);
};
window.I = {
arrow: () => →,
sun: () => ☀,
moon: () => ☾,
plus: () => +,
close: () => ✕,
cog: () => ⚙,
upload: () => ⬆,
};
window.Placeholder = function Placeholder({ label = '', tone = 'default' }) {
return (
{label || 'No Image'}
);
};
window.useApp = function useApp() {
return {
theme: document.documentElement.dataset.theme || 'dark',
toggleTheme: () => {
const current = document.documentElement.dataset.theme || 'dark';
document.documentElement.dataset.theme =
current === 'dark' ? 'light' : 'dark';
},
navigate: (path) => {
window.location.hash = '#/' + path;
},
showToast: (msg) => alert(msg),
formatPrice: window.formatPKR
};
};
const { useState, useEffect, useRef, useCallback, useMemo } = React;
// ── Config ────────────────────────────────────────────────────
window.cfg = window.__SITE_CONFIG__ || {};
window.brand = window.cfg.brand || { brandName: 'Aurelle Bride', whatsapp: '+923358154775' };
window.seo = window.cfg.seo || {};
// ── API ───────────────────────────────────────────────────────
window.API_BASE = '/api';
window.apiFetch = async function(path, opts = {}) {
const token = localStorage.getItem('ab_token');
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
if (token) headers['Authorization'] = `Bearer ${token}`;
// Auto-append .php if path has no extension and no query string starting segment
const fullPath = path.replace(/^(\/(admin\/)?[a-z_]+)(\/|$|\?)/, (m, seg, adm, rest) => {
return seg + '.php' + rest;
});
const res = await fetch(window.API_BASE + fullPath, { ...opts, headers });
if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
return res.json();
};
// ── Formatting ────────────────────────────────────────────────
// formatPKR: admin-only, always shows raw PKR. Public prices use .
window.formatPKR = function(val) {
if (!val) return '—';
return '₨ ' + Number(val).toLocaleString();
};
// Alias kept so any legacy code doesn't break during migration
window.formatPrice = window.formatPKR;
window.formatDate = function(d) {
if (!d) return '—';
return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
};
window.timeAgo = function(d) {
if (!d) return '';
const diff = Date.now() - new Date(d).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return Math.floor(hrs / 24) + 'd ago';
};
window.readingTime = function(html) {
const words = (html || '').replace(/<[^>]+>/g, '').split(/\s+/).length;
return Math.max(1, Math.round(words / 200));
};
// ── Seed / Fallback Data ──────────────────────────────────────
window.SEED_PRODUCTS = [
{ id: 8, name: 'Saffron Veil Lehenga', col: 'Lehenga', status: 'AVAILABLE', priceValue: 268000, tone: 'gold',
desc: 'Saffron lehenga with hand-couched gold dabka and a sheer silk veil.',
fabric_detail: 'Raw silk base with gold dabka threadwork. Sheer chiffon dupatta with scalloped border.',
care: 'Dry clean only. Store flat, avoid direct sunlight.',
delivery_weeks: 8, tags: 'bridal,lehenga,saffron,gold',
image_url: 'https://aurellebride.com/uploads/177c6a178b39a0b6_Lehnga.jpg', date: '2026-04-10',
gallery: [{ url: 'https://aurellebride.com/uploads/177c6a178b39a0b6_Lehnga.jpg', label: 'On model' },
{ url: 'https://aurellebride.com/uploads/8bfc7416e2aa0556_ChatGPT Image May 2, 2026, 01_42_12 PM.png', label: 'Embroidery detail' }] },
{ id: 9, name: 'Bridal Gown', col: 'Gown', status: '1 OF 1', priceValue: 298000, tone: 'default',
desc: 'Black silk-satin couture gown for the modern reception bride.',
fabric_detail: 'Heavyweight silk-satin with hand-set crystal embellishments on bodice.',
care: 'Professional dry clean only.',
delivery_weeks: 10, tags: 'bridal,gown,black,reception',
image_url: 'https://aurellebride.com/uploads/7a08b8243982c91a_bridal gown.jpg', date: '2026-04-12',
gallery: [{ url: 'https://aurellebride.com/uploads/7a08b8243982c91a_bridal gown.jpg', label: 'On model' }] },
{ id: 1777709176119, name: 'Bridal Mexi', col: 'Bridal Mexi', status: '1 OF 1', priceValue: 250000, tone: 'rich',
desc: 'A richly embroidered mexi silhouette in deep blush.',
delivery_weeks: 8, tags: 'bridal,mexi,blush',
image_url: 'https://aurellebride.com/uploads/14d49814aaea6b7f_bridal mexi.jpg', date: '2026-05-02',
gallery: [{ url: 'https://aurellebride.com/uploads/14d49814aaea6b7f_bridal mexi.jpg', label: 'On model' }] },
{ id: 1777711526393, name: 'Sharara Pink', col: 'Sharara', status: '1 OF 1', priceValue: 150000, tone: 'rich',
desc: 'Blush-pink sharara with fine kiran border and organza kameez.',
delivery_weeks: 7, tags: 'bridal,sharara,pink',
image_url: 'https://aurellebride.com/uploads/2f586c5dcc02e4b7_Sharara.jpg', date: '2026-05-02',
gallery: [{ url: 'https://aurellebride.com/uploads/2f586c5dcc02e4b7_Sharara.jpg', label: 'On model' }] },
{ id: 1777711611120, name: 'Anarkali', col: 'Anarkali', status: '1 OF 1', priceValue: 225000, tone: 'rich',
desc: 'Floor-length anarkali with mirror-work yoke and flared skirt.',
delivery_weeks: 9, tags: 'bridal,anarkali',
image_url: 'https://aurellebride.com/uploads/9bc496a9d070d6fc_Anarkali.jpg', date: '2026-05-02',
gallery: [{ url: 'https://aurellebride.com/uploads/9bc496a9d070d6fc_Anarkali.jpg', label: 'On model' }] },
{ id: 1777711644161, name: 'New Garara', col: 'Gharara', status: '1 OF 1', priceValue: 175000, tone: 'rich',
desc: 'Ivory gharara with vintage gota patti work and raw silk kameez.',
delivery_weeks: 8, tags: 'bridal,gharara,ivory',
image_url: 'https://aurellebride.com/uploads/b17c151b9d328450_garara.jpg', date: '2026-05-02',
gallery: [{ url: 'https://aurellebride.com/uploads/b17c151b9d328450_garara.jpg', label: 'On model' }] },
];
window.SEED_REVIEWS = [
{ id: 1, name: 'Sana M.', location: 'Dubai, UAE', occasion: 'Wedding · Jan 2026', rating: 5,
title: 'Worth every penny', body: 'I wore the Saffron Veil Lehenga for my walima and received compliments all night. The craftsmanship is extraordinary — you can feel the hand-work in every inch.', featured: 1, status: 'approved' },
{ id: 2, name: 'Ayesha K.', location: 'London, UK', occasion: 'Engagement · Dec 2025', rating: 5,
title: 'A dream come true', body: 'Aurelle created my custom lehenga from scratch. The team was patient with my measurements and the final result was beyond what I imagined.', featured: 1, status: 'approved' },
{ id: 3, name: 'Zara R.', location: 'Karachi, PK', occasion: 'Barat · Feb 2026', rating: 5,
title: 'Truly one of a kind', body: 'The detail on the embroidery is something I have never seen from any other atelier. Every stitch feels intentional. My mother cried when she saw me.', featured: 1, status: 'approved' },
];
// ══════════════════════════════════════════════════════════════
// HOOKS
// ══════════════════════════════════════════════════════════════
window.useToast = function() {
const [toast, setToast] = useState(null);
const show = useCallback((msg, type = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3400);
}, []);
return { toast, show };
};
window.useReveal = function(ref) {
useEffect(() => {
if (!ref.current) return;
const obs = new IntersectionObserver(
entries => entries.forEach(e => e.isIntersecting && e.target.classList.add('in')),
{ threshold: 0.08 }
);
ref.current.querySelectorAll('.reveal').forEach(el => obs.observe(el));
return () => obs.disconnect();
}, [ref]);
};
window.useProducts = function() {
const [products, setProducts] = useState(window.SEED_PRODUCTS);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetch('/api/products.php')
.then(r => r.json())
.then(d => { if (Array.isArray(d) && d.length) setProducts(d); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
return { products, setProducts, loading };
};
// ══════════════════════════════════════════════════════════════
// UI PRIMITIVES
// ══════════════════════════════════════════════════════════════
window.Spinner = function Spinner({ size = 20 }) {
return (
);
};
window.Toast = function Toast({ toast }) {
if (!toast) return null;
return {toast.msg}
;
};
window.Modal = function Modal({ open, onClose, title, children, maxW = '620px' }) {
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [open]);
if (!open) return null;
return (
e.target === e.currentTarget && onClose()}>
);
};
window.Field = function Field({ label, required, error, hint, children, style }) {
return (
{label && (
)}
{children}
{error && ⚠ {error}}
);
};
window.StatusPill = function StatusPill({ status }) {
const s = (status || '').toLowerCase().replace(/\s+/g, '-');
return {status};
};
window.StarRating = function StarRating({ value = 5, onChange, size = 20 }) {
const [hover, setHover] = useState(0);
return (
{[1, 2, 3, 4, 5].map(n => (
onChange && onChange(n)}
onMouseEnter={() => onChange && setHover(n)}
onMouseLeave={() => onChange && setHover(0)}
style={{
fontSize: size, color: n <= (hover || value) ? 'var(--gold)' : 'var(--line-2)',
cursor: onChange ? 'pointer' : 'default', transition: 'color .15s',
}}>★
))}
);
};
window.VideoEmbed = function VideoEmbed({ provider, videoId, videoUrl, thumbnail, title = '' }) {
const src = {
vimeo: `https://player.vimeo.com/video/${videoId}?dnt=1&byline=0&portrait=0&title=0`,
youtube: `https://www.youtube-nocookie.com/embed/${videoId}?rel=0`,
bunny: videoUrl,
self: videoUrl,
}[provider] || videoUrl;
if (provider === 'bunny' || provider === 'self') {
return (
);
}
return (
);
};
// ══════════════════════════════════════════════════════════════
// MEASUREMENTS FORM
// ══════════════════════════════════════════════════════════════
window.MeasurementsForm = function MeasurementsForm({ value, onChange }) {
const fields = [
{ key: 'bust', label: 'Bust', hint: 'fullest point' },
{ key: 'waist', label: 'Waist', hint: 'natural waist' },
{ key: 'hips', label: 'Hips', hint: 'fullest point' },
{ key: 'height', label: 'Height', hint: 'without shoes' },
{ key: 'shoulder', label: 'Shoulder', hint: 'seam to seam' },
{ key: 'sleeve_length', label: 'Sleeve Length', hint: 'shoulder to wrist' },
{ key: 'shirt_length', label: 'Shirt / Kameez Length', hint: '' },
{ key: 'trouser_length', label: 'Trouser / Pyjama Length', hint: '' },
];
const u = value.unit || 'cm';
const set = (k, v) => onChange({ ...value, [k]: v });
return (
{fields.map(f => (
set(f.key, e.target.value)} />
))}
💡 Don't have a tailor nearby?
WhatsApp us a photo of yourself in fitted clothing with a measuring tape — our team will guide you through it.
);
};
// ══════════════════════════════════════════════════════════════
// PRODUCT CARD
// ══════════════════════════════════════════════════════════════
window.ProductCard = function ProductCard({ product, onClick, index = 0 }) {
const statusColor = { 'AVAILABLE': 'badge', 'SOLD': 'badge-ink', '1 OF 1': 'badge', 'ARCHIVED': 'badge-ink' };
const cls = statusColor[product.status] || 'badge-ink';
return (
onClick && onClick(product)}
role="button" tabIndex={0}
onKeyDown={e => e.key === 'Enter' && onClick && onClick(product)}
aria-label={`View ${product.name}`}>
{product.image_url
?

:
}
{product.status}
{product.name}
{product.col}
{product.desc &&
{product.desc}
}
{product.delivery_weeks && (
~{product.delivery_weeks}w delivery
)}
);
};
// ══════════════════════════════════════════════════════════════
// REVIEW CARD
// ══════════════════════════════════════════════════════════════
window.ReviewCard = function ReviewCard({ review }) {
const initials = (review.name || '?').split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
return (
{review.title &&
{review.title}
}
"{review.body}"
{initials}
{review.name}
{[review.occasion, review.location].filter(Boolean).join(' · ')}
);
};