// booking.jsx — Google Calendar-connected booking flow // Pulls a mentor's live availability, lets user request an appointment, // then continues to payment for the session. const { useState: useStateB, useMemo: useMemoB, useEffect: useEffectB } = React; const { Card, DisplayHeading } = window.LAUNCH_SCREENS_1; const { MENTORS, MentorAvatar } = window.LAUNCH_MENTORS; const DOW_SHORT = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; const MONTH_SHORT = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']; // Seed a deterministic 14-day availability board for each mentor. // Real product would call Google Calendar FreeBusy API and overlay events. // Seeds off the coach id (string) so every coach — including ones added in // Supabase — gets a stable, distinct board. function hashSeed(str) { let h = 0; const s = String(str || 'coach'); for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; return (Math.abs(h) % 233000) + 7; } // Does a [start,end) slot overlap any real busy interval ([{start,end}] ISO)? function overlapsBusy(slotStart, slotEnd, busy) { for (let i = 0; i < busy.length; i++) { const bs = new Date(busy[i].start).getTime(); const be = new Date(busy[i].end).getTime(); if (slotStart.getTime() < be && slotEnd.getTime() > bs) return true; } return false; } // Build a 14-day availability board anchored on TODAY. // busyIntervals = array → use real Google Calendar freebusy // busyIntervals = null → deterministic simulated busy (demo / not connected) function buildSchedule(mentorId, busyIntervals) { const today = new Date(); today.setHours(0, 0, 0, 0); const now = new Date(); let seed = hashSeed(mentorId); const rand = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }; const BUSY = [ 'Studio · Sky Blue Sessions', 'Co-writing · K. Halsey', 'A&R review · Vintage', 'Mix bus revisions', 'Family · blocked', 'Internal · A&R sync', 'Tracking · D. Mason', 'Tracking · Hour One', 'Logic session', 'Sync placement call', 'Pre-production', 'Lunch', 'Mentor calls (other)', 'Mastering · pickup', ]; const useReal = Array.isArray(busyIntervals); const busyChance = 0.30 + (seed % 20) / 100; // 0.30–0.49, stable per coach const days = []; for (let i = 0; i < 14; i++) { const d = new Date(today); d.setDate(today.getDate() + i); const dow = d.getDay(); const isWeekend = dow === 0 || dow === 6; const hours = isWeekend ? [10, 11] : [9, 10, 11, 13, 14, 15, 16, 17]; const slots = hours.map(h => { const slotStart = new Date(d); slotStart.setHours(h, 0, 0, 0); const slotEnd = new Date(slotStart.getTime() + 60 * 60 * 1000); // Past times are never bookable. if (slotStart < now) return { hour: h, busy: true, label: 'Earlier' }; if (useReal) { const isBusy = overlapsBusy(slotStart, slotEnd, busyIntervals); return { hour: h, busy: isBusy, label: isBusy ? 'Busy · Google Calendar' : null }; } const isBusy = rand() < busyChance; return { hour: h, busy: isBusy, label: isBusy ? BUSY[Math.floor(rand() * BUSY.length)] : null }; }); days.push({ date: d, dow, slots, isToday: i === 0, isPast: false, }); } return days; } function formatHour(h) { const ampm = h >= 12 ? 'PM' : 'AM'; const hh = h % 12 === 0 ? 12 : h % 12; return `${hh}:00 ${ampm}`; } function formatHourRange(h, mins) { const end = h + mins / 60; const fmt = (x) => { const m = Math.round((x % 1) * 60); const hh = Math.floor(x) % 12 === 0 ? 12 : Math.floor(x) % 12; const ampm = Math.floor(x) >= 12 ? 'PM' : 'AM'; return `${hh}:${m.toString().padStart(2, '0')} ${ampm}`; }; return `${fmt(h)} – ${fmt(end)}`; } // Tiny Google "G" mark function GoogleMark({ size = 14 }) { return ( ); } function BookingScreen({ theme, mentorId, onBack, onConfirm, classMode = false }) { // Roster from the Supabase `coaches` table (falls back to the built-in list). const [mentors, setMentors] = useStateB(MENTORS); useEffectB(() => { let alive = true; if (window.LAUNCH_USER && window.LAUNCH_USER.fetchCoaches) { window.LAUNCH_USER.fetchCoaches().then((list) => { if (alive && list && list.length) setMentors(list); }); } return () => { alive = false; }; }, []); const wantedId = (mentorId || '').replace(/^mentor-/, ''); const mentor = mentors.find(m => String(m.id) === wantedId) || mentors[0]; const palette = (mentor.palette && mentor.palette.length >= 2) ? mentor.palette : [mentor.color || '#C72820', '#0A0A0A']; // Real Google Calendar busy times (null = simulated until/unless they load). const [busy, setBusy] = useStateB(null); useEffectB(() => { let alive = true; setBusy(null); if (window.LAUNCH_USER && window.LAUNCH_USER.fetchCoachAvailability && mentor && mentor.id) { const tMin = new Date(); tMin.setHours(0, 0, 0, 0); const tMax = new Date(tMin); tMax.setDate(tMax.getDate() + 14); window.LAUNCH_USER.fetchCoachAvailability(mentor.id, tMin.toISOString(), tMax.toISOString()) .then(list => { if (alive) setBusy(list); }); } return () => { alive = false; }; }, [mentor.id]); const schedule = useMemoB(() => buildSchedule(mentor.id, busy), [mentor.id, busy]); const liveAvail = Array.isArray(busy); const todayIdx = schedule.findIndex(d => d.isToday); const [weekOffset, setWeekOffset] = useStateB(0); // 0 = current week, 1 = next const [selectedDayIdx, setSelectedDayIdx] = useStateB(todayIdx >= 0 ? todayIdx : 0); const [selectedSlot, setSelectedSlot] = useStateB(null); // { dayIdx, hour } const [duration, setDuration] = useStateB(classMode ? 20 : 60); const [topic, setTopic] = useStateB(''); const [scheduling, setScheduling] = useStateB(false); const [bookingErr, setBookingErr] = useStateB(null); const weekDays = schedule.slice(weekOffset * 7, weekOffset * 7 + 7); const selectedDay = schedule[selectedDayIdx] || schedule[0]; const isCurrent = selectedSlot && selectedSlot.dayIdx === selectedDayIdx; // Animate the "synced" timestamp counter const [syncedSec, setSyncedSec] = useStateB(124); useEffectB(() => { const t = setInterval(() => setSyncedSec(s => s + 1), 1000); return () => clearInterval(t); }, []); const syncedAgo = syncedSec < 60 ? `${syncedSec}S` : `${Math.floor(syncedSec / 60)}M ${syncedSec % 60}S`; // Pick day from weekDays, mapping back to global index const pickDay = (dayInWeek) => { const idx = schedule.findIndex(d => d.date.getTime() === dayInWeek.date.getTime()); setSelectedDayIdx(idx); }; const confirm = async () => { if (!selectedSlot || isCurrent === false || scheduling) return; setScheduling(true); setBookingErr(null); // Reserve the slot (pending booking). The Google Calendar event is created // only after payment is confirmed (confirm-checkout → schedule-session). const start = new Date(selectedDay.date); start.setHours(selectedSlot.hour, 0, 0, 0); // VIP free 20-min class → book directly with the plan benefit (no payment). if (classMode) { const r = await window.LAUNCH_USER.bookFreeClass(mentor.id, start.toISOString(), 'Music class · 20 min'); setScheduling(false); if (!r || !r.ok) { setBookingErr((r && r.error) || 'Could not book the class.'); return; } onConfirm && onConfirm({ mentorId: 'mentor-' + mentor.id, date: selectedDay.date, hour: selectedSlot.hour, duration: 20, topic: 'Music class · 20 min', classMode: true, bookingId: r.bookingId, }); return; } const res = await window.LAUNCH_USER.requestBooking({ coachId: mentor.id, startsAt: start.toISOString(), durationMin: duration, topic, }); setScheduling(false); if (res && res.error) { setBookingErr(res.error); return; } onConfirm && onConfirm({ mentorId: 'mentor-' + mentor.id, date: selectedDay.date, hour: selectedSlot.hour, duration, topic, bookingId: res && res.booking && res.booking.id, }); }; const price = (duration / 60) * mentor.rate; const canConfirm = !!selectedSlot && isCurrent; return (
{classMode &&
🎓 MUSIC CLASS · 20 MIN · FREE WITH VIP
} {/* ─── Hero with deep field + mentor ─── */}
{/* Top bar */}
{liveAvail ? `CALENDAR SYNCED · ${syncedAgo} AGO` : 'PREVIEW AVAILABILITY'}
{/* Title block */}
BOOK 1:1 · ${mentor.rate}/HR
{mentor.name}
{mentor.role}
{/* ─── Week navigator ─── */}
{weekOffset === 0 ? 'THIS WEEK' : 'NEXT WEEK'} · {MONTH_SHORT[weekDays[0].date.getMonth()]} {weekDays[0].date.getDate()}–{weekDays[6].date.getDate()}
{/* Day chips */}
{weekDays.map(d => { const idx = schedule.findIndex(x => x.date.getTime() === d.date.getTime()); const isSel = idx === selectedDayIdx; const freeCount = d.slots.filter(s => !s.busy).length; const isUnavail = d.isPast || freeCount === 0; return ( ); })}
{/* ─── Day timeline ─── */}
{DOW_SHORT[selectedDay.dow]} · {MONTH_SHORT[selectedDay.date.getMonth()]} {selectedDay.date.getDate()} NASHVILLE · CST
{/* Legend */}
{[ { c: '#22C55E', l: 'AVAILABLE' }, { c: theme.accent, l: 'SELECTED' }, { c: theme.muted, l: 'BUSY · GCAL' }, ].map(x => (
{x.l}
))}
{selectedDay.slots.map((s, i) => { const isSel = selectedSlot && selectedSlot.dayIdx === selectedDayIdx && selectedSlot.hour === s.hour; return ( ); })}
{/* ─── Session details (visible once a slot is picked) ─── */} {selectedSlot && isCurrent && (
SESSION LENGTH
{[30, 60, 90].map(m => ( ))}
WHAT DO YOU WANT TO COVER?