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

156 lines
5.9 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.

// ── Shop origin ──────────────────────────────────────────────────────────────
export const SHOP_LAT = 41.2284
export const SHOP_LNG = -73.0590 // 554 Boston Post Rd, Milford CT
// ── Rates ─────────────────────────────────────────────────────────────────────
export type DeliveryTier = 'dropoff' | 'classic' | 'organic'
export const RATES: Record<DeliveryTier, { base: number; perMile: number; label: string }> = {
dropoff: {
base: 20_00, // cents
perMile: 1_60,
label: 'Drop-off delivery',
},
classic: {
base: 30_00,
perMile: 2_90,
label: 'Setup & strike (arches / columns)',
},
organic: {
base: 50_00,
perMile: 2_90,
label: 'Organic setup & strike (with Chiara Wall)',
},
}
// ── On-site job duration (minutes) per delivery tier ─────────────────────────
export const JOB_MINUTES: Record<DeliveryTier, number> = {
dropoff: 60, // 1 hr on-site window
classic: 150, // 2.5 hrs setup + strike
organic: 240, // 4 hrs setup + strike
}
// ── Configurable rates type (file I/O lives in delivery-rates.ts, server only) ─
export type DeliveryRatesConfig = Record<DeliveryTier, { base: number; perMile: number; label: string }>
/** Straight-line distance fallback (haversine) with road overhead factor */
function haversineMiles(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 3958.8 // Earth radius in miles
const dLat = ((lat2 - lat1) * Math.PI) / 180
const dLng = ((lng2 - lng1) * Math.PI) / 180
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLng / 2) ** 2
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
/** Driving info via OSRM — uses self-hosted instance if OSRM_URL is set,
* falls back to the public demo server, retries 3×, then haversine if all fail */
export async function drivingInfo(
lat1: number, lng1: number,
lat2: number, lng2: number,
): Promise<{ miles: number; minutes: number }> {
const base = (process.env.OSRM_URL ?? 'https://router.project-osrm.org').replace(/\/$/, '')
const url = `${base}/route/v1/driving/${lng1},${lat1};${lng2},${lat2}?overview=false`
// Self-hosted gets a generous timeout; public demo gets a short one
const timeoutMs = process.env.OSRM_URL ? 10_000 : 6_000
for (let attempt = 0; attempt < 3; attempt++) {
try {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
const res = await fetch(url, {
headers: { 'User-Agent': 'BeachPartyBalloonsShop/1.0' },
next: { revalidate: 86400 },
signal: controller.signal,
})
clearTimeout(timer)
if (!res.ok) throw new Error(`OSRM ${res.status}`)
const data = await res.json()
if (data.code !== 'Ok' || !data.routes?.length) throw new Error('No route found')
const miles = (data.routes[0].distance as number) / 1609.344
const minutes = Math.ceil((data.routes[0].duration as number) / 60)
return { miles, minutes }
} catch {
if (attempt < 2) await new Promise((r) => setTimeout(r, 500 * (attempt + 1)))
}
}
// Fallback: straight-line distance × 1.3 road overhead, ~25 mph average
console.warn('[delivery] OSRM unavailable — using haversine fallback')
const miles = haversineMiles(lat1, lng1, lat2, lng2) * 1.3
const minutes = Math.ceil((miles / 25) * 60)
return { miles, minutes }
}
/** Geocode an address string via Nominatim (no API key required) */
export async function geocode(address: string): Promise<{ lat: number; lng: number } | null> {
const params = new URLSearchParams({ q: address, format: 'json', limit: '1', countrycodes: 'us' })
const res = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
headers: { 'User-Agent': 'BeachPartyBalloonsShop/1.0' },
next: { revalidate: 86400 }, // cache geocode results for 24h
})
if (!res.ok) return null
const data = await res.json()
if (!data.length) return null
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) }
}
export interface DeliveryQuote {
tier: DeliveryTier
miles: number
driveMinutes: number
baseCents: number
mileCents: number
totalCents: number
label: string
lat: number
lng: number
}
export async function calcDelivery(
destLat: number,
destLng: number,
tier: DeliveryTier,
rates?: DeliveryRatesConfig,
): Promise<DeliveryQuote> {
const rate = (rates ?? RATES)[tier]
const { miles: rawMiles, minutes: driveMinutes } =
await drivingInfo(SHOP_LAT, SHOP_LNG, destLat, destLng)
const miles = Math.ceil(rawMiles * 10) / 10
const mileCents = Math.ceil(miles) * rate.perMile
const totalCents = rate.base + mileCents
return {
tier,
miles,
driveMinutes,
baseCents: rate.base,
mileCents,
totalCents,
label: rate.label,
lat: destLat,
lng: destLng,
}
}
/**
* Infer delivery tier from item names in the cart.
* Rules (in priority order):
* organic items + "chiara wall" → organic
* arch or column → classic
* anything else → dropoff
*/
export function inferTier(itemNames: string[]): DeliveryTier {
const names = itemNames.map((n) => n.toLowerCase())
const hasOrganic = names.some((n) => /organic|garland/.test(n))
const hasBackdrop = names.some((n) => /chiara/.test(n))
const hasClassic = names.some((n) => /arch|column/.test(n))
if (hasOrganic && hasBackdrop) return 'organic'
if (hasClassic) return 'classic'
return 'dropoff'
}