From 92e9abdb84cc121e473a93887a2bfbdd54eef11e Mon Sep 17 00:00:00 2001 From: chris Date: Tue, 30 Jun 2026 10:05:58 -0400 Subject: [PATCH] Replace mailto with server-sent invoice request On checkout error, customer can click "send us your order details" instead of opening their email app. Posts to /api/invoice-request which: - Emails shop@ with full order details, reply-to set to customer's address so replies go directly to them - Sends customer a confirmation email with order summary Falls back to the mailto link if the API call fails. Co-Authored-By: Claude Sonnet 4.6 --- estore/src/app/api/invoice-request/route.ts | 45 +++++++++++ estore/src/components/PaymentForm.tsx | 65 ++++++++++++++-- estore/src/lib/notify.ts | 85 +++++++++++++++++++++ 3 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 estore/src/app/api/invoice-request/route.ts diff --git a/estore/src/app/api/invoice-request/route.ts b/estore/src/app/api/invoice-request/route.ts new file mode 100644 index 0000000..1e5ae8b --- /dev/null +++ b/estore/src/app/api/invoice-request/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server' +import type { EmailLineItem } from '@/lib/notify' + +interface InvoiceRequestBody { + customerName: string + customerEmail: string + customerPhone: string + lineItems: EmailLineItem[] + fulfillment: 'delivery' | 'pickup' + slotISO?: string + address?: string + deliveryNotes?: string + deliveryCents?: number + grandTotal: number +} + +export async function POST(req: NextRequest) { + let body: InvoiceRequestBody + try { + body = (await req.json()) as InvoiceRequestBody + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + + const { customerName, customerEmail, customerPhone, lineItems, grandTotal } = body + + if ( + typeof customerName !== 'string' || !customerName.trim() || + typeof customerEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(customerEmail) || + typeof customerPhone !== 'string' || !customerPhone.trim() || + !Array.isArray(lineItems) || lineItems.length === 0 || + typeof grandTotal !== 'number' + ) { + return NextResponse.json({ error: 'Missing or invalid required fields' }, { status: 400 }) + } + + try { + const { sendInvoiceRequest } = await import('@/lib/notify') + await sendInvoiceRequest(body) + return NextResponse.json({ success: true }) + } catch (err) { + console.error('[invoice-request] Failed to send:', err) + return NextResponse.json({ error: 'Failed to send — please email us directly.' }, { status: 500 }) + } +} diff --git a/estore/src/components/PaymentForm.tsx b/estore/src/components/PaymentForm.tsx index c833681..f3f2b02 100644 --- a/estore/src/components/PaymentForm.tsx +++ b/estore/src/components/PaymentForm.tsx @@ -147,6 +147,7 @@ export default function PaymentForm({ payload, onSuccess, onError, active }: Pro const [applePayReady, setApplePayReady] = useState(false) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState('') + const [invoiceState, setInvoiceState] = useState<'idle' | 'sending' | 'sent' | 'failed'>('idle') const cardRef = useRef(null) const googlePayRef = useRef(null) @@ -331,6 +332,32 @@ export default function PaymentForm({ payload, onSuccess, onError, active }: Pro ) } + async function handleSendInvoice() { + setInvoiceState('sending') + try { + const res = await fetch(`${BASE}/api/invoice-request`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + customerName: `${payload.customerFirstName} ${payload.customerLastName}`.trim(), + customerEmail: payload.customerEmail, + customerPhone: payload.customerPhone, + lineItems: payload.lineItems, + fulfillment: payload.deliverySlotISO ? 'delivery' : 'pickup', + slotISO: payload.deliverySlotISO ?? payload.pickupSlotISO, + address: payload.deliveryAddress, + deliveryNotes: payload.deliveryNotes, + deliveryCents: payload.deliveryCents, + grandTotal: payload.grandTotal, + }), + }) + if (!res.ok) throw new Error('send failed') + setInvoiceState('sent') + } catch { + setInvoiceState('failed') + } + } + const hasWallet = googlePayReady || applePayReady return ( @@ -392,14 +419,36 @@ export default function PaymentForm({ payload, onSuccess, onError, active }: Pro {error}

- If this keeps happening, you can{' '} - - email us your order details - - {' '}and we'll send you an invoice. + {invoiceState === 'sent' ? ( + + ✓ Sent! Check your email at {payload.customerEmail} — we'll be in touch shortly. + + ) : invoiceState === 'failed' ? ( + <> + Couldn't send automatically.{' '} + + Email us directly + {' '} + and we'll invoice you. + + ) : ( + <> + If this keeps happening,{' '} + + {' '}and we'll invoice you. + + )}

)} diff --git a/estore/src/lib/notify.ts b/estore/src/lib/notify.ts index ef29616..a0b3c2a 100644 --- a/estore/src/lib/notify.ts +++ b/estore/src/lib/notify.ts @@ -107,6 +107,7 @@ async function send(params: { to: string subject: string text: string + replyTo?: string attachments?: Array<{ filename: string; content: string; contentType: string }> }): Promise { const from = process.env.ALERT_EMAIL_FROM ?? 'shop@beachpartyballoons.com' @@ -122,6 +123,7 @@ async function send(params: { await transporter.sendMail({ from, to: params.to, + replyTo: params.replyTo, subject: params.subject, text: params.text, attachments: params.attachments, @@ -453,6 +455,89 @@ export async function sendBookingRequest(params: { } } +export async function sendInvoiceRequest(params: { + customerName: string + customerEmail: string + customerPhone: string + lineItems: EmailLineItem[] + fulfillment: 'delivery' | 'pickup' + slotISO?: string + address?: string + deliveryNotes?: string + deliveryCents?: number + grandTotal: number // cents +}): Promise { + const to = process.env.ALERT_EMAIL_TO ?? 'info@beachpartyballoons.com' + const from = process.env.ALERT_EMAIL_FROM ?? 'shop@beachpartyballoons.com' + const firstName = params.customerName.split(' ')[0] || params.customerName + + const fmtD = (iso: string) => + new Date(iso).toLocaleString('en-US', { timeZone: 'America/New_York', dateStyle: 'full', timeStyle: 'short' }) + const fmt$ = (c: number) => `$${(c / 100).toFixed(2)}` + + const fulfillmentLines = [ + `Type: ${params.fulfillment === 'delivery' ? 'Delivery' : 'Pickup'}`, + params.slotISO ? `Date/time: ${fmtD(params.slotISO)}` : null, + params.address ? `Address: ${params.address}` : null, + params.deliveryNotes ? `Notes: ${params.deliveryNotes}` : null, + ].filter((l): l is string => l !== null) + + const itemsBlock = formatLineItems(params.lineItems) + const itemSubtotal = params.lineItems.reduce((s: number, li: EmailLineItem) => s + li.priceCents * li.quantity, 0) + const estTax = Math.round(itemSubtotal * 0.0635) + + const priceLines = [ + `Items: ${fmt$(itemSubtotal)}`, + params.deliveryCents ? `Delivery: ${fmt$(params.deliveryCents)}` : null, + `Est. tax: ${fmt$(estTax)}`, + `Total: ${fmt$(params.grandTotal)}`, + ].filter((l): l is string => l !== null) + + // ── Alert email to shop (reply-to set to customer so you can reply directly) ─ + await send({ + to, + replyTo: params.customerEmail, + subject: `📋 Invoice request — ${params.customerName}`, + text: [ + `Invoice request from ${params.customerName}`, + `Reply to this email to contact the customer directly.`, + '', + `Customer: ${params.customerName}`, + `Email: ${params.customerEmail}`, + `Phone: ${params.customerPhone}`, + '', + ...fulfillmentLines, + '', + itemsBlock, + '', + ...priceLines, + ].join('\n'), + }) + + // ── Confirmation to customer ────────────────────────────────────────────────── + await send({ + to: params.customerEmail, + replyTo: from, + subject: `We received your request — Beach Party Balloons`, + text: [ + `Hi ${firstName},`, + '', + `We got your order details and will send you an invoice shortly.`, + '', + itemsBlock, + '', + ...fulfillmentLines, + '', + ...priceLines, + '', + `Questions? Just reply to this email or call us any time.`, + '', + `— Beach Party Balloons`, + `beachpartyballoons.com`, + ].join('\n'), + }) +} + export async function sendAdminErrorAlert(params: { subject: string message: string