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>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<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' }}>
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
<label style={{ fontWeight: 600, fontSize: '0.85rem', display: 'block', marginBottom: '0.4rem' }}>
|
<label style={{ fontWeight: 600, fontSize: '0.85rem', display: 'block', marginBottom: '0.4rem' }}>
|
||||||
Delivery arrival window (minutes)
|
Delivery arrival window (minutes)
|
||||||
|
|||||||
@ -196,7 +196,8 @@ export default function CartDrawer() {
|
|||||||
const fullAddress = [street, city, state, zip].filter(Boolean).join(', ')
|
const fullAddress = [street, city, state, zip].filter(Boolean).join(', ')
|
||||||
const canQuote = street.trim() && city.trim()
|
const canQuote = street.trim() && city.trim()
|
||||||
const pickupSlots = useMemo(() => getPickupSlots(pickupDate, hoursConfig), [pickupDate, hoursConfig])
|
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)
|
const maxDateStr = new Date(Date.now() + 90 * 86400_000).toISOString().slice(0, 10)
|
||||||
|
|
||||||
// Unit price — uses selected variation price if set, otherwise product default
|
// 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
|
// Pre-compute pickup disabled dates (closed days) for the next 90 days
|
||||||
const pickupDisabledDates = useMemo(() => {
|
const pickupDisabledDates = useMemo(() => {
|
||||||
const disabled = new Set<string>()
|
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++) {
|
for (let i = 0; i < 90; i++) {
|
||||||
const d = new Date(base + i * 86400_000).toISOString().slice(0, 10)
|
const d = new Date(base + i * 86400_000).toISOString().slice(0, 10)
|
||||||
if (getPickupSlots(d, hoursConfig).length === 0) disabled.add(d)
|
if (getPickupSlots(d, hoursConfig).length === 0) disabled.add(d)
|
||||||
}
|
}
|
||||||
return disabled
|
return disabled
|
||||||
}, [hoursConfig])
|
}, [hoursConfig, leadMs])
|
||||||
|
|
||||||
const CT_TAX_RATE = 0.0635
|
const CT_TAX_RATE = 0.0635
|
||||||
const subtotal = entries.reduce((sum, e) => sum + entryUnitPrice(e) * e.quantity, 0)
|
const subtotal = entries.reduce((sum, e) => sum + entryUnitPrice(e) * e.quantity, 0)
|
||||||
|
|||||||
@ -23,15 +23,15 @@ interface Props {
|
|||||||
|
|
||||||
type SlotState = 'idle' | 'loading' | 'loaded' | 'error'
|
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)
|
const maxDate = new Date(Date.now() + 90 * 86400_000).toISOString().slice(0, 10)
|
||||||
|
|
||||||
export default function DeliveryDatePicker({ address, tier, value, onChange }: Props) {
|
export default function DeliveryDatePicker({ address, tier, value, onChange }: Props) {
|
||||||
const [date, setDate] = useState(value?.date ?? '')
|
const [date, setDate] = useState(value?.date ?? '')
|
||||||
const [slots, setSlots] = useState<TimeSlot[]>([])
|
const [slots, setSlots] = useState<TimeSlot[]>([])
|
||||||
const [drive, setDrive] = useState(0)
|
const [drive, setDrive] = useState(0)
|
||||||
const [status, setStatus] = useState<SlotState>('idle')
|
const [status, setStatus] = useState<SlotState>('idle')
|
||||||
const [errMsg, setErrMsg] = useState('')
|
const [errMsg, setErrMsg] = useState('')
|
||||||
|
const [leadTimeHours, setLeadTimeHours] = useState(48)
|
||||||
|
|
||||||
// Dates that returned 0 slots — shown as strikethrough on the calendar
|
// Dates that returned 0 slots — shown as strikethrough on the calendar
|
||||||
const [noSlotDates, setNoSlotDates] = useState<Set<string>>(new Set())
|
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
|
// Busy dates from CalDAV — shown with an orange dot
|
||||||
const [busyDates, setBusyDates] = useState<Set<string>>(new Set())
|
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
|
// Pre-mark all closed delivery days from admin hours config
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${BASE}/api/hours`)
|
fetch(`${BASE}/api/hours`)
|
||||||
.then(r => r.ok ? r.json() : null)
|
.then(r => r.ok ? r.json() : null)
|
||||||
.then((config: HoursConfig | null) => {
|
.then((config: HoursConfig | null) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
|
const lead = config.leadTimeHours ?? 48
|
||||||
|
setLeadTimeHours(lead)
|
||||||
const closed = new Set<string>()
|
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++) {
|
for (let i = 0; i < 90; i++) {
|
||||||
const dateStr = new Date(start + i * 86400_000).toISOString().slice(0, 10)
|
const dateStr = new Date(start + i * 86400_000).toISOString().slice(0, 10)
|
||||||
const dow = new Date(`${dateStr}T12:00:00Z`).getUTCDay()
|
const dow = new Date(`${dateStr}T12:00:00Z`).getUTCDay()
|
||||||
|
|||||||
@ -11,10 +11,12 @@ export interface HoursConfig {
|
|||||||
delivery: WeekHours
|
delivery: WeekHours
|
||||||
pickup: WeekHours
|
pickup: WeekHours
|
||||||
deliveryWindowMinutes: number // customer-facing arrival window (email + Square)
|
deliveryWindowMinutes: number // customer-facing arrival window (email + Square)
|
||||||
|
leadTimeHours: number // minimum hours in advance a slot can be booked
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_HOURS: HoursConfig = {
|
export const DEFAULT_HOURS: HoursConfig = {
|
||||||
deliveryWindowMinutes: 60,
|
deliveryWindowMinutes: 60,
|
||||||
|
leadTimeHours: 48,
|
||||||
delivery: {
|
delivery: {
|
||||||
'0': { open: 480, close: 1020 }, // Sun 8:00 AM – 5:00 PM
|
'0': { open: 480, close: 1020 }, // Sun 8:00 AM – 5:00 PM
|
||||||
'1': null, // Mon closed
|
'1': null, // Mon closed
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export function getHoursConfig(): HoursConfig {
|
|||||||
delivery: { ...DEFAULT_HOURS.delivery, ...(saved.delivery ?? {}) },
|
delivery: { ...DEFAULT_HOURS.delivery, ...(saved.delivery ?? {}) },
|
||||||
pickup: { ...DEFAULT_HOURS.pickup, ...(saved.pickup ?? {}) },
|
pickup: { ...DEFAULT_HOURS.pickup, ...(saved.pickup ?? {}) },
|
||||||
deliveryWindowMinutes: saved.deliveryWindowMinutes ?? DEFAULT_HOURS.deliveryWindowMinutes,
|
deliveryWindowMinutes: saved.deliveryWindowMinutes ?? DEFAULT_HOURS.deliveryWindowMinutes,
|
||||||
|
leadTimeHours: saved.leadTimeHours ?? DEFAULT_HOURS.leadTimeHours,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_HOURS
|
return DEFAULT_HOURS
|
||||||
|
|||||||
@ -111,7 +111,8 @@ export async function getAvailableSlots(
|
|||||||
const closeTotalMin = hours.close
|
const closeTotalMin = hours.close
|
||||||
const closeUTC = etToUtc(date, Math.floor(hours.close / 60), hours.close % 60)
|
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) {
|
for (let arrivalTotalMin = openTotalMin; arrivalTotalMin < closeTotalMin; arrivalTotalMin += SLOT_STEP) {
|
||||||
const arrivalH = Math.floor(arrivalTotalMin / 60)
|
const arrivalH = Math.floor(arrivalTotalMin / 60)
|
||||||
@ -120,7 +121,7 @@ export async function getAvailableSlots(
|
|||||||
const departUTC = new Date(arrivalUTC.getTime() - driveMinutes * 60_000)
|
const departUTC = new Date(arrivalUTC.getTime() - driveMinutes * 60_000)
|
||||||
const returnUTC = new Date(departUTC.getTime() + blockMinutes * 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
|
if (returnUTC > closeUTC) break
|
||||||
|
|
||||||
// Reject any slot whose full driver block overlaps an existing event
|
// 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 openTotalMins = hours.open
|
||||||
const closeTotalMins = hours.close
|
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[] = []
|
const slots: TimeSlot[] = []
|
||||||
for (let total = openTotalMins; total < closeTotalMins; total += SLOT_STEP) {
|
for (let total = openTotalMins; total < closeTotalMins; total += SLOT_STEP) {
|
||||||
const h = Math.floor(total / 60)
|
const h = Math.floor(total / 60)
|
||||||
const m = total % 60
|
const m = total % 60
|
||||||
const slotUTC = etToUtc(date, h, m)
|
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) })
|
slots.push({ startISO: slotUTC.toISOString(), label: fmtLabel(h, m) })
|
||||||
}
|
}
|
||||||
return slots
|
return slots
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user