diff --git a/estore/src/app/api/checkout/route.ts b/estore/src/app/api/checkout/route.ts index fb1fe64..5620bee 100644 --- a/estore/src/app/api/checkout/route.ts +++ b/estore/src/app/api/checkout/route.ts @@ -5,13 +5,14 @@ import { getHoursConfig } from '@/lib/hours' import { getStoreStatus } from '@/lib/store-status' interface LineItem { - name: string - quantity: number - priceCents: number - catalogItemId?: string - colors?: string[] - note?: string - modifiers?: Array<{ catalogObjectId: string; name: string }> + name: string + quantity: number + priceCents: number + catalogItemId?: string + colors?: string[] + note?: string + modifiers?: Array<{ catalogObjectId: string; name: string }> + bouquetGroupId?: string } interface CheckoutBody { @@ -210,30 +211,32 @@ export async function POST(req: NextRequest) { const isSandbox = process.env.SQUARE_ENVIRONMENT !== 'production' - const order = await createSquareOrder({ + // Build Square line items — withCatalogIds=false strips all catalogObjectIds for a fallback retry + const buildOrderLineItems = (withCatalogIds: boolean) => lineItems.map((li) => ({ + catalogObjectId: (withCatalogIds && !isSandbox) ? li.catalogItemId : undefined, + 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: (withCatalogIds && !isSandbox) ? m.catalogObjectId : undefined, + name: m.name, + basePriceMoney: { amount: BigInt(0), currency: 'USD' }, + })) + : undefined, + })) + + const orderBase = { 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 ? { @@ -252,7 +255,25 @@ export async function POST(req: NextRequest) { pickupAt: pickupSlotISO, } : undefined, - }) + } + + // Try with catalog IDs first; on NOT_FOUND (stale cache) retry as ad-hoc and refresh cache + let order: Awaited> + try { + order = await createSquareOrder({ ...orderBase, lineItems: buildOrderLineItems(true) }) + } catch (firstErr: unknown) { + const isNotFound = (firstErr as { errors?: Array<{ code?: string }> })?.errors?.[0]?.code === 'NOT_FOUND' + if (!isNotFound) throw firstErr + const badId = (firstErr as { errors?: Array<{ detail?: string }> })?.errors?.[0]?.detail ?? '' + console.warn('[checkout] NOT_FOUND for catalog ID — retrying as ad-hoc order:', badId) + // Refresh catalog cache asynchronously so next order uses fresh IDs + void import('@/lib/catalog-cache').then(({ refreshCatalog }) => + refreshCatalog() + .then(() => console.log('[checkout] Catalog cache refreshed after NOT_FOUND')) + .catch(() => {}) + ) + order = await createSquareOrder({ ...orderBase, lineItems: buildOrderLineItems(false) }) + } if (!order?.id || !order.totalMoney) { throw new Error('Order creation returned no ID or total') @@ -486,10 +507,29 @@ export async function POST(req: NextRequest) { void (async () => { try { const { sendAdminErrorAlert } = await import('@/lib/notify') + const itemLines = lineItems.map((li) => { + const price = `$${(li.priceCents / 100).toFixed(2)}` + const parts = [` ${li.quantity}× ${li.name} — ${price}`] + if (li.modifiers?.length) parts.push(` Options: ${li.modifiers.map((m) => m.name).join(', ')}`) + if (li.colors?.length) parts.push(` Colors: ${li.colors.join(', ')}`) + if (li.note) parts.push(` Note: ${li.note}`) + return parts.join('\n') + }).join('\n') + const fulfillmentStr = deliveryAddress + ? `Delivery to ${deliveryAddress}${deliverySlotISO ? ` at ${new Date(deliverySlotISO).toLocaleString('en-US', { timeZone: 'America/New_York', dateStyle: 'medium', timeStyle: 'short' })}` : ''}` + : pickupSlotISO ? `Pickup at ${new Date(pickupSlotISO).toLocaleString('en-US', { timeZone: 'America/New_York', dateStyle: 'medium', timeStyle: 'short' })}` : 'Unknown' await sendAdminErrorAlert({ subject: 'Checkout error', - message: err instanceof Error ? err.message : String(err), - context: { code: code || '(none)', customerEmail, customerName }, + message: [ + err instanceof Error ? err.message : String(err), + '', + `Customer: ${customerName ?? '(unknown)'} <${customerEmail ?? '(none)'}> ${customerPhone ?? ''}`, + `${fulfillmentStr}`, + '', + 'Order:', + itemLines, + ].join('\n'), + context: { code: code || '(none)' }, }) } catch { /* best effort */ } })() diff --git a/estore/src/components/CartDrawer.tsx b/estore/src/components/CartDrawer.tsx index dac6f1e..2d5500d 100644 --- a/estore/src/components/CartDrawer.tsx +++ b/estore/src/components/CartDrawer.tsx @@ -287,13 +287,14 @@ export default function CartDrawer() { ] } return [{ - name: e.product.name, - quantity: e.quantity, - priceCents: entryUnitPrice(e), - catalogItemId: e.selectedVariationId ?? e.product.id, - colors: e.selectedColors.length ? e.selectedColors : undefined, - note: e.notes || undefined, - modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => { + name: e.product.name, + quantity: e.quantity, + priceCents: entryUnitPrice(e), + catalogItemId: e.selectedVariationId ?? e.product.id, + colors: e.selectedColors.length ? e.selectedColors : undefined, + note: e.notes || undefined, + bouquetGroupId: e.bouquetGroupId || undefined, + modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => { const ml = e.product.modifiers?.find((m) => m.id === listId) if (!ml) return [] return optIds.map((optId) => ({ diff --git a/estore/src/components/PaymentForm.tsx b/estore/src/components/PaymentForm.tsx index 6fb236c..a517f40 100644 --- a/estore/src/components/PaymentForm.tsx +++ b/estore/src/components/PaymentForm.tsx @@ -43,13 +43,14 @@ interface TokenResult { export interface CheckoutPayload { lineItems: Array<{ - name: string - quantity: number - priceCents: number - catalogItemId?: string - colors?: string[] - note?: string - modifiers?: Array<{ catalogObjectId: string; name: string }> + name: string + quantity: number + priceCents: number + catalogItemId?: string + colors?: string[] + note?: string + modifiers?: Array<{ catalogObjectId: string; name: string }> + bouquetGroupId?: string }> selectedColors: string[] deliverySlotISO?: string @@ -80,9 +81,15 @@ const SDK_URL = : 'https://sandbox.web.squarecdn.com/v1/square.js' function buildMailtoLink(payload: CheckoutPayload): string { - const { fmt: fmtCents } = { fmt: (c: number) => `$${(c / 100).toFixed(2)}` } + const fmtCents = (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})` : ''}`) + .map((li) => { + const lines = [` • ${li.quantity}× ${li.name} — ${fmtCents(li.priceCents * li.quantity)}`] + if (li.modifiers?.length) lines.push(` Options: ${li.modifiers.map((m) => m.name).join(', ')}`) + if (li.colors?.length) lines.push(` Colors: ${li.colors.join(', ')}`) + if (li.note) lines.push(` Note: ${li.note}`) + return lines.join('\n') + }) .join('\n') const fulfillment = payload.deliverySlotISO ? `Delivery: ${new Date(payload.deliverySlotISO).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })} to ${payload.deliveryAddress ?? ''}` diff --git a/estore/src/lib/notify.ts b/estore/src/lib/notify.ts index 45141c4..ef29616 100644 --- a/estore/src/lib/notify.ts +++ b/estore/src/lib/notify.ts @@ -42,23 +42,49 @@ function fmtWindow(startISO: string, endISO: string): string { // ── Shared types ─────────────────────────────────────────────────────────────── export interface EmailLineItem { - name: string - quantity: number - priceCents: number - colors?: string[] - note?: string - modifiers?: Array<{ name: string }> + name: string + quantity: number + priceCents: number + colors?: string[] + note?: string + modifiers?: Array<{ name: string }> + bouquetGroupId?: string +} + +function formatOneItem(li: EmailLineItem, indent = ''): string { + const price = `$${(li.priceCents / 100).toFixed(2)}` + const lines = [`${indent}${li.quantity}× ${li.name} — ${price}`] + if (li.modifiers?.length) lines.push(`${indent} Options: ${li.modifiers.map((m) => m.name).join(', ')}`) + if (li.colors?.length) lines.push(`${indent} Colors: ${li.colors.join(', ')}`) + if (li.note) lines.push(`${indent} Note: ${li.note}`) + return lines.join('\n') } function formatLineItems(lineItems: EmailLineItem[]): string { - return lineItems.map((li) => { - const price = `$${(li.priceCents / 100).toFixed(2)}` - const lines = [`${li.quantity}× ${li.name} — ${price}`] - if (li.modifiers?.length) lines.push(` Add-ons: ${li.modifiers.map((m) => m.name).join(', ')}`) - if (li.colors?.length) lines.push(` Colors: ${li.colors.join(', ')}`) - if (li.note) lines.push(` Note: ${li.note}`) - return lines.join('\n') - }).join('\n') + // Separate bouquet groups from standalone items + const groups = new Map() + const standalone: EmailLineItem[] = [] + for (const li of lineItems) { + if (li.bouquetGroupId) { + const g = groups.get(li.bouquetGroupId) ?? [] + g.push(li) + groups.set(li.bouquetGroupId, g) + } else { + standalone.push(li) + } + } + + const parts: string[] = standalone.map((li) => formatOneItem(li)) + + let bouquetNum = 1 + for (const items of Array.from(groups.values())) { + const subtotal = items.reduce((s: number, li: EmailLineItem) => s + li.priceCents * li.quantity, 0) + parts.push(`── BYO Bouquet #${bouquetNum} ($${(subtotal / 100).toFixed(2)} subtotal, ${items.length} item${items.length !== 1 ? 's' : ''}) ──`) + for (const li of items) parts.push(formatOneItem(li, ' ')) + bouquetNum++ + } + + return parts.join('\n') } function getTransporter() {