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>
This commit is contained in:
parent
134705792c
commit
f969e5d242
@ -281,6 +281,26 @@ function HoursEditor() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ fontWeight: 600, fontSize: '0.85rem', display: 'block', marginBottom: '0.4rem' }}>
|
||||
Booking lead time (hours)
|
||||
</label>
|
||||
<p style={{ fontSize: '0.78rem', color: '#888', marginBottom: '0.5rem' }}>
|
||||
Minimum notice required before a delivery or pickup can be booked. Dates within this window are greyed out on the calendar.
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
className="input is-small"
|
||||
min={1}
|
||||
max={168}
|
||||
step={1}
|
||||
value={config.leadTimeHours ?? 48}
|
||||
onChange={(e) => setConfig((prev) => prev ? { ...prev, leadTimeHours: Math.max(1, parseInt(e.target.value) || 48) } : prev)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<span style={{ marginLeft: 8, fontSize: '0.82rem', color: '#666' }}>hours</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ fontWeight: 600, fontSize: '0.85rem', display: 'block', marginBottom: '0.4rem' }}>
|
||||
Delivery arrival window (minutes)
|
||||
|
||||
@ -196,7 +196,8 @@ export default function CartDrawer() {
|
||||
const fullAddress = [street, city, state, zip].filter(Boolean).join(', ')
|
||||
const canQuote = street.trim() && city.trim()
|
||||
const pickupSlots = useMemo(() => getPickupSlots(pickupDate, hoursConfig), [pickupDate, hoursConfig])
|
||||
const todayStr = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
||||
const leadMs = (hoursConfig?.leadTimeHours ?? 48) * 60 * 60 * 1000
|
||||
const todayStr = new Date(Date.now() + leadMs).toISOString().slice(0, 10)
|
||||
const maxDateStr = new Date(Date.now() + 90 * 86400_000).toISOString().slice(0, 10)
|
||||
|
||||
// Unit price — uses selected variation price if set, otherwise product default
|
||||
@ -221,13 +222,13 @@ export default function CartDrawer() {
|
||||
// Pre-compute pickup disabled dates (closed days) for the next 90 days
|
||||
const pickupDisabledDates = useMemo(() => {
|
||||
const disabled = new Set<string>()
|
||||
const base = Date.now() + 24 * 60 * 60 * 1000 // start from tomorrow
|
||||
const base = Date.now() + leadMs
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const d = new Date(base + i * 86400_000).toISOString().slice(0, 10)
|
||||
if (getPickupSlots(d, hoursConfig).length === 0) disabled.add(d)
|
||||
}
|
||||
return disabled
|
||||
}, [hoursConfig])
|
||||
}, [hoursConfig, leadMs])
|
||||
|
||||
const CT_TAX_RATE = 0.0635
|
||||
const subtotal = entries.reduce((sum, e) => sum + entryUnitPrice(e) * e.quantity, 0)
|
||||
|
||||
@ -23,15 +23,15 @@ interface Props {
|
||||
|
||||
type SlotState = 'idle' | 'loading' | 'loaded' | 'error'
|
||||
|
||||
const minDate = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
||||
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 [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())
|
||||
@ -39,14 +39,18 @@ export default function DeliveryDatePicker({ address, tier, value, onChange }: P
|
||||
// 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() + 24 * 60 * 60 * 1000
|
||||
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()
|
||||
|
||||
@ -11,10 +11,12 @@ export interface HoursConfig {
|
||||
delivery: WeekHours
|
||||
pickup: WeekHours
|
||||
deliveryWindowMinutes: number // customer-facing arrival window (email + Square)
|
||||
leadTimeHours: number // minimum hours in advance a slot can be booked
|
||||
}
|
||||
|
||||
export const DEFAULT_HOURS: HoursConfig = {
|
||||
deliveryWindowMinutes: 60,
|
||||
leadTimeHours: 48,
|
||||
delivery: {
|
||||
'0': { open: 480, close: 1020 }, // Sun 8:00 AM – 5:00 PM
|
||||
'1': null, // Mon closed
|
||||
|
||||
@ -17,6 +17,7 @@ export function getHoursConfig(): HoursConfig {
|
||||
delivery: { ...DEFAULT_HOURS.delivery, ...(saved.delivery ?? {}) },
|
||||
pickup: { ...DEFAULT_HOURS.pickup, ...(saved.pickup ?? {}) },
|
||||
deliveryWindowMinutes: saved.deliveryWindowMinutes ?? DEFAULT_HOURS.deliveryWindowMinutes,
|
||||
leadTimeHours: saved.leadTimeHours ?? DEFAULT_HOURS.leadTimeHours,
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_HOURS
|
||||
|
||||
@ -111,7 +111,8 @@ export async function getAvailableSlots(
|
||||
const closeTotalMin = hours.close
|
||||
const closeUTC = etToUtc(date, Math.floor(hours.close / 60), hours.close % 60)
|
||||
|
||||
const cutoffUTC = new Date(Date.now() + 24 * 60 * 60_000)
|
||||
const leadMs = (hoursConfig ?? DEFAULT_HOURS).leadTimeHours * 60 * 60_000
|
||||
const cutoffUTC = new Date(Date.now() + leadMs)
|
||||
|
||||
for (let arrivalTotalMin = openTotalMin; arrivalTotalMin < closeTotalMin; arrivalTotalMin += SLOT_STEP) {
|
||||
const arrivalH = Math.floor(arrivalTotalMin / 60)
|
||||
@ -120,7 +121,7 @@ export async function getAvailableSlots(
|
||||
const departUTC = new Date(arrivalUTC.getTime() - driveMinutes * 60_000)
|
||||
const returnUTC = new Date(departUTC.getTime() + blockMinutes * 60_000)
|
||||
|
||||
if (arrivalUTC < cutoffUTC) continue // enforce 24-hour lead time
|
||||
if (arrivalUTC < cutoffUTC) continue // enforce lead time
|
||||
if (returnUTC > closeUTC) break
|
||||
|
||||
// Reject any slot whose full driver block overlaps an existing event
|
||||
@ -164,14 +165,15 @@ export function getPickupSlots(date: string, hoursConfig?: HoursConfig): TimeSlo
|
||||
const openTotalMins = hours.open
|
||||
const closeTotalMins = hours.close
|
||||
|
||||
const cutoffUTC = new Date(Date.now() + 24 * 60 * 60_000)
|
||||
const leadMs = (hoursConfig ?? DEFAULT_HOURS).leadTimeHours * 60 * 60_000
|
||||
const cutoffUTC = new Date(Date.now() + leadMs)
|
||||
|
||||
const slots: TimeSlot[] = []
|
||||
for (let total = openTotalMins; total < closeTotalMins; total += SLOT_STEP) {
|
||||
const h = Math.floor(total / 60)
|
||||
const m = total % 60
|
||||
const slotUTC = etToUtc(date, h, m)
|
||||
if (slotUTC < cutoffUTC) continue // enforce 24-hour lead time
|
||||
if (slotUTC < cutoffUTC) continue // enforce lead time
|
||||
slots.push({ startISO: slotUTC.toISOString(), label: fmtLabel(h, m) })
|
||||
}
|
||||
return slots
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user