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 [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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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'll send you an invoice.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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 } : {}),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user