chris 6705293e50 fix/feat: hex conflict, scroll-to-top, search all, admin error emails
- Fix Chrome Rose Gold hex (#B76E79 → #C17F87) so it no longer
  conflicts with Classic Rose Gold; image still used for display
- ScrollToTop hides when cart drawer is open and uses z-index 98
  (below the drawer); uses drawerOpen from CartContext
- Search now switches to All tab automatically so results span every
  item, not just the active category
- Add sendAdminErrorAlert() to notify.ts; checkout route emails
  admin@beachpartyballoons.com on unexpected server errors and on
  critical calendar-write failures; card decline errors are not
  forwarded (customers can self-resolve those)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:19:29 -04:00

477 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } from 'next/server'
import { createHash } from 'crypto'
import { inferTier, JOB_MINUTES, type DeliveryTier } from '@/lib/delivery'
interface LineItem {
name: string
quantity: number
priceCents: number
catalogItemId?: string
colors?: string[]
note?: string
modifiers?: Array<{ catalogObjectId: string; name: string }>
}
interface CheckoutBody {
lineItems: LineItem[]
selectedColors: string[]
deliverySlotISO?: string
driveMinutes?: number
deliveryAddress?: string
deliveryTier?: string
deliveryNotes?: string
deliveryCents?: number
pickupSlotISO?: string
sourceId: string
idempotencyKey?: string
customerFirstName?: string
customerLastName?: string
customerEmail?: string
customerPhone?: string
}
export async function POST(req: NextRequest) {
if (!process.env.SQUARE_ACCESS_TOKEN) {
return NextResponse.json(
{ error: 'Square is not configured on this server.' },
{ status: 500 }
)
}
let body: CheckoutBody
try {
body = (await req.json()) as CheckoutBody
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
// ── Input validation ────────────────────────────────────────────────────────
const {
lineItems, selectedColors, deliverySlotISO, driveMinutes,
deliveryAddress, deliveryTier, deliveryNotes, deliveryCents, pickupSlotISO, sourceId,
idempotencyKey, customerFirstName, customerLastName, customerEmail, customerPhone,
} = body
if (!Array.isArray(lineItems) || lineItems.length === 0) {
return NextResponse.json({ error: 'Cart is empty' }, { status: 400 })
}
for (const li of lineItems) {
if (typeof li.name !== 'string' || li.name.length > 255) {
return NextResponse.json({ error: 'Invalid item name' }, { status: 400 })
}
if (!Number.isInteger(li.quantity) || li.quantity < 1 || li.quantity > 999) {
return NextResponse.json({ error: 'Invalid item quantity' }, { status: 400 })
}
if (!Number.isInteger(li.priceCents) || li.priceCents < 0) {
return NextResponse.json({ error: 'Invalid item price' }, { status: 400 })
}
}
if (typeof sourceId !== 'string' || !sourceId) {
return NextResponse.json({ error: 'Missing payment source' }, { status: 400 })
}
if (customerEmail && (typeof customerEmail !== 'string' || customerEmail.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(customerEmail))) {
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 })
}
if (deliveryAddress && typeof deliveryAddress === 'string' && deliveryAddress.length > 500) {
return NextResponse.json({ error: 'Delivery address too long' }, { status: 400 })
}
if (deliveryNotes && typeof deliveryNotes === 'string' && deliveryNotes.length > 1000) {
return NextResponse.json({ error: 'Delivery notes too long' }, { status: 400 })
}
const customerName = [customerFirstName, customerLastName].filter(Boolean).join(' ') || undefined
const resolvedTier: DeliveryTier =
(deliveryTier === 'dropoff' || deliveryTier === 'classic' || deliveryTier === 'organic')
? deliveryTier
: inferTier(lineItems.map((l) => l.name))
const arrivalISO = deliverySlotISO
const jobMin = JOB_MINUTES[resolvedTier]
const windowDurationStr = deliverySlotISO
? `PT${Math.floor(jobMin / 60) > 0 ? `${Math.floor(jobMin / 60)}H` : ''}${jobMin % 60 > 0 ? `${jobMin % 60}M` : ''}`
: undefined
const fmtSlot = (iso: string) =>
new Date(iso).toLocaleString('en-US', { timeZone: 'America/New_York', dateStyle: 'medium', timeStyle: 'short' })
const noteParts = [
selectedColors.length > 0 ? `Colors: ${selectedColors.join(', ')}` : null,
deliverySlotISO ? `Delivery: ${fmtSlot(deliverySlotISO)}` : null,
pickupSlotISO ? `Pickup: ${fmtSlot(pickupSlotISO)}` : null,
deliveryAddress ? `Address: ${deliveryAddress}` : null,
deliveryNotes ? `Instructions: ${deliveryNotes}` : null,
].filter(Boolean)
const note = noteParts.join(' | ')
// ── Slot pre-check ──────────────────────────────────────────────────────────
// If an idempotency key is present this may be a retry of an already-completed
// order. The calendar event already exists from the first attempt so the slot
// will appear taken — we must not block here. Fall through to Square to check
// for idempotency replay.
let slotConflict = false
if (deliverySlotISO && driveMinutes != null && process.env.CALDAV_URL) {
try {
const { getBusyDates } = await import('@/lib/caldav')
const { getAvailableSlots } = await import('@/lib/slots')
const slotDate = deliverySlotISO.slice(0, 10)
const dayStart = new Date(`${slotDate}T00:00:00Z`)
const dayEnd = new Date(`${slotDate}T23:59:59Z`)
const freshBusy = await getBusyDates(dayStart, dayEnd)
const stillFree = (await getAvailableSlots(slotDate, driveMinutes, resolvedTier, freshBusy))
.some((s) => s.startISO === deliverySlotISO)
if (!stillFree) {
if (!idempotencyKey) {
return NextResponse.json(
{ error: 'That delivery time was just booked by someone else. Please go back and choose a different slot.' },
{ status: 409 }
)
}
slotConflict = true
console.warn('[checkout] Slot appears taken on retry — checking Square for idempotency replay:', idempotencyKey)
}
} catch (slotErr) {
console.error('[checkout] Slot pre-check failed, proceeding without guard:', slotErr)
}
}
// ── Inventory check (skip on likely replay) ─────────────────────────────────
if (!slotConflict) {
try {
const { getInventoryCounts } = await import('@/lib/square')
const variationIds = lineItems
.filter((li) => li.catalogItemId)
.map((li) => li.catalogItemId as string)
if (variationIds.length) {
const counts = await getInventoryCounts(variationIds)
for (const li of lineItems) {
if (!li.catalogItemId) continue
const stock = counts.get(li.catalogItemId)
if (stock !== undefined && stock < li.quantity) {
return NextResponse.json(
{ error: `"${li.name}" ${stock === 0 ? 'is sold out' : `only has ${stock} left`}. Please update your cart.` },
{ status: 409 }
)
}
}
}
} catch (invErr) {
console.error('[checkout] Inventory pre-check failed, proceeding:', invErr)
}
}
try {
const {
createSquareOrder, createSquarePayment,
completeSquarePayment, cancelSquarePayment,
retrieveSquareOrder, upsertSquareCustomer,
} = await import('@/lib/square')
// ── Customer upsert (skip on likely replay) ─────────────────────────────
let customerId: string | undefined
if (customerEmail && customerPhone && !slotConflict) {
try {
customerId = await upsertSquareCustomer({
givenName: customerFirstName?.trim() ?? '',
familyName: customerLastName?.trim() ?? '',
emailAddress: customerEmail,
phoneNumber: customerPhone,
})
} catch (custErr) {
console.error('[checkout] Failed to upsert customer:', custErr)
}
}
const isSandbox = process.env.SQUARE_ENVIRONMENT !== 'production'
const order = await createSquareOrder({
note,
customerId,
idempotencyKey,
serviceCharge: deliveryCents
? { name: 'Delivery', amountCents: deliveryCents, taxable: false }
: undefined,
lineItems: lineItems.map((li) => ({
catalogObjectId: isSandbox ? undefined : li.catalogItemId,
name: li.name,
quantity: String(li.quantity),
basePriceMoney: { amount: BigInt(li.priceCents), currency: 'USD' },
note: [
li.colors?.length ? `Colors: ${li.colors.join(', ')}` : null,
li.note || null,
].filter(Boolean).join(' | ') || undefined,
modifiers: li.modifiers?.length
? li.modifiers.map((m) => ({
catalogObjectId: isSandbox ? undefined : m.catalogObjectId,
name: m.name,
basePriceMoney: { amount: BigInt(0), currency: 'USD' },
}))
: undefined,
})),
fulfillment: customerName && customerPhone
? deliveryAddress
? {
type: 'delivery' as const,
recipientName: customerName,
recipientPhone: customerPhone,
addressLine1: deliveryAddress,
deliverAt: arrivalISO,
deliveryWindowDuration: windowDurationStr,
note: deliveryNotes,
}
: {
type: 'pickup' as const,
recipientName: customerName,
recipientPhone: customerPhone,
pickupAt: pickupSlotISO,
}
: undefined,
})
if (!order?.id || !order.totalMoney) {
throw new Error('Order creation returned no ID or total')
}
const shortRef = order.id!.slice(-6).toUpperCase()
const itemsSummary = lineItems.map((l) => `${l.quantity}× ${l.name}`).join(', ')
const totalCents = order.totalMoney!.amount!
// ── Idempotency replay: order already fully completed ───────────────────
// The entire flow ran on a previous attempt — payment captured, calendar
// written. The response just never reached the client.
if (order.state === 'COMPLETED') {
console.log('[checkout] Idempotency replay — order already completed:', order.id)
return NextResponse.json({
success: true, orderId: order.id,
shortRef, paymentId: undefined,
})
}
// ── Genuine slot conflict on retry ──────────────────────────────────────
// The slot is taken AND Square created a brand-new OPEN order (not a replay
// of a prior pre-auth). Someone else genuinely booked the slot.
// Check the full order for existing tenders to confirm this is truly new.
if (slotConflict) {
const fullOrder = await retrieveSquareOrder(order.id!)
const hasExistingPayment = (fullOrder?.tenders?.length ?? 0) > 0
if (!hasExistingPayment) {
console.warn('[checkout] Genuine slot conflict on retry — no prior tender found:', order.id)
return NextResponse.json(
{ error: 'That delivery time was just booked by someone else. Please go back and choose a different slot.' },
{ status: 409 }
)
}
// Prior tender exists — fall through to re-attempt calendar write + capture
console.log('[checkout] Retry with existing tender — completing pre-auth:', order.id)
}
// ── Payment idempotency key ─────────────────────────────────────────────
// Derived from the nonce (sourceId) so the key is unique per card tokenization.
// Square nonces are single-use — tying the key to the nonce means:
// • Same nonce on auto-retry → same key → Square idempotency replay (safe)
// • New nonce on manual retry → new key → fresh payment attempt (no mismatch error)
const paymentIdempotencyKey = createHash('sha256').update(sourceId).digest('hex').slice(0, 45)
// ── Pre-authorize card (hold funds, do not capture yet) ─────────────────
// We capture only AFTER the calendar write succeeds. If the calendar fails,
// we void the hold — the customer is never charged without a confirmed booking.
const payment = await createSquarePayment({
sourceId: sourceId,
orderId: order.id!,
amountMoney: { amount: order.totalMoney.amount!, currency: 'USD' },
note,
idempotencyKey: paymentIdempotencyKey,
autocomplete: false, // hold only — capture after calendar write
})
if (!payment?.id) throw new Error('Pre-authorization returned no payment ID')
// ── Write to calendar (awaited) ─────────────────────────────────────────
// This is no longer fire-and-forget. If it fails, we void the hold.
// Calendar writes are idempotent (orderId-based UID) so retries are safe.
const calendarWriteError = await (async () => {
try {
if (deliverySlotISO && driveMinutes != null && deliveryAddress && process.env.CALDAV_URL) {
const { createDeliveryEvent } = await import('@/lib/caldav')
await createDeliveryEvent({
startTime: new Date(deliverySlotISO),
tier: resolvedTier,
driveMinutes,
address: deliveryAddress,
lineItems,
colors: selectedColors,
customerName: customerName ?? 'Customer',
customerPhone: customerPhone ?? '',
notes: deliveryNotes,
orderId: order.id!,
})
} else if (pickupSlotISO && process.env.CALDAV_URL) {
const { createPickupEvent } = await import('@/lib/caldav')
await createPickupEvent({
startTime: new Date(pickupSlotISO),
lineItems,
colors: selectedColors,
customerName: customerName ?? 'Customer',
customerPhone: customerPhone ?? '',
notes: deliveryNotes,
orderId: order.id!,
})
}
return null // success
} catch (err) {
return err
}
})()
// ── Calendar failed → void the hold, never charge the customer ──────────
if (calendarWriteError) {
console.error('[checkout] CRITICAL: calendar write failed — voiding pre-auth to avoid charge without booking:', {
orderId: order.id, paymentId: payment.id, error: calendarWriteError,
})
void (async () => {
try {
const { sendAdminErrorAlert } = await import('@/lib/notify')
await sendAdminErrorAlert({
subject: 'Calendar write failed — order not booked',
message: `Calendar write failed for order ${order.id}. Pre-auth ${payment.id} is being voided. Customer: ${customerName} (${customerEmail}).`,
context: { orderId: order.id, paymentId: payment.id, error: String(calendarWriteError) },
})
} catch { /* best effort */ }
})()
try {
await cancelSquarePayment(payment.id!)
console.log('[checkout] Pre-auth voided successfully:', payment.id)
} catch (cancelErr) {
// Cancellation failed — this is a critical state: pre-auth exists but
// calendar is not written. Alert immediately so it can be manually voided.
console.error('[checkout] CRITICAL: failed to void pre-auth after calendar failure — MANUAL ACTION REQUIRED:', {
orderId: order.id, paymentId: payment.id, cancelErr,
})
try {
const { sendSlotConflictAlert } = await import('@/lib/notify')
await sendSlotConflictAlert({
shortRef,
orderId: order.id!,
customerName: customerName ?? 'Unknown',
customerPhone: customerPhone ?? '',
slotISO: (deliverySlotISO ?? pickupSlotISO)!,
address: deliveryAddress ?? 'PICKUP',
items: `URGENT: pre-auth ${payment.id!} could not be voided after calendar failure. Manual void required. Items: ${itemsSummary}`,
})
} catch { /* best effort */ }
}
return NextResponse.json(
{ error: 'Our booking system is temporarily unavailable. Your card has not been charged — please try again in a few minutes or contact us.' },
{ status: 503 }
)
}
// ── Calendar written — now capture the payment ──────────────────────────
const captured = await completeSquarePayment(payment.id!)
if (!captured) throw new Error('Payment capture returned no result')
// ── Fire-and-forget: emails only (calendar already written above) ────────
const subtotalCents = lineItems.reduce((sum, li) => sum + li.priceCents * li.quantity, 0)
const slotEndISO = deliverySlotISO
? new Date(new Date(deliverySlotISO).getTime() + jobMin * 60_000).toISOString()
: undefined
void (async () => {
try {
const { sendNewOrderAlert } = await import('@/lib/notify')
await sendNewOrderAlert({
shortRef,
orderId: order.id!,
customerName: customerName ?? 'Unknown',
customerPhone: customerPhone ?? '',
customerEmail: customerEmail ?? '',
fulfillment: deliveryAddress ? 'delivery' : 'pickup',
slotISO: (deliverySlotISO ?? pickupSlotISO)!,
slotEndISO,
address: deliveryAddress,
lineItems,
colors: selectedColors,
subtotalCents,
deliveryCents: deliveryCents ?? undefined,
totalCents,
})
} catch (err) {
console.error('[checkout] Failed to send new order alert:', err)
}
if (customerEmail && (deliverySlotISO ?? pickupSlotISO)) {
try {
const { sendOrderConfirmationEmail } = await import('@/lib/notify')
await sendOrderConfirmationEmail({
shortRef,
orderId: order.id!,
customerName: customerName ?? 'Customer',
customerEmail,
fulfillment: deliveryAddress ? 'delivery' : 'pickup',
slotISO: (deliverySlotISO ?? pickupSlotISO)!,
slotEndISO,
address: deliveryAddress,
lineItems,
colors: selectedColors,
subtotalCents,
deliveryCents: deliveryCents ?? undefined,
totalCents,
})
} catch (err) {
console.error('[checkout] Failed to send order confirmation email:', err)
}
}
})()
return NextResponse.json({
success: true,
orderId: order.id,
shortRef,
paymentId: captured.id,
})
} catch (err: unknown) {
console.error('[checkout] Error:', err)
const squareErrors = (err as { errors?: Array<{ code?: string; detail?: string; category?: string }> })?.errors
const code = squareErrors?.[0]?.code ?? ''
const CARD_MESSAGES: Record<string, string> = {
CARD_DECLINED: 'Your card was declined. Please try a different card or contact your bank.',
CARD_DECLINED_CALL_ISSUER: 'Your bank declined this charge. Please call the number on the back of your card to authorise it, then try again.',
CARD_DECLINED_VERIFICATION_REQUIRED: 'Your bank requires additional verification. Please contact them, then try again.',
CARD_EXPIRED: 'Your card has expired. Please use a different card.',
CARD_NOT_SUPPORTED: 'This card type is not accepted. Please try a Visa, Mastercard, Discover, or Amex.',
CVV_FAILURE: 'The security code (CVV) you entered is incorrect. Please double-check and try again.',
ADDRESS_VERIFICATION_FAILURE: 'The billing zip code did not match your card. Please check and try again.',
INSUFFICIENT_FUNDS: 'Your card has insufficient funds. Please use a different card.',
INVALID_CARD: 'Your card details could not be verified. Please check your number and try again.',
INVALID_EXPIRATION: 'The expiration date you entered is invalid. Please check and try again.',
NONCE_USED: 'Your payment session expired — please tap "Place Order" once more to try again.',
INVALID_NONCE: 'Your payment session expired — please tap "Place Order" once more to try again.',
IDEMPOTENCY_KEY_REUSED: 'A duplicate request was detected. If you were charged, please contact us — otherwise tap "Place Order" again.',
GENERIC_DECLINE: 'Your card was declined. Please try a different card or contact your bank.',
}
const userMessage = CARD_MESSAGES[code]
?? 'Something went wrong with your payment. Please try again or contact us for help.'
// Email admin for unexpected server errors (not card declines the customer can self-resolve)
if (!CARD_MESSAGES[code]) {
void (async () => {
try {
const { sendAdminErrorAlert } = await import('@/lib/notify')
await sendAdminErrorAlert({
subject: 'Checkout error',
message: err instanceof Error ? err.message : String(err),
context: { code: code || '(none)', customerEmail, customerName },
})
} catch { /* best effort */ }
})()
}
return NextResponse.json({ error: userMessage }, { status: 500 })
}
}