/* global React, NA_DATA, NASite, naSupabase */ const { useState, useMemo, useEffect } = React; // Calendly-style booking flow for Nottingham Athletic. // Steps: squad -> calendar (day picker + sessions) -> pay-choice modal -> // details -> pay (gocardless | stripe | cash) -> confirmation. function SessionsPage({ setPage }) { const { SQUADS } = NA_DATA; const PER_SESSION = 6.50; const [step, setStep] = useState('squad'); const [squadId, setSquadId] = useState(null); const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date('2026-05-11'))); const [selectedDay, setSelectedDay] = useState(null); const [selectedSession, setSelectedSession] = useState(null); const [payChoice, setPayChoice] = useState(null); // 'monthly' | 'payg' | 'cash' const [details, setDetails] = useState({}); const [confirmRecord, setConfirmRecord] = useState(null); const [ddBusy, setDdBusy] = useState(null); // squad id currently starting a mandate const [ddError, setDdError] = useState(''); // Start a real GoCardless Direct Debit mandate for a monthly membership. // Hands the visitor to the GoCardless hosted page (which collects their // name, email and bank details), then returns them to #join-success. async function startDirectDebit(s) { setDdError(''); setDdBusy(s.id); try { const res = await fetch('https://zpsfpgkkuromgihdcast.supabase.co/functions/v1/gocardless', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'create-redirect-flow', squad: s.id, plan: 'monthly', amount: s.monthly, description: `${s.name} — monthly membership (£${s.monthly}/mo)`, success_url: window.location.origin + window.location.pathname + '#join-success', }), }); if (!res.ok) { const body = await res.text().catch(() => ''); throw new Error(`GoCardless ${res.status}: ${body.slice(0, 140)}`); } const { redirect_url } = await res.json(); if (redirect_url) { window.location.href = redirect_url; return; } throw new Error('No redirect URL returned.'); } catch (err) { console.warn('GoCardless mandate failed:', err); setDdError('Could not start the Direct Debit setup. Please try again, or email admin@nottinghamathletic.com.'); setDdBusy(null); } } // Live session capacity from the coach app (Supabase view: session_capacity) const [remoteCapacity, setRemoteCapacity] = useState([]); const [capacityState, setCapacityState] = useState('loading'); // 'loading'|'ok'|'empty'|'error' useEffect(() => { let cancelled = false; (async () => { try { const rows = await naSupabase.fetchSessionCapacity(); if (cancelled) return; setRemoteCapacity(rows.map(naSupabase.normaliseSession)); setCapacityState(rows.length ? 'ok' : 'empty'); } catch (e) { if (!cancelled) setCapacityState('error'); } })(); return () => { cancelled = true; }; }, []); const squad = SQUADS.find(s => s.id === squadId); // -------- Sessions for the visible week -------- // Prefer real session_capacity rows when present. Fall back to generating // placeholder sessions from the squad training schedule when the coach // app hasn't published any yet. const sessionsByDay = useMemo(() => { if (!squad) return {}; // Live data path: filter rows for this squad + visible week const weekEnd = new Date(weekStart.getTime() + 7 * 86400000); const live = remoteCapacity.filter(s => s.squad === squad.id && s.date >= weekStart && s.date < weekEnd ); if (live.length) { const map = {}; live.forEach(s => { const key = s.date.toDateString(); if (!map[key]) map[key] = []; map[key].push({ ...s, endTime: addHour(s.time), type: 'practice', }); }); return map; } // Fallback: generate from training schedule const dayMap = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 0 }; // Squads made up of several teams that each train at their own time // (e.g. the Junior Local League — U14 / U16 / U18 on Mondays) declare // them via `subTeams`. Each becomes its own bookable session, labelled. const slots = (squad.subTeams && squad.subTeams.length) ? squad.subTeams.map(t => { const [d, time] = t.slot.split(' '); return { day: dayMap[d], time: time || '18:00', label: t.label }; }).filter(s => s.day !== undefined) : squad.training.split('·').map(s => s.trim()).map(s => { const [d, t] = s.split(' '); return { day: dayMap[d], time: t || '18:00' }; }).filter(s => s.day !== undefined); const map = {}; for (let i = 0; i < 7; i++) { const d = new Date(weekStart.getTime() + i * 86400000); const key = d.toDateString(); slots.forEach(slot => { if (d.getDay() === slot.day) { if (!map[key]) map[key] = []; const taken = 4 + Math.floor(seedRand(squad.id + key + slot.time + (slot.label || '')) * 11); map[key].push({ id: `${squad.id}-${d.toISOString().slice(0,10)}-${slot.time}-${(slot.label || '').replace(/\s+/g,'')}`, date: d, time: slot.time, endTime: addHour(slot.time), venue: squad.venue, type: 'practice', label: slot.label || null, capacity: 16, taken, }); } }); } // Sort each day's sessions by start time so teams read top-to-bottom. Object.values(map).forEach(arr => arr.sort((a, b) => a.time.localeCompare(b.time))); return map; }, [squadId, weekStart, remoteCapacity]); const goToSquad = () => setStep('squad'); const goToCalendar = () => setStep('calendar'); // ============ STEP 1 — SQUAD ============ if (step === 'squad') { // Bookable = open Local League teams (£20/mo) + U12 (£15/mo). // Invite only = National League squads (selected at trials). // YBL teams have open registration but prices are still TBC, so they // aren't bookable here yet — they live on the Teams page. const bookable = SQUADS.filter(s => s.bookable); const inviteOnly = SQUADS.filter(s => s.inviteOnly); return (
Step 1 of 4 · Pick your squad

Book a session.

Open booking is available for our U12 squad and our Local League teams — U14, U16, U18 and Mens, all training Monday nights. National League squads are invite only; speak to a coach about trialling.

{bookable.map(s => (
{s.tag}
{s.name}
{summarise(s.training)} · {s.venue}
£6.50/session · or £{s.monthly}/mo unlimited
))}
{ddError && (
{ddError}
)} {/* Invite-only callout — National League squads */}
Invite only

National League squads — Senior, U23, U16 & U14.

Players on the National League pathway are selected at trials and managed by the head coach. These squads aren't bookable online — your coach will add you to the schedule once you're on the roster. Want to trial? setPage('teams')}>See teams & trial info →

{inviteOnly.map(s => (
{s.name} {s.leagues[0] ? s.leagues[0].name : ''} · {s.venue} Invite only
))}
); } // ============ STEP 2 — CALENDAR ============ if (step === 'calendar') { const days = []; for (let i = 0; i < 7; i++) { const d = new Date(weekStart.getTime() + i * 86400000); days.push(d); } const visibleDay = selectedDay || days.find(d => sessionsByDay[d.toDateString()]) || days[0]; const todaySessions = sessionsByDay[visibleDay.toDateString()] || []; return (
← {squad.name} · Step 2 of 4 · Pick a session

When can you make it?

All sessions take place at {squad.venue}. Sessions go live four weeks ahead.

{/* WEEK NAV + DAY GRID */}
{rangeLabel(weekStart)}
{days.map(d => { const has = !!sessionsByDay[d.toDateString()]; const isSelected = visibleDay.toDateString() === d.toDateString(); return ( ); })}
Available No session
{/* RIGHT PANE — SESSIONS */}
{visibleDay.toLocaleDateString('en-GB', { weekday: 'long' })}
{visibleDay.toLocaleDateString('en-GB', { day: 'numeric', month: 'long' })}
{todaySessions.length === 0 ? (
No sessions on this day. Pick another from the week.
) : (
{todaySessions.map(sx => { const remaining = sx.capacity - sx.taken; const soldOut = remaining <= 0; const filling = remaining <= 4 && remaining > 0; return (
{sx.time} — {sx.endTime}
{sx.label ? `${sx.label}` : `${squad.name} training`} {sx.type}
{sx.venue}
{soldOut ? 'Sold out' : <>{sx.taken} of {sx.capacity} spots taken — {remaining} left}
£{PER_SESSION.toFixed(2)} or included monthly
{soldOut ? ( ) : ( )}
); })}
)}
); } // ============ STEP 3 — PAY CHOICE MODAL ============ if (step === 'pay-choice') { return (
← Back to sessions · Step 3 of 4 · How will you pay?

How would you like to pay?

Most members go monthly — cheaper per session, and you don't have to remember each week.

£
Train 4× this month? That's £{(PER_SESSION * 4).toFixed(2)} pay-as-you-go. Monthly Direct Debit covers unlimited training for £{squad.monthly} — saving you ~£{Math.max(0, (PER_SESSION * 4) - squad.monthly).toFixed(2)}.
{ setPayChoice('cash'); setStep('details'); }}>Skip — I'll pay cash on the door →
); } // ============ STEP 4 — DETAILS ============ if (step === 'details') { const onSubmit = (e) => { e.preventDefault(); const fd = new FormData(e.target); const data = Object.fromEntries(fd.entries()); setDetails(data); if (payChoice === 'cash') { finalise({ provider: 'cash', providerRef: 'CASH-' + Math.random().toString(36).slice(2,10).toUpperCase() }, data); } else { setStep(payChoice === 'monthly' ? 'pay-mandate' : 'pay-card'); } }; const isUnder18 = (() => { if (!details.dob) return false; const dob = new Date(details.dob); const now = new Date('2026-05-05'); return (now.getFullYear() - dob.getFullYear()) < 18; })(); return (
setStep('pay-choice')}>← Back · Step 4 of 4 · Player details

A few details.

{payChoice === 'monthly' ? `Setting up monthly Direct Debit for ${squad.name}.` : payChoice === 'cash' ? 'Booked, but pay cash on the door — coach will mark it off on the day.' : 'Paying £6.50 for this session by card.'}

Player
setDetails(d => ({ ...d, dob: e.target.value }))} />
{isUnder18 && (
)}
Contact
); } // ============ STEP 5a — STRIPE-STYLE CARD ============ if (step === 'pay-card') { return (
setStep('details')}>← Back · Pay for your session

Pay £6.50 — Stripe Checkout.

Card details are sent securely to Stripe. We don't store them.

{ e.preventDefault(); finalise({ provider: 'stripe', providerRef: 'pi_' + Math.random().toString(36).slice(2,14) }, details); }}>
{squad.name} — {selectedSession.date.toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' })} · {selectedSession.time}£6.50
Total£6.50
Stripe · 3D Secure if needed
); } // ============ STEP 5b — GOCARDLESS MANDATE ============ if (step === 'pay-mandate') { return (
setStep('details')}>← Back · Direct Debit setup

Set up your Direct Debit.

£{squad.monthly}/month for unlimited {squad.name} training. First charge on the 1st of next month. You'll finish setup securely on GoCardless.

{squad.name} monthly£{squad.monthly}.00
First charge1st of next month
Today£0.00

Tap below to set up your Direct Debit securely with GoCardless. They'll collect your name, email and bank details on their FCA-regulated page — we never see or store your bank information. Protected by the Direct Debit Guarantee; cancel any time with 30 days' notice.

{ddError && (
{ddError}
)}
GoCardless · FCA-regulated
); } // ============ CONFIRMATION ============ if (step === 'done' && confirmRecord) { const c = confirmRecord; return (
Confirmed

{c.plan === 'monthly' ? "You're a member." : "You're booked."}

{c.plan === 'monthly' ? <>You're set up on {c.squadName} monthly membership. First Direct Debit on {c.firstChargeDate}, then monthly. Confirmation in your inbox. : <>{c.squadName} on {c.sessionDate} · {c.sessionTime} at {c.sessionVenue}. Confirmation emailed.}

Add to your calendar
What happens next
{c.plan === 'monthly' ? ( <>
01
Welcome email — within an hour. Includes safeguarding pack and your first training date.
02
Auto-marked in registers. Coaches see you as paid in the coach app — no need to bring cash.
03
First Direct Debit on {c.firstChargeDate}. £{c.monthlyAmount}.00, monthly. Cancel any time.
) : c.plan === 'cash' ? ( <>
01
Booked — pay cash on arrival. Coach will collect £6.50 before tip-off and mark you in.
02
Reminder 24h before. Time, venue, and what to bring.
03
Liked it? Set up monthly → Cheaper per session if you train more than 3× a month.
) : ( <>
01
Receipt emailed now. Reference {c.providerRef}.
02
Synced to registers on the day. Coach app marks you paid right before tip-off.
03
Reminder 24h before. Includes venue, time, and what to bring.
)}
{c.plan !== 'monthly' && (
Liked this?

Set up monthly Direct Debit for unlimited training — cheaper per session.

)}
Coach-app sync record (Supabase POST payload)
{JSON.stringify(c, null, 2)}
); } function reset() { setStep('squad'); setSquadId(null); setSelectedSession(null); setPayChoice(null); setDetails({}); setConfirmRecord(null); setSelectedDay(null); } function finalise(payProvider, det) { const isMonthly = payChoice === 'monthly'; const record = { // Player data playerName: det.playerName, dob: det.dob, parentName: det.parentName || null, email: det.email, phone: det.phone, emergencyContact: det.emergencyContact || null, // Squad / session squad: squad.id, squadName: squad.name, sessionId: !isMonthly && selectedSession ? selectedSession.id : null, sessionDate: !isMonthly && selectedSession ? selectedSession.date.toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' }) : null, sessionTime: !isMonthly && selectedSession ? selectedSession.time : null, sessionVenue: !isMonthly && selectedSession ? selectedSession.venue : squad.venue, // Payment plan: payChoice, // 'monthly' | 'payg' | 'cash' provider: payProvider.provider, providerRef: payProvider.providerRef, stripePaymentIntentId: payProvider.provider === 'stripe' ? payProvider.providerRef : null, goCardlessMandateId: payProvider.provider === 'gocardless' ? payProvider.providerRef : null, amount: isMonthly ? squad.monthly : 6.50, currency: 'GBP', monthlyAmount: isMonthly ? squad.monthly : null, firstChargeDate: payProvider.firstChargeDate || null, // Coach-app status hint coachAppStatus: isMonthly ? 'PAID — auto-marked attended on every session' : payChoice === 'cash' ? 'CASH PENDING — collect on the door' : 'PAID — sync to register on session day', capturedAt: new Date().toISOString(), }; setConfirmRecord(record); setStep('done'); } } // ----------------- Helpers ----------------- function Tick() { return ; } function Lock() { return ; } function startOfWeek(date) { const d = new Date(date); const day = d.getDay(); const diff = day === 0 ? 6 : day - 1; // Monday-start d.setDate(d.getDate() - diff); d.setHours(0,0,0,0); return d; } function rangeLabel(weekStart) { const end = new Date(weekStart.getTime() + 6 * 86400000); const o = { day: 'numeric', month: 'short' }; return `${weekStart.toLocaleDateString('en-GB', o)} – ${end.toLocaleDateString('en-GB', o)}`; } function addHour(time) { const [h, m] = time.split(':').map(Number); const nh = (h + 1) % 24; return `${String(nh).padStart(2,'0')}:${String(m||0).padStart(2,'0')}`; } function summarise(training) { const parts = training.split('·').map(s => s.trim()); if (parts.length <= 2) return parts.join(' + '); return parts.slice(0, 2).join(' + ') + ' + more'; } function seedRand(str) { let h = 0; for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) & 0xffffffff; return ((h >>> 0) % 1000) / 1000; } window.SessionsPage = SessionsPage;