useScheduler.ts276 lines · main
1import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
3import { bookMeeting, fetchBookingInfo } from './api'
4import type {
5 BookingConfirmation,
6 BookingInfo,
7 BookingRequest,
8 SchedulerStep,
9 TimeSlot,
10} from './types'
11
12/** How many months to prefetch (current + 3 ahead) */
13const MONTHS_TO_FETCH = 4
14
15function getUserTimezone(): string {
16 try {
17 return Intl.DateTimeFormat().resolvedOptions().timeZone
18 } catch {
19 return 'America/New_York'
20 }
21}
22
23function getUserLocale(): string {
24 try {
25 return navigator.language || 'en-US'
26 } catch {
27 return 'en-US'
28 }
29}
30
31export interface SchedulerState {
32 step: SchedulerStep
33 timezone: string
34 selectedDate: string | null // YYYY-MM-DD
35 selectedSlot: TimeSlot | null
36 selectedDuration: number // milliseconds
37 confirmation: BookingConfirmation | null
38 error: string | null
39 /** Current month offset from today (0 = current month) */
40 monthOffset: number
41}
42
43export function useScheduler(slug: string, enabled = true) {
44 const [timezone, setTimezoneState] = useState('America/New_York')
45 const [locale, setLocale] = useState('en-US')
46
47 // Hydrate timezone/locale from browser after mount to avoid SSR mismatches
48 useEffect(() => {
49 setTimezoneState(getUserTimezone())
50 setLocale(getUserLocale())
51 }, [])
52
53 /** Cached booking info keyed by month offset */
54 const [monthCache, setMonthCache] = useState<Record<number, BookingInfo>>({})
55
56 const [state, setState] = useState<SchedulerState>({
57 step: 'loading',
58 timezone,
59 selectedDate: null,
60 selectedSlot: null,
61 selectedDuration: 0,
62 confirmation: null,
63 error: null,
64 monthOffset: 0,
65 })
66
67 const loadAllMonths = useCallback(
68 async (tz?: string) => {
69 const effectiveTz = tz ?? timezone
70 try {
71 setState((s) => ({ ...s, step: 'loading', error: null }))
72
73 const results = await Promise.all(
74 Array.from({ length: MONTHS_TO_FETCH }, (_, i) => fetchBookingInfo(slug, effectiveTz, i))
75 )
76
77 const cache: Record<number, BookingInfo> = {}
78 for (let i = 0; i < results.length; i++) {
79 cache[i] = results[i]
80 }
81
82 const defaultDuration = results[0]?.customParams?.availableDurations?.[0] ?? 1800000
83
84 setMonthCache(cache)
85 setState((s) => ({
86 ...s,
87 step: 'date-select',
88 selectedDuration: defaultDuration,
89 selectedDate: null,
90 selectedSlot: null,
91 monthOffset: 0,
92 }))
93 } catch (err) {
94 setState((s) => ({
95 ...s,
96 step: 'error',
97 error: err instanceof Error ? err.message : 'Failed to load booking info',
98 }))
99 }
100 },
101 [slug, timezone]
102 )
103
104 useEffect(() => {
105 if (enabled) loadAllMonths()
106 }, [enabled, loadAllMonths])
107
108 const setTimezone = useCallback(
109 (tz: string) => {
110 setTimezoneState(tz)
111 setState((s) => ({ ...s, timezone: tz }))
112 loadAllMonths(tz)
113 },
114 [loadAllMonths]
115 )
116
117 const selectDate = useCallback((date: string) => {
118 setState((s) => ({ ...s, selectedDate: date, selectedSlot: null, step: 'time-select' }))
119 }, [])
120
121 const selectSlot = useCallback((slot: TimeSlot) => {
122 setState((s) => ({ ...s, selectedSlot: slot, step: 'form' }))
123 }, [])
124
125 const goBackToDate = useCallback(() => {
126 setState((s) => ({ ...s, selectedSlot: null, step: 'date-select', selectedDate: null }))
127 }, [])
128
129 const goBackToTime = useCallback(() => {
130 setState((s) => ({ ...s, selectedSlot: null, step: 'time-select' }))
131 }, [])
132
133 const retry = useCallback(() => {
134 loadAllMonths()
135 }, [loadAllMonths])
136
137 const dismissError = useCallback(() => {
138 setState((s) => ({ ...s, error: null }))
139 }, [])
140
141 const setDuration = useCallback((duration: number) => {
142 setState((s) => ({ ...s, selectedDuration: duration }))
143 }, [])
144
145 const submitBooking = useCallback(
146 async (form: {
147 firstName: string
148 lastName: string
149 email: string
150 guestEmails?: string[]
151 }) => {
152 if (!state.selectedSlot) return
153
154 setState((s) => ({ ...s, step: 'submitting' }))
155
156 const request: BookingRequest = {
157 slug,
158 firstName: form.firstName,
159 lastName: form.lastName,
160 email: form.email,
161 startTime: state.selectedSlot.startMillisUtc,
162 duration: state.selectedDuration,
163 timezone,
164 locale,
165 guestEmails: form.guestEmails,
166 }
167
168 try {
169 const confirmation = await bookMeeting(request)
170 setState((s) => ({ ...s, step: 'confirmed', confirmation }))
171 } catch (err) {
172 setState((s) => ({
173 ...s,
174 step: 'form',
175 error: err instanceof Error ? err.message : 'Booking failed',
176 }))
177 }
178 },
179 [slug, timezone, locale, state.selectedSlot, state.selectedDuration]
180 )
181
182 /** All slots merged across all cached months */
183 const allSlots = useMemo(() => {
184 const durationKey = String(state.selectedDuration)
185 const slots: TimeSlot[] = []
186 for (const info of Object.values(monthCache)) {
187 const monthSlots = info.linkAvailability?.[durationKey] ?? []
188 slots.push(...monthSlots)
189 }
190 return slots
191 }, [monthCache, state.selectedDuration])
192
193 /** Get available time slots for the selected date and duration */
194 const slotsForSelectedDate = useMemo(() => {
195 if (!state.selectedDate) return []
196
197 return allSlots.filter((slot) => {
198 const date = new Date(slot.startMillisUtc)
199 const dateStr = date.toLocaleDateString('en-CA', { timeZone: timezone })
200 return dateStr === state.selectedDate
201 })
202 }, [allSlots, state.selectedDate, timezone])
203
204 /** Get set of dates (YYYY-MM-DD) that have availability */
205 const availableDates = useMemo(() => {
206 const dates = new Set<string>()
207 for (const slot of allSlots) {
208 const date = new Date(slot.startMillisUtc)
209 const dateStr = date.toLocaleDateString('en-CA', { timeZone: timezone })
210 dates.add(dateStr)
211 }
212 return dates
213 }, [allSlots, timezone])
214
215 /** Month offsets that have at least one available slot */
216 const availableMonthOffsets = useMemo(() => {
217 // Compute "now" in the selected timezone to avoid ±1 month drift near boundaries
218 const nowStr = new Date().toLocaleDateString('en-CA', { timeZone: timezone })
219 const [nowY, nowM] = nowStr.split('-').map(Number)
220 const offsets = new Set<number>()
221 for (const slot of allSlots) {
222 const date = new Date(slot.startMillisUtc)
223 const localDateStr = date.toLocaleDateString('en-CA', { timeZone: timezone })
224 const [y, m] = localDateStr.split('-').map(Number)
225 const offset = (y - nowY) * 12 + (m - nowM)
226 if (offset >= 0 && offset < MONTHS_TO_FETCH) {
227 offsets.add(offset)
228 }
229 }
230 return Array.from(offsets).sort((a, b) => a - b)
231 }, [allSlots, timezone])
232
233 /** Auto-navigate to first month with availability */
234 const hasSetInitialMonth = useRef(false)
235 useEffect(() => {
236 if (hasSetInitialMonth.current) return
237 if (availableMonthOffsets.length > 0 && state.step === 'date-select') {
238 hasSetInitialMonth.current = true
239 setState((s) => ({ ...s, monthOffset: availableMonthOffsets[0] }))
240 }
241 }, [availableMonthOffsets, state.step])
242
243 const changeMonth = useCallback(
244 (offset: number) => {
245 if (availableMonthOffsets.length === 0) return
246 const current = state.monthOffset
247 if (offset > current) {
248 const next = availableMonthOffsets.find((o) => o > current)
249 if (next !== undefined) setState((s) => ({ ...s, monthOffset: next }))
250 } else if (offset < current) {
251 const prev = [...availableMonthOffsets].reverse().find((o) => o < current)
252 if (prev !== undefined) setState((s) => ({ ...s, monthOffset: prev }))
253 }
254 },
255 [availableMonthOffsets, state.monthOffset]
256 )
257
258 return {
259 state,
260 timezone,
261 locale,
262 availableDates,
263 slotsForSelectedDate,
264 availableMonthOffsets,
265 selectDate,
266 selectSlot,
267 goBackToDate,
268 goBackToTime,
269 changeMonth,
270 setTimezone,
271 setDuration,
272 submitBooking,
273 retry,
274 dismissError,
275 }
276}