From 02e49ba41bcd3794843ca92bdd682ae5ce1068ad Mon Sep 17 00:00:00 2001 From: chris Date: Fri, 5 Jun 2026 19:57:34 -0400 Subject: [PATCH] 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 --- estore/src/app/api/booking-request/route.ts | 31 +++++ estore/src/app/api/slots/route.ts | 14 ++- estore/src/components/BookingRequestPanel.tsx | 107 ++++++++++++++++++ estore/src/components/CartDrawer.tsx | 25 +++- estore/src/components/DeliveryDatePicker.tsx | 13 ++- estore/src/lib/notify.ts | 52 +++++++++ estore/src/lib/square.ts | 4 +- 7 files changed, 231 insertions(+), 15 deletions(-) create mode 100644 estore/src/app/api/booking-request/route.ts create mode 100644 estore/src/components/BookingRequestPanel.tsx diff --git a/estore/src/app/api/booking-request/route.ts b/estore/src/app/api/booking-request/route.ts new file mode 100644 index 0000000..c507aee --- /dev/null +++ b/estore/src/app/api/booking-request/route.ts @@ -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 }) + } +} diff --git a/estore/src/app/api/slots/route.ts b/estore/src/app/api/slots/route.ts index 98a7c76..daadab4 100644 --- a/estore/src/app/api/slots/route.ts +++ b/estore/src/app/api/slots/route.ts @@ -49,15 +49,17 @@ export async function GET(req: NextRequest) { } 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') { console.error('[slots] CalDAV fetch failed:', busyResult.reason) - } else { - console.log(`[slots] ${date}: ${busyBlocks.length} busy block(s) from calendar`) + return NextResponse.json( + { 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 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 blockMinutes = driveMinutes + JOB_MINUTES[tier] + driveMinutes - return NextResponse.json({ slots, driveMinutes, blockMinutes, calendarConnected }) + return NextResponse.json({ slots, driveMinutes, blockMinutes }) } catch (err) { console.error('[slots] Unexpected error:', err) return NextResponse.json( diff --git a/estore/src/components/BookingRequestPanel.tsx b/estore/src/components/BookingRequestPanel.tsx new file mode 100644 index 0000000..05f30a4 --- /dev/null +++ b/estore/src/components/BookingRequestPanel.tsx @@ -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 ( +
+ Request sent! +

+ We’ll reach out to confirm your delivery time. Check your email for a copy of your request. +

+
+ ) + } + + return ( +
+

+ Send us your order & we’ll confirm a time +

+ +
+ setName(e.target.value)} + style={{ flex: 1 }} + /> + setPhone(e.target.value)} + style={{ flex: 1 }} + /> +
+ setEmail(e.target.value)} + style={{ marginBottom: '0.4rem', width: '100%' }} + /> + setPreferredTime(e.target.value)} + style={{ marginBottom: '0.5rem', width: '100%' }} + /> + + {error && ( +

{error}

+ )} + + +
+ ) +} diff --git a/estore/src/components/CartDrawer.tsx b/estore/src/components/CartDrawer.tsx index 7ec395f..51c7168 100644 --- a/estore/src/components/CartDrawer.tsx +++ b/estore/src/components/CartDrawer.tsx @@ -12,6 +12,7 @@ import type { HoursConfig } from '@/lib/hours-config' import PaymentForm from './PaymentForm' import type { CheckoutPayload } from './PaymentForm' import ColorPicker from './ColorPicker' +import BookingRequestPanel from './BookingRequestPanel' import CalendarPicker from './CalendarPicker' 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 [balloonAgreement, setBalloonAgreement] = useState(false) const [deliveryStepError, setDeliveryStepError] = useState('') + const [calendarError, setCalendarError] = useState(false) const isValidEmail = (v: string) => /^[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) } - const resetQuote = () => { setQuote(null); setQuoteErr(''); setDeliverySlot(null) } + const resetQuote = () => { setQuote(null); setQuoteErr(''); setDeliverySlot(null); setCalendarError(false) } const goBack = () => { const i = STEP_ORDER.indexOf(step) @@ -613,8 +615,29 @@ export default function CartDrawer() { tier={quote.tier} value={deliverySlot} onChange={(v) => { setDeliverySlot(v); if (v) setDeliveryStepError('') }} + onCalendarError={() => setCalendarError(true)} /> + {calendarError && ( + ({ + 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 })) + }), + }))} + /> + )} +