fix: delivery slot required; redirect user back if missing; email fallback on error

- Revert ASAP fallback — SCHEDULED is always correct; server validates before Square
- validateAndContinue now catches a cleared delivery/pickup slot and redirects
  back to the delivery step with an inline error message rather than letting
  the order reach Square with a missing deliver_at
- PaymentForm onError prop: 400-level checkout errors call onError so CartDrawer
  can navigate the user to the right step to fix the problem
- On any payment error, show an "email us your order details" mailto link that
  pre-fills name, phone, items, fulfillment, and total so we can issue an invoice

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-05-05 10:47:44 -04:00
parent bc0540d36a
commit 2be379a029
3 changed files with 80 additions and 10 deletions

View File

@ -150,12 +150,29 @@ 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 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())
const isValidPhone = (v: string) => v.replace(/\D/g, '').length === 10 const isValidPhone = (v: string) => v.replace(/\D/g, '').length === 10
const goToDeliveryWithError = (msg: string) => {
setDeliveryStepError(msg)
setStep('delivery')
}
const validateAndContinue = () => { const validateAndContinue = () => {
// If the delivery slot was cleared after the user moved past the delivery step
// (e.g. a high-rate item was removed), send them back to re-select.
if (effectiveFulfillment === 'delivery' && !deliverySlot) {
goToDeliveryWithError('Please select a delivery date and time to continue.')
return
}
if (effectiveFulfillment === 'pickup' && !pickupSlot) {
setStep('delivery')
return
}
const errors: typeof infoErrors = {} const errors: typeof infoErrors = {}
if (!custFirst.trim()) errors.firstName = 'First name is required' if (!custFirst.trim()) errors.firstName = 'First name is required'
if (!custLast.trim()) errors.lastName = 'Last name is required' if (!custLast.trim()) errors.lastName = 'Last name is required'
@ -172,6 +189,10 @@ export default function CartDrawer() {
} }
} }
const handleCheckoutError = (msg: string) => {
goToDeliveryWithError(msg)
}
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])
@ -592,7 +613,7 @@ export default function CartDrawer() {
address={fullAddress} address={fullAddress}
tier={quote.tier} tier={quote.tier}
value={deliverySlot} value={deliverySlot}
onChange={setDeliverySlot} onChange={(v) => { setDeliverySlot(v); if (v) setDeliveryStepError('') }}
/> />
<div className="field" style={{ marginTop: '0.75rem' }}> <div className="field" style={{ marginTop: '0.75rem' }}>
@ -612,6 +633,11 @@ export default function CartDrawer() {
const deliveryFooter = ( const deliveryFooter = (
<> <>
{deliveryStepError && (
<p style={{ fontSize: '0.8rem', color: '#c0392b', background: '#fff5f5', border: '1px solid #f5c6cb', borderRadius: '6px', padding: '0.5rem 0.75rem', marginBottom: '0.6rem' }}>
{deliveryStepError}
</p>
)}
{effectiveFulfillment === 'delivery' && ( {effectiveFulfillment === 'delivery' && (
<p style={{ fontSize: '0.72rem', color: '#999', marginBottom: '0.5rem' }}> <p style={{ fontSize: '0.72rem', color: '#999', marginBottom: '0.5rem' }}>
Delivery fee is based on driving distance from our shop. Delivery fee is based on driving distance from our shop.
@ -941,7 +967,7 @@ export default function CartDrawer() {
{bodyContent[step]} {bodyContent[step]}
{/* PaymentForm stays mounted to prevent Square DOM errors on step changes */} {/* PaymentForm stays mounted to prevent Square DOM errors on step changes */}
<div style={{ display: step === 'payment' && !orderId ? undefined : 'none' }}> <div style={{ display: step === 'payment' && !orderId ? undefined : 'none' }}>
<PaymentForm payload={checkoutPayload} onSuccess={handleSuccess} active={step === 'payment' && !orderId} /> <PaymentForm payload={checkoutPayload} onSuccess={handleSuccess} onError={handleCheckoutError} active={step === 'payment' && !orderId} />
</div> </div>
</> </>
)} )}

View File

@ -55,6 +55,7 @@ export interface CheckoutPayload {
interface Props { interface Props {
payload: CheckoutPayload payload: CheckoutPayload
onSuccess: (orderId: string, shortRef: string) => void onSuccess: (orderId: string, shortRef: string) => void
onError?: (message: string, status: number) => void
active: boolean // true only when the payment step is visible active: boolean // true only when the payment step is visible
} }
@ -63,7 +64,36 @@ const SDK_URL =
? 'https://web.squarecdn.com/v1/square.js' ? 'https://web.squarecdn.com/v1/square.js'
: 'https://sandbox.web.squarecdn.com/v1/square.js' : 'https://sandbox.web.squarecdn.com/v1/square.js'
export default function PaymentForm({ payload, onSuccess, active }: Props) { function buildMailtoLink(payload: CheckoutPayload): string {
const { fmt: fmtCents } = { fmt: (c: number) => `$${(c / 100).toFixed(2)}` }
const items = payload.lineItems
.map((li) => `${li.quantity}× ${li.name}${fmtCents(li.priceCents * li.quantity)}${li.note ? ` (${li.note})` : ''}`)
.join('\n')
const fulfillment = payload.deliverySlotISO
? `Delivery: ${new Date(payload.deliverySlotISO).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })} to ${payload.deliveryAddress ?? ''}`
: payload.pickupSlotISO
? `Pickup: ${new Date(payload.pickupSlotISO).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })}`
: 'Fulfillment: not set'
const body = [
`Hi Beach Party Balloons,`,
``,
`I tried to place an order online but ran into a payment error. Could you please send me an invoice?`,
``,
`Name: ${payload.customerFirstName} ${payload.customerLastName}`,
`Email: ${payload.customerEmail}`,
`Phone: ${payload.customerPhone}`,
``,
`Order:`,
items,
``,
fulfillment,
`Total: ${fmtCents(payload.grandTotal)}`,
].join('\n')
return `mailto:info@beachpartyballoons.com?subject=${encodeURIComponent('Online Order — Invoice Request')}&body=${encodeURIComponent(body)}`
}
export default function PaymentForm({ payload, onSuccess, onError, active }: Props) {
const appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID ?? '' const appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID ?? ''
const locationId = process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID ?? '' const locationId = process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID ?? ''
@ -183,8 +213,13 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
const data = await res.json() const data = await res.json()
if (!res.ok || !data.success) { if (!res.ok || !data.success) {
setError(data.error ?? 'Checkout failed — please try again or contact us.') const msg = data.error ?? 'Checkout failed — please try again or contact us.'
if (res.status !== 409) console.error('[checkout] response:', data) if (res.status !== 409) console.error('[checkout] response:', data)
if (res.status === 400 && onError) {
onError(msg, res.status)
return
}
setError(msg)
return return
} }
@ -236,11 +271,20 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
)} )}
{error && ( {error && (
<div style={{ <div style={{ marginTop: '0.6rem' }}>
fontSize: '0.78rem', color: '#cc3333', <p style={{ fontSize: '0.78rem', color: '#cc3333', lineHeight: 1.45 }}>
marginTop: '0.6rem', lineHeight: 1.45, {error}
}}> </p>
{error} <p style={{ fontSize: '0.75rem', color: '#555', marginTop: '0.5rem', lineHeight: 1.5 }}>
If this keeps happening, you can{' '}
<a
href={buildMailtoLink(payload)}
style={{ color: '#0d6e75', textDecoration: 'underline' }}
>
email us your order details
</a>
{' '}and we&apos;ll send you an invoice.
</p>
</div> </div>
)} )}

View File

@ -280,7 +280,7 @@ export async function createSquareOrder(params: {
phoneNumber: params.fulfillment.recipientPhone, phoneNumber: params.fulfillment.recipientPhone,
address: { addressLine1: params.fulfillment.addressLine1 }, address: { addressLine1: params.fulfillment.addressLine1 },
}, },
scheduleType: params.fulfillment.deliverAt ? 'SCHEDULED' : 'ASAP', scheduleType: 'SCHEDULED',
...(params.fulfillment.deliverAt ? { deliverAt: params.fulfillment.deliverAt } : {}), ...(params.fulfillment.deliverAt ? { deliverAt: params.fulfillment.deliverAt } : {}),
...(params.fulfillment.deliveryWindowDuration ? { deliveryWindowDuration: params.fulfillment.deliveryWindowDuration } : {}), ...(params.fulfillment.deliveryWindowDuration ? { deliveryWindowDuration: params.fulfillment.deliveryWindowDuration } : {}),
...(params.fulfillment.note ? { note: params.fulfillment.note } : {}), ...(params.fulfillment.note ? { note: params.fulfillment.note } : {}),