// HOS — Admin / back-office panel // Same brand palette, dense layout, real interactions on mock data. /* ───────────── Mock back-office data ───────────── */ // Generate 30-day sales series with some natural waviness const ADMIN_SALES_30D = (() => { const arr = []; const today = new Date(); let base = 3200; for (let i = 29; i >= 0; i--) { const d = new Date(today); d.setDate(today.getDate() - i); // gentle sine + noise + weekend lift const wave = Math.sin(i * 0.42) * 900; const noise = (Math.random() - 0.4) * 1100; const weekend = (d.getDay() === 0 || d.getDay() === 6) ? 1200 : 0; const v = Math.max(900, Math.round(base + wave + noise + weekend)); arr.push({ date: d, value: v }); } return arr; })(); const ADMIN_KPIS = (() => { const total = ADMIN_SALES_30D.reduce((s, d) => s + d.value, 0); const prevTotal = total * 0.83; const orders = 312; return [ { id:'revenue', label:'Revenue (30d)', value:`$${total.toLocaleString()}`, delta:+18.2, accent:'rose' }, { id:'orders', label:'Orders (30d)', value: orders.toString(), delta:+12.4, accent:'rose' }, { id:'aov', label:'Avg. order', value:`$${Math.round(total/orders)}`, delta:+5.1, accent:'rose' }, { id:'cvr', label:'Conversion', value:'3.42%', delta:-0.6, accent:'rose' }, ]; })(); const ADMIN_ORDERS = [ { id:'1208', customer:'Léa Bernard', email:'lea.b@email.com', total:248, status:'paid', items:1, date:'2026-06-04 · 14:32', country:'FR', address:'12 Rue de Rivoli, 75001 Paris, France', trackingNumber:'', notes:'', lineItems:[{name:'Aurelie Silk Slip Dress', color:'Blush', size:'S', qty:1, price:248, img:'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=80&q=60'}] }, { id:'1207', customer:'Imane Saadi', email:'imane.s@email.com', total:412, status:'paid', items:2, date:'2026-06-04 · 11:08', country:'TN', address:'45 Avenue Habib Bourguiba, 1000 Tunis, Tunisia', trackingNumber:'', notes:'', lineItems:[{name:'Adèle Bias Maxi Dress', color:'Champagne', size:'M', qty:1, price:248, img:'https://images.unsplash.com/photo-1572804013309-59a88b7e92f1?w=80&q=60'},{name:'Solene Knot Top',color:'Ivory',size:'S',qty:1,price:164,img:'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=80&q=60'}] }, { id:'1206', customer:'Sophie Tremblay', email:'sophie.t@email.com', total:184, status:'shipped', items:1, date:'2026-06-04 · 09:47', country:'CA', address:'220 Laurier Ave W, Ottawa ON K1P 5Z9, Canada', trackingNumber:'DHL-4928374651', notes:'', lineItems:[{name:'Mireille Pleated Skirt', color:'Blush', size:'XS', qty:1, price:184, img:'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=80&q=60'}] }, { id:'1205', customer:'Camille Roy', email:'camille.r@email.com', total:325, status:'fulfilled', items:1, date:'2026-06-03', country:'FR', address:'8 Rue du Faubourg Saint-Antoine, 75011 Paris', trackingNumber:'DHL-3817263540', notes:'Gift wrap requested.', lineItems:[{name:'Margaux Tailored Blazer', color:'Cocoa', size:'M', qty:1, price:325, img:'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=80&q=60'}] }, { id:'1204', customer:'Olivia Park', email:'olivia.p@email.com', total:498, status:'paid', items:2, date:'2026-06-03', country:'CA', address:'1020 Burrard St, Vancouver BC V6Z 1Y5, Canada', trackingNumber:'', notes:'', lineItems:[{name:'Eloise Satin Slip', color:'Ivory', size:'S', qty:1, price:268, img:'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=80&q=60'},{name:'Romy Cropped Cardigan',color:'Blush',size:'XS',qty:1,price:230,img:'https://images.unsplash.com/photo-1572804013309-59a88b7e92f1?w=80&q=60'}] }, { id:'1203', customer:'Yasmine Khaled', email:'yasmine.k@email.com', total:168, status:'refunded', items:1, date:'2026-06-02', country:'TN', address:'15 Rue Ibn Khaldoun, 3000 Sfax, Tunisia', trackingNumber:'DHL-2736451823', notes:'Return received 2026-06-01.', lineItems:[{name:'Romy Cropped Cardigan', color:'Champagne', size:'M', qty:1, price:168, img:'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=80&q=60'}] }, { id:'1202', customer:'Anaïs Lemay', email:'anais.l@email.com', total:198, status:'shipped', items:1, date:'2026-06-02', country:'CA', address:'2100 Mackay St, Montréal QC H3G 2J1, Canada', trackingNumber:'DHL-1837465029', notes:'', lineItems:[{name:'Lila Wide-Leg Trousers', color:'Ivory', size:'S', qty:1, price:198, img:'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=80&q=60'}] }, { id:'1201', customer:'Margaux Petit', email:'margaux.p@email.com', total:368, status:'fulfilled', items:1, date:'2026-06-01', country:'FR', address:'3 Allée des Roses, 69001 Lyon, France', trackingNumber:'DHL-0928374651', notes:'', lineItems:[{name:'Adèle Bias Maxi Dress', color:'Blush', size:'S', qty:1, price:368, img:'https://images.unsplash.com/photo-1572804013309-59a88b7e92f1?w=80&q=60'}] }, { id:'1200', customer:'Rim Ben Salah', email:'rim.bs@email.com', total:124, status:'fulfilled', items:1, date:'2026-06-01', country:'TN', address:'7 Rue de Marseille, 5000 Monastir, Tunisia', trackingNumber:'DHL-9182736450', notes:'', lineItems:[{name:'Solene Knot Top', color:'Cocoa', size:'XS', qty:1, price:124, img:'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=80&q=60'}] }, { id:'1199', customer:'Juliette Marec', email:'juliette.m@email.com',total:295, status:'fulfilled', items:1, date:'2026-05-31', country:'FR', address:'18 Boulevard des Capucines, 75009 Paris, France', trackingNumber:'DHL-8273645019', notes:'', lineItems:[{name:'Celeste Linen Co-ord Set', color:'Champagne', size:'M', qty:1, price:295, img:'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=80&q=60'}] }, ]; const ADMIN_TICKETS = [ { id:'T-309', customer:'Léa Bernard', email:'lea.b@email.com', subject:'Size exchange for Aurelie slip', status:'open', priority:'normal', date:'24 min ago', unread:true, snippet:"Hi, I’d love to exchange the size S for M — the fit is beautiful but slightly small at the bust…", messages:[{ id:1, from:'customer', author:'Léa Bernard', body:"Hi,\n\nI’d love to exchange the size S for M — the fit is beautiful but slightly small at the bust. Is it still possible to do an exchange?\n\nBest,\nLéa", date:'24 min ago' }] }, { id:'T-308', customer:'Sophie Tremblay', email:'sophie.t@email.com', subject:'When will my order ship?', status:'replied', priority:'normal', date:'1 h ago', unread:false, snippet:"Hello, my order #1206 was placed two days ago and hasn’t shipped yet…", messages:[ { id:1, from:'customer', author:'Sophie Tremblay', body:"Hello,\n\nMy order #1206 was placed two days ago and hasn’t shipped yet — do you have an estimated dispatch date?\n\nThanks,\nSophie", date:'3 h ago' }, { id:2, from:'team', author:'House of Sheylas', body:"Hi Sophie,\n\nYour order has been picked and is being packed. You’ll receive a DHL tracking confirmation within 24 hours.\n\nWarmly,\nThe HOS Team", date:'1 h ago' } ]}, { id:'T-307', customer:'Olivia Park', email:'olivia.p@email.com', subject:'Combine two orders into one parcel?', status:'open', priority:'low', date:'3 h ago', unread:true, snippet:"Hi team, I placed two orders this morning and was wondering if you could ship them together…", messages:[{ id:1, from:'customer', author:'Olivia Park', body:"Hi team,\n\nI placed two orders this morning and was wondering if you could ship them together to save on packaging?\n\nThanks!\nOlivia", date:'3 h ago' }] }, { id:'T-306', customer:'Yasmine Khaled', email:'yasmine.k@email.com',subject:'Refund question', status:'open', priority:'high', date:'Yesterday', unread:true, snippet:"Hello, I returned the Romy cardigan a week ago — when can I expect the refund…", messages:[{ id:1, from:'customer', author:'Yasmine Khaled', body:"Hello,\n\nI returned the Romy cardigan a week ago — the carrier confirmed delivery on May 28. When can I expect the refund to appear on my card?\n\nBest,\nYasmine", date:'Yesterday · 09:14' }] }, { id:'T-305', customer:'Press @ Vogue', email:'press@vogue.fr', subject:'Press loan request — Spring Bloom', status:'replied', priority:'high', date:'Yesterday', unread:false, snippet:"Bonjour, we’d love to feature pieces from the Spring Bloom Édition in our April issue…", messages:[ { id:1, from:'customer', author:'Vogue Press Team', body:"Bonjour,\n\nWe’d love to feature pieces from the Spring Bloom Édition in our April issue. Could we arrange a press loan of 3–5 looks for a shoot on June 12?\n\nMerci,\nVogue FR", date:'Yesterday · 11:30' }, { id:2, from:'team', author:'House of Sheylas', body:"Bonjour,\n\nThank you — we’d be delighted to collaborate! Our PR team will follow up with loan agreement details by EOD.\n\nÀ bientôt,\nThe HOS Team", date:'Yesterday · 14:20' } ]}, { id:'T-304', customer:'Rim Ben Salah', email:'rim.bs@email.com', subject:'Wholesale enquiry', status:'closed', priority:'normal', date:'2 days ago', unread:false, snippet:"Bonjour, je tiens une concept-store à Tunis — j’aimerais discuter d’une collaboration…", messages:[ { id:1, from:'customer', author:'Rim Ben Salah', body:"Bonjour,\n\nJe tiens une concept-store à Tunis — j’aimerais discuter d’une collaboration wholesale. Pouvez-vous partager vos conditions?\n\nCordialement,\nRim", date:'2 days ago · 10:00' }, { id:2, from:'team', author:'House of Sheylas', body:"Bonjour Rim,\n\nMerci pour votre intérêt! Notre équipe wholesale va vous contacter avec notre catalogue B2B et nos conditions.\n\nÀ très bientôt,\nHOS", date:'2 days ago · 15:00' } ]}, { id:'T-303', customer:'Anaïs Lemay', email:'anais.l@email.com', subject:'Pricing in CAD', status:'replied', priority:'low', date:'2 days ago', unread:false, snippet:"Hello, do you offer prices in Canadian dollars for shipments to Canada?", messages:[ { id:1, from:'customer', author:'Anaïs Lemay', body:"Hello, do you offer prices in Canadian dollars for shipments to Canada? The USD conversion makes it a bit tricky to budget.", date:'2 days ago · 08:30' }, { id:2, from:'team', author:'House of Sheylas', body:"Hi Anaïs,\n\nAt the moment we invoice in USD, but your bank will convert at the daily rate. We’re exploring multi-currency support for later this year!\n\nThank you,\nHOS", date:'2 days ago · 11:00' } ]}, ]; const ADMIN_TOP_PRODUCTS = [ { id:'bs2', name:'Adèle Bias Maxi Dress', sold:48, revenue:48*368 }, { id:'na1', name:'Aurelie Silk Slip Dress', sold:42, revenue:42*248 }, { id:'bs1', name:'Margaux Tailored Blazer', sold:31, revenue:31*325 }, { id:'na4', name:'Sienna Wrap Blouse', sold:28, revenue:28*148 }, { id:'bs3', name:'Camille Cashmere Knit', sold:24, revenue:24*285 }, ]; const ADMIN_CHANNELS = [ { name:'Direct', pct:46, color:'#B86A78' }, { name:'Instagram',pct:24, color:'#4A2D33' }, { name:'Search', pct:18, color:'#F6D6DA' }, { name:'Other', pct:12, color:'#F2E3DC' }, ]; const ADMIN_COUNTRIES = [ { code:'FR', name:'France', pct:42 }, { code:'CA', name:'Canada', pct:31 }, { code:'TN', name:'Tunisia', pct:14 }, { code:'US', name:'United States', pct:8 }, { code:'·', name:'Other', pct:5 }, ]; /* ───────────── Admin state context ───────────── */ const AdminCtx = createContext(null); function AdminProvider({ children }) { const { siteData, updateSiteData, resetSiteData } = useSiteData(); const products = siteData.products; const categories = siteData.categories.map((category) => ({ ...category, productCount: siteData.products.filter((product) => product.category === category.id).length, })); const [orders, setOrders] = useState(ADMIN_ORDERS); const [tickets, setTickets] = useState(ADMIN_TICKETS); const [toast, setToast] = useState(null); const flash = (msg, tone='success') => { setToast({ msg, tone, ts: Date.now() }); setTimeout(() => setToast(null), 2400); }; const saveProduct = (p) => { updateSiteData((current) => { const nextProducts = [...current.products]; const index = nextProducts.findIndex((item) => item.id === p.id); if (index >= 0) { nextProducts[index] = { ...nextProducts[index], ...p }; } else { nextProducts.unshift({ ...p, id: p.id || `new-${Date.now()}` }); } return { ...current, products: nextProducts }; }); flash('Product saved'); }; const deleteProduct = (id) => { updateSiteData((current) => ({ ...current, products: current.products.filter((product) => product.id !== id), })); flash('Product deleted','warn'); }; const adjustStock = (id, delta) => { updateSiteData((current) => ({ ...current, products: current.products.map((product) => product.id === id ? { ...product, stock: Math.max(0, (product.stock || 0) + delta) } : product ), })); }; const setStock = (id, value) => { updateSiteData((current) => ({ ...current, products: current.products.map((product) => product.id === id ? { ...product, stock: Math.max(0, value) } : product ), })); flash('Stock updated'); }; const restock = (id, amount = 40) => { updateSiteData((current) => ({ ...current, products: current.products.map((product) => product.id === id ? { ...product, stock: (product.stock || 0) + amount } : product ), })); flash(`Restocked +${amount}`); }; const saveCategory = (c) => { updateSiteData((current) => { const nextCategories = [...current.categories]; const index = nextCategories.findIndex((item) => item.id === c.id); const category = { ...c, id: c.id || `cat-${Date.now()}`, subcategories: c.subcategories || [], }; if (index >= 0) nextCategories[index] = { ...nextCategories[index], ...category }; else nextCategories.unshift(category); return { ...current, categories: nextCategories }; }); flash('Category saved'); }; const deleteCategory = (id) => { updateSiteData((current) => ({ ...current, categories: current.categories.filter((category) => category.id !== id), })); flash('Category deleted','warn'); }; const savePages = (pages) => { updateSiteData((current) => ({ ...current, pages })); flash('Pages saved'); }; const saveContent = (key, value) => { updateSiteData((current) => ({ ...current, [key]: value })); flash('Content saved'); }; const saveSettings = (settings) => { updateSiteData((current) => ({ ...current, settings: { ...current.settings, ...settings } })); flash('Settings saved'); }; const updateOrderStatus = (id, status) => { setOrders(c => c.map(o => o.id === id ? {...o, status} : o)); flash('Order updated'); }; const updateOrderField = (id, patch) => { setOrders(c => c.map(o => o.id === id ? {...o, ...patch} : o)); }; const updateTicket = (id, patch) => { setTickets(c => c.map(t => t.id === id ? {...t, ...patch} : t)); }; const addTicketMessage = (id, msg) => { setTickets(c => c.map(t => t.id === id ? { ...t, messages: [...(t.messages || []), msg], status: 'replied', unread: false } : t )); flash('Reply sent'); }; return ( {children} ); } const useAdmin = () => useContext(AdminCtx); /* ───────────── Common admin pieces ───────────── */ function StatusPill({ status }) { const map = { paid: { bg:'bg-blush', text:'text-deep', label:'Paid' }, shipped: { bg:'bg-champagne', text:'text-deep', label:'Shipped' }, fulfilled: { bg:'bg-rose/15', text:'text-rose', label:'Fulfilled' }, refunded: { bg:'bg-deep/10', text:'text-deep/70', label:'Refunded' }, live: { bg:'bg-rose/15', text:'text-rose', label:'Live' }, draft: { bg:'bg-deep/10', text:'text-deep/70', label:'Draft' }, open: { bg:'bg-rose', text:'text-ivory', label:'Open' }, replied: { bg:'bg-champagne', text:'text-deep', label:'Replied' }, closed: { bg:'bg-deep/10', text:'text-deep/60', label:'Closed' }, high: { bg:'bg-rose', text:'text-ivory', label:'High' }, normal: { bg:'bg-deep/10', text:'text-deep/70', label:'Normal' }, low: { bg:'bg-blush-2', text:'text-deep/70', label:'Low' }, }; const m = map[status] || { bg:'bg-deep/10', text:'text-deep/70', label:status }; return ( {m.label} ); } function AdminCard({ children, className = '', accent = false }) { return (
{children}
); } function AdminToast() { const a = useAdmin(); if (!a?.toast) return null; return (
{a.toast.msg}
); } /* ───────────── Charts ───────────── */ function SalesChart({ data }) { const W = 720, H = 220, P = 28; const max = Math.max(...data.map(d => d.value)) * 1.15; const min = 0; const x = (i) => P + (i / (data.length - 1)) * (W - P*2); const y = (v) => H - P - ((v - min) / (max - min)) * (H - P*2); const points = data.map((d, i) => [x(i), y(d.value)]); const linePath = points.map((p, i) => `${i===0?'M':'L'} ${p[0]} ${p[1]}`).join(' '); const areaPath = `${linePath} L ${points[points.length-1][0]} ${H-P} L ${points[0][0]} ${H-P} Z`; const [hover, setHover] = useState(null); const days = data.map(d => d.date); const dayLabels = [0, 7, 14, 21, 29].map(i => ({ x: x(i), label: days[i] ? days[i].toLocaleDateString('en', { day:'numeric', month:'short' }) : '', })); return (
{/* grid lines */} {[0.25, 0.5, 0.75].map(t => ( ))} {/* area */} {/* line */} {/* hover marker */} {hover !== null && ( <> )} {/* hit areas */} {data.map((d, i) => ( setHover(i)} onMouseLeave={() => setHover(null)} /> ))} {/* x labels */} {dayLabels.map((l, i) => ( {l.label} ))} {hover !== null && (
{data[hover].date.toLocaleDateString('en', { weekday:'short', day:'numeric', month:'short' })}
${data[hover].value.toLocaleString()}
)}
); } function DonutChart({ segments, size = 140 }) { const r = size/2 - 18; const c = 2 * Math.PI * r; let offset = 0; return ( {segments.map((s, i) => { const len = (s.pct / 100) * c; const el = ( ); offset += len; return el; })} ); } /* ───────────── Admin shell ───────────── */ function AdminShell({ children, active }) { const a = useAdmin(); const auth = useAdminAuth(); const [open, setOpen] = useState(false); // mobile sidebar const links = [ { id:'home', label:'Dashboard', href:'#admin', icon: AdminIcons.dash }, { id:'products', label:'Products', href:'#admin/products', icon: AdminIcons.bag, count: a.products.length }, { id:'categories', label:'Categories', href:'#admin/categories',icon: AdminIcons.grid, count: a.categories.length }, { id:'content', label:'Content', href:'#admin/content', icon: AdminIcons.edit, count: a.pages.length }, { id:'inventory', label:'Inventory', href:'#admin/inventory', icon: AdminIcons.layers, count: a.products.filter(p => (p.stock||0) < 10).length, dot: a.products.some(p => (p.stock||0) < 10) }, { id:'orders', label:'Orders', href:'#admin/orders', icon: AdminIcons.box, count: a.orders.length }, { id:'tickets', label:'Tickets', href:'#admin/tickets', icon: AdminIcons.chat, count: a.tickets.filter(t => t.unread).length, dot:true }, { id:'analytics', label:'Analytics', href:'#admin/analytics', icon: AdminIcons.chart }, { id:'settings', label:'Settings', href:'#admin/settings', icon: AdminIcons.cog }, ]; return (
{/* Sidebar */} {/* Overlay for mobile */} {open &&
setOpen(false)}/>} {/* Main */}
{/* Top bar */}
House of Sheylas · Admin
New product
{children}
); } /* ───────────── Icons (admin-specific minimal set) ───────────── */ const AdminIcons = { dash: (p) => , bag: (p) => , grid: (p) => , box: (p) => , chat: (p) => , chart: (p) => , cog: (p) => , bell: (p) => , exit: (p) => , trash: (p) => , edit: (p) => , dots: (p) => , upload:(p) => , filter:(p) => , layers:(p) => , alert: (p) => , trend: (p) => , }; /* ───────────── Page: Dashboard ───────────── */ function AdminDashboard() { const a = useAdmin(); return (
{/* KPIs */}
{ADMIN_KPIS.map(k => (
{k.label}
{k.value}
= 0 ? 'text-rose' : 'text-deep/60'}`}> {k.delta >= 0 ? '↑' : '↓'} {Math.abs(k.delta)}% vs previous
))}
{/* Sales chart + channels */}
Revenue · Last 30 days
${ADMIN_SALES_30D.reduce((s,d)=>s+d.value,0).toLocaleString()}
{['7d','30d','90d','12m'].map((r, i) => ( ))}
{/* Channels donut */}
Traffic sources
    {ADMIN_CHANNELS.map(c => (
  • {c.name} {c.pct}%
  • ))}
{/* Top products + recent orders + tickets */}
{/* Top products */}
Top products
View all
    {ADMIN_TOP_PRODUCTS.map((p, i) => { const full = ALL_PRODUCTS.find(x => x.id === p.id); return (
  • 0{i+1}
    {full && }
    {p.name}
    {p.sold} sold
    ${p.revenue.toLocaleString()}
  • ); })}
{/* Recent orders */}
Recent orders
View all
{a.orders.slice(0, 6).map(o => ( ))}
Order Customer Date Status Total
#{o.id} {o.customer} {o.date} ${o.total}
{/* Tickets + Geography */}
Latest tickets
Go to inbox
    {a.tickets.slice(0, 4).map(t => (
  • {t.customer} · {t.date}
    {t.subject}
    {t.snippet}
  • ))}
By country
    {ADMIN_COUNTRIES.map(c => (
  • {c.name} {c.pct}%
  • ))}
{/* Inventory health */}
Inventory health
Manage stock
{(() => { const units = a.products.reduce((s,p) => s + (p.stock||0), 0); const value = a.products.reduce((s,p) => s + (p.stock||0)*(p.price||0), 0); const low = a.products.filter(p => (p.stock||0) > 0 && (p.stock||0) < 10); const out = a.products.filter(p => (p.stock||0) === 0).length; return (
{[ ['Units in stock', units.toLocaleString()], ['Inventory value', `$${value.toLocaleString()}`], ['Low stock', low.length, low.length>0], ['Out of stock', out, out>0], ].map(([label, val, warn]) => (
{label}
{val}
))} {low.length > 0 && (
Running low: {low.slice(0,5).map(p => ( {p.name} {p.stock} left ))}
)}
); })()}
); } function PageHeading({ title, sub, actions }) { return (

{title}

{sub &&

{sub}

}
{actions &&
{actions}
}
); } /* ───────────── Page: Products list ───────────── */ function AdminProductsPage() { const a = useAdmin(); const [filter, setFilter] = useState('all'); const [q, setQ] = useState(''); const rows = useMemo(() => { let arr = a.products; if (filter !== 'all') arr = arr.filter(p => p.status === filter); if (q) arr = arr.filter(p => p.name.toLowerCase().includes(q.toLowerCase())); return arr; }, [a.products, filter, q]); return (
Add product } /> {/* Toolbar */}
{[['all','All'], ['live','Live'], ['draft','Drafts']].map(([k, l]) => ( ))}
setQ(e.target.value)} placeholder="Search products…" className="w-full bg-blush-2/40 border border-transparent hover:border-deep/15 focus:border-deep focus:bg-ivory focus:outline-none transition-all pl-9 pr-3 py-2 font-sans text-[12px] tracking-wider-2 text-deep placeholder:text-deep/40"/>
{/* Table */}
{rows.map(p => ( ))}
Product Category Price Stock Status Actions
{p.name}
{p.id}
{p.category} ${p.price}
{p.stock} {p.stock < 10 && Low}
{rows.length === 0 && (
Nothing here

No products match your filters.

)}
{rows.length} of {a.products.length} pieces
1 / 1
); } /* ───────────── Variant / media editors ───────────── */ function ColorComposer({ onAdd }) { const [duo, setDuo] = useState(false); const [a, setA] = useState('#B86A78'); const [b, setB] = useState('#2A4C7D'); const [name, setName] = useState(''); const add = () => { const color = duo ? { name: name.trim() || 'Two-tone', hex: a, hex2: b } : { name: name.trim() || a.toUpperCase(), hex: a }; onAdd(color); setName(''); }; const preview = { background: duo ? `linear-gradient(135deg, ${a} 0 50%, ${b} 50% 100%)` : a }; return (
Create a colour
{duo && ( )} setName(e.target.value)} placeholder="Name (optional)" className="admin-input !w-[150px] !py-2 self-end"/>
{duo && (

Split swatch — half {a.toUpperCase()}, half {b.toUpperCase()}.

)}
); } function TagEditor({ tags, onChange }) { const [input, setInput] = useState(''); const presets = ['New','Just in','Limited','Bestseller','Most loved','Restocked','Back soon','Exclusive']; const addTag = (t) => { const v = (t || '').trim(); if (!v || tags.some(x => x.toLowerCase() === v.toLowerCase())) { setInput(''); return; } onChange([...tags, v]); setInput(''); }; return (
{tags.length > 0 && (
{tags.map((t, i) => ( {i === 0 && } {t} ))}
)}
setInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addTag(input); } }} placeholder="Type a tag, press Enter…" className="admin-input"/>
{presets.filter(p => !tags.some(t => t.toLowerCase() === p.toLowerCase())).map(p => ( ))}

The first tag shows as the badge on the storefront.

); } function ImageUploader({ value, onChange }) { const inputRef = useRef(null); const [drag, setDrag] = useState(false); const isData = typeof value === 'string' && value.startsWith('data:'); const readFile = (file) => { if (!file || !file.type.startsWith('image/')) return; const fr = new FileReader(); fr.onload = () => onChange(fr.result); fr.readAsDataURL(file); }; return ( <>
inputRef.current && inputRef.current.click()} onDragOver={e => { e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={e => { e.preventDefault(); setDrag(false); readFile(e.dataTransfer.files[0]); }} className={`group mt-3 relative aspect-[3/4] overflow-hidden bg-blush-2 cursor-pointer transition-all ${drag ? 'ring-2 ring-rose ring-offset-2 ring-offset-ivory' : ''}`}> {value && }
{drag ? 'Drop to upload' : value ? 'Replace image' : 'Click or drop image'}
PNG · JPG · WEBP
readFile(e.target.files[0])}/> onChange(e.target.value)} className="admin-input mt-3" placeholder="…or paste an image URL"/> {isData &&
Uploaded file attached ✓
} ); } /* ───────────── Page: Product form (new / edit) ───────────── */ function AdminProductFormPage({ productId }) { const a = useAdmin(); const editing = productId && productId !== 'new'; const existing = editing ? a.products.find(p => p.id === productId) : null; const [form, setForm] = useState(() => { if (existing) return { ...existing, tags: existing.tags || (existing.tag ? [existing.tag] : []) }; return { name: '', price: 0, category: 'dresses', stock: 20, status: 'draft', description: '', img: IMG('photo-1539109136881-3be0616acf4b', 700), colors: [SW.blush, SW.ivory], tags: ['New'], tag: 'New', }; }); const set = (k, v) => setForm(f => ({...f, [k]: v})); const setTags = (tags) => setForm(f => ({...f, tags, tag: tags[0] || ''})); const onSave = (e) => { e.preventDefault(); a.saveProduct(form); window.location.hash = '#admin/products'; }; return (
Cancel } />
{/* Main form */}
Basics
set('name', e.target.value)} className="admin-input" placeholder="Aurelie Silk Slip Dress"/> set('id', e.target.value)} className="admin-input" placeholder="auto" disabled={editing}/>
Description