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:
parent
a49075b167
commit
02e49ba41b
31
estore/src/app/api/booking-request/route.ts
Normal file
31
estore/src/app/api/booking-request/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
107
estore/src/components/BookingRequestPanel.tsx
Normal file
107
estore/src/components/BookingRequestPanel.tsx
Normal 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’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 & we’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 & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -15,17 +15,18 @@ export interface DeliverySelection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
address: string
|
address: string
|
||||||
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user