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:
parent
bc0540d36a
commit
2be379a029
@ -150,12 +150,29 @@ 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 isValidEmail = (v: string) =>
|
||||
/^[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 goToDeliveryWithError = (msg: string) => {
|
||||
setDeliveryStepError(msg)
|
||||
setStep('delivery')
|
||||
}
|
||||
|
||||
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 = {}
|
||||
if (!custFirst.trim()) errors.firstName = 'First 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 canQuote = street.trim() && city.trim()
|
||||
const pickupSlots = useMemo(() => getPickupSlots(pickupDate, hoursConfig), [pickupDate, hoursConfig])
|
||||
@ -592,7 +613,7 @@ export default function CartDrawer() {
|
||||
address={fullAddress}
|
||||
tier={quote.tier}
|
||||
value={deliverySlot}
|
||||
onChange={setDeliverySlot}
|
||||
onChange={(v) => { setDeliverySlot(v); if (v) setDeliveryStepError('') }}
|
||||
/>
|
||||
|
||||
<div className="field" style={{ marginTop: '0.75rem' }}>
|
||||
@ -612,6 +633,11 @@ export default function CartDrawer() {
|
||||
|
||||
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' && (
|
||||
<p style={{ fontSize: '0.72rem', color: '#999', marginBottom: '0.5rem' }}>
|
||||
Delivery fee is based on driving distance from our shop.
|
||||
@ -941,7 +967,7 @@ export default function CartDrawer() {
|
||||
{bodyContent[step]}
|
||||
{/* PaymentForm stays mounted to prevent Square DOM errors on step changes */}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -55,6 +55,7 @@ export interface CheckoutPayload {
|
||||
interface Props {
|
||||
payload: CheckoutPayload
|
||||
onSuccess: (orderId: string, shortRef: string) => void
|
||||
onError?: (message: string, status: number) => void
|
||||
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://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 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()
|
||||
|
||||
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 === 400 && onError) {
|
||||
onError(msg, res.status)
|
||||
return
|
||||
}
|
||||
setError(msg)
|
||||
return
|
||||
}
|
||||
|
||||
@ -236,11 +271,20 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
fontSize: '0.78rem', color: '#cc3333',
|
||||
marginTop: '0.6rem', lineHeight: 1.45,
|
||||
}}>
|
||||
{error}
|
||||
<div style={{ marginTop: '0.6rem' }}>
|
||||
<p style={{ fontSize: '0.78rem', color: '#cc3333', lineHeight: 1.45 }}>
|
||||
{error}
|
||||
</p>
|
||||
<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'll send you an invoice.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -280,7 +280,7 @@ export async function createSquareOrder(params: {
|
||||
phoneNumber: params.fulfillment.recipientPhone,
|
||||
address: { addressLine1: params.fulfillment.addressLine1 },
|
||||
},
|
||||
scheduleType: params.fulfillment.deliverAt ? 'SCHEDULED' : 'ASAP',
|
||||
scheduleType: 'SCHEDULED',
|
||||
...(params.fulfillment.deliverAt ? { deliverAt: params.fulfillment.deliverAt } : {}),
|
||||
...(params.fulfillment.deliveryWindowDuration ? { deliveryWindowDuration: params.fulfillment.deliveryWindowDuration } : {}),
|
||||
...(params.fulfillment.note ? { note: params.fulfillment.note } : {}),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user