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>
181 lines
6.5 KiB
TypeScript
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 & 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 · Please reserve <strong>at least 1 hour</strong> at your venue · 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>
|
|
)
|
|
}
|