- 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>
389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
/**
|
||
* Email notifications via your own SMTP server (nodemailer).
|
||
*
|
||
* Required env vars:
|
||
* SMTP_HOST — your mail server hostname (e.g. mail.beachpartyballoons.com)
|
||
* SMTP_PORT — 587 (STARTTLS) or 465 (SSL), defaults to 587
|
||
* SMTP_USER — SMTP login username
|
||
* SMTP_PASS — SMTP login password
|
||
* ALERT_EMAIL_TO — address that receives alerts (e.g. chris@beachpartyballoons.com)
|
||
* ALERT_EMAIL_FROM — sender address (e.g. shop@beachpartyballoons.com)
|
||
*/
|
||
|
||
import nodemailer from 'nodemailer'
|
||
|
||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||
|
||
/** Format a slot as "4/14/26 2:00 PM" */
|
||
function fmtDate(iso: string): string {
|
||
const d = new Date(iso)
|
||
const { month, day, year, hour, minute, dayPeriod } = Object.fromEntries(
|
||
new Intl.DateTimeFormat('en-US', {
|
||
timeZone: 'America/New_York',
|
||
month: 'numeric', day: 'numeric', year: '2-digit',
|
||
hour: 'numeric', minute: '2-digit', hour12: true,
|
||
}).formatToParts(d).map(({ type, value }) => [type, value])
|
||
)
|
||
return `${month}/${day}/${year} ${hour}:${minute} ${dayPeriod}`
|
||
}
|
||
|
||
/** Format a window as "4/14/26 2:00 – 5:00 PM" (shared date, shared AM/PM if same) */
|
||
function fmtWindow(startISO: string, endISO: string): string {
|
||
const start = fmtDate(startISO) // "4/14/26 2:00 PM"
|
||
const endTime = new Intl.DateTimeFormat('en-US', {
|
||
timeZone: 'America/New_York',
|
||
hour: 'numeric', minute: '2-digit', hour12: true,
|
||
}).format(new Date(endISO)) // "5:00 PM"
|
||
// Strip the time portion from start and append range
|
||
const datePart = start.replace(/ \d+:\d+ [AP]M$/, '')
|
||
return `${datePart} ${start.match(/\d+:\d+ [AP]M$/)?.[0]} – ${endTime}`
|
||
}
|
||
|
||
// ── Shared types ───────────────────────────────────────────────────────────────
|
||
|
||
export interface EmailLineItem {
|
||
name: string
|
||
quantity: number
|
||
priceCents: number
|
||
colors?: string[]
|
||
note?: string
|
||
modifiers?: Array<{ name: string }>
|
||
}
|
||
|
||
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')
|
||
}
|
||
|
||
function getTransporter() {
|
||
const host = process.env.SMTP_HOST
|
||
const port = parseInt(process.env.SMTP_PORT ?? '587', 10)
|
||
const user = process.env.SMTP_USER
|
||
const pass = process.env.SMTP_PASS
|
||
|
||
if (!host || !user || !pass) return null
|
||
|
||
return nodemailer.createTransport({
|
||
host,
|
||
port,
|
||
secure: port === 465,
|
||
auth: { user, pass },
|
||
})
|
||
}
|
||
|
||
async function send(params: {
|
||
to: string
|
||
subject: string
|
||
text: string
|
||
attachments?: Array<{ filename: string; content: string; contentType: string }>
|
||
}): Promise<void> {
|
||
const from = process.env.ALERT_EMAIL_FROM ?? 'shop@beachpartyballoons.com'
|
||
const transporter = getTransporter()
|
||
|
||
if (!transporter) {
|
||
const missing = ['SMTP_HOST', 'SMTP_USER', 'SMTP_PASS'].filter((k) => !process.env[k])
|
||
console.error('[notify] SMTP not configured — missing env vars:', missing.join(', '), '— email skipped:', params.subject)
|
||
return
|
||
}
|
||
|
||
try {
|
||
await transporter.sendMail({
|
||
from,
|
||
to: params.to,
|
||
subject: params.subject,
|
||
text: params.text,
|
||
attachments: params.attachments,
|
||
})
|
||
console.log('[notify] Email sent:', params.subject, '→', params.to)
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err)
|
||
console.error('[notify] SMTP send failed:', msg, '| subject:', params.subject, '| to:', params.to)
|
||
throw err // re-throw so callers can handle/log it
|
||
}
|
||
}
|
||
|
||
// ── ICS builder for customer calendar attachment ────────────────────────────
|
||
|
||
function buildCustomerICS(params: {
|
||
uid: string
|
||
startISO: string
|
||
endISO: string
|
||
summary: string
|
||
description: string
|
||
location?: string
|
||
}): string {
|
||
function toStamp(d: Date): string {
|
||
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
|
||
}
|
||
function toET(d: Date): string {
|
||
const parts = new Intl.DateTimeFormat('en-US', {
|
||
timeZone: 'America/New_York',
|
||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||
}).formatToParts(d)
|
||
const p: Record<string, string> = {}
|
||
for (const { type, value } of parts) p[type] = value
|
||
const h = p.hour === '24' ? '00' : p.hour
|
||
return `${p.year}${p.month}${p.day}T${h}${p.minute}${p.second}`
|
||
}
|
||
function fold(s: string): string {
|
||
const out: string[] = []
|
||
while (s.length > 73) { out.push(s.slice(0, 73)); s = ' ' + s.slice(73) }
|
||
out.push(s)
|
||
return out.join('\r\n')
|
||
}
|
||
|
||
const start = new Date(params.startISO)
|
||
const end = new Date(params.endISO)
|
||
const lines = [
|
||
'BEGIN:VCALENDAR',
|
||
'VERSION:2.0',
|
||
'PRODID:-//BeachPartyBalloons//Shop//EN',
|
||
'BEGIN:VEVENT',
|
||
`UID:${params.uid}`,
|
||
`DTSTAMP:${toStamp(new Date())}`,
|
||
`DTSTART;TZID=America/New_York:${toET(start)}`,
|
||
`DTEND;TZID=America/New_York:${toET(end)}`,
|
||
fold(`SUMMARY:${params.summary}`),
|
||
...(params.location ? [fold(`LOCATION:${params.location}`)] : []),
|
||
fold(`DESCRIPTION:${params.description}`),
|
||
'STATUS:CONFIRMED',
|
||
'END:VEVENT',
|
||
'END:VCALENDAR',
|
||
]
|
||
return lines.join('\r\n')
|
||
}
|
||
|
||
// ── Public helpers ─────────────────────────────────────────────────────────────
|
||
|
||
export async function sendOrderConfirmationEmail(params: {
|
||
shortRef: string
|
||
orderId: string
|
||
customerName: string
|
||
customerEmail: string
|
||
fulfillment: 'delivery' | 'pickup'
|
||
slotISO: string
|
||
slotEndISO?: string
|
||
address?: string
|
||
lineItems: EmailLineItem[]
|
||
colors: string[] // order-level color selection (when not per-item)
|
||
subtotalCents?: number
|
||
deliveryCents?: number
|
||
totalCents: bigint
|
||
}): Promise<void> {
|
||
const isDelivery = params.fulfillment === 'delivery'
|
||
const slotStr = params.slotEndISO
|
||
? fmtWindow(params.slotISO, params.slotEndISO)
|
||
: fmtDate(params.slotISO)
|
||
const total = `$${(Number(params.totalCents) / 100).toFixed(2)}`
|
||
|
||
// Charges breakdown (only shown when subtotal is provided)
|
||
const chargesLines: string[] = []
|
||
if (params.subtotalCents != null) {
|
||
chargesLines.push(`Subtotal: $${(params.subtotalCents / 100).toFixed(2)}`)
|
||
if (params.deliveryCents) {
|
||
chargesLines.push(`Delivery: $${(params.deliveryCents / 100).toFixed(2)}`)
|
||
}
|
||
const impliedTax = Number(params.totalCents) - (params.subtotalCents) - (params.deliveryCents ?? 0)
|
||
if (impliedTax > 0) {
|
||
chargesLines.push(`Tax: $${(impliedTax / 100).toFixed(2)}`)
|
||
}
|
||
chargesLines.push(`Total: ${total}`)
|
||
} else {
|
||
chargesLines.push(`Total: ${total}`)
|
||
}
|
||
|
||
const itemsBlock = formatLineItems(params.lineItems)
|
||
// Order-level colors (when colors aren't set per-item)
|
||
const hasPerItemColors = params.lineItems.some((li) => li.colors?.length)
|
||
const orderColors = !hasPerItemColors && params.colors.length ? params.colors : []
|
||
|
||
const lines = [
|
||
`Hi ${params.customerName.split(' ')[0]},`,
|
||
``,
|
||
`Your order is confirmed! Here's a summary:`,
|
||
``,
|
||
`Order #: ${params.shortRef}`,
|
||
``,
|
||
itemsBlock,
|
||
...(orderColors.length ? [``, `Colors: ${orderColors.join(', ')}`] : []),
|
||
``,
|
||
isDelivery ? `Delivery: ${slotStr}` : `Pickup: ${slotStr}`,
|
||
...(params.address ? [`Address: ${params.address}`] : []),
|
||
``,
|
||
...chargesLines,
|
||
``,
|
||
isDelivery
|
||
? `If you need to make any changes, please call or text us as soon as possible.`
|
||
: [
|
||
`Pick up at 554 Boston Post Rd, Milford CT 06460.`,
|
||
`Please bring this confirmation. If you need to reschedule, give us a call!`,
|
||
].join('\n'),
|
||
``,
|
||
`Thank you for choosing Beach Party Balloons!`,
|
||
``,
|
||
`— The Beach Party Balloons Team`,
|
||
`beachpartyballoons.com`,
|
||
]
|
||
|
||
// Build ICS calendar attachment
|
||
const icsEndISO = params.slotEndISO ?? new Date(new Date(params.slotISO).getTime() + 60 * 60_000).toISOString()
|
||
const itemsSummary = params.lineItems.map((li) => `${li.quantity}× ${li.name}`).join(', ')
|
||
const icsDesc = [
|
||
`Order #${params.shortRef}`,
|
||
`Items: ${itemsSummary}`,
|
||
...(params.colors.length ? [`Colors: ${params.colors.join(', ')}`] : []),
|
||
...(params.address ? [`Address: ${params.address}`] : []),
|
||
].join('\\n')
|
||
const icsSummary = isDelivery ? 'Balloon Delivery — Beach Party Balloons' : 'Balloon Pickup — Beach Party Balloons'
|
||
const icsContent = buildCustomerICS({
|
||
uid: `customer-${params.orderId}@beachpartyballoons.com`,
|
||
startISO: params.slotISO,
|
||
endISO: icsEndISO,
|
||
summary: icsSummary,
|
||
description: icsDesc,
|
||
location: params.address ?? (isDelivery ? undefined : '554 Boston Post Rd, Milford CT 06460'),
|
||
})
|
||
|
||
await send({
|
||
to: params.customerEmail,
|
||
subject: `Your Beach Party Balloons order is confirmed! (#${params.shortRef})`,
|
||
text: lines.join('\n'),
|
||
attachments: [{
|
||
filename: 'appointment.ics',
|
||
content: icsContent,
|
||
contentType: 'text/calendar; method=REQUEST',
|
||
}],
|
||
})
|
||
}
|
||
|
||
export async function sendSlotConflictAlert(params: {
|
||
shortRef: string
|
||
orderId: string
|
||
customerName: string
|
||
customerPhone: string
|
||
slotISO: string
|
||
address: string
|
||
items: string
|
||
}): Promise<void> {
|
||
const to = process.env.ALERT_EMAIL_TO
|
||
if (!to) { console.warn('[notify] ALERT_EMAIL_TO not set'); return }
|
||
|
||
await send({
|
||
to,
|
||
subject: `🚨 ACTION REQUIRED: Slot not blocked — Order #${params.shortRef}`,
|
||
text: [
|
||
`URGENT — Payment succeeded but the delivery slot was NOT written to your calendar.`,
|
||
``,
|
||
`You must manually block this slot immediately to prevent a double-booking.`,
|
||
``,
|
||
`Order: #${params.shortRef} (${params.orderId})`,
|
||
`Customer: ${params.customerName} ${params.customerPhone}`,
|
||
`Slot: ${fmtDate(params.slotISO)}`,
|
||
`Address: ${params.address}`,
|
||
`Items: ${params.items}`,
|
||
``,
|
||
`Steps:`,
|
||
` 1. Block the slot in your calendar now.`,
|
||
` 2. Confirm the booking with the customer by phone.`,
|
||
` 3. Check Square dashboard for the full order details.`,
|
||
].join('\n'),
|
||
})
|
||
}
|
||
|
||
export async function sendNewOrderAlert(params: {
|
||
shortRef: string
|
||
orderId: string
|
||
customerName: string
|
||
customerPhone: string
|
||
customerEmail: string
|
||
fulfillment: 'delivery' | 'pickup'
|
||
slotISO: string
|
||
slotEndISO?: string
|
||
address?: string
|
||
lineItems: EmailLineItem[]
|
||
colors: string[]
|
||
subtotalCents?: number
|
||
deliveryCents?: number
|
||
totalCents: bigint
|
||
}): Promise<void> {
|
||
const to = process.env.ALERT_EMAIL_TO
|
||
if (!to) return
|
||
|
||
const slotStr = params.slotEndISO
|
||
? fmtWindow(params.slotISO, params.slotEndISO)
|
||
: fmtDate(params.slotISO)
|
||
const total = `$${(Number(params.totalCents) / 100).toFixed(2)}`
|
||
|
||
const chargesLines: string[] = []
|
||
if (params.subtotalCents != null) {
|
||
chargesLines.push(`Subtotal: $${(params.subtotalCents / 100).toFixed(2)}`)
|
||
if (params.deliveryCents) {
|
||
chargesLines.push(`Delivery: $${(params.deliveryCents / 100).toFixed(2)}`)
|
||
}
|
||
const impliedTax = Number(params.totalCents) - params.subtotalCents - (params.deliveryCents ?? 0)
|
||
if (impliedTax > 0) {
|
||
chargesLines.push(`Tax: $${(impliedTax / 100).toFixed(2)}`)
|
||
}
|
||
chargesLines.push(`Total: ${total}`)
|
||
} else {
|
||
chargesLines.push(`Total: ${total}`)
|
||
}
|
||
|
||
const slotLine = params.fulfillment === 'delivery'
|
||
? `Delivery: ${slotStr}`
|
||
: `Pickup: ${slotStr}`
|
||
|
||
const itemsBlock = formatLineItems(params.lineItems)
|
||
const hasPerItemColors = params.lineItems.some((li) => li.colors?.length)
|
||
const orderColors = !hasPerItemColors && params.colors.length ? params.colors : []
|
||
|
||
const lines = [
|
||
`New ${params.fulfillment} order — #${params.shortRef}`,
|
||
``,
|
||
`Customer: ${params.customerName}`,
|
||
`Phone: ${params.customerPhone}`,
|
||
`Email: ${params.customerEmail}`,
|
||
``,
|
||
slotLine,
|
||
...(params.address ? [`Address: ${params.address}`] : []),
|
||
``,
|
||
itemsBlock,
|
||
...(orderColors.length ? [``, `Colors: ${orderColors.join(', ')}`] : []),
|
||
``,
|
||
...chargesLines,
|
||
``,
|
||
`View in Square: https://squareup.com/dashboard/orders`,
|
||
]
|
||
|
||
await send({
|
||
to,
|
||
subject: `🎈 New order #${params.shortRef} — ${params.customerName} (${params.fulfillment})`,
|
||
text: lines.join('\n'),
|
||
})
|
||
}
|
||
|
||
export async function sendAdminErrorAlert(params: {
|
||
subject: string
|
||
message: string
|
||
context?: Record<string, unknown>
|
||
}): Promise<void> {
|
||
const to = 'admin@beachpartyballoons.com'
|
||
const lines = [
|
||
params.message,
|
||
'',
|
||
...(params.context
|
||
? Object.entries(params.context).map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
|
||
: []),
|
||
'',
|
||
`Time: ${new Date().toISOString()}`,
|
||
]
|
||
await send({ to, subject: `⚠️ ${params.subject}`, text: lines.join('\n') })
|
||
}
|