balloon-shop/src/lib/delivery.ts
chris e7fec9ea72 Merge beachPartyBalloons estore features into balloons-shop
- Multi-category support: CatalogItem gains categories/categoryLabels arrays;
  catalog route applies categoriesOverride; FeaturedProducts filters by array
- Featured sorting: featured items sort first in catalog route
- Admin panel: featured toggle, requiresDelivery with per-item rate overrides,
  multi-category checkboxes, variation visibility, AdminColorFilter modal,
  delivery rates tab (DeliveryRatesEditor)
- Per-item delivery rate overrides: delivery-quote route accepts rateOverride
  and reads from delivery-rates.json via readDeliveryRates()
- disabledColors, hiddenVariationIds applied in catalog and admin routes
- ScrollToTop button added to layout
- GuidedTour gains optional onStart prop; tourInit resets category/search
- Occasion tab deduplication in FeaturedProducts
- New components: ScrollToTop, AdminColorFilter, useLockBodyScroll,
  delivery-rates lib, admin/delivery-rates API route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 16:27:27 -04:00

155 lines
5.7 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 type DeliveryRatesConfig = Record<DeliveryTier, { base: number; perMile: number; label: string }>
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<DeliveryTier, number> = {
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<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'
}