beachPartyBalloons/estore/src/components/DeliveryDatePicker.tsx
chris 50680a323f Major overhaul: shared nav, admin improvements, email enhancements, routing fixes
Navigation & layout
- Replace per-page hardcoded nav/footer with shared nav.js (client-side injection)
- Add nginx reverse proxy back to docker-compose for clean localhost routing
- Rename /color-picker/ to /color/ across nav, directory, and references

eStore admin
- Add variation hiding controls (mirrors existing modifier hiding)
- Add delivery rate editor (base fee + per-mile per tier, persisted to data/)
- Fix all missing BASE prefix on fetch calls (admin PATCH/DELETE, availability, slots, colors)
- Mount estore/data/ as a Docker volume so admin config survives rebuilds

Booking & calendar
- Set pickup calendar events to TRANSPARENT (free) so they don't block delivery slots
- Skip CANCELLED events in busy-time calculation
- Re-check slot availability at checkout before charging (409 on conflict)

Phone & email validation
- Auto-format phone as (XXX) XXX-XXXX as user types
- Require exactly 10 digits; tighten email regex

Confirmation emails (store alert + customer)
- Full item detail per line: name, price, add-ons, colors, note
- Charges breakdown: subtotal, delivery fee, tax, total
- Delivery window: simplified M/D/YY h:mm – h:mm AM/PM format
- .ics calendar attachment on customer confirmation

Delivery rates
- Extract configurable rates to delivery-rates.ts (server-only, no fs in client bundle)
- calcDelivery() accepts optional rates param; delivery-quote route passes configured rates

Content
- Change all "40+ latex colors" references to "70+"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 21:14:06 -04:00

158 lines
5.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 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 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('')
// 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())
// 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 2 hours</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>
)
}