// ── 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 type DeliveryRatesConfig = Record export const RATES: DeliveryRatesConfig = { 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 = { dropoff: 60, // 1 hr on-site window classic: 150, // 2.5 hrs setup + strike organic: 240, // 4 hrs setup + strike } /** 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 { 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' }