custom schedule ui

This commit is contained in:
Alan Daniel committed 2026-03-25 12:20:41 -04:00
1 parent 6af66f3c28
commit fec3638bc4
4 files changed
+74 -16

No files matched your search

@@ -37,5 +37,27 @@ export async function GET(
}
const data = await res.json()
// HubSpot returns `durations` but our types expect `availableDurations`
if (data.customParams?.durations && !data.customParams.availableDurations) {
data.customParams.availableDurations = data.customParams.durations
}
// Transform linkAvailability from HubSpot's nested structure to a flat Record<string, TimeSlot[]>
// HubSpot returns: { linkAvailabilityByDuration: { "1800000": { availabilities: [...] } } }
// We need: { "1800000": [...] }
if (data.linkAvailability?.linkAvailabilityByDuration) {
const flat: Record<string, Array<{ startMillisUtc: number; endMillisUtc: number }>> = {}
for (const [duration, value] of Object.entries(
data.linkAvailability.linkAvailabilityByDuration as Record<
string,
{ availabilities?: Array<{ startMillisUtc: number; endMillisUtc: number }> }
>
)) {
flat[duration] = value.availabilities ?? []
}
data.linkAvailability = flat
}
return NextResponse.json(data)
}
@@ -38,7 +38,7 @@ export default function HubSpotMeetingSection({ section }: { section: GoHubSpotM
timezone,
availableDates,
slotsForSelectedDate,
maxMonthOffset,
availableMonthOffsets,
selectDate,
selectSlot,
goBackToDate,
@@ -120,7 +120,7 @@ export default function HubSpotMeetingSection({ section }: { section: GoHubSpotM
{state.step === 'date-select' && (
<CalendarPicker
monthOffset={state.monthOffset}
maxMonthOffset={maxMonthOffset}
availableMonthOffsets={availableMonthOffsets}
timezone={timezone}
availableDates={availableDates}
selectedDate={state.selectedDate}
@@ -3,7 +3,7 @@ import { cn } from 'ui'
interface CalendarPickerProps {
monthOffset: number
maxMonthOffset: number
availableMonthOffsets: number[]
timezone: string
availableDates: Set<string>
selectedDate: string | null
@@ -15,7 +15,7 @@ const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
export default function CalendarPicker({
monthOffset,
maxMonthOffset,
availableMonthOffsets,
timezone,
availableDates,
selectedDate,
@@ -55,16 +55,19 @@ export default function CalendarPicker({
const today = new Date().toLocaleDateString('en-CA', { timeZone: timezone })
const hasPrev = availableMonthOffsets.some((o) => o < monthOffset)
const hasNext = availableMonthOffsets.some((o) => o > monthOffset)
return (
<div className="w-full">
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={() => onChangeMonth(monthOffset - 1)}
disabled={monthOffset <= 0}
disabled={!hasPrev}
className={cn(
'p-2 rounded-md text-foreground-lighter transition-colors',
monthOffset > 0 ? 'hover:bg-surface-300/50 hover:text-foreground' : 'opacity-30 cursor-not-allowed'
hasPrev ? 'hover:bg-surface-300/50 hover:text-foreground' : 'opacity-30 cursor-not-allowed'
)}
aria-label="Previous month"
>
@@ -74,10 +77,10 @@ export default function CalendarPicker({
<button
type="button"
onClick={() => onChangeMonth(monthOffset + 1)}
disabled={monthOffset >= maxMonthOffset}
disabled={!hasNext}
className={cn(
'p-2 rounded-md text-foreground-lighter transition-colors',
monthOffset < maxMonthOffset ? 'hover:bg-surface-300/50 hover:text-foreground' : 'opacity-30 cursor-not-allowed'
hasNext ? 'hover:bg-surface-300/50 hover:text-foreground' : 'opacity-30 cursor-not-allowed'
)}
aria-label="Next month"
>
@@ -121,11 +121,6 @@ export function useScheduler(slug: string, enabled = true) {
setState((s) => ({ ...s, selectedSlot: null, step: 'time-select' }))
}, [])
const changeMonth = useCallback((offset: number) => {
const clamped = Math.max(0, Math.min(offset, MONTHS_TO_FETCH - 1))
setState((s) => ({ ...s, monthOffset: clamped }))
}, [])
const retry = useCallback(() => {
loadAllMonths()
}, [loadAllMonths])
@@ -203,8 +198,46 @@ export function useScheduler(slug: string, enabled = true) {
return dates
}, [allSlots, timezone])
/** Max month offset the user can navigate to */
const maxMonthOffset = MONTHS_TO_FETCH - 1
/** Month offsets that have at least one available slot */
const availableMonthOffsets = useMemo(() => {
const now = new Date()
const offsets = new Set<number>()
for (const slot of allSlots) {
const date = new Date(slot.startMillisUtc)
const localDateStr = date.toLocaleDateString('en-CA', { timeZone: timezone })
const [y, m] = localDateStr.split('-').map(Number)
const offset = (y - now.getFullYear()) * 12 + (m - 1 - now.getMonth())
if (offset >= 0 && offset < MONTHS_TO_FETCH) {
offsets.add(offset)
}
}
return Array.from(offsets).sort((a, b) => a - b)
}, [allSlots, timezone])
/** Auto-navigate to first month with availability */
const hasSetInitialMonth = useRef(false)
useEffect(() => {
if (hasSetInitialMonth.current) return
if (availableMonthOffsets.length > 0 && state.step === 'date-select') {
hasSetInitialMonth.current = true
setState((s) => ({ ...s, monthOffset: availableMonthOffsets[0] }))
}
}, [availableMonthOffsets, state.step])
const changeMonth = useCallback(
(offset: number) => {
if (availableMonthOffsets.length === 0) return
const current = state.monthOffset
if (offset > current) {
const next = availableMonthOffsets.find((o) => o > current)
if (next !== undefined) setState((s) => ({ ...s, monthOffset: next }))
} else if (offset < current) {
const prev = [...availableMonthOffsets].reverse().find((o) => o < current)
if (prev !== undefined) setState((s) => ({ ...s, monthOffset: prev }))
}
},
[availableMonthOffsets, state.monthOffset]
)
return {
state,
@@ -212,7 +245,7 @@ export function useScheduler(slug: string, enabled = true) {
locale,
availableDates,
slotsForSelectedDate,
maxMonthOffset,
availableMonthOffsets,
selectDate,
selectSlot,
goBackToDate,