// 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?
)}
{/* ─── Sticky request bar ─── */}
{bookingErr && (
⚠ {bookingErr}
)}
);
}
// ─────────────────────────────────────────────────────────────
// MySessionsScreen — the member's booked 1:1 sessions
// ─────────────────────────────────────────────────────────────
function fmtSessionDate(iso) {
const d = new Date(iso);
return `${DOW_SHORT[d.getDay()]} · ${MONTH_SHORT[d.getMonth()]} ${d.getDate()}`;
}
function fmtSessionTime(iso, durationMin) {
const d = new Date(iso);
const h = d.getHours();
return formatHourRange(h + d.getMinutes() / 60, durationMin || 60);
}
// Demo sample so the preview isn't empty when there's no backend.
function demoSessions() {
const mk = (daysFromNow, hour, coach, color, lbl, status) => {
const d = new Date(); d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + daysFromNow); d.setHours(hour);
return {
id: 's-' + daysFromNow + '-' + hour,
starts_at: d.toISOString(), duration_min: 60, status,
topic: 'Mix feedback + arrangement notes',
meet_link: 'https://meet.google.com/demo-launch-session',
coaches: { name: coach, lbl, color },
};
};
return [
mk(2, 14, 'Jay Brunswick', '#C72820', 'JB', 'confirmed'),
mk(6, 11, 'PG Banker', '#7A5AE0', 'PG', 'confirmed'),
];
}
const CANCEL_CUTOFF_MS = 24 * 60 * 60 * 1000; // must cancel ≥24h before
function MySessionsScreen({ theme, onBack, onOpenBooking }) {
const [bookings, setBookings] = useStateB(undefined); // undefined=loading
const [isDemo, setIsDemo] = useStateB(false);
const [cancelingId, setCancelingId] = useStateB(null);
const [cancelErr, setCancelErr] = useStateB(null);
const [choosingId, setChoosingId] = useStateB(null); // booking showing refund/credit choice
const [cancelMsg, setCancelMsg] = useStateB(null);
const [credit, setCredit] = useStateB(0); // account credit, cents
useEffectB(() => {
let alive = true;
window.LAUNCH_USER.fetchMyBookings().then(list => {
if (!alive) return;
if (list === null) { setIsDemo(true); setBookings(demoSessions()); }
else setBookings(list);
});
if (window.LAUNCH_USER.fetchMyCredit) {
window.LAUNCH_USER.fetchMyCredit().then(c => { if (alive) setCredit(c || 0); });
}
return () => { alive = false; };
}, []);
const dollars = (c) => '$' + Math.round((c || 0) / 100);
const doCancel = async (b, resolution) => {
setCancelErr(null); setCancelMsg(null);
setCancelingId(b.id);
const res = await window.LAUNCH_USER.cancelBooking(b.id, resolution);
setCancelingId(null);
if (res && res.error) { setCancelErr(res.error); return; }
setChoosingId(null);
setBookings(prev => (prev || []).map(x => x.id === b.id ? { ...x, status: 'cancelled' } : x));
if (window.LAUNCH_USER.fetchMyCredit) {
window.LAUNCH_USER.fetchMyCredit().then(c => setCredit(c || 0));
}
if (resolution === 'credit') {
setCancelMsg('Session cancelled · ' + (res.credited ? dollars(res.credited) + ' added as account credit' : 'credit added to your account'));
} else {
setCancelMsg('Session cancelled · ' + (res.refunded ? dollars(res.refunded) + ' refunded to your card' : 'refund processed'));
}
};
const list = bookings || [];
const now = Date.now();
const upcoming = list.filter(b => new Date(b.starts_at).getTime() >= now && b.status !== 'cancelled');
const past = list.filter(b => new Date(b.starts_at).getTime() < now || b.status === 'cancelled');
const Row = ({ b }) => {
const c = b.coaches || {};
const isUpcoming = new Date(b.starts_at).getTime() >= now && b.status !== 'cancelled';
const msUntil = new Date(b.starts_at).getTime() - now;
const canCancel = isUpcoming && msUntil >= CANCEL_CUTOFF_MS;
const tooLateToCancel = isUpcoming && msUntil < CANCEL_CUTOFF_MS;
const canceling = cancelingId === b.id;
return (
{c.lbl || (c.name ? c.name.slice(0, 2).toUpperCase() : '★')}
{c.name || 'COACH'}
{fmtSessionDate(b.starts_at)} · {fmtSessionTime(b.starts_at, b.duration_min)}
{(b.status || 'confirmed').toUpperCase()}
{b.topic && (
“{b.topic}”
)}
{isUpcoming && b.meet_link && (
JOIN GOOGLE MEET →
)}
{canCancel && choosingId !== b.id && (
)}
{canCancel && choosingId === b.id && (
HOW DO YOU WANT YOUR MONEY BACK?
)}
{tooLateToCancel && (
CANCELLATION CLOSED · LESS THAN 24H AWAY
)}
);
};
return (
{/* Top bar */}
{isDemo && (
PREVIEW DATA
)}
{/* Hero */}
★ BOOKED 1:1s
MY SESSIONS
{credit > 0 && (
★
${Math.round(credit / 100)} ACCOUNT CREDIT
APPLIED AUTOMATICALLY ON YOUR NEXT SESSION
)}
{cancelErr && (
)}
{cancelMsg && (
)}
{bookings === undefined ? (
LOADING…
) : list.length === 0 ? (
📅
NO SESSIONS YET
Book a 1:1 with a coach and it'll show up here with the Google Meet link.
{onOpenBooking && (
)}
) : (
<>
{upcoming.length > 0 && (
UPCOMING · {upcoming.length}
{upcoming.map(b =>
)}
)}
{past.length > 0 && (
PAST · {past.length}
{past.map(b =>
)}
)}
>
)}
);
}
window.LAUNCH_BOOKING = { BookingScreen, MySessionsScreen };