/* GPS Design-Capture Admin dashboard — loaded before app.jsx; exposes window.Admin. Wired to the live backend via window.GPSApi: login-gated, with events (the captured guest designs + CSV export), a campaign Builder with the storefront theme designer, casinos, and kiosk device provisioning. */ (function () { const { useState, useEffect } = React; const Api = window.GPSApi; const cfg = window.GPS_CONFIG || {}; const T = window.GPSTheme; const fmt = (n) => Number(n || 0).toLocaleString("en-US"); const fmtDate = (s) => (s ? new Date(s).toLocaleString() : ""); /* ---------------- small pieces ---------------- */ function StatusBadge({ status }) { const s = String(status || "").toLowerCase(); const cls = s === "active" ? "b-active" : s === "scheduled" ? "b-scheduled" : "b-draft"; const label = s ? s.charAt(0).toUpperCase() + s.slice(1) : "—"; return {label}; } function StatePill({ state }) { const s = String(state || "").toLowerCase(); const cls = s === "shared" ? "b-active" : s === "started" ? "b-scheduled" : "b-low"; const label = s === "shared" ? "Captured" : s.charAt(0).toUpperCase() + s.slice(1); return {label}; } /* ---------------- Storefront branding (theme designer) ---------------- */ function ColorField({ label, hint, value, onChange }) { const hex = T.oklchToHex(value); return (
{label}
{hex.toUpperCase()}
{hint &&
{hint}
}
); } function FontSelect({ label, value, options, onChange }) { return (
Aa — Design Your Custom Nikes
); } function ContrastChips({ theme }) { const d = T.diagnostics(theme); const rows = [ { k: "Body text on background", v: d.textOnBg }, { k: "Button label on accent", v: d.buttonText }, { k: "CTA label on button", v: d.ctaText }, { k: "Accent on background", v: d.accentOnBg }, ]; const tone = (label) => (label === "AAA" || label === "AA") ? "ok" : label === "AA Large" ? "warn" : "bad"; return (
{rows.map((r) => (
{r.k} {r.v.ratio.toFixed(1)}:1 · {r.v.label}
))}
); } function ThemePreview({ theme }) { const vars = T.cssVars(theme); return (
storefront · {theme.casinoName || "Casino"} kiosk
LOGO
{theme.casinoName || "Casino Name"}
{theme.tagline || "Design Studio"}
Players Club
— Design Studio —
Design Your Custom Nikes
Create your one-of-a-kind pair.
Your Design
✓ Saved
); } function BrandingPanel({ theme, setTheme }) { const patch = (p) => setTheme((t) => ({ ...t, ...p })); const applyPreset = (p) => { const next = { ...p }; setTheme((t) => ({ ...next, casinoName: t.casinoName || next.casinoName, tagline: t.tagline || next.tagline })); T.ensureFonts(p); }; return (

Storefront Branding

Skin this casino's kiosk — colors and fonts cascade through every screen, from the welcome to the check-in form.

{T.PRESETS.map((p) => ( ))}
patch({ casinoName: e.target.value })} placeholder="Riverwind" />
patch({ tagline: e.target.value })} placeholder="Casino · Design Studio" />
patch({ surface: v })} /> patch({ accent: v })} /> patch({ button: v })} /> patch({ light: v })} />
Lighter and darker shades are generated automatically to keep the kiosk's depth and glow.
patch({ heading: v })} /> patch({ body: v })} />
Live preview
Changes here re-skin the live kiosk when you save & activate.
); } /* ---------------- Tab 1: Campaigns (events) ---------------- */ function CampaignsTab({ campaigns, loading, onNew, onEdit, onActivate }) { return ( <>
Events
Active and upcoming design-studio events. Only one event is live at a time across all kiosks — activating one ends the other and makes its theme dominant.
{loading ? ( ) : campaigns.map((c) => ( ))}
CasinoEventStatus ShoesEntriesWindowActions
Loading…
{c.casino}{c.location} {c.name} {c.offeredShoes} shoes {fmt(c.entries)} {c.startDate || "—"} – {c.endDate || "—"}
{c.status === "active" ? Live : }
); } /* ---------------- Tab 2: Campaign Builder ---------------- */ function BuilderTab({ casinos, shoes, flash, onCreated, onSaved, onCreateCasino, editId }) { const [casino, setCasino] = useState(""); const [name, setName] = useState(""); const [picked, setPicked] = useState({}); const [customUrls, setCustomUrls] = useState({}); // { shoeId: nikeByYouBuilderUrl } const [shoeImages, setShoeImages] = useState({}); // { shoeId: imageUrl override } const [shoeImageDefaults, setShoeImageDefaults] = useState({}); // { shoeId: catalog default } const [premades, setPremades] = useState({}); // { shoeId: [{ name, shareUrl, imageUrl, colors:[hex,hex,hex] }] } const [start, setStart] = useState("2026-06-01"); const [end, setEnd] = useState("2026-08-31"); const [theme, setTheme] = useState(() => T.loadTheme()); const [saving, setSaving] = useState(false); const [showAddCasino, setShowAddCasino] = useState(false); const [ncName, setNcName] = useState(""); const [ncLoc, setNcLoc] = useState(""); const [addingCasino, setAddingCasino] = useState(false); useEffect(() => { if (!editId && !casino && casinos.length) setCasino(casinos[0].id); }, [casinos]); useEffect(() => { if (!editId) return; Api.adminGetCampaign(editId).then((d) => { setCasino(d.casinoId); setName(d.name || ""); setStart((d.startDate || "").slice(0, 10)); setEnd((d.endDate || "").slice(0, 10)); if (d.theme) { setTheme(d.theme); T.ensureFonts(d.theme); } const p = {}, cu = {}, pm = {}, si = {}, sd = {}; (d.shoes || []).forEach((s) => { p[s.shoeId] = true; cu[s.shoeId] = s.customUrl || ""; si[s.shoeId] = s.imageUrl || ""; sd[s.shoeId] = s.defaultImageUrl || ""; pm[s.shoeId] = (s.premades || []).map((x) => ({ name: x.name || "", shareUrl: x.shareUrl || "", imageUrl: x.imageUrl || "", colors: (x.colors || []).slice(0, 3) })); }); setPicked(p); setCustomUrls(cu); setShoeImages(si); setShoeImageDefaults(sd); setPremades(pm); }).catch((e) => flash(e.message)); }, [editId]); const count = Object.values(picked).filter(Boolean).length; const toggle = (id) => setPicked((p) => ({ ...p, [id]: !p[id] })); const addPremade = (shoeId) => setPremades((m) => ({ ...m, [shoeId]: [...(m[shoeId] || []), { name: "", shareUrl: "", imageUrl: "", colors: ["#cccccc", "#cccccc", "#cccccc"] }] })); const updatePremade = (shoeId, idx, patch) => setPremades((m) => ({ ...m, [shoeId]: (m[shoeId] || []).map((r, i) => (i === idx ? { ...r, ...patch } : r)) })); const removePremade = (shoeId, idx) => setPremades((m) => ({ ...m, [shoeId]: (m[shoeId] || []).filter((_, i) => i !== idx) })); const pickCasino = (id) => { setCasino(id); const c = casinos.find((x) => x.id === id); if (c) setTheme((t) => ({ ...t, casinoName: c.name.replace(/ (Casino|Resort).*$/, "") })); }; const addCasino = async () => { if (!ncName.trim()) { flash("Casino name is required."); return; } setAddingCasino(true); try { const c = await onCreateCasino({ name: ncName.trim(), location: ncLoc.trim() }); setCasino(c.id); setTheme((t) => ({ ...t, casinoName: c.name.replace(/ (Casino|Resort).*$/, "") })); setShowAddCasino(false); setNcName(""); setNcLoc(""); flash(`Added ${c.name}.`); } catch (e) { flash(e.message || "Could not add casino."); } finally { setAddingCasino(false); } }; const buildPayload = (status) => { const p = { casinoId: casino, name: name.trim() || "Untitled Event", startDate: start, endDate: end, theme, shoes: Object.keys(picked).filter((id) => picked[id]).map((id) => { const s = shoes.find((x) => x.id === id); return { shoeId: id, name: s ? s.name : id, customUrl: (customUrls[id] || "").trim(), imageUrl: (shoeImages[id] || "").trim(), premades: (premades[id] || []) .filter((r) => (r.name || "").trim() && (r.shareUrl || "").trim()) .map((r) => ({ name: r.name.trim(), shareUrl: r.shareUrl.trim(), imageUrl: (r.imageUrl || "").trim(), colors: (r.colors || []).filter(Boolean) })), }; }), }; if (status) p.status = status; return p; }; const submit = async (status, label) => { if (!casino) { flash("Pick a casino first."); return; } setSaving(true); try { await Api.adminCreateCampaign(buildPayload(status)); flash(`${label} — ${theme.casinoName} · ${count} shoes.`); onCreated && onCreated(); } catch (e) { flash(e.message || "Save failed."); } finally { setSaving(false); } }; const save = async (status, label) => { setSaving(true); try { await Api.adminUpdateCampaign(editId, buildPayload(status)); flash(label); onSaved && onSaved(); } catch (e) { flash(e.message || "Save failed."); } finally { setSaving(false); } }; const inp = { padding: "6px 9px", borderRadius: 8, border: "1.5px solid rgba(120,120,120,.3)", fontSize: 13, fontFamily: "inherit" }; return ( <>
{editId ? "Edit Event" : "Event Builder"}
{editId ? "Update this event's shoes, Nike By You links, dates, and branding — then save or activate." : "Configure a new design-studio event — name, casino, the silhouettes it offers, and branding."}
setName(e.target.value)} placeholder="Summer Sneaker Studio" />
{!editId && showAddCasino && (
setNcName(e.target.value)} style={{ flex: "1 1 170px" }} /> setNcLoc(e.target.value)} style={{ flex: "1 1 150px" }} />
)}
Pick which Nike By You shoes this event offers, and paste each one's "Customize Your Own" builder link (the page the kiosk opens).
{shoes.map((s) => (
toggle(s.id)}>
{picked[s.id] ? "✓" : ""}
{s.name}
{s.brand}
{picked[s.id] && (
Customize Your Own link setCustomUrls((u) => ({ ...u, [s.id]: e.target.value }))} style={{ ...inp, flex: "1 1 340px" }} />
Card image (16:10) setShoeImages((u) => ({ ...u, [s.id]: e.target.value }))} style={{ ...inp, flex: "1 1 340px" }} /> {(shoeImages[s.id] || shoeImageDefaults[s.id] || `/img/shoes/${s.id}.png`) && { e.target.style.display = "none"; }} style={{ width: 56, height: 35, objectFit: "cover", borderRadius: 6, border: "1px solid var(--line)" }} />}
Prebuilt designs — each needs a name + a Nike By You SHARE link (Share → Copy Link) ({(premades[s.id] || []).filter((r) => (r.name || "").trim() && (r.shareUrl || "").trim()).length})
{(premades[s.id] || []).map((row, idx) => (
updatePremade(s.id, idx, { name: e.target.value })} style={{ ...inp, flex: "0 0 150px" }} /> updatePremade(s.id, idx, { shareUrl: e.target.value })} style={{ ...inp, flex: "1 1 220px" }} /> updatePremade(s.id, idx, { imageUrl: e.target.value })} style={{ ...inp, flex: "1 1 160px" }} /> {[0, 1, 2].map((ci) => ( { const c = [...(row.colors || [])]; c[ci] = e.target.value; updatePremade(s.id, idx, { colors: c }); }} style={{ width: 30, height: 30, padding: 0, border: "none", background: "none", cursor: "pointer" }} /> ))}
))}
)}
))}
setStart(e.target.value)} />
setEnd(e.target.value)} />
{editId ? ( <> ) : ( <> )}
); } /* ---------------- Tab 3: Entries (captured designs + CSV) ---------------- */ function EntriesTab({ campaigns, flash }) { const [filters, setFilters] = useState({ campaignId: "", state: "", range: "" }); const [data, setData] = useState({ entries: [], summary: null }); const [loading, setLoading] = useState(true); const load = () => { setLoading(true); Api.adminEntries(filters).then(setData).catch((e) => flash(e.message)).finally(() => setLoading(false)); }; useEffect(() => { load(); }, [filters.campaignId, filters.state, filters.range]); const setF = (k) => (e) => setFilters((s) => ({ ...s, [k]: e.target.value })); const s = data.summary || {}; return ( <>
Entries
Every guest check-in and the Nike By You design they shared. Download the CSV after the event.
Check-ins
{fmt(s.total)}
guests started
Designs captured
{fmt(s.shared)}
share links saved
Capture rate
{s.shareRatePct || 0}%
of check-ins
{loading ? ( ) : data.entries.length === 0 ? ( ) : data.entries.map((e) => ( ))}
WhenGuestPlayer IDFit / Size ShoeStatusDesign (mid)Link
Loading…
No entries yet.
{fmtDate(e.createdAt)} {e.guestName} {e.playerId} {(e.shoeGender === "womens" ? "Women's" : "Men's")} · {e.shoeSize} {e.shoeName || e.shoeId || "—"} {e.designMid || "—"} {e.shareUrl ? open ↗ : "—"}
); } /* ---------------- Tab 4: Devices ---------------- */ function DevicesTab({ casinos, flash }) { const [devices, setDevices] = useState([]); const [form, setForm] = useState({ casinoId: "", label: "" }); const [newToken, setNewToken] = useState(null); const load = () => Api.adminDevices().then((d) => setDevices(d.devices || [])).catch((e) => flash(e.message)); useEffect(() => { load(); }, []); const create = async (e) => { e.preventDefault(); if (!form.casinoId || !form.label) { flash("Pick a casino and label."); return; } try { const r = await Api.adminCreateDevice(form); setNewToken(r.token); setForm({ casinoId: "", label: "" }); load(); } catch (ex) { flash(ex.message); } }; const revoke = async (id) => { if (!window.confirm("Revoke this device token?")) return; try { await Api.adminRevokeDevice(id); load(); } catch (ex) { flash(ex.message); } }; return ( <>
Kiosk devices
Each physical kiosk carries a device token bound to one casino. The token is shown once at creation.
setForm((s) => ({ ...s, label: e.target.value }))} />
{newToken && (
Device token (shown once): {newToken}
)}
{devices.map((d) => ( ))}
CasinoLabelLast seenStatus
{d.casino_id}{d.label}{fmtDate(d.last_seen_at)} {d.revoked_at ? Revoked : Active} {d.revoked_at ? null : }
); } /* ---------------- Login gate ---------------- */ function LoginGate({ onAuthed }) { const [email, setEmail] = useState(cfg.ADMIN_EMAIL || ""); const [password, setPassword] = useState(""); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); const submit = async (e) => { e.preventDefault(); setBusy(true); setErr(null); try { const user = await Api.adminLogin(email, password); onAuthed(user); } catch (ex) { setErr(ex.message || "Sign-in failed."); } finally { setBusy(false); } }; return (
GPS Admin · Design Studio
Sign in
Operations dashboard
setEmail(e.target.value)} autoComplete="username" />
setPassword(e.target.value)} autoComplete="current-password" placeholder="sandbox-admin-pw" />
{err &&
{err}
}
); } /* ---------------- shell ---------------- */ function Dashboard({ onExit, user }) { const [tab, setTab] = useState("campaigns"); const [editId, setEditId] = useState(null); const [toast, setToast] = useState(null); const [campaigns, setCampaigns] = useState([]); const [casinos, setCasinos] = useState([]); const [shoes, setShoes] = useState([]); const [loading, setLoading] = useState({}); const flash = (msg) => { setToast(msg); clearTimeout(window.__admT); window.__admT = setTimeout(() => setToast(null), 2800); }; const loadCampaigns = () => { setLoading((l) => ({ ...l, campaigns: true })); Api.adminCampaigns().then((d) => setCampaigns(d.campaigns || [])).catch((e) => flash(e.message)).finally(() => setLoading((l) => ({ ...l, campaigns: false }))); }; const loadCasinos = () => Api.adminCasinos().then((d) => setCasinos(d.casinos || [])).catch((e) => flash(e.message)); const createCasino = async (payload) => { const r = await Api.adminCreateCasino(payload); await loadCasinos(); return r.casino; }; const startNew = () => { setEditId(null); setTab("builder"); }; const startEdit = (id) => { setEditId(id); setTab("builder"); }; const activate = (c) => { if (!window.confirm(`Make "${c.name}" the live event? Only one event is live at a time — it becomes the dominant event (and theme) on every kiosk, and any other live event is ended.`)) return; Api.adminUpdateCampaign(c.id, { status: "active" }) .then(() => { flash("Activated — reload the kiosk to see it."); loadCampaigns(); }) .catch((e) => flash(e.message)); }; useEffect(() => { loadCampaigns(); loadCasinos(); Api.adminShoes().then((d) => setShoes(d.shoes || [])).catch((e) => flash(e.message)); }, []); const TABS = [ { id: "campaigns", label: "Events", glyph: "▤", count: campaigns.length }, { id: "builder", label: "Event Builder", glyph: "✎" }, { id: "entries", label: "Entries", glyph: "▦" }, { id: "devices", label: "Kiosk Devices", glyph: "▥" }, ]; return (
GPS Admin · Design Studio Live · sandbox
{(user.name || "?").split(" ").map((s) => s[0]).join("").slice(0, 2)} {user.name} · {user.role}
{tab === "campaigns" && } {tab === "builder" && { loadCampaigns(); setTab("campaigns"); }} onSaved={() => { loadCampaigns(); setEditId(null); setTab("campaigns"); }} />} {tab === "entries" && } {tab === "devices" && }
{toast &&
{toast}
}
); } function Admin({ onExit }) { const [user, setUser] = useState(null); useEffect(() => { if (!user && Api.isAdminAuthed()) { Api.adminMe().then((d) => setUser(d.user)).catch(() => Api.adminLogout()); } }, []); if (!user && !Api.isAdminAuthed()) return ; if (!user) return
GPSAdmin
Loading…
; return ; } window.Admin = Admin; })();