// app.jsx — LAUNCH app shell: tab nav, theme, tweaks wiring const { useState: useStateApp } = React; const S1 = window.LAUNCH_SCREENS_1; const S2 = window.LAUNCH_SCREENS_2; const { HomeScreen, LessonScreen, useTheme, COPY, TabBar } = S1; const { RoadmapScreen, FeedScreen, ProfileScreen } = S2; const S3 = window.LAUNCH_SCREENS_3; const { ReviewScreen, ProReviewCTA } = S3; const S4 = window.LAUNCH_SCREENS_4; const { PlansScreen } = S4; const S5 = window.LAUNCH_SCREENS_5; const { MessagesScreen, ThreadScreen, MemberProfileScreen, THREADS: INITIAL_THREADS, MEMBERS: DEMO_MEMBERS } = S5; const S6 = window.LAUNCH_SCREENS_6; const { LeaderboardScreen } = S6; const S7 = window.LAUNCH_SCREENS_7; const { OnboardingFlow } = S7; const S8 = window.LAUNCH_SCREENS_8; const { SettingsScreen, EditProfileScreen } = S8; const S9 = window.LAUNCH_SCREENS_9; const { CreateScreen } = S9; const S10 = window.LAUNCH_SCREENS_10; const { MyEvaluationsScreen } = S10; const { PaymentScreen, AdminDashboard } = window.LAUNCH_PAYMENT; const { BookingScreen, MySessionsScreen } = window.LAUNCH_BOOKING; const { CoachQueueScreen } = window.LAUNCH_REVIEW_QUEUE; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "accent": "#C72820", "fontPair": "anton-grotesk", "dark": true, "density": "regular", "tone": "hype" }/*EDITMODE-END*/; const FONT_PAIRS = { 'anton-grotesk': { display: 'Anton, "Archivo Black", system-ui', body: '"Space Grotesk", system-ui, sans-serif' }, 'archivo-inter': { display: '"Archivo Black", system-ui', body: '"DM Sans", system-ui, sans-serif' }, 'serif-mono': { display: '"Fraunces", Georgia, serif', body: '"IBM Plex Mono", monospace' }, 'condensed-sans': { display: '"Bebas Neue", "Anton", system-ui', body: '"Manrope", system-ui, sans-serif' }, }; const ACCENT_OPTIONS = [ '#C72820', // mars '#4A7FD8', // neptune '#7A5AE0', // nebula '#E8A736', // solar ]; function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const [tab, setTab] = useStateApp('home'); const [onboarding, setOnboarding] = useStateApp(() => { // Only show onboarding on a fresh tab session; refreshes within a session skip it. try { return sessionStorage.getItem('launch.appOpened') !== '1'; } catch (e) { return true; } }); React.useEffect(() => { try { sessionStorage.setItem('launch.appOpened', '1'); } catch (e) {} }, []); // Returning from Stripe Checkout: ?checkout=success&session_id=… → verify the // payment server-side, then land the user on their reviews. Always strip the // query string afterwards so a refresh doesn't re-trigger it. React.useEffect(() => { let params; try { params = new URLSearchParams(window.location.search); } catch (e) { return; } const co = params.get('checkout'); if (!co) return; if (co === 'success') { const sid = params.get('session_id'); setOnboarding(false); setTab('home'); // Verify server-side. If it was a 1:1 session, the booking is now scheduled // (Google event created) — land the user on My Sessions to see the Meet link. if (sid) { window.LAUNCH_USER.confirmCheckout(sid).then((r) => { if (r && r.bookingId) setTab('sessions'); }); } } try { window.history.replaceState({}, '', window.location.origin + window.location.pathname); } catch (e) {} }, []); // Returning from a Google / Apple OAuth redirect: the provider bounces the // browser back here with a session in the URL. Finish sign-in (seed the // profile from the provider identity), skip onboarding, land on home, then // strip the OAuth tokens from the URL so a refresh doesn't re-trigger it. React.useEffect(() => { let pending = false; try { pending = !!localStorage.getItem('launch.oauthPending'); } catch (e) {} if (!pending) return; try { localStorage.removeItem('launch.oauthPending'); } catch (e) {} window.LAUNCH_USER.completeOAuthSignIn().then((res) => { if (res && res.signedIn) { setOnboarding(false); setTab('home'); } try { window.history.replaceState({}, '', window.location.origin + window.location.pathname); } catch (e) {} }); }, []); const [loggedToday, setLoggedToday] = useStateApp(true); const [currentPlan, setCurrentPlan] = useStateApp('orbit'); const liveMode = window.LAUNCH_USER.isConfigured(); const [following, setFollowing] = useStateApp(liveMode ? [] : ['sasha.vee', 'kai.park']); const [threads, setThreads] = useStateApp(liveMode ? [] : INITIAL_THREADS); const [activeThread, setActiveThread] = useStateApp(null); const [activeMember, setActiveMember] = useStateApp(null); // Live chat: member directory (handle→member) + online handles (presence). // Null/empty until loaded; in demo mode we fall back to the sample directory. const [liveMembers, setLiveMembers] = useStateApp(null); const [online, setOnline] = useStateApp(() => new Set()); const [onlineCount, setOnlineCount] = useStateApp(0); // Refs so the realtime subscription (set up once) reads current values. const liveMembersRef = React.useRef(null); const activeThreadRef = React.useRef(null); React.useEffect(() => { liveMembersRef.current = liveMembers; }, [liveMembers]); React.useEffect(() => { activeThreadRef.current = activeThread; }, [activeThread]); // Load the live chat backend once: directory, existing threads, realtime // inbox subscription, and presence. All no-ops in demo mode. React.useEffect(() => { const U = window.LAUNCH_USER; if (!U.isConfigured()) return; let alive = true; U.fetchMembersDirectory().then(dir => { if (alive && dir) setLiveMembers(dir); }); U.fetchThreads().then(ths => { if (alive && ths) setThreads(ths); }); U.fetchFollowing().then(f => { if (alive && f) setFollowing(f); }); const onIncoming = (m) => { const dir = liveMembersRef.current || {}; let entry = Object.values(dir).find(x => x.id === m.sender_id); if (!entry) { U.fetchMembersDirectory().then(d => { if (alive && d) setLiveMembers(d); }); } const handle = entry ? entry.handle : m.sender_id; const msg = { from: 'them', t: m.body, ts: 'now' }; setThreads(prev => { const exists = prev.find(t => t.id === handle); if (exists) { return prev.map(t => t.id === handle ? { ...t, messages: [...t.messages, msg], lastTime: 'NOW', unread: activeThreadRef.current === handle ? 0 : (t.unread + 1) } : t); } return [{ id: handle, member: handle, otherId: m.sender_id, unread: 1, lastTime: 'NOW', messages: [msg] }, ...prev]; }); }; const unsub = U.subscribeMessages(onIncoming); const leave = U.joinPresence((ids, count) => { if (alive) { setOnline(new Set(ids)); setOnlineCount(count || 0); } }); return () => { alive = false; try { unsub(); } catch (e) {} try { leave(); } catch (e) {} }; }, []); // The resolved member directory passed to every social screen: live profiles // with presence merged in, or the demo directory when there's no backend. const members = React.useMemo(() => { if (!liveMode) return DEMO_MEMBERS; const src = liveMembers || {}; const out = {}; Object.keys(src).forEach(h => { const m = src[h]; out[h] = { ...m, online: online.has(h) || (m.id && online.has(m.id)) }; }); return out; }, [liveMode, liveMembers, online]); const toggleFollow = (handle) => { if (!canInteract) { openPlans(); return; } // no plan → can't follow const isFollowing = following.includes(handle); setFollowing(prev => isFollowing ? prev.filter(h => h !== handle) : [...prev, handle]); if (liveMode) { const target = (liveMembers && liveMembers[handle]) ? liveMembers[handle].id : null; if (target) { if (isFollowing) window.LAUNCH_USER.unfollowUser(target); else window.LAUNCH_USER.followUser(target); // Optimistically reflect the follower count on the directory so the // member profile updates immediately (DB has the real count for next load). setLiveMembers(prev => { if (!prev || !prev[handle]) return prev; const delta = isFollowing ? -1 : 1; return { ...prev, [handle]: { ...prev[handle], followers: Math.max(0, (prev[handle].followers || 0) + delta) } }; }); } } }; const sendMessage = (threadId, text) => { if (!canInteract) { openPlans(); return; } // no plan → can't chat setThreads(prev => prev.map(th => th.id === threadId ? { ...th, messages: [...th.messages, { from: 'me', t: text, ts: 'now' }], lastTime: 'NOW', unread: 0 } : th )); if (liveMode) { const th = threads.find(t => t.id === threadId); const otherId = (th && th.otherId) || (liveMembers && liveMembers[threadId] && liveMembers[threadId].id); if (otherId) window.LAUNCH_USER.sendMessageLive(otherId, text); } }; const openMessages = () => { setTab('messages'); setActiveThread(null); }; const openLeaderboard = () => setTab('leaderboard'); const openSettings = () => setTab('settings'); const openThread = (id) => { setThreads(prev => prev.map(th => th.id === id ? { ...th, unread: 0 } : th)); setActiveThread(id); setTab('thread'); if (liveMode) { const th = threads.find(t => t.id === id); if (th && th.otherId) window.LAUNCH_USER.markThreadRead(th.otherId); } }; const openMember = (handle) => { // Tapping your OWN avatar → go to your Profile tab. You're excluded from the // members directory, so MemberProfileScreen would otherwise render blank. const mh = me && me.handle; if (handle && mh && String(handle).toLowerCase() === String(mh).toLowerCase()) { setTab('profile'); return; } setActiveMember(handle); setTab('member'); }; const startThreadWith = (handle) => { if (!canInteract) { openPlans(); return; } // no plan → can't start a chat if (!threads.find(t => t.id === handle)) { const otherId = liveMembers && liveMembers[handle] ? liveMembers[handle].id : undefined; setThreads(prev => [ { id: handle, member: handle, otherId, unread: 0, lastTime: 'NOW', messages: [] }, ...prev, ]); } openThread(handle); }; const fonts = FONT_PAIRS[t.fontPair] || FONT_PAIRS['anton-grotesk']; const theme = useTheme({ accent: t.accent, dark: t.dark, density: t.density, fontDisplay: fonts.display, fontBody: fonts.body, }); const copy = COPY[t.tone] || COPY.hype; const toggleToday = () => setLoggedToday(v => !v); const openReview = () => setTab('review'); const openEvaluations = () => setTab('evaluations'); const openPlans = () => setTab('plans'); const [payItem, setPayItem] = useStateApp('mentor-jay'); const [payBookingId, setPayBookingId] = useStateApp(null); const openPayment = (item, bookingId) => { setPayItem(item || 'mentor-jay'); setPayBookingId(bookingId || null); setTab('payment'); }; const [bookingMentor, setBookingMentor] = useStateApp('mentor-jay'); const [bookingClass, setBookingClass] = useStateApp(false); const openBooking = (mentorItem) => { setBookingClass(false); setBookingMentor(mentorItem || 'mentor-jay'); setTab('booking'); }; // VIP: book a free 20-min class with a coach. const openClassBooking = () => { setBookingClass(true); setBookingMentor('mentor-jay'); setTab('booking'); }; const openAdmin = () => setTab('admin'); const openCoachQueue = () => setTab('coachQueue'); const openSessions = () => setTab('sessions'); // Who can see the coach review queue. In live mode it's gated to coach/admin // roles; in demo mode (no backend) it's always reachable so the flow can be // tested end-to-end in the preview. const me = window.LAUNCH_USER.useUser(); const plan = window.LAUNCH_USER.usePlan(); // Only an active paid plan (basic/vip) can interact with anything social. const canInteract = !!(plan && plan.active); // Subscribe flow: 'free' clears the plan; basic/vip route through payment → activate. const handleSubscribe = (planId) => { if (planId === 'free') { window.LAUNCH_USER.subscribe('free'); return; } openPayment('sub-' + planId); }; const coachAccess = !window.LAUNCH_USER.isConfigured() || ['coach', 'admin'].includes(me.role); // Sign out → clear the session and drop back into onboarding so a new // account can be created / signed in. const logOut = async () => { try { await window.LAUNCH_USER.signOut(); } catch (e) {} setTab('home'); setOnboarding(true); }; const screens = { home: setTab('lesson')} logged={loggedToday} onToggleToday={toggleToday} onOpenReview={openReview} onOpenMessages={openMessages} onOpenLeaderboard={openLeaderboard} onOpenCoachQueue={coachAccess ? openCoachQueue : null} onOpenEvaluations={openEvaluations} onOpenPlans={openPlans} unreadCount={threads.reduce((a,t)=>a+t.unread, 0)} />, lesson: setTab('home')} onOpenReview={openReview} />, feed: , create: , profile: a+t.unread, 0)} followingCount={following.length} />, review: setTab('home')} onOpenEvaluations={openEvaluations} />, evaluations: setTab('review')} />, plans: setTab('profile')} onSubscribe={handleSubscribe} />, messages: setTab('home')} onOpenThread={openThread} onOpenMember={openMember} following={following} onToggleFollow={toggleFollow} />, thread: setTab('messages')} onSend={sendMessage} onOpenMember={openMember} following={following} onToggleFollow={toggleFollow} />, member: setTab(threads.find(t=>t.id===activeMember) ? 'messages' : 'feed')} following={following} onToggleFollow={toggleFollow} onMessage={startThreadWith} />, leaderboard: setTab('home')} />, settings: setTab('profile')} dark={t.dark} onChangeDark={(v) => setTweak('dark', v)} accent={t.accent} onChangeAccent={(v) => setTweak('accent', v)} onEditProfile={() => setTab('editProfile')} onOpenSubscription={openPlans} onOpenPayment={() => openPayment('subscription')} onOpenAdmin={openAdmin} onOpenCoachQueue={coachAccess ? openCoachQueue : null} />, editProfile: setTab('settings')} />, payment: setTab(payItem.startsWith('sub-') ? 'plans' : payItem.startsWith('mentor') ? 'booking' : payItem === 'subscription' ? 'settings' : 'review')} onSuccess={() => setTab(payItem.startsWith('sub-') ? 'profile' : payItem.startsWith('mentor') ? 'sessions' : payItem === 'subscription' ? 'settings' : 'review')} />, booking: setTab('profile')} onConfirm={(req) => req.classMode ? setTab('sessions') : openPayment(req.mentorId, req.bookingId)} />, sessions: setTab('profile')} onOpenBooking={() => openBooking()} />, admin: setTab('settings')} />, coachQueue: setTab('settings')} />, }; // Inject global font for body/display via style tag const fontStyle = `body, button { font-family: ${fonts.body}; }`; // Which tabs show the bottom tab bar (sub-screens hide it). const showTabBar = !['review','plans','messages','thread','member','leaderboard','settings','editProfile','payment','admin','booking','coachQueue','evaluations','sessions'].includes(tab); // PRODUCTION = served from a real domain (not localhost / file://). Force with // ?prod=1 or ?preview=1. In production we drop the iPhone mockup + the dev // tweaks panel and render full-screen + responsive (mobile-first column). const PRODUCTION = (() => { try { const q = new URLSearchParams(window.location.search); if (q.get('preview') === '1') return false; if (q.get('prod') === '1') return true; const h = window.location.hostname; return !(window.location.protocol === 'file:' || h === 'localhost' || h === '127.0.0.1'); } catch (e) { return false; } })(); if (PRODUCTION) { return (
{onboarding ? (
setOnboarding(false)} />
) : ( <>
{screens[tab]}
{showTabBar && } )}
); } return (
{onboarding ? (
setOnboarding(false)} />
) : ( <>
{screens[tab]}
{showTabBar && } )}
setTweak('accent', v)} /> setTweak('dark', v)} /> setTweak('fontPair', v)} /> setTweak('density', v)} /> setTweak('tone', v)} />
{['home','review','plans','messages'].map(s => ( ))}
); } ReactDOM.createRoot(document.getElementById('root')).render();