// screens-flows.jsx — sheets and flows used across the app: // • ClassSheet (slide-up class detail; "Book" → review → confirmed) // • WaitlistSheet (when class is full) // • PackagesSheet (grid of packs to buy) + CheckoutSheet // • SplashScreen / SignInScreen (returning member focused) // • SettingsSheet (notification prefs etc) const { fmtIDR, findClass, findCoach, findFriend, minutesUntil, fmtCountdown, DAYS_SHORT, DAYS_LONG, DATES_THIS_WEEK } = window.NADI_HELPERS; // ── Class detail sheet (slide-up; first step of booking) ───────────── function ClassSheet({ session, user, onClose, onBookConfirmed, onWaitlist, onBuyPack, toggleSavedClass, toggleSavedCoach, openFriendProfile }) { const [reviewing, setReviewing] = useState(false); const [attendAnon, setAttendAnon] = useState(false); // Reset privacy choice each time a fresh session opens; default to user pref useEffect(() => { if (session) { setReviewing(false); setAttendAnon(!user.visibleByDefault); } }, [session && session.id]); if (!session) return null; const c = findClass(session.classId); const co = findCoach(session.coachId); const left = session.capacity - session.booked; const full = left === 0; const booked = user.booked.includes(session.id); const onWait = user.waitlist.includes(session.id); const friends = (session.friends || []).map(findFriend).filter(Boolean); const isFav = user.savedClasses.includes(c.id); // packs sorted by expiry — used to show which credit funds this const packs = (user.creditPacks || []).slice().sort((a, b) => new Date(a.expires) - new Date(b.expires)); const fundingPack = packs.find((p) => p.credits > 0); const fmtDate = (iso) => new Date(iso).toLocaleDateString("en-GB", { day: "2-digit", month: "short" }).toLowerCase(); // Two screens slide horizontally inside the sheet body return (
{/* ── DETAIL ── */}
{DAYS_LONG[session.day].toLowerCase()} · {session.time} · {c.duration} min

{c.name}

{c.tagline}.

{/* Teacher block */} {/* Detail grid */}
{/* Friends going */} {friends.length > 0 && (
going
{friends.length} from your circle {session.anonCount > 0 ? ` · ${session.anonCount} anon` : ""}
{friends.slice(0, 6).map((f) => ( ))}
)} {/* About this class */}
about this class

A {c.duration}-minute class for {c.level.toLowerCase()} practitioners. Slow warm-up, deliberate sequencing, and time at the end for stillness. Mats and props provided.

{/* Save toggle */} {/* Sticky CTA */}
{booked ? ( ) : full ? ( onWait ? ( ) : ( ) ) : fundingPack ? ( ) : ( )} cancel up to 1h before · credit refunded
{/* ── REVIEW (step 2) ── */}
booking · review

Confirm your seat.

1 credit will be used.
{DAYS_LONG[session.day].toLowerCase()} · {session.time}

{c.name}

with {co.name.toLowerCase()} · {session.roomShort.toLowerCase()}
{fundingPack && (
credit from
{fundingPack.packageName} expires {fmtDate(fundingPack.expires)}
{fundingPack.credits} {fundingPack.credits - 1} credit{fundingPack.credits - 1 === 1 ? "" : "s"} left
)} {/* Add to calendar toggle */}
Add to calendar
plus a 30-min before reminder
{/* Anonymous attendance toggle */}
{attendAnon ? "Going anonymously" : "Going as Sinta"}
{attendAnon ? "hidden from your friends" : "visible to your circle"}
you can cancel up to 1h before class
); } function DetailCell({ label, value, sub, accent }) { return (
{label}
{value}
{sub}
); } function ToggleSwitch({ defaultOn, onChange }) { const [on, setOn] = useState(!!defaultOn); const flip = () => { const next = !on; setOn(next); onChange && onChange(next); }; return ( ); } // ── Confirmation full-screen ───────────────────────────────────────── function BookingConfirmed({ open, session, onClose }) { if (!open || !session) return null; const c = findClass(session.classId), co = findCoach(session.coachId); return (

You're on
the mat.

{c.name} · {DAYS_LONG[session.day]} at {session.time}.
with {co.name}.

we'll send a quiet reminder.
); } // ── Waitlist sheet ─────────────────────────────────────────────────── function WaitlistSheet({ open, session, onClose, onConfirm }) { if (!open || !session) return null; const c = findClass(session.classId), co = findCoach(session.coachId); return (
waitlist · this class is full

Want a seat if one opens?

{DAYS_LONG[session.day].toLowerCase()} · {session.time}
{c.name}
with {co.name.toLowerCase()}
); } // ── Packages sheet ────────────────────────────────────────────────── function PackagesSheet({ open, onClose, onChoose }) { return (
packages · every pack 30-day

Practice often,
pay less.

Buy a pack of class credits. Every pack is good for thirty days. Use them on any class, any teacher.

{window.NADI.PACKAGES.map((p) => { const isStd = p.id === "five"; return ( ); })}
how it works
    {[ ["01", "Buy a pack — valid 30 days."], ["02", "Spend one credit per class, any teacher."], ["03", "Cancel up to 1h before to get the credit back."], ].map(([n, t]) => (
  • {n} {t}
  • ))}
); } // ── Checkout sheet (payment method) ───────────────────────────────── // Bank transfer is the only enabled method for now — QRIS and Card are // shown as "coming soon" so members still see the roadmap. function CheckoutSheet({ pkg, onClose, onConfirm }) { const method = "Bank Transfer"; if (!pkg) return null; const methods = [ { id: "Bank Transfer", sub: "transfer & upload proof", enabled: true }, { id: "QRIS", sub: "coming soon", enabled: false }, { id: "Card", sub: "coming soon", enabled: false }, ]; return (
checkout

{pkg.name}.

{pkg.classes} class{pkg.classes !== 1 ? "es" : ""} · valid {pkg.validityDays} days
Total {fmtIDR(pkg.price)}
payment
{methods.map((m) => { const sel = m.id === method; return (
{m.id}
{m.sub}
{m.enabled ? ( ) : ( soon )}
); })}
next: transfer & upload your proof
); } // ── Bank transfer sheet (after checkout: account list + upload proof) ──── function BankTransferSheet({ pkg, onClose, onSubmit }) { const [selected, setSelected] = useState(null); const [copied, setCopied] = useState(null); const [proof, setProof] = useState(null); // {name, dataUrl} const [note, setNote] = useState(""); const fileRef = React.useRef(null); // pick first active bank by default when sheet opens useEffect(() => { if (pkg) { const banks = (window.NADI_ADMIN.BANK_ACCOUNTS || []).filter((b) => b.active); setSelected(banks[0]?.id || null); setProof(null); setNote(""); setCopied(null); } }, [pkg && pkg.id]); if (!pkg) return null; const banks = (window.NADI_ADMIN.BANK_ACCOUNTS || []).filter((b) => b.active); const copy = (text, key) => { try { navigator.clipboard && navigator.clipboard.writeText(text); } catch (e) {} setCopied(key); setTimeout(() => setCopied(null), 1400); }; const onPick = (e) => { const f = e.target.files && e.target.files[0]; if (!f) return; const reader = new FileReader(); reader.onload = () => setProof({ name: f.name, dataUrl: reader.result, size: f.size }); reader.readAsDataURL(f); }; const canSubmit = !!selected && !!proof; const submit = () => { if (!canSubmit) return; onSubmit && onSubmit({ pkg, bankId: selected, proof, note }); }; return (
step 2 of 2 · bank transfer

Transfer {fmtIDR(pkg.price)}

Send to any of the accounts below, then upload a screenshot of your receipt. The studio will confirm within a few hours and your credits will be activated.

{/* Order summary card */}
{pkg.name}
{pkg.classes} credit{pkg.classes === 1 ? "" : "s"} · valid {pkg.validityDays}d
{fmtIDR(pkg.price)}
{/* Bank accounts */}
01 · transfer to one of
{banks.length === 0 && ( no accounts configured · contact studio )} {banks.map((b) => { const sel = selected === b.id; return (
{b.number}
a/n {b.name.toLowerCase()}
); })}
{/* Upload proof */}
02 · upload your receipt {!proof ? ( ) : (
receipt
uploaded ✓
{proof.name}
)}
{/* Optional note */}
note (optional) setNote(e.target.value)} placeholder="e.g. transferred from my BCA account" style={{ marginTop: 10, width: "100%", padding: "12px 14px", border: "1px solid var(--rule)", background: "var(--bg)", fontFamily: "Newsreader, serif", fontSize: 15, color: "var(--ink)", }} />
credits activate once the studio verifies your transfer
); } // ── Purchase pending confirmation (after submitting proof) ────────── function PurchaseSubmittedSheet({ open, pkg, onClose }) { if (!pkg) return null; return (
received · awaiting confirmation

Thank you,
we'll verify shortly.

Your {pkg.name} is pending. Once the studio matches your transfer we'll activate your {pkg.classes} credit{pkg.classes === 1 ? "" : "s"} and notify you.

); } // ── Splash / Sign-in (returning member focused) ───────────────────── function SplashScreen({ onSignIn, onContinue, onAuth }) { const { useState, useEffect, useRef } = React; const googleBtnRef = useRef(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const finishGoogle = async (credential) => { setLoading(true); try { const res = await fetch("/api/auth/google", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ credential }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.detail || "Google authentication failed"); } const data = await res.json(); localStorage.setItem("nadi_token", data.access_token); if (onAuth) { onAuth(data.user); } else { window.location.reload(); } } catch (err) { setError(err.message); } finally { setLoading(false); } }; useEffect(() => { let cancelled = false; let attempts = 0; const renderGoogleButton = () => { fetch("/api/config") .then((r) => r.ok ? r.json() : { googleClientId: "" }) .then((d) => { const clientId = d.googleClientId; if (!clientId || cancelled) { renderMockButton(); return; } const checkGis = () => { if (window.google && window.google.accounts && window.google.accounts.id) { window.google.accounts.id.initialize({ client_id: clientId, callback: (resp) => { if (resp && resp.credential) finishGoogle(resp.credential); }, ux_mode: "popup", auto_select: false, }); if (googleBtnRef.current) { googleBtnRef.current.innerHTML = ""; window.google.accounts.id.renderButton(googleBtnRef.current, { type: "standard", theme: "outline", size: "large", text: "continue_with", shape: "rectangular", logo_alignment: "center", width: 320, }); } } else if (attempts++ < 50) { setTimeout(checkGis, 100); } }; checkGis(); }) .catch(() => { if (!cancelled) renderMockButton(); }); }; const renderMockButton = () => { if (googleBtnRef.current) { googleBtnRef.current.innerHTML = ""; const btn = document.createElement("button"); btn.className = "press"; btn.style.width = "100%"; btn.style.padding = "14px 18px"; btn.style.background = "var(--bg)"; btn.style.border = "1px solid var(--rule)"; btn.style.display = "flex"; btn.style.alignItems = "center"; btn.style.justifyContent = "center"; btn.style.gap = "12px"; btn.innerHTML = ` Continue with Google `; btn.onclick = () => { if (onAuth) { onAuth({ name: "Sinta Putri", email: "sinta@mail.com" }); } else if (onContinue) { onContinue(); } }; googleBtnRef.current.appendChild(btn); } }; renderGoogleButton(); return () => { cancelled = true; }; }, [onAuth, onContinue]); return (
{/* Ambient breath */}
est. 2026
suvarna sutera · tangerang

Breath as
practice.
The body as
home.

A small studio for the slow, attentive practice — in two open-air rooms above the lake.

{error && (
{error}
)} {/* Google sign-in button container */}
tangerang · open daily 06:00 — 21:00
); } // ── Sign-in screen (full-screen, mobile-native) ───────────────────── // ── Sign-in screen (full-screen, mobile-native) ───────────────────── function SignInScreen({ onClose, onAuth }) { const [mode, setMode] = useState("signin"); // signin | register const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [name, setName] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const googleBtnRef = React.useRef(null); const API_BASE = window.location.origin.includes("localhost") ? "http://localhost:8001" : ""; // Google Sign-In finish const finishGoogle = async (credential) => { setError(null); setLoading(true); try { const res = await fetch(API_BASE + "/api/auth/google", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ credential }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.detail || "Google authentication failed"); } const data = await res.json(); localStorage.setItem("nadi_token", data.access_token); onAuth(data.user); } catch (err) { setError(err.message); } finally { setLoading(false); } }; // Load Google client ID useEffect(() => { let cancelled = false; let attempts = 0; const renderGoogleButton = () => { fetch(API_BASE + "/api/config") .then((r) => r.ok ? r.json() : { googleClientId: "" }) .then((d) => { const clientId = d.googleClientId; if (!clientId || cancelled) return; const checkGis = () => { if (window.google && window.google.accounts && window.google.accounts.id) { window.google.accounts.id.initialize({ client_id: clientId, callback: (resp) => { if (resp && resp.credential) finishGoogle(resp.credential); }, ux_mode: "popup", auto_select: false, }); if (googleBtnRef.current) { googleBtnRef.current.innerHTML = ""; window.google.accounts.id.renderButton(googleBtnRef.current, { type: "standard", theme: "outline", size: "large", text: mode === "signin" ? "signin_with" : "signup_with", shape: "rectangular", logo_alignment: "center", width: 320, }); } } else if (attempts++ < 50) { setTimeout(checkGis, 100); } }; checkGis(); }) .catch(() => {}); }; renderGoogleButton(); return () => { cancelled = true; }; }, [mode]); const submit = async () => { setError(null); setLoading(true); try { const endpoint = mode === "signin" ? "/api/auth/signin" : "/api/auth/register"; const body = mode === "signin" ? { email, password } : { name, email, password }; const res = await fetch(API_BASE + endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { const data = await res.json(); throw new Error(data.detail || "Authentication failed"); } const data = await res.json(); localStorage.setItem("nadi_token", data.access_token); onAuth(data.user); } catch (err) { setError(err.message); } finally { setLoading(false); } }; return (

{mode === "signin" ? <>Welcome back. : <>Make a start.}

{mode === "signin" ? "Sign in to book classes and check your credits." : "Tell us where to send your booking confirmations."}

or with email
{mode === "register" && }
{error && (
{error}
)}
); } function GoogleG({ size = 18 }) { return ( ); } // ── Settings sheet ───────────────────────────────────────────────── function SettingsSheet({ open, onClose, onSignOut, user, setUser, showToast }) { return (
settings

Preferences.

{/* Privacy */}
privacy
Show me to friends
your default — you can switch per class
{ if (setUser && user) setUser({ ...user, visibleByDefault: v }); showToast && showToast(v ? "Visible to your friends." : "Going incognito by default."); }} />
{user && } {user && user.visibleByDefault ? "friends will see you in class lists" : "you'll appear as +1 anon in their feed"}
notifications
{[ { label: "Class reminders", sub: "30 min before · push", on: true }, { label: "Waitlist alerts", sub: "the moment a seat opens", on: true }, { label: "Pack expiry warnings", sub: "7 days before credit lapse", on: true }, { label: "Friend activity", sub: "when someone you follow books a shared class", on: true }, { label: "Weekly schedule digest", sub: "every monday morning", on: false }, { label: "New teacher / class news", sub: "rare and slow", on: false }, ].map((item, i) => (
{item.label}
{item.sub}
))}
); } Object.assign(window, { ClassSheet, BookingConfirmed, WaitlistSheet, PackagesSheet, CheckoutSheet, BankTransferSheet, PurchaseSubmittedSheet, SplashScreen, SignInScreen, SettingsSheet, FindFriendsSheet, FriendProfileSheet, ToggleSwitch, }); // ── Find Friends sheet ───────────────────────────────────────────── function FindFriendsSheet({ open, onClose, user, sendRequest, openFriendProfile, showToast }) { const [query, setQuery] = useState(""); const suggestions = window.NADI.FRIEND_SUGGESTIONS; const filtered = query ? suggestions.filter((s) => s.name.toLowerCase().includes(query.toLowerCase())) : suggestions; const sent = (id) => user.sentRequests.includes(id); return (
friends · find people

Find your circle.

Practice is quieter when someone you know is on the next mat. Follow people you've shared a class with.

{/* Search */}
setQuery(e.target.value)} placeholder="Search by name or phone…" style={{ width: "100%", padding: "12px 14px 12px 38px", border: "1px solid var(--rule)", background: "var(--bg)", fontFamily: "Geist, sans-serif", fontSize: 14, color: "var(--ink)", }} />
{/* Quick add methods */}
{/* Suggestions */}
suggested · {filtered.length} people you've shared classes with
{filtered.length === 0 ? (
no matches
) : filtered.map((s) => (
))}
); } // ── Friend profile sheet ─────────────────────────────────────────── function FriendProfileSheet({ friend, user, onClose, unfollowFriend, sendRequest, acceptRequest, declineRequest, showToast }) { if (!friend) return null; const isFollowing = user.following.includes(friend.id); const isPending = !!friend.isPending; const requested = user.sentRequests.includes(friend.id); // Find shared classes — sessions where this friend goes that current user has saved/booked const sharedSessions = window.NADI.SCHEDULE .filter((s) => s.friends.includes(friend.id)) .slice(0, 5); const nextShared = sharedSessions .filter((s) => user.booked.includes(s.id))[0]; // Mock stats const stats = { classesThisMonth: 6, favouriteClass: "Slow Flow", favouriteTeacher: "Ayu", practicing: "since Mar 2025", }; return (
{isPending ? "follow request" : isFollowing ? "in your circle" : "discover"}

{friend.name}

practicing {stats.practicing.toLowerCase()}
{/* Mutuals + meta */} {(friend.mutual || friend.reason) && (
{friend.reason || `${friend.mutual || 0} mutual friend${(friend.mutual || 0) === 1 ? "" : "s"}`}
)} {/* Stats */}
this month
{stats.classesThisMonth}
classes
often books
{stats.favouriteClass}
with {stats.favouriteTeacher.toLowerCase()}
{/* Shared upcoming */} {nextShared && (
you're both going
{window.NADI_HELPERS.findClass(nextShared.classId).name}
{window.NADI_HELPERS.DAYS_LONG[nextShared.day].toLowerCase()} · {nextShared.time}
)} {/* Recent shared classes */} {sharedSessions.length > 0 && (
recent shared classes
{sharedSessions.map((s) => { const c = window.NADI_HELPERS.findClass(s.classId); return (
{c.name} {window.NADI_HELPERS.DAYS_SHORT[s.day].toLowerCase()} {s.time}
); })}
)} {/* CTA */}
{isPending ? (
) : isFollowing ? ( ) : requested ? ( ) : ( )}
); }