Fix checkout: block false slots when calendar down, add booking request fallback

- Change fulfillment state from RESERVED to PROPOSED (Square rejects RESERVED)
- Return 503 from slots API when CalDAV is unreachable instead of serving empty
  busy blocks that made all time slots appear falsely available
- Add BookingRequestPanel and /api/booking-request endpoint: when the calendar
  server is down, customers can submit their order and preferred time; server
  emails info@beachpartyballoons.com and sends a confirmation to the customer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-06-05 19:57:34 -04:00
parent a49075b167
commit 02e49ba41b
7 changed files with 231 additions and 15 deletions

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { sendBookingRequest } from '@/lib/notify'
import type { EmailLineItem } from '@/lib/notify'
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const { customerName, customerPhone, customerEmail, preferredTime, address, lineItems } = body
if (!customerName?.trim() || !customerPhone?.trim() || !customerEmail?.trim() || !preferredTime?.trim()) {
return NextResponse.json(
{ error: 'Name, phone, email, and preferred time are required.' },
{ status: 400 }
)
}
await sendBookingRequest({
customerName: customerName.trim(),
customerPhone: customerPhone.trim(),
customerEmail: customerEmail.trim(),
preferredTime: preferredTime.trim(),
address: address?.trim() ?? '',
lineItems: (lineItems ?? []) as EmailLineItem[],
})
return NextResponse.json({ ok: true })
} catch (err) {
console.error('[booking-request]', err)
return NextResponse.json({ error: 'Failed to send request. Please try again.' }, { status: 500 })
}
}

View File

@ -49,15 +49,17 @@ export async function GET(req: NextRequest) {
} }
const { lat, lng } = coordsResult.value const { lat, lng } = coordsResult.value
const busyBlocks = busyResult.status === 'fulfilled' ? busyResult.value : []
const calendarConnected = !!process.env.CALDAV_URL && busyResult.status === 'fulfilled'
if (busyResult.status === 'rejected') { if (busyResult.status === 'rejected') {
console.error('[slots] CalDAV fetch failed:', busyResult.reason) console.error('[slots] CalDAV fetch failed:', busyResult.reason)
} else { return NextResponse.json(
console.log(`[slots] ${date}: ${busyBlocks.length} busy block(s) from calendar`) { error: 'Availability check failed — please contact us to book your delivery.' },
{ status: 503 }
)
} }
const busyBlocks = busyResult.value
console.log(`[slots] ${date}: ${busyBlocks.length} busy block(s) from calendar`)
const driveResult = await drivingInfo(SHOP_LAT, SHOP_LNG, lat, lng) const driveResult = await drivingInfo(SHOP_LAT, SHOP_LNG, lat, lng)
const driveMinutes = driveResult?.minutes ?? 30 // fall back to 30 min if OSRM is down const driveMinutes = driveResult?.minutes ?? 30 // fall back to 30 min if OSRM is down
@ -65,7 +67,7 @@ export async function GET(req: NextRequest) {
const slots = await getAvailableSlots(date, driveMinutes, tier, busyBlocks, lat, lng, hoursConfig) const slots = await getAvailableSlots(date, driveMinutes, tier, busyBlocks, lat, lng, hoursConfig)
const blockMinutes = driveMinutes + JOB_MINUTES[tier] + driveMinutes const blockMinutes = driveMinutes + JOB_MINUTES[tier] + driveMinutes
return NextResponse.json({ slots, driveMinutes, blockMinutes, calendarConnected }) return NextResponse.json({ slots, driveMinutes, blockMinutes })
} catch (err) { } catch (err) {
console.error('[slots] Unexpected error:', err) console.error('[slots] Unexpected error:', err)
return NextResponse.json( return NextResponse.json(

View File

@ -0,0 +1,107 @@
'use client'
import { useState } from 'react'
import { BASE } from '@/lib/basepath'
import type { EmailLineItem } from '@/lib/notify'
interface Props {
address: string
defaultName: string
defaultPhone: string
defaultEmail: string
lineItems: EmailLineItem[]
}
export default function BookingRequestPanel({ address, defaultName, defaultPhone, defaultEmail, lineItems }: Props) {
const [name, setName] = useState(defaultName)
const [phone, setPhone] = useState(defaultPhone)
const [email, setEmail] = useState(defaultEmail)
const [preferredTime, setPreferredTime] = useState('')
const [submitting, setSubmitting] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async () => {
setError('')
setSubmitting(true)
try {
const res = await fetch(`${BASE}/api/booking-request`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customerName: name, customerPhone: phone, customerEmail: email, preferredTime, address, lineItems }),
})
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Something went wrong.'); return }
setSuccess(true)
} catch {
setError('Network error — please try again.')
} finally {
setSubmitting(false)
}
}
if (success) {
return (
<div style={{ marginTop: '0.75rem', padding: '0.75rem', background: '#f0f9fa', border: '1px solid #b2e0e4', borderRadius: '8px', fontSize: '0.82rem' }}>
<strong style={{ color: '#0d6e75' }}>Request sent!</strong>
<p style={{ marginTop: '4px', color: '#444' }}>
We&rsquo;ll reach out to confirm your delivery time. Check your email for a copy of your request.
</p>
</div>
)
}
return (
<div style={{ marginTop: '0.75rem', padding: '0.75rem', background: '#fff8e1', border: '1px solid #f6c000', borderRadius: '8px' }}>
<p style={{ fontSize: '0.82rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#5a4000' }}>
Send us your order &amp; we&rsquo;ll confirm a time
</p>
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.4rem' }}>
<input
className="input is-small"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
style={{ flex: 1 }}
/>
<input
className="input is-small"
placeholder="Phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
style={{ flex: 1 }}
/>
</div>
<input
className="input is-small"
placeholder="Email address"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{ marginBottom: '0.4rem', width: '100%' }}
/>
<input
className="input is-small"
placeholder="Preferred delivery date &amp; time (e.g. Saturday June 14, afternoon)"
value={preferredTime}
onChange={(e) => setPreferredTime(e.target.value)}
style={{ marginBottom: '0.5rem', width: '100%' }}
/>
{error && (
<p style={{ fontSize: '0.75rem', color: '#cc3333', marginBottom: '0.4rem' }}>{error}</p>
)}
<button
className={`button is-warning is-small is-fullwidth${submitting ? ' is-loading' : ''}`}
disabled={submitting || !name.trim() || !phone.trim() || !email.trim() || !preferredTime.trim()}
onClick={handleSubmit}
style={{ fontWeight: 'bold' }}
>
Send booking request
</button>
</div>
)
}

View File

@ -12,6 +12,7 @@ import type { HoursConfig } from '@/lib/hours-config'
import PaymentForm from './PaymentForm' import PaymentForm from './PaymentForm'
import type { CheckoutPayload } from './PaymentForm' import type { CheckoutPayload } from './PaymentForm'
import ColorPicker from './ColorPicker' import ColorPicker from './ColorPicker'
import BookingRequestPanel from './BookingRequestPanel'
import CalendarPicker from './CalendarPicker' import CalendarPicker from './CalendarPicker'
import type { CartEntry } from '@/context/CartContext' import type { CartEntry } from '@/context/CartContext'
@ -151,6 +152,7 @@ export default function CartDrawer() {
const [infoErrors, setInfoErrors] = useState<{ firstName?: string; lastName?: string; email?: string; phone?: string; balloon?: string }>({}) const [infoErrors, setInfoErrors] = useState<{ firstName?: string; lastName?: string; email?: string; phone?: string; balloon?: string }>({})
const [balloonAgreement, setBalloonAgreement] = useState(false) const [balloonAgreement, setBalloonAgreement] = useState(false)
const [deliveryStepError, setDeliveryStepError] = useState('') const [deliveryStepError, setDeliveryStepError] = useState('')
const [calendarError, setCalendarError] = useState(false)
const isValidEmail = (v: string) => const isValidEmail = (v: string) =>
/^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(v.trim()) /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(v.trim())
@ -331,7 +333,7 @@ export default function CartDrawer() {
setDeliverySlot(null) setDeliverySlot(null)
} }
const resetQuote = () => { setQuote(null); setQuoteErr(''); setDeliverySlot(null) } const resetQuote = () => { setQuote(null); setQuoteErr(''); setDeliverySlot(null); setCalendarError(false) }
const goBack = () => { const goBack = () => {
const i = STEP_ORDER.indexOf(step) const i = STEP_ORDER.indexOf(step)
@ -613,8 +615,29 @@ export default function CartDrawer() {
tier={quote.tier} tier={quote.tier}
value={deliverySlot} value={deliverySlot}
onChange={(v) => { setDeliverySlot(v); if (v) setDeliveryStepError('') }} onChange={(v) => { setDeliverySlot(v); if (v) setDeliveryStepError('') }}
onCalendarError={() => setCalendarError(true)}
/> />
{calendarError && (
<BookingRequestPanel
address={fullAddress}
defaultName={[custFirst, custLast].filter(Boolean).join(' ')}
defaultPhone={custPhone}
defaultEmail={custEmail}
lineItems={entries.map((e) => ({
name: e.product.name,
quantity: e.quantity,
priceCents: entryUnitPrice(e),
colors: e.selectedColors.length ? e.selectedColors : undefined,
note: e.notes || undefined,
modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => {
const ml = e.product.modifiers?.find((m) => m.id === listId)
return optIds.map((id) => ({ name: ml?.options.find((o) => o.id === id)?.name ?? id }))
}),
}))}
/>
)}
<div className="field" style={{ marginTop: '0.75rem' }}> <div className="field" style={{ marginTop: '0.75rem' }}>
<label className="label is-small">Delivery instructions <span style={{ fontWeight: 'normal', color: '#999' }}>(optional)</span></label> <label className="label is-small">Delivery instructions <span style={{ fontWeight: 'normal', color: '#999' }}>(optional)</span></label>
<textarea <textarea

View File

@ -19,13 +19,14 @@ interface Props {
tier: DeliveryTier tier: DeliveryTier
value: DeliverySelection | null value: DeliverySelection | null
onChange: (v: DeliverySelection | null) => void onChange: (v: DeliverySelection | null) => void
onCalendarError?: () => void
} }
type SlotState = 'idle' | 'loading' | 'loaded' | 'error' type SlotState = 'idle' | 'loading' | 'loaded' | 'error'
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, onCalendarError }: 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)
@ -87,7 +88,7 @@ export default function DeliveryDatePicker({ address, tier, value, onChange }: P
fetch(`${BASE}/api/slots?${params}`) fetch(`${BASE}/api/slots?${params}`)
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { .then((data) => {
if (data.error) { setErrMsg(data.error); setStatus('error'); return } if (data.error) { setErrMsg(data.error); setStatus('error'); onCalendarError?.(); return }
const fetched: TimeSlot[] = data.slots ?? [] const fetched: TimeSlot[] = data.slots ?? []
setSlots(fetched) setSlots(fetched)
setDrive(data.driveMinutes ?? 0) setDrive(data.driveMinutes ?? 0)

View File

@ -375,6 +375,58 @@ export async function sendNewOrderAlert(params: {
}) })
} }
export async function sendBookingRequest(params: {
customerName: string
customerPhone: string
customerEmail: string
preferredTime: string
address: string
lineItems: EmailLineItem[]
}): Promise<void> {
const to = process.env.ALERT_EMAIL_TO ?? 'info@beachpartyballoons.com'
const itemsBlock = formatLineItems(params.lineItems)
await send({
to,
subject: `📋 Booking request — ${params.customerName}`,
text: [
`Booking request — could not complete online checkout (scheduling unavailable).`,
``,
`Customer: ${params.customerName}`,
`Phone: ${params.customerPhone}`,
`Email: ${params.customerEmail}`,
``,
`Preferred time: ${params.preferredTime}`,
`Address: ${params.address || '(not provided)'}`,
``,
itemsBlock,
``,
`Please reach out to confirm availability and take payment.`,
].join('\n'),
})
if (params.customerEmail) {
await send({
to: params.customerEmail,
subject: `We received your request — Beach Party Balloons`,
text: [
`Hi ${params.customerName.split(' ')[0] || params.customerName},`,
``,
`We got your booking request and will reach out shortly to confirm your delivery time.`,
``,
`Preferred time: ${params.preferredTime}`,
``,
itemsBlock,
``,
`Questions? Call or text us any time.`,
``,
`— Beach Party Balloons`,
`beachpartyballoons.com`,
].join('\n'),
})
}
}
export async function sendAdminErrorAlert(params: { export async function sendAdminErrorAlert(params: {
subject: string subject: string
message: string message: string

View File

@ -273,7 +273,7 @@ export async function createSquareOrder(params: {
? params.fulfillment.type === 'delivery' ? params.fulfillment.type === 'delivery'
? [{ ? [{
type: 'DELIVERY', type: 'DELIVERY',
state: 'RESERVED', state: 'PROPOSED',
deliveryDetails: { deliveryDetails: {
recipient: { recipient: {
displayName: params.fulfillment.recipientName, displayName: params.fulfillment.recipientName,
@ -288,7 +288,7 @@ export async function createSquareOrder(params: {
}] }]
: [{ : [{
type: 'PICKUP', type: 'PICKUP',
state: 'RESERVED', state: 'PROPOSED',
pickupDetails: { pickupDetails: {
recipient: { recipient: {
displayName: params.fulfillment.recipientName, displayName: params.fulfillment.recipientName,