// app.jsx — main shell, routing, state. Member shell + Admin shell. const { useState, useEffect, useMemo } = React; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "showStatusBar": false, "accent": "#B5563C", "showFriends": true }/*EDITMODE-END*/; function applyAccent(hex) { document.documentElement.style.setProperty("--primary", hex); } const DEFAULT_USER = { name: "", email: "", initials: "", joined: "", visits: 0, streak: 0, creditPacks: [], credits: 0, booked: [], bookingPrivacy: {}, visibleByDefault: true, waitlist: [], savedClasses: [], savedCoaches: [], following: [], pendingFollowers: [], sentRequests: [], monthly: [], history: [] }; function hydrateUser(rawUser) { let meta = {}; if (rawUser.metadata) { try { meta = typeof rawUser.metadata === "string" ? JSON.parse(rawUser.metadata) : rawUser.metadata; } catch (e) { console.error("Failed to parse metadata", e); } } const creditPacks = (rawUser.creditPacks || []).map(p => ({ id: p.id, packageId: p.package_id || p.packageId, packageName: p.package_name || p.packageName, credits: p.credits, total: p.total, purchased: p.purchased, expires: p.expires })); const names = (rawUser.name || "Member").split(" "); const initials = names.map(n => n[0]).slice(0, 2).join("").toUpperCase(); return { id: rawUser.id, name: rawUser.name || "Member", email: rawUser.email || "", joined: rawUser.joined || "2026", visits: rawUser.visits || 0, streak: meta.streak || 0, creditPacks: creditPacks, credits: creditPacks.reduce((sum, p) => sum + p.credits, 0), booked: rawUser.booked || [], bookingPrivacy: meta.bookingPrivacy || {}, visibleByDefault: meta.visibleByDefault !== false, // default true waitlist: meta.waitlist || [], savedClasses: meta.savedClasses || [], savedCoaches: meta.savedCoaches || [], following: meta.following || [], pendingFollowers: meta.pendingFollowers || [], sentRequests: meta.sentRequests || [], monthly: meta.monthly || [ { m: "Dec", n: 2 }, { m: "Jan", n: 4 }, { m: "Feb", n: 5 }, { m: "Mar", n: 7 }, { m: "Apr", n: 9 }, { m: "May", n: 6 }, ], history: rawUser.history || [], initials: initials, tone: meta.tone || "var(--primary)", kind: rawUser.kind || "member", role: rawUser.role || "Member", roles: rawUser.roles || ["Member"], permissions: rawUser.permissions || [] }; } function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); // Member mode state const [user, setUser] = useState(DEFAULT_USER); const [splash, setSplash] = useState(false); const [auth, setAuth] = useState(false); const [tab, setTab] = useState("today"); const [classSheet, setClassSheet] = useState(null); const [confirmed, setConfirmed] = useState(null); const [waitlist, setWaitlist] = useState(null); const [packagesOpen, setPackagesOpen] = useState(false); const [checkoutPkg, setCheckoutPkg] = useState(null); const [bankTransferPkg, setBankTransferPkg] = useState(null); const [purchaseSubmitted, setPurchaseSubmitted] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); // Admin mode state const [adminMode, setAdminMode] = useState(false); const [admin, setAdmin] = useState(window.NADI.CURRENT_ADMIN); const [adminTab, setAdminTab] = useState("now"); const [adminSubRoute, setAdminSubRoute] = useState(null); // packages|classes|studios|accounts|payments|reports const [adminMember, setAdminMember] = useState(null); const [adminCoach, setAdminCoach] = useState(null); const [adminSession, setAdminSession] = useState(null); const [adminRoster, setAdminRoster] = useState(null); const [adminAddSession, setAdminAddSession] = useState(false); // Friend sheets const [findFriendsOpen, setFindFriendsOpen] = useState(false); const [friendProfile, setFriendProfile] = useState(null); // Toast const [toast, setToast] = useState(null); const showToast = (msg) => { setToast(msg); setTimeout(() => setToast(null), 2800); }; // Schedule and bookings caches const [schedule, setSchedule] = useState(window.NADI.SCHEDULE); const [upcomingBookings, setUpcomingBookings] = useState([]); const [dataLoaded, setDataLoaded] = useState(false); window.NADI_SET_SCHEDULE = setSchedule; window.NADI_RELOAD_SCHEDULE = async () => { try { const res = await fetch("/api/schedule"); if (res.ok) { const schData = await res.json(); const mapped = schData.map((s) => { const [y, m, d] = s.date.split("-").map(Number); const dateObj = new Date(y, m - 1, d); const dayOfWeek = (dateObj.getDay() + 6) % 7; let roomShort = "Daun"; if (s.room.includes("Akar") || s.room.includes("B")) { roomShort = "Akar"; } return { id: s.id, day: dayOfWeek, date: s.date, time: s.time, classId: s.class_id, coachId: s.coach_id, room: s.room, roomShort: roomShort, capacity: s.capacity, booked: s.booked, friends: s.friends || [], anonCount: s.anonCount || 0, waitlistCount: s.waitlistCount || 0, attendedCount: s.attendedCount || 0 }; }); window.NADI.SCHEDULE = mapped; setSchedule(mapped); } } catch (e) { console.error("Failed to reload schedule", e); } }; const loadUserData = async (rawUser) => { const token = localStorage.getItem("nadi_token"); const headers = token ? { "Authorization": `Bearer ${token}` } : {}; let bookedIds = []; let historyList = []; let stats = {}; try { const upcomingRes = await fetch("/api/bookings/upcoming", { headers }); if (upcomingRes.ok) { const upcomingData = await upcomingRes.json(); setUpcomingBookings(upcomingData); bookedIds = upcomingData.map(b => b.session_id); } } catch (e) { console.error(e); } try { const historyRes = await fetch("/api/bookings/history", { headers }); if (historyRes.ok) { const historyData = await historyRes.json(); historyList = historyData.map(h => ({ id: h.id, date: new Date(h.date).toLocaleDateString("en-GB", { weekday: "short", day: "2-digit", month: "short" }), classId: h.class_id, coachId: h.coach_id, roomShort: h.room.includes("—") ? h.room.split("—")[1].trim() : h.room.includes("-") ? h.room.split("-")[1].trim() : h.room })); } } catch (e) { console.error(e); } try { const statsRes = await fetch("/api/user/stats", { headers }); if (statsRes.ok) { stats = await statsRes.json(); } } catch (e) { console.error(e); } const hydrated = hydrateUser({ ...rawUser, booked: bookedIds, history: historyList, }); if (stats.streak !== undefined) hydrated.streak = stats.streak; if (stats.visits !== undefined) hydrated.visits = stats.visits; if (stats.monthly !== undefined) hydrated.monthly = stats.monthly; setUser(hydrated); if (hydrated.kind === "admin") { setAdmin(hydrated); } return hydrated; }; window.NADI_REFRESH_DATA = async () => { const token = localStorage.getItem("nadi_token"); if (!token) return; try { const profileRes = await fetch("/api/user/profile", { headers: { "Authorization": `Bearer ${token}` } }); if (profileRes.ok) { const rawUser = await profileRes.json(); await loadUserData(rawUser); } await window.NADI_RELOAD_SCHEDULE(); } catch (e) { console.error("Failed to refresh user data", e); } }; const saveUserMetadata = async (updatedUser) => { const token = localStorage.getItem("nadi_token"); if (!token) return; try { const payload = { streak: updatedUser.streak, bookingPrivacy: updatedUser.bookingPrivacy, visibleByDefault: updatedUser.visibleByDefault, savedClasses: updatedUser.savedClasses, savedCoaches: updatedUser.savedCoaches, following: updatedUser.following, pendingFollowers: updatedUser.pendingFollowers, sentRequests: updatedUser.sentRequests, monthly: updatedUser.monthly, tone: updatedUser.tone }; await fetch("/api/user/metadata", { method: "PUT", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, body: JSON.stringify(payload) }); } catch (e) { console.error("Failed to save user metadata", e); } }; const loadAdminData = async () => { const token = localStorage.getItem("nadi_token"); const headers = token ? { "Authorization": `Bearer ${token}` } : {}; try { const [mRes, pAllRes, pPendRes] = await Promise.all([ fetch("/api/admin/members", { headers }), fetch("/api/admin/payments", { headers }), fetch("/api/admin/payments?status=pending", { headers }) ]); if (mRes.ok) { const mData = await mRes.json(); window.NADI_ADMIN.MEMBERS = mData.map(m => { const names = (m.name || "Member").split(" "); const initials = names.map(n => n[0]).slice(0, 2).join("").toUpperCase(); return { id: m.id, name: m.name, email: m.email, phone: m.phone || "+62 812 1100 2030", joined: m.joined, status: m.status, visits: m.visits, credits: m.credits || 0, package: m.package || "—", lastVisit: m.last_visit || "—", initials: initials, tone: "#B5563C" }; }); } if (pAllRes.ok) { const pData = await pAllRes.json(); window.NADI_ADMIN.PAYMENTS = pData.map(p => { const dt = p.created_at || "2026-05-05T12:00:00"; return { id: p.id, date: dt.includes("T") ? dt.split("T")[0] : dt.split(" ")[0], time: dt.includes("T") ? dt.split("T")[1].slice(0, 5) : dt.split(" ")[1]?.slice(0, 5) || "12:00", member: p.user_name || "Member", memberId: p.user_id, item: p.package_name || "Package", amount: p.amount, method: p.method }; }); } if (pPendRes.ok) { const pendingData = await pPendRes.json(); window.NADI_ADMIN.PACK_PURCHASES = pendingData.map(p => { const dt = p.created_at || "2026-05-05T12:00:00"; const dateStr = dt.includes("T") ? dt.replace("T", " ").slice(0, 16) : dt.slice(0, 16); return { id: p.id, memberId: p.user_id, memberName: p.user_name || "Member", packageId: p.package_id, packageName: p.package_name || "Package", classes: p.package_classes || 5, validityDays: p.package_validity_days || 30, amount: p.amount, bankId: p.bank_id || "b1", submitted: dateStr, status: p.status, note: p.note || "", proof: p.proof }; }); } } catch (e) { console.error("Failed to load admin data", e); } }; window.NADI_RELOAD_ADMIN_DATA = loadAdminData; // Load public data once useEffect(() => { const loadPublic = async () => { try { const [ctRes, coRes, pkgRes, stRes] = await Promise.all([ fetch("/api/class-types"), fetch("/api/coaches"), fetch("/api/packages"), fetch("/api/studios") ]); if (ctRes.ok) window.NADI.CLASS_TYPES = await ctRes.json(); if (coRes.ok) { const coData = await coRes.json(); window.NADI.COACHES = coData.map(c => ({ id: c.id, name: c.name, firstName: c.name.split(" ")[0], title: c.title || "", initials: c.name.split(" ").map(n => n[0]).join("").toUpperCase(), tone: c.id === "ayu" ? "#C28464" : c.id === "made" ? "#8A7E5C" : c.id === "kade" ? "#A14A2E" : "#6D7A53", bio: c.bio || "", specialties: c.specialties ? c.specialties.split(",") : [], yrs: c.yrs || 5, userId: c.user_id, rate: c.rate || 200000 })); } if (pkgRes.ok) { const pkgData = await pkgRes.json(); window.NADI.PACKAGES = pkgData.map(p => ({ id: p.id, name: p.name, classes: p.classes, price: p.price, validityDays: p.validity_days || p.validityDays, badge: p.badge, desc: p.desc })); } if (stRes.ok) { const stData = await stRes.json(); window.NADI.STUDIOS = stData.map(s => ({ id: s.id, name: s.name.includes("—") ? s.name.split("—")[1].trim() : s.name, full: s.name, desc: s.style || "" })); } await window.NADI_RELOAD_SCHEDULE(); } catch (e) { console.error("Failed to load public configuration data", e); } finally { setDataLoaded(true); } }; window.NADI_RELOAD_PUBLIC = loadPublic; loadPublic(); }, []); // Auto-login or show splash after public configuration is parsed useEffect(() => { if (!dataLoaded) return; const token = localStorage.getItem("nadi_token"); if (token) { fetch("/api/user/profile", { headers: { "Authorization": `Bearer ${token}` } }) .then(res => { if (res.ok) return res.json(); throw new Error("Invalid session token"); }) .then(u => { loadUserData(u); }) .catch(() => { localStorage.removeItem("nadi_token"); setSplash(true); }); } else { setSplash(true); } }, [dataLoaded]); useEffect(() => { applyAccent(t.accent || "#B5563C"); }, [t.accent]); // ── Member actions ──────────────────────────────────────── const onConfirmBooking = async (session, privacy) => { setClassSheet(null); const token = localStorage.getItem("nadi_token"); const headers = token ? { "Authorization": `Bearer ${token}` } : {}; try { const res = await fetch("/api/bookings", { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify({ session_id: session.id }) }); if (res.ok) { const nextPrivacy = { ...(user.bookingPrivacy || {}), [session.id]: privacy }; const nextUser = { ...user, bookingPrivacy: nextPrivacy, booked: [...new Set([...(user.booked || []), session.id])], }; setUser(nextUser); setConfirmed(session); await saveUserMetadata(nextUser); const profileRes = await fetch("/api/user/profile", { headers }); if (profileRes.ok) { const rawUser = await profileRes.json(); await loadUserData(rawUser); } await window.NADI_RELOAD_SCHEDULE(); } else { const errData = await res.json(); showToast(errData.detail || "Failed to book class"); } } catch (e) { console.error(e); showToast("Network error occurred."); } }; const onCancelBooking = async (session) => { const booking = upcomingBookings.find((b) => b.id === session.bookingId) || upcomingBookings.find((b) => b.session_id === session.id); if (!booking) { setUser({ ...user, booked: (user.booked || []).filter((id) => id !== session.id) }); showToast("Booking cancelled."); return; } const token = localStorage.getItem("nadi_token"); const headers = token ? { "Authorization": `Bearer ${token}` } : {}; try { const res = await fetch(`/api/bookings/${booking.id}`, { method: "DELETE", headers: headers }); if (res.ok) { showToast("Booking cancelled — credit refunded."); const profileRes = await fetch("/api/user/profile", { headers }); if (profileRes.ok) { const rawUser = await profileRes.json(); await loadUserData(rawUser); } await window.NADI_RELOAD_SCHEDULE(); } else { const errData = await res.json(); showToast(errData.detail || "Failed to cancel booking"); } } catch (e) { console.error(e); showToast("Network error occurred."); } }; const handleContinueAsSinta = async () => { try { const res = await fetch("/api/auth/signin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "sinta@mail.com", password: "password123" }), }); if (res.ok) { const data = await res.json(); localStorage.setItem("nadi_token", data.access_token); await loadUserData(data.user); setSplash(false); setTab("me"); } else { setSplash(false); setUser(window.NADI.CURRENT_USER); setTab("me"); } } catch (e) { setSplash(false); setUser(window.NADI.CURRENT_USER); setTab("me"); } }; const onJoinWaitlist = (session) => { const nextUser = { ...user, waitlist: [...new Set([...(user.waitlist || []), session.id])] }; setUser(nextUser); showToast("Added to the waitlist. We'll text you."); saveUserMetadata(nextUser); }; const onChoosePackage = (pkg) => { setPackagesOpen(false); setCheckoutPkg(pkg); }; const onConfirmCheckout = (pkg) => { setCheckoutPkg(null); setBankTransferPkg(pkg); }; const onSubmitBankTransfer = async ({ pkg, bankId, proof, note }) => { const token = localStorage.getItem("nadi_token"); const headers = token ? { "Authorization": `Bearer ${token}` } : {}; try { const res = await fetch("/api/checkout", { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify({ package_id: pkg.id, method: "Bank Transfer", proof: proof ? proof.dataUrl : null, note: note || "" }) }); if (res.ok) { setBankTransferPkg(null); setPurchaseSubmitted(pkg); } else { const errData = await res.json(); showToast(errData.detail || "Failed to submit transfer proof"); } } catch (e) { console.error(e); showToast("Network error occurred."); } }; const toggleSavedClass = (id) => { const has = user.savedClasses.includes(id); const nextUser = { ...user, savedClasses: has ? user.savedClasses.filter((x) => x !== id) : [...user.savedClasses, id] }; setUser(nextUser); showToast(has ? "Removed from saved." : "Saved."); saveUserMetadata(nextUser); }; const toggleSavedCoach = (id) => { const has = user.savedCoaches.includes(id); const nextUser = { ...user, savedCoaches: has ? user.savedCoaches.filter((x) => x !== id) : [...user.savedCoaches, id] }; setUser(nextUser); showToast(has ? "Removed teacher." : "Teacher saved."); saveUserMetadata(nextUser); }; // ── Friends actions ─────────────────────────────────────── const sendFriendRequest = (id) => { if (user.sentRequests.includes(id)) return; const nextUser = { ...user, sentRequests: [...user.sentRequests, id] }; setUser(nextUser); showToast("Request sent."); saveUserMetadata(nextUser); }; const acceptFriendRequest = (id) => { const nextUser = { ...user, following: [...user.following, id], pendingFollowers: user.pendingFollowers.filter((x) => x !== id), }; setUser(nextUser); showToast("Now following."); saveUserMetadata(nextUser); }; const declineFriendRequest = (id) => { const nextUser = { ...user, pendingFollowers: user.pendingFollowers.filter((x) => x !== id) }; setUser(nextUser); showToast("Request declined."); saveUserMetadata(nextUser); }; const unfollowFriend = (id) => { const nextUser = { ...user, following: user.following.filter((x) => x !== id) }; setUser(nextUser); showToast("Unfollowed."); saveUserMetadata(nextUser); }; // ── Admin actions ───────────────────────────────────────── const enterAdmin = async () => { await loadAdminData(); setAdminMode(true); setAdminTab("now"); setAdminSubRoute(null); }; const exitAdmin = () => { setAdminMode(false); setTab("today"); }; const badges = useMemo(() => ({ today: (user.booked || []).length > 0 ? 1 : 0 }), [user.booked]); // ── SPLASH / AUTH ───────────────────────────────────────── if (splash) return ( <> {t.showStatusBar && } { setSplash(false); setAuth(true); }} onAuth={async (u) => { setSplash(false); await loadUserData(u); setTab("me"); showToast(`Signed in as ${(u.name || "Member").split(" ")[0]}.`); }} /> ); if (auth) return ( <> {t.showStatusBar && } { setAuth(false); setSplash(true); }} onAuth={async (u) => { setAuth(false); await loadUserData(u); setTab("me"); showToast(`Signed in as ${(u.name || "Member").split(" ")[0]}.`); }} /> ); // ── ADMIN SHELL ─────────────────────────────────────────── if (adminMode) { return ( <> {t.showStatusBar && }
{adminSubRoute ? setAdminSubRoute(null)} openMember={setAdminMember} openCoach={setAdminCoach} showToast={showToast} /> : adminTab === "now" && } {!adminSubRoute && adminTab === "schedule" && setAdminAddSession(true)} showToast={showToast} />} {!adminSubRoute && adminTab === "people" && } {!adminSubRoute && adminTab === "more" && }
{ setAdminTab(t); setAdminSubRoute(null); }} /> {/* Admin sheets */} setAdminMember(null)} showToast={showToast} /> setAdminCoach(null)} showToast={showToast} /> setAdminSession(null)} showToast={showToast} openRoster={(s) => { setAdminSession(null); setAdminRoster(s); }} /> setAdminRoster(null)} openMember={setAdminMember} showToast={showToast} /> setAdminAddSession(false)} showToast={showToast} /> { setAdminMode(v); if (!v) setTab("today"); }, setAdminTab, setAdminSubRoute }} /> ); } // ── MEMBER SHELL ────────────────────────────────────────── return ( <> {t.showStatusBar && }
{tab === "today" && setClassSheet(s)} setTab={setTab} openCheckout={() => setPackagesOpen(true)} showToast={showToast} onCancelBooking={onCancelBooking} upcomingBookings={upcomingBookings} />} {tab === "book" && } {tab === "saved" && setFindFriendsOpen(true)} openFriendProfile={setFriendProfile} unfollowFriend={unfollowFriend} />} {tab === "me" && setPackagesOpen(true)} onSignOut={() => { localStorage.removeItem("nadi_token"); setUser(DEFAULT_USER); setSplash(true); }} openSettings={() => setSettingsOpen(true)} showToast={showToast} enterAdmin={user.kind === "admin" ? enterAdmin : null} upcomingBookings={upcomingBookings} />}
{/* Member sheets and overlays */} setClassSheet(null)} onBookConfirmed={onConfirmBooking} onWaitlist={(s) => { setClassSheet(null); setWaitlist(s); }} onBuyPack={() => { setClassSheet(null); setPackagesOpen(true); }} toggleSavedClass={toggleSavedClass} toggleSavedCoach={toggleSavedCoach} openFriendProfile={(f) => { setClassSheet(null); setFriendProfile(f); }} /> setConfirmed(null)} /> setWaitlist(null)} onConfirm={onJoinWaitlist} /> setPackagesOpen(false)} onChoose={onChoosePackage} /> setCheckoutPkg(null)} onConfirm={onConfirmCheckout} /> setBankTransferPkg(null)} onSubmit={onSubmitBankTransfer} /> setPurchaseSubmitted(null)} /> setSettingsOpen(false)} onSignOut={() => { localStorage.removeItem("nadi_token"); setSettingsOpen(false); setUser(DEFAULT_USER); setSplash(true); }} user={user} setUser={setUser} showToast={showToast} /> setFindFriendsOpen(false)} user={user} sendRequest={sendFriendRequest} openFriendProfile={(f) => { setFindFriendsOpen(false); setFriendProfile(f); }} showToast={showToast} /> setFriendProfile(null)} sendRequest={sendFriendRequest} acceptRequest={acceptFriendRequest} declineRequest={declineFriendRequest} unfollowFriend={unfollowFriend} showToast={showToast} /> { setAdminMode(v); if (!v) setTab("today"); }, setAdminTab, setAdminSubRoute }} /> ); } // ── Admin sub-routes (packages, classes, studios, accounts, payments, reports) ─ function AdminSubRoute({ route, onBack, openMember, openCoach, showToast }) { switch (route) { case "packages": return ; case "classes": return ; case "studios": return ; case "accounts": return ; case "payments": return ; case "reports": return ; case "banks": return ; case "purchases":return ; default: return null; } } function TweaksHost({ t, setTweak, setTab, setSplash, setAuth, setPackagesOpen, enterAdmin, adminMode, setAdminMode, setAdminTab, setAdminSubRoute }) { return ( setTweak("accent", v)} /> setTweak("showStatusBar", v)} /> setTweak("showFriends", v)} />
{[["today", "Today"], ["book", "Book"], ["saved", "Saved"], ["me", "Me"]].map(([k, l]) => ( ))}
{!adminMode ? ( ) : ( <>
{[["now", "Now"], ["schedule", "Schedule"], ["people", "People"], ["more", "More"]].map(([k, l]) => ( ))}
{[["packages","Packs"],["classes","Class types"],["studios","Studios"],["accounts","Accounts"],["payments","Payments"],["reports","Reports"],["banks","Bank acct"],["purchases","Purchases"]].map(([k, l]) => ( ))}
setAdminMode && setAdminMode(false)} />
)} { setSplash(true); setAuth(false); }} /> { setSplash(false); setAuth(true); }} /> setPackagesOpen(true)} />
); } ReactDOM.createRoot(document.getElementById("root")).render();