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

389 lines
14 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.

/**
* 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') })
}