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 <noreply@anthropic.com>
This commit is contained in:
chris 2026-06-30 10:05:58 -04:00
parent 8aaf2f1dcb
commit 92e9abdb84
3 changed files with 187 additions and 8 deletions

View File

@ -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 })
}
}

View File

@ -147,6 +147,7 @@ export default function PaymentForm({ payload, onSuccess, onError, active }: Pro
const [applePayReady, setApplePayReady] = useState(false) const [applePayReady, setApplePayReady] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [invoiceState, setInvoiceState] = useState<'idle' | 'sending' | 'sent' | 'failed'>('idle')
const cardRef = useRef<SquareCard | null>(null) const cardRef = useRef<SquareCard | null>(null)
const googlePayRef = useRef<SquareWalletMethod | null>(null) const googlePayRef = useRef<SquareWalletMethod | null>(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 const hasWallet = googlePayReady || applePayReady
return ( return (
@ -392,14 +419,36 @@ export default function PaymentForm({ payload, onSuccess, onError, active }: Pro
{error} {error}
</p> </p>
<p style={{ fontSize: '0.75rem', color: '#555', marginTop: '0.5rem', lineHeight: 1.5 }}> <p style={{ fontSize: '0.75rem', color: '#555', marginTop: '0.5rem', lineHeight: 1.5 }}>
If this keeps happening, you can{' '} {invoiceState === 'sent' ? (
<a <span style={{ color: '#1a7a40' }}>
href={buildMailtoLink(payload)} Sent! Check your email at {payload.customerEmail} we&apos;ll be in touch shortly.
style={{ color: '#0d6e75', textDecoration: 'underline' }} </span>
> ) : invoiceState === 'failed' ? (
email us your order details <>
</a> Couldn&apos;t send automatically.{' '}
{' '}and we&apos;ll send you an invoice. <a href={buildMailtoLink(payload)} style={{ color: '#0d6e75', textDecoration: 'underline' }}>
Email us directly
</a>{' '}
and we&apos;ll invoice you.
</>
) : (
<>
If this keeps happening,{' '}
<button
onClick={handleSendInvoice}
disabled={invoiceState === 'sending'}
style={{
background: 'none', border: 'none', padding: 0,
color: '#0d6e75', textDecoration: 'underline',
cursor: invoiceState === 'sending' ? 'default' : 'pointer',
fontSize: 'inherit',
}}
>
{invoiceState === 'sending' ? 'Sending…' : 'send us your order details'}
</button>
{' '}and we&apos;ll invoice you.
</>
)}
</p> </p>
</div> </div>
)} )}

View File

@ -107,6 +107,7 @@ async function send(params: {
to: string to: string
subject: string subject: string
text: string text: string
replyTo?: string
attachments?: Array<{ filename: string; content: string; contentType: string }> attachments?: Array<{ filename: string; content: string; contentType: string }>
}): Promise<void> { }): Promise<void> {
const from = process.env.ALERT_EMAIL_FROM ?? 'shop@beachpartyballoons.com' const from = process.env.ALERT_EMAIL_FROM ?? 'shop@beachpartyballoons.com'
@ -122,6 +123,7 @@ async function send(params: {
await transporter.sendMail({ await transporter.sendMail({
from, from,
to: params.to, to: params.to,
replyTo: params.replyTo,
subject: params.subject, subject: params.subject,
text: params.text, text: params.text,
attachments: params.attachments, 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<void> {
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: { export async function sendAdminErrorAlert(params: {
subject: string subject: string
message: string message: string