/* =========================================================================== VESTIRÉ STUDIO — root shell, routing, state & Tweaks (Supabase-backed) =========================================================================== */ const { useState, useEffect, useRef, useMemo } = React; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "visualStyle": "atelier", "accent": ["#6f8a63", "#4f6a47", "#e0e8d8"], "requestsLayout": "cards", "statusUI": "stepper" }/*EDITMODE-END*/; /* ---- layout / component CSS --------------------------------------------- */ const VS_LAYOUT_CSS = ` .gown-grid{display:grid; grid-template-columns:repeat(auto-fill,minmax(238px,1fr)); gap:24px;} .proc-grid{display:grid; grid-template-columns:repeat(4,1fr); gap:18px;} .stat-grid{display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:18px;} .form-grid{display:grid; grid-template-columns:1fr 1fr; gap:14px;} /* prevent flex/grid children from overflowing and text overlapping neighbours */ .field,.form-grid > *,.cart-grid > *,.detail-grid > *,.dash-grid > *,.split-grid > *,.col,.grow{min-width:0;} .input,.select,.textarea{min-width:0; max-width:100%;} .gcard h3,.disp{overflow-wrap:anywhere;} .row.between > *{min-width:0;} /* gown card */ .gcard{cursor:pointer; transition:transform .3s var(--ease);} .gcard:hover{transform:translateY(-5px);} .gcard-media{position:relative; border-radius:var(--r-lg); overflow:hidden; aspect-ratio:4/5; background:var(--surface-2); box-shadow:var(--shadow-sm);} .gcard-media img{width:100%; height:100%; object-fit:cover; transition:transform .55s var(--ease);} .gcard:hover .gcard-media img{transform:scale(1.05);} .gcard-tags{position:absolute; top:12px; left:12px; display:flex; gap:6px; flex-wrap:wrap;} .gcard-quick{position:absolute; left:12px; right:12px; bottom:12px; opacity:0; transform:translateY(8px); transition:.3s var(--ease);} .gcard:hover .gcard-quick{opacity:1; transform:none;} /* qty / icon buttons */ .qbtn{width:26px; height:26px; border-radius:50%; border:none; background:transparent; color:var(--ink); display:flex; align-items:center; justify-content:center; cursor:pointer; transition:.15s;} .qbtn:hover{background:var(--accent-soft);} .iconbtn{width:34px; height:34px; border-radius:50%; border:1px solid var(--line); background:var(--surface); color:var(--ink-2); display:flex; align-items:center; justify-content:center; cursor:pointer; transition:.18s;} .iconbtn:hover{border-color:var(--ink); color:var(--ink); background:var(--surface-2);} .iconbtn--danger:hover{border-color:var(--cancelled); color:var(--cancelled); background:var(--cancelled-bg);} /* customer nav */ .cnav{position:sticky; top:0; z-index:500; background:color-mix(in srgb, var(--paper) 82%, transparent); backdrop-filter:blur(16px); border-bottom:1px solid var(--line);} .cnav-inner{display:flex; align-items:center; justify-content:space-between; gap:20px; padding:14px 28px; max-width:1240px; margin:0 auto;} .cnav-links{display:flex; gap:6px; align-items:center;} .cnav-link{font-size:13px; font-weight:600; letter-spacing:.03em; padding:9px 14px; border-radius:50px; color:var(--ink-2); cursor:pointer; transition:.18s; position:relative;} .cnav-link:hover{color:var(--ink); background:var(--surface-2);} .cnav-link--on{color:var(--ink); background:var(--surface);} .cnav-icon{position:relative; width:40px; height:40px; border-radius:50%; display:flex; align-items:center; justify-content:center; cursor:pointer; color:var(--ink); transition:.18s;} .cnav-icon:hover{background:var(--surface-2);} .cbadge{position:absolute; top:2px; right:2px; min-width:17px; height:17px; padding:0 4px; border-radius:50px; background:var(--accent); color:var(--accent-ink); font-size:10px; font-weight:700; display:flex; align-items:center; justify-content:center;} .menu-btn{display:none;} .dropdown{position:absolute; right:0; top:48px; background:var(--surface); border:1px solid var(--line); border-radius:var(--r-md); box-shadow:var(--shadow-lg); min-width:190px; overflow:hidden; z-index:600; animation:vsPop .18s var(--ease) both;} .dropdown a{display:flex; align-items:center; gap:10px; padding:11px 16px; font-size:13.5px; font-weight:500; cursor:pointer; transition:.15s;} .dropdown a:hover{background:var(--surface-2); color:var(--accent-2);} .dropdown .sep{height:1px; background:var(--line);} /* footer */ .cfoot{background:var(--ink); color:var(--paper); margin-top:40px;} .cfoot a{color:color-mix(in srgb,var(--paper) 72%, transparent); font-size:13.5px; cursor:pointer; transition:.15s;} .cfoot a:hover{color:var(--accent);} .foot-grid{display:grid; grid-template-columns:1.6fr 1fr 1fr 1.4fr; gap:36px; padding:56px 28px 30px; max-width:1240px; margin:0 auto;} /* admin shell */ .ashell{display:grid; grid-template-columns:248px 1fr; min-height:100vh;} .asidebar{position:sticky; top:0; height:100vh; background:var(--surface); border-right:1px solid var(--line); display:flex; flex-direction:column; padding:22px 16px; gap:6px;} .anav{display:flex; align-items:center; gap:12px; padding:11px 14px; border-radius:var(--r-sm); font-size:13.5px; font-weight:600; color:var(--ink-2); cursor:pointer; transition:.16s;} .anav:hover{background:var(--surface-2); color:var(--ink);} .anav--on{background:var(--ink); color:var(--paper);} .anav--on svg{color:var(--accent);} .amain{padding:30px 36px 60px; max-width:1280px; overflow-x:hidden;} .atopbar{display:none;} @media(max-width:980px){ .proc-grid{grid-template-columns:repeat(2,1fr);} .hero-grid,.detail-grid,.cart-grid,.dash-grid,.split-grid,.req-card-grid{grid-template-columns:1fr !important;} .foot-grid{grid-template-columns:1fr 1fr;} .ashell{grid-template-columns:1fr;} .asidebar{position:fixed; left:0; top:0; bottom:0; width:240px; transform:translateX(-100%); transition:transform .3s var(--ease); z-index:700; box-shadow:var(--shadow-lg);} .asidebar--open{transform:none;} .atopbar{display:flex; align-items:center; justify-content:space-between; padding:14px 20px; border-bottom:1px solid var(--line); position:sticky; top:0; background:var(--paper); z-index:300;} .amain{padding:22px 20px 60px;} } @media(max-width:720px){ .cnav-links{display:none;} .menu-btn{display:flex;} .gown-grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:14px;} .form-grid{grid-template-columns:1fr;} .foot-grid{grid-template-columns:1fr; gap:26px;} .container{padding:0 18px;} .cnav-inner{padding:12px 18px;} } @media(max-width:440px){ .gown-grid{grid-template-columns:1fr 1fr;} } /* boot loader */ .vs-loader{min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:18px; color:var(--muted);} .vs-spin{width:30px; height:30px; border-radius:50%; border:3px solid var(--line); border-top-color:var(--accent); animation:vsSpin .8s linear infinite;} @keyframes vsSpin{to{transform:rotate(360deg);}} /* logout transition overlay */ .vs-logout{position:fixed; inset:0; z-index:9999; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:20px; background:color-mix(in srgb, var(--paper) 86%, transparent); -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px); animation:vsLogoutIn .3s var(--ease) both;} .vs-logout-mark{width:64px; height:64px; border-radius:50%; object-fit:cover; box-shadow:var(--shadow-md);} .vs-logout-ring{width:30px; height:30px; border-radius:50%; border:3px solid var(--line); border-top-color:var(--accent); animation:vsSpin .8s linear infinite;} .vs-logout-txt{font-family:var(--font-display); font-size:20px; letter-spacing:.04em; color:var(--accent-2);} @keyframes vsLogoutIn{from{opacity:0;} to{opacity:1;}} `; /* ---- CUSTOMER SHELL (top nav — guests & Home) ---------------------------- */ function CustomerShell({ ctx, children }) { const [menu, setMenu] = useState(false); const [prof, setProf] = useState(false); const cartN = ctx.cart.reduce((a, b) => a + b.qty, 0); const links = [["home", "Home"], ["browse", "Collection"], ["requests", "My Requests"], ["contact", "Contact"]]; const active = ctx.route.name; const me = ctx.currentUser; const adminOnly = !me && ctx.isAdmin; return (
ctx.nav("home")} style={{ cursor: "pointer" }}>
ctx.nav("browse")} title="Search"> ctx.nav("cart")} title="Bag"> {cartN > 0 && {cartN}}
setProf((p) => !p)} title="Account"> {prof && (
{menu && (
{links.map(([r, l]) =>
{ ctx.nav(r); setMenu(false); }}>{l}
)}
)}
{children}
); } /* ---- CUSTOMER SIDEBAR (admin-style, for signed-in users) ----------------- */ function CustomerSidebar({ ctx, children }) { const [open, setOpen] = useState(false); const cartN = ctx.cart.reduce((a, b) => a + b.qty, 0); const me = ctx.currentUser; const active = ctx.route.name; const items = [ ["home", "Home", "grid"], ["browse", "Collection", "hanger"], ["cart", "Bag", "bag"], ["requests", "My Requests", "calendar"], ["contact", "Contact", "mail"], ]; const isActive = (r) => r === active || (r === "browse" && active === "detail") || (r === "cart" && (active === "checkout" || active === "confirm")) || (r === "requests" && active === "request"); const go = (r) => { ctx.nav(r); setOpen(false); }; return (
{open &&
setOpen(false)} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.3)", zIndex: 650 }} />}
setOpen(true)}> go("cart")} title="Bag" style={{ position: "relative" }}> {cartN > 0 && {cartN}}
{children}
); } /* ---- ADMIN SHELL --------------------------------------------------------- */ function AdminShell({ ctx, children }) { const [open, setOpen] = useState(false); const items = [ ["dashboard", "Dashboard", "grid"], ["requests", "Requests", "bag"], ["gowns", "Gowns", "hanger"], ["users", "Customers", "users"], ["messages", "Messages", "mail"], ["sales", "Sales", "chart"] ]; const unread = ctx.messages.filter((m) => !m.isRead).length; const active = ctx.route.name === "gownEdit" ? "gowns" : ctx.route.name; const adminName = ctx.adminName || "Admin"; return (
{open &&
setOpen(false)} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.3)", zIndex: 650 }} />}
setOpen(true)}> ctx.logout()} title="Logout">
{children}
); } /* ---- ROOT APP ------------------------------------------------------------ */ const CUSTOMER_MAP = { home: CHome, browse: CBrowse, detail: CDetail, cart: CCart, checkout: CCheckout, confirm: CConfirm, requests: CRequests, request: CRequestDetail, profile: CProfile, contact: CContact }; const ADMIN_MAP = { dashboard: ADashboard, requests: ARequests, gowns: AGowns, gownEdit: AGownEdit, users: AUsers, messages: AMessages, sales: ASales }; const AUTH_SCREENS = { login: CLogin, register: CRegister, adminLogin: CAdminLogin }; const AUTHED_ROUTES = ["cart", "checkout", "requests", "request", "profile", "confirm"]; /* ---- history routing (clean, shareable URLs, no #) ---------------------- */ function routeToPath(mode, route) { const p = route.params || {}; let path = mode === "admin" ? ("/admin/" + (route.name || "dashboard")) : ("/" + (route.name === "home" ? "" : (route.name || ""))); if (p.id != null && p.id !== "") path += (path.endsWith("/") ? "" : "/") + p.id; const q = []; ["category", "transaction", "focus"].forEach((k) => { if (p[k] != null && p[k] !== "") q.push(k + "=" + encodeURIComponent(p[k])); }); if (path === "") path = "/"; return path + (q.length ? "?" + q.join("&") : ""); } function pathToRoute(pathname, search) { const segs = (pathname || "/").split("/").filter(Boolean); let mode = "customer"; if (segs[0] === "admin") { mode = "admin"; segs.shift(); } const name = segs[0] || (mode === "admin" ? "dashboard" : "home"); const params = {}; if (segs[1] != null) params.id = /^\d+$/.test(segs[1]) ? Number(segs[1]) : segs[1]; if (search) new URLSearchParams(search).forEach((v, k) => { params[k] = v; }); return { mode, route: { name, params } }; } function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const initial = pathToRoute(window.location.pathname, window.location.search); const [mode, setMode] = useState(initial.mode); const [route, setRoute] = useState(initial.route); const modeRef = useRef(initial.mode); modeRef.current = mode; const [cart, setCart] = useState([]); const [requests, setRequests] = useState([]); const [gowns, setGowns] = useState([]); const [users, setUsers] = useState([]); const [messages, setMessages] = useState([]); const [currentUser, setCurrentUser] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [adminName, setAdminName] = useState(""); const [loading, setLoading] = useState(true); const [bootError, setBootError] = useState(""); const [signingOut, setSigningOut] = useState(false); const [toast, setToast] = useState(null); const toastTimer = useRef(null); function showToast(msg, icon) { setToast({ msg, icon }); clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(null), 2600); } function applyRoute(m, name, params = {}) { setMode(m); modeRef.current = m; setRoute({ name, params }); const path = routeToPath(m, { name, params }); if (window.location.pathname + window.location.search !== path) window.history.pushState(null, "", path); window.scrollTo({ top: 0, behavior: "auto" }); } function nav(name, params = {}) { applyRoute(modeRef.current, name, params); } function updateGowns(arr) { VS.setGowns(arr); setGowns(arr); } function upsertRequest(req) { if (!req) return; setRequests((rs) => rs.some((x) => x.id === req.id) ? rs.map((x) => x.id === req.id ? req : x) : [req, ...rs]); } async function loadBoot() { const b = await VS.api("bootstrap"); updateGowns(b.gowns || []); VS.currentUser = b.currentUser || null; setRequests(b.requests || []); setMessages(b.messages || []); setUsers(b.users || []); setCurrentUser(b.currentUser || null); setIsAdmin(!!b.isAdmin); if (b.adminName) setAdminName(b.adminName); return b; } useEffect(() => { loadBoot().catch((e) => setBootError(e.message)).finally(() => setLoading(false)); }, []); // Keep the URL and app state in sync (back/forward, reload, shared links). useEffect(() => { const onPop = () => { const next = pathToRoute(window.location.pathname, window.location.search); setMode(next.mode); modeRef.current = next.mode; setRoute(next.route); window.scrollTo({ top: 0, behavior: "auto" }); }; window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); // Auth guards: a signed-in user (customer or admin) shouldn't sit on any auth screen. useEffect(() => { const onAuthScreen = route.name === "login" || route.name === "register" || route.name === "adminLogin"; if (isAdmin && onAuthScreen) { applyRoute("admin", "dashboard", {}); return; } if (currentUser && onAuthScreen) applyRoute("customer", "home", {}); }, [currentUser, isAdmin, route.name]); const ctx = { t, route, mode, cart, requests, gowns, users, messages, currentUser, isAdmin, adminName, nav, showToast, enterAdmin: () => { if (isAdmin) applyRoute("admin", "dashboard", {}); else applyRoute("customer", "adminLogin", {}); }, exitAdmin: () => { applyRoute("customer", "home", {}); }, // auth login: async (id, pw) => { await VS.api("login", { method: "POST", body: { identifier: id, password: pw } }); await loadBoot(); applyRoute("customer", "home", {}); }, register: async (f) => { await VS.api("register", { method: "POST", body: f }); await loadBoot(); applyRoute("customer", "home", {}); }, adminLogin: async (email, pw) => { const d = await VS.api("admin/login", { method: "POST", body: { email, password: pw } }); await loadBoot(); setAdminName(d.adminName || "Admin"); applyRoute("admin", "dashboard", {}); }, logout: async () => { setSigningOut(true); const minShow = new Promise((r) => setTimeout(r, 900)); // let the animation play try { await VS.api("logout", { method: "POST" }); } catch (e) {} await loadBoot(); await minShow; applyRoute("customer", "home", {}); setSigningOut(false); }, // cart (client-only) addToCart: (item) => setCart((c) => [...c, { ...item }]), updateCart: (i, patch) => setCart((c) => c.map((x, idx) => idx === i ? { ...x, ...patch } : x)), removeFromCart: (i) => setCart((c) => c.filter((_, idx) => idx !== i)), // requests submitRequest: async (form, pay) => { try { const items = cart.map((c) => ({ gownId: c.gownId, size: c.size, lineType: c.lineType, qty: c.qty })); const rentalItem = cart.find((c) => c.lineType === "rental"); const d = await VS.api("requests", { method: "POST", body: { items, payment: pay, name: form.name, email: form.email, phone: form.phone, address: form.address, rentalStart: rentalItem ? rentalItem.rentalStart : null, rentalDays: rentalItem ? rentalItem.rentalDays : 0 } }); upsertRequest(d.request); setCart([]); const gd = await VS.api("gowns"); updateGowns(gd.gowns); return d.request; } catch (e) { showToast(e.message, "x"); return null; } }, cancelRequest: async (id) => { try { const d = await VS.api("requests/" + id + "/cancel", { method: "POST" }); upsertRequest(d.request); showToast("Request cancelled", "x"); } catch (e) { showToast(e.message, "x"); } }, setRequestStatus: async (id, status) => { try { const d = await VS.api("requests/" + id + "/status", { method: "POST", body: { status } }); upsertRequest(d.request); showToast("Status updated to " + status); } catch (e) { showToast(e.message, "x"); } }, // messages addMessage: async (form) => { try { const d = await VS.api("messages", { method: "POST", body: { name: form.name, email: form.email, subject: form.subject, body: form.body } }); if (isAdmin) setMessages((m) => [d.message, ...m]); } catch (e) { showToast(e.message, "x"); } }, markRead: async (id) => { try { const d = await VS.api("messages/" + id + "/read", { method: "POST", body: { isRead: true } }); setMessages((m) => m.map((x) => x.id === id ? d.message : x)); } catch (e) {} }, toggleRead: async (id) => { const cur = messages.find((x) => x.id === id); try { const d = await VS.api("messages/" + id + "/read", { method: "POST", body: { isRead: !(cur && cur.isRead) } }); setMessages((m) => m.map((x) => x.id === id ? d.message : x)); } catch (e) {} }, // gowns setStock: async (id, n) => { try { const d = await VS.api("gowns/" + id + "/stock", { method: "POST", body: { stock: n } }); updateGowns(gowns.map((g) => g.id === id ? d.gown : g)); } catch (e) { showToast(e.message, "x"); } }, deleteGown: async (id) => { try { await VS.api("gowns/" + id, { method: "DELETE" }); updateGowns(gowns.filter((g) => g.id !== id)); showToast("Gown removed", "trash"); } catch (e) { showToast(e.message, "x"); } }, saveGown: async (g) => { try { const d = await VS.api("gowns", { method: "POST", body: g }); const gd = await VS.api("gowns"); updateGowns(gd.gowns); } catch (e) { showToast(e.message, "x"); } }, // users deleteUser: async (id) => { try { await VS.api("users/" + id, { method: "DELETE" }); setUsers((u) => u.filter((x) => x.id !== id)); showToast("Customer removed", "trash"); } catch (e) { showToast(e.message, "x"); } } }; let content; if (loading) { content =
Vestiré Studio
; } else if (bootError) { content =
Couldn't reach the studio{bootError} { setLoading(true); setBootError(""); loadBoot().catch((e) => setBootError(e.message)).finally(() => setLoading(false)); }}>Retry
; } else if (mode === "admin" && isAdmin) { const Screen = ADMIN_MAP[route.name] || ADashboard; content = ; } else if (AUTH_SCREENS[route.name]) { const Screen = AUTH_SCREENS[route.name]; content = ; } else if (AUTHED_ROUTES.includes(route.name) && !currentUser) { content = ; } else { const Screen = CUSTOMER_MAP[route.name] || CHome; // Signed-in customers get the sidebar layout — except on Home, which keeps the top nav. const Shell = (currentUser && route.name !== "home") ? CustomerSidebar : CustomerShell; content = ; } return (
{content} {signingOut && (
Signing you out…
)} {toast && } setTweak("visualStyle", v)} /> setTweak("accent", v)} /> setTweak("requestsLayout", v)} /> setTweak("statusUI", v)} />
); } injectVSStyles(); (function () { const s = document.createElement("style"); s.id = "vs-layout-css"; s.textContent = VS_LAYOUT_CSS; document.head.appendChild(s); })(); ReactDOM.createRoot(document.getElementById("root")).render();