beachPartyBalloons/estore/src/components/DeliveryDatePicker.tsx
chris f969e5d242 feat: configurable booking lead time in admin (default 48h)
Adds leadTimeHours to HoursConfig. Slot generation, calendar minDate,
and pickup disabled-date precomputation all read from the config.
Admin hours page has a new input to adjust it without a redeploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 07:48:13 -04:00

181 lines
6.5 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import { BASE } from '@/lib/basepath'
import type { DeliveryTier } from '@/lib/delivery'
import type { TimeSlot } from '@/lib/slots'
import type { HoursConfig } from '@/lib/hours-config'
import CalendarPicker from './CalendarPicker'
export interface DeliverySelection {
date: string // YYYY-MM-DD
slotISO: string // UTC ISO start time
label: string // human label e.g. "10:00 AM"
driveMinutes: number
}
interface Props {
address: string
tier: DeliveryTier
value: DeliverySelection | null
onChange: (v: DeliverySelection | null) => void
}
type SlotState = 'idle' | 'loading' | 'loaded' | 'error'
const maxDate = new Date(Date.now() + 90 * 86400_000).toISOString().slice(0, 10)
export default function DeliveryDatePicker({ address, tier, value, onChange }: Props) {
const [date, setDate] = useState(value?.date ?? '')
const [slots, setSlots] = useState<TimeSlot[]>([])
const [drive, setDrive] = useState(0)
const [status, setStatus] = useState<SlotState>('idle')
const [errMsg, setErrMsg] = useState('')
const [leadTimeHours, setLeadTimeHours] = useState(48)
// Dates that returned 0 slots — shown as strikethrough on the calendar
const [noSlotDates, setNoSlotDates] = useState<Set<string>>(new Set())
// Busy dates from CalDAV — shown with an orange dot
const [busyDates, setBusyDates] = useState<Set<string>>(new Set())
const minDate = new Date(Date.now() + leadTimeHours * 60 * 60 * 1000).toISOString().slice(0, 10)
// Pre-mark all closed delivery days from admin hours config
useEffect(() => {
fetch(`${BASE}/api/hours`)
.then(r => r.ok ? r.json() : null)
.then((config: HoursConfig | null) => {
if (!config) return
const lead = config.leadTimeHours ?? 48
setLeadTimeHours(lead)
const closed = new Set<string>()
const start = Date.now() + lead * 60 * 60 * 1000
for (let i = 0; i < 90; i++) {
const dateStr = new Date(start + i * 86400_000).toISOString().slice(0, 10)
const dow = new Date(`${dateStr}T12:00:00Z`).getUTCDay()
if (!config.delivery[String(dow)]) closed.add(dateStr)
}
setNoSlotDates(closed)
})
.catch(() => {/* non-fatal */})
}, [])
// Fetch busy dates once on mount
useEffect(() => {
if (!process.env.NEXT_PUBLIC_SITE_URL && typeof window === 'undefined') return
fetch(`${BASE}/api/availability?days=90`)
.then(r => r.ok ? r.json() : null)
.then((data) => {
if (!data?.busy) return
const dates = new Set<string>(
(data.busy as Array<{ start: string }>).map(b => b.start.slice(0, 10))
)
setBusyDates(dates)
})
.catch(() => {/* non-fatal */})
}, [])
// Fetch slots when date or address changes
useEffect(() => {
if (!date || !address) return
setStatus('loading')
setSlots([])
onChange(null)
const params = new URLSearchParams({ date, address, tier })
fetch(`${BASE}/api/slots?${params}`)
.then((r) => r.json())
.then((data) => {
if (data.error) { setErrMsg(data.error); setStatus('error'); return }
const fetched: TimeSlot[] = data.slots ?? []
setSlots(fetched)
setDrive(data.driveMinutes ?? 0)
setStatus('loaded')
if (fetched.length === 0) {
setNoSlotDates(prev => { const s = new Set(prev); s.add(date); return s })
}
})
.catch(() => { setErrMsg('Could not load time slots.'); setStatus('error') })
}, [date, address, tier]) // eslint-disable-line react-hooks/exhaustive-deps
const selectSlot = (slot: TimeSlot) => {
onChange({ date, slotISO: slot.startISO, label: slot.label, driveMinutes: drive })
}
return (
<div style={{ marginBottom: '0.75rem' }}>
<p style={{ fontWeight: 'bold', fontSize: '0.85rem', marginBottom: '0.5rem' }}>
Delivery date &amp; time
</p>
<div style={{ border: '1px solid #e0e0e0', borderRadius: '8px', padding: '0.6rem', marginBottom: '0.5rem' }}>
<CalendarPicker
selected={date}
onSelect={(d) => { setDate(d); onChange(null) }}
minDate={minDate}
maxDate={maxDate}
disabledDates={noSlotDates}
busyDates={busyDates}
/>
</div>
{status === 'loading' && (
<p className="is-size-7 has-text-grey">Checking availability</p>
)}
{status === 'error' && (
<p style={{ fontSize: '0.75rem', color: '#cc3333', lineHeight: 1.4 }}>
{errMsg}{' '}
<a href="https://beachpartyballoons.com/contact/" style={{ color: '#cc3333', textDecoration: 'underline' }}>
Contact us
</a>{' '}
to book manually.
</p>
)}
{status === 'loaded' && slots.length === 0 && (
<p style={{ fontSize: '0.78rem', color: '#888' }}>
No availability on this date please try another day, or{' '}
<a href="https://beachpartyballoons.com/contact/" style={{ textDecoration: 'underline' }}>
contact us
</a>.
</p>
)}
{status === 'loaded' && slots.length > 0 && (
<>
<p className="is-size-7 has-text-grey" style={{ marginBottom: '0.35rem' }}>
~{drive} min drive &nbsp;·&nbsp; Please reserve <strong>at least 1 hour</strong> at your venue &nbsp;·&nbsp; select a start time
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5px' }}>
{slots.map((slot) => {
const chosen = value?.slotISO === slot.startISO
return (
<button
key={slot.startISO}
type="button"
onClick={() => selectSlot(slot)}
style={{
padding: '5px 4px',
fontSize: '0.78rem',
borderRadius: '6px',
border: `1px solid ${chosen ? '#11b3be' : '#d0d0d0'}`,
background: chosen ? '#11b3be' : '#fff',
color: chosen ? '#fff' : '#333',
cursor: 'pointer',
fontWeight: chosen ? 'bold' : 'normal',
fontFamily: 'inherit',
}}
>
{slot.label}
</button>
)
})}
</div>
</>
)}
</div>
)
}