- 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>
155 lines
5.7 KiB
TypeScript
155 lines
5.7 KiB
TypeScript
// ── 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'
|
||
}
|