115 lines
3.9 KiB
TypeScript
115 lines
3.9 KiB
TypeScript
/**
|
|
* Delivery areas and fees. Eligibility and pricing are deliberately separate:
|
|
* being inside a zone never implies free delivery.
|
|
*/
|
|
import type { DeliveryZone, LocationConfig, Reason } from "./types";
|
|
|
|
/** Uppercase, strip spaces/dashes. "n2h-5z6" → "N2H5Z6"; "02134-1234" → "021341234". */
|
|
export function normalizePostal(postal: string): string {
|
|
return (postal || "").toUpperCase().replace(/[\s-]/g, "");
|
|
}
|
|
|
|
export function postalMatches(postal: string, pattern: string): boolean {
|
|
const p = normalizePostal(postal);
|
|
const raw = pattern.trim().toUpperCase();
|
|
if (!p || !raw) return false;
|
|
// Range on a shared-length prefix: "N2A..N2H" or "10001..10010"
|
|
if (raw.includes("..")) {
|
|
const [lo, hi] = raw.split("..").map(normalizePostal);
|
|
if (!lo || lo.length !== hi.length) return false;
|
|
const head = p.slice(0, lo.length);
|
|
return head >= lo && head <= hi;
|
|
}
|
|
const pat = normalizePostal(raw);
|
|
if (pat.endsWith("*")) return p.startsWith(pat.slice(0, -1));
|
|
return p === pat || (pat.length === 3 && p.startsWith(pat)); // bare Canadian FSA "N2H"
|
|
}
|
|
|
|
export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
|
const R = 6371;
|
|
const toRad = (x: number) => (x * Math.PI) / 180;
|
|
const dLat = toRad(lat2 - lat1);
|
|
const dLng = toRad(lng2 - lng1);
|
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
|
return 2 * R * Math.asin(Math.sqrt(a));
|
|
}
|
|
|
|
export interface CustomerAddress {
|
|
postal?: string;
|
|
lat?: number;
|
|
lng?: number;
|
|
/** Pre-computed driving distance per location id, from the maps adapter. */
|
|
drivingKm?: Record<string, number | null>;
|
|
}
|
|
|
|
export interface ZoneMatch {
|
|
zone: DeliveryZone | null;
|
|
distanceKm: number | null;
|
|
reason?: Reason;
|
|
/** True when a zone needed a maps lookup that wasn't available. */
|
|
unverified?: boolean;
|
|
}
|
|
|
|
/**
|
|
* First matching zone wins (merchant orders zones, typically cheapest/closest first).
|
|
* A zone matches when ANY of its configured criteria match.
|
|
* If a driving-distance zone can't be checked (maps outage), it does NOT match —
|
|
* we never accept unverified addresses.
|
|
*/
|
|
export function matchZone(loc: LocationConfig, addr: CustomerAddress): ZoneMatch {
|
|
const radial =
|
|
loc.lat != null && loc.lng != null && addr.lat != null && addr.lng != null
|
|
? haversineKm(loc.lat, loc.lng, addr.lat, addr.lng)
|
|
: null;
|
|
let unverified = false;
|
|
for (const z of loc.zones) {
|
|
if (z.postalPatterns?.length && addr.postal && z.postalPatterns.some((pt) => postalMatches(addr.postal!, pt)))
|
|
return { zone: z, distanceKm: radial };
|
|
if (z.radiusKm != null && radial != null && radial <= z.radiusKm) return { zone: z, distanceKm: radial };
|
|
if (z.drivingKm != null) {
|
|
const d = addr.drivingKm?.[loc.id];
|
|
if (d == null) unverified = true;
|
|
else if (d <= z.drivingKm) return { zone: z, distanceKm: d };
|
|
}
|
|
}
|
|
return {
|
|
zone: null,
|
|
distanceKm: radial,
|
|
unverified,
|
|
reason: {
|
|
code: "OUTSIDE_DELIVERY_AREA",
|
|
message: unverified
|
|
? "We couldn't verify your address right now. Please try pickup or check again shortly."
|
|
: `${loc.name} doesn't deliver to this address yet`,
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface FeeQuote {
|
|
fee: number;
|
|
baseFee: number;
|
|
waived: boolean;
|
|
surcharges: { label: string; amount: number }[];
|
|
belowMinimum: boolean;
|
|
minOrder: number | null;
|
|
}
|
|
|
|
export function quoteFee(
|
|
zone: DeliveryZone | null,
|
|
subtotal: number,
|
|
extras: { label: string; amount: number }[] = [],
|
|
): FeeQuote {
|
|
const base = zone?.fee ?? 0;
|
|
const waived = zone?.freeOver != null && subtotal >= zone.freeOver;
|
|
const surcharges = extras.filter((e) => e.amount > 0);
|
|
const minOrder = zone?.minOrder ?? null;
|
|
return {
|
|
baseFee: base,
|
|
waived,
|
|
surcharges,
|
|
fee: (waived ? 0 : base) + surcharges.reduce((s, e) => s + e.amount, 0),
|
|
belowMinimum: minOrder != null && subtotal < minOrder,
|
|
minOrder,
|
|
};
|
|
}
|