chris 50680a323f Major overhaul: shared nav, admin improvements, email enhancements, routing fixes
Navigation & layout
- Replace per-page hardcoded nav/footer with shared nav.js (client-side injection)
- Add nginx reverse proxy back to docker-compose for clean localhost routing
- Rename /color-picker/ to /color/ across nav, directory, and references

eStore admin
- Add variation hiding controls (mirrors existing modifier hiding)
- Add delivery rate editor (base fee + per-mile per tier, persisted to data/)
- Fix all missing BASE prefix on fetch calls (admin PATCH/DELETE, availability, slots, colors)
- Mount estore/data/ as a Docker volume so admin config survives rebuilds

Booking & calendar
- Set pickup calendar events to TRANSPARENT (free) so they don't block delivery slots
- Skip CANCELLED events in busy-time calculation
- Re-check slot availability at checkout before charging (409 on conflict)

Phone & email validation
- Auto-format phone as (XXX) XXX-XXXX as user types
- Require exactly 10 digits; tighten email regex

Confirmation emails (store alert + customer)
- Full item detail per line: name, price, add-ons, colors, note
- Charges breakdown: subtotal, delivery fee, tax, total
- Delivery window: simplified M/D/YY h:mm – h:mm AM/PM format
- .ics calendar attachment on customer confirmation

Delivery rates
- Extract configurable rates to delivery-rates.ts (server-only, no fs in client bundle)
- calcDelivery() accepts optional rates param; delivery-quote route passes configured rates

Content
- Change all "40+ latex colors" references to "70+"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 21:14:06 -04:00

371 lines
13 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'),
})
}