From 65986913721abe7fe1889dba8877331d6540d467 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Mon, 24 Aug 2026 03:27:21 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=205=20=E2=80=94=20multi-location,?= =?UTF-8?q?=20zones,=20rates,=20auto-assignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prisma: Zone (postal-code list or radius), Rate (zone- or distance-band keyed), GeocodeCache (permanent address->lat/lng cache per IMPLEMENTATION_PLAN.md §9), Location.shopifyLocationId (maps to Shopify's own Location resource for inventory checks), Booking.zoneId (needed for per-zone delivery-density counts, not just per-location). - app/lib/geo.ts: pure haversine distance + postal-code matching, unit tested against known city-to-city distances. - app/services/zones.server.ts: geocoding (Google Maps Geocoding API, cached — never re-geocodes the same address twice), zone eligibility, nearest-location auto-assignment ranked by distance, delivery-density threshold checks (a sparse zone doesn't unlock until minOrders bookings have already routed through it), and inventory-based location exclusion via Shopify's InventoryLevel API (locations without a mapped shopifyLocationId are left in rather than false-negative excluded). - app/services/rates.server.ts: pure rate resolution by zone or distance band, cheapest-match-wins when bands overlap. - apps.scheduling.availability.tsx: LOCAL_DELIVERY requests with a postalCode/address now auto-assign to the nearest eligible, density-qualified zone/location instead of the shop's default location; response includes the matched rate. Also fixed a real gap left over from Phase 4: this route never actually read Booking counts into getAvailability's `consumed` map, so capacity always showed as fully available regardless of existing bookings — now it does. - extensions/datetime-widget: LOCAL_DELIVERY now asks for a postal code before showing dates; PICKUP shows a Google Maps pin for the location (both gated on an optional Maps API key — a block setting in the theme editor, since it needs to be public/client-side, not an app secret); confirmation display and cart attributes (dd_zone_id, dd_rate_label) carry the resolved zone/rate through to checkout. - extensions/delivery-customization: now appends the resolved rate to the relabeled delivery option ("Local delivery — Aug 25 ($5.99)") when one's configured — real Cart Transform-based fee *charging* stays deferred to v2 per IMPLEMENTATION_PLAN.md §5.4, this is display-only. - Admin: /app/zones and /app/rates (Polaris CRUD, mirroring Phase 1's patterns), plus shopifyLocationId and auto-geocode-on-save added to the location edit form. Fixed one real bug caught only by `npm run build` (not tsc/vitest, which both passed clean): app.rates._index.tsx's component called formatPriceLabel from rates.server.ts, and Remix correctly refuses to bundle anything imported from a .server.ts path for the client. Moved the pure (no I/O, no Prisma) formatter to app/lib/currency.ts. Verified: lint, typecheck, 86 unit tests (+21 new: geo, zones, rates, delivery-customization's rate-label case with a real WASM fixture run), 16 integration tests against live Postgres (+8 new: geocode caching, postal/radius zone matching, nearest-first ranking, density thresholds), both builds, and a live script exercising the full zone-match -> density-check -> rate-resolve -> availability pipeline together against the Postgres container. Co-Authored-By: Claude Sonnet 5 --- README.md | 15 +- app/lib/currency.ts | 9 + app/lib/geo.ts | 49 ++++ app/routes/app.locations.$id.tsx | 51 +++- app/routes/app.rates._index.tsx | 229 +++++++++++++++++ app/routes/app.tsx | 2 + app/routes/app.zones._index.tsx | 233 ++++++++++++++++++ app/routes/apps.scheduling.availability.tsx | 101 +++++++- app/services/booking.server.ts | 2 + app/services/rates.server.ts | 58 +++++ app/services/zones.server.ts | 224 +++++++++++++++++ .../assets/datetime-widget.css | 18 ++ .../datetime-widget/assets/datetime-widget.js | 2 +- .../blocks/datetime-picker.liquid | 10 + .../datetime-widget/locales/en.default.json | 5 +- .../locales/en.default.schema.json | 4 +- ...art_delivery_options_transform_run.graphql | 3 + .../cart_delivery_options_transform_run.js | 13 +- .../delivery-customization/src/evaluate.js | 6 +- .../tests/fixtures/no-operations.json | 1 + .../tests/fixtures/renames-pickup-option.json | 1 + .../fixtures/renames-with-rate-label.json | 28 +++ .../migration.sql | 60 +++++ .../migration.sql | 5 + prisma/schema.prisma | 83 ++++++- tests/integration/zones.test.ts | 166 +++++++++++++ tests/unit/delivery-customization.test.ts | 10 + tests/unit/geo.test.ts | 83 +++++++ tests/unit/rates.test.ts | 77 ++++++ tests/unit/zones.test.ts | 82 ++++++ widget-src/datetime-widget/datetime-widget.ts | 191 ++++++++++++-- 31 files changed, 1757 insertions(+), 64 deletions(-) create mode 100644 app/lib/currency.ts create mode 100644 app/lib/geo.ts create mode 100644 app/routes/app.rates._index.tsx create mode 100644 app/routes/app.zones._index.tsx create mode 100644 app/services/rates.server.ts create mode 100644 app/services/zones.server.ts create mode 100644 extensions/delivery-customization/tests/fixtures/renames-with-rate-label.json create mode 100644 prisma/migrations/20260824071137_add_zones_rates_geocode/migration.sql create mode 100644 prisma/migrations/20260824071304_add_booking_zone_id/migration.sql create mode 100644 tests/integration/zones.test.ts create mode 100644 tests/unit/geo.test.ts create mode 100644 tests/unit/rates.test.ts create mode 100644 tests/unit/zones.test.ts diff --git a/README.md b/README.md index e2b9488..0dc7403 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,17 @@ public launch or Built-for-Shopify submission** — don't ship without it. ## Status -Phase 0 (scaffold & CI) through Phase 4 (enforcement Functions + -slot-holds) are complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased -build order and acceptance criteria — next up is Phase 5 (multi-location, -zones, rates, auto-assignment). +Phase 0 (scaffold & CI) through Phase 5 (zones, rates, auto-assignment) are +complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased build order and +acceptance criteria — next up is Phase 6 (ops/dispatch dashboard). + +Phase 5's Google Maps / geocoding features (pickup-location map in the +widget, radius-zone eligibility, address auto-geocoding on Save Location) +are only live if `GOOGLE_MAPS_API_KEY` is set — either as an env var for +server-side geocoding, or as the "Google Maps API key" block setting in the +theme editor for the storefront map. Without a key, everything else in +Phase 5 (postal-code zones, distance-band rates, delivery-density +thresholds) still works — those don't need Maps at all. The storefront widget's TypeScript source lives in `widget-src/datetime-widget/`, **not** inside `extensions/datetime-widget/` — a Theme App Extension's diff --git a/app/lib/currency.ts b/app/lib/currency.ts new file mode 100644 index 0000000..56f158f --- /dev/null +++ b/app/lib/currency.ts @@ -0,0 +1,9 @@ +// Pure formatting, no I/O, no server-only dependencies — safe to import +// from both client components and server code (unlike rates.server.ts, +// which pulls in @prisma/client and can't be bundled for the browser). + +export function formatPriceLabel(priceCents: number, currencyCode = "USD"): string { + const amount = (priceCents / 100).toFixed(2); + const symbol = currencyCode === "USD" || currencyCode === "CAD" ? "$" : `${currencyCode} `; + return `${symbol}${amount}`; +} diff --git a/app/lib/geo.ts b/app/lib/geo.ts new file mode 100644 index 0000000..8d6522c --- /dev/null +++ b/app/lib/geo.ts @@ -0,0 +1,49 @@ +// Pure geographic math — no network, no DB (CLAUDE.md: pure functions, +// inject data, no I/O in the math). Geocoding itself (address -> lat/lng) +// is I/O and lives in services/zones.server.ts; this file is just the +// distance/eligibility arithmetic once coordinates are known. + +export interface Coordinates { + lat: number; + lng: number; +} + +const EARTH_RADIUS_KM = 6371; + +function toRadians(degrees: number): number { + return (degrees * Math.PI) / 180; +} + +/** Great-circle (straight-line) distance between two points, in kilometers. */ +export function haversineDistanceKm(a: Coordinates, b: Coordinates): number { + const dLat = toRadians(b.lat - a.lat); + const dLng = toRadians(b.lng - a.lng); + const lat1 = toRadians(a.lat); + const lat2 = toRadians(b.lat); + + const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; + const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); + + return EARTH_RADIUS_KM * c; +} + +export function isWithinRadiusKm(point: Coordinates, center: Coordinates, radiusKm: number): boolean { + return haversineDistanceKm(point, center) <= radiusKm; +} + +/** Loose normalization for postal/ZIP comparison: uppercase, strip spaces. Matches "V6B 1A1" against "v6b1a1". */ +export function normalizePostalCode(code: string): string { + return code.toUpperCase().replace(/\s+/g, ""); +} + +export function isPostalCodeListed(postalCode: string, listed: string[]): boolean { + const normalized = normalizePostalCode(postalCode); + return listed.some((entry) => normalizePostalCode(entry) === normalized); +} + +/** Sorts locations by distance from a point, nearest first. */ +export function sortByDistance(point: Coordinates, items: T[]): T[] { + return [...items].sort( + (a, b) => haversineDistanceKm(point, a.coordinates) - haversineDistanceKm(point, b.coordinates), + ); +} diff --git a/app/routes/app.locations.$id.tsx b/app/routes/app.locations.$id.tsx index f6a0e3e..37f80a2 100644 --- a/app/routes/app.locations.$id.tsx +++ b/app/routes/app.locations.$id.tsx @@ -14,6 +14,7 @@ import { import { TitleBar } from "@shopify/app-bridge-react"; import { authenticate } from "../shopify.server"; import db from "../db.server"; +import { geocodeAddress } from "../services/zones.server"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { session } = await authenticate.admin(request); @@ -43,6 +44,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const address = String(formData.get("address") || "").trim(); const timezone = String(formData.get("timezone") || "").trim(); const active = formData.get("active") === "true"; + const shopifyLocationId = String(formData.get("shopifyLocationId") || "").trim() || null; const errors: Record = {}; if (!name) errors.name = "Name is required"; @@ -51,9 +53,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return { errors }; } + // Auto-geocode on save so radius zones and the widget's pickup map have + // coordinates without a separate manual step — no-ops silently if + // GOOGLE_MAPS_API_KEY isn't configured (see zones.server.ts). + const coordinates = address ? await geocodeAddress(address) : null; + await db.location.updateMany({ where: { id: params.id, shopDomain: session.shop }, - data: { name, address, timezone, active }, + data: { + name, + address, + timezone, + active, + shopifyLocationId, + ...(coordinates ? { lat: coordinates.lat, lng: coordinates.lng } : {}), + }, }); return { errors }; @@ -78,7 +92,16 @@ function LocationForm({ errors, isSubmitting, }: { - location: { id: string; name: string; address: string; timezone: string; active: boolean }; + location: { + id: string; + name: string; + address: string; + timezone: string; + active: boolean; + shopifyLocationId: string | null; + lat: number | null; + lng: number | null; + }; errors?: Record; isSubmitting: boolean; }) { @@ -86,6 +109,7 @@ function LocationForm({ const [address, setAddress] = useState(location.address); const [timezone, setTimezone] = useState(location.timezone); const [active, setActive] = useState(location.active); + const [shopifyLocationId, setShopifyLocationId] = useState(location.shopifyLocationId ?? ""); return ( @@ -101,7 +125,19 @@ function LocationForm({ error={errors?.name} requiredIndicator /> - + + + diff --git a/app/routes/app.rates._index.tsx b/app/routes/app.rates._index.tsx new file mode 100644 index 0000000..6481193 --- /dev/null +++ b/app/routes/app.rates._index.tsx @@ -0,0 +1,229 @@ +import { useState } from "react"; +import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; +import { Form, useLoaderData, useNavigation } from "@remix-run/react"; +import { + Page, + Card, + BlockStack, + InlineStack, + Text, + Button, + Select, + TextField, + IndexTable, + EmptyState, +} from "@shopify/polaris"; +import { TitleBar } from "@shopify/app-bridge-react"; +import type { Method } from "@prisma/client"; +import { authenticate } from "../shopify.server"; +import db from "../db.server"; +import { formatPriceLabel } from "../lib/currency"; + +const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]; +const KEYED_BY_OPTIONS = [ + { label: "Zone", value: "zone" }, + { label: "Distance band", value: "distance" }, +]; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { session } = await authenticate.admin(request); + + const [rates, zones] = await Promise.all([ + db.rate.findMany({ where: { shopDomain: session.shop }, include: { zone: true }, orderBy: { createdAt: "asc" } }), + db.zone.findMany({ where: { shopDomain: session.shop }, include: { location: true }, orderBy: { name: "asc" } }), + ]); + + return { rates, zones }; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { session } = await authenticate.admin(request); + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "delete") { + const id = String(formData.get("id") || ""); + await db.rate.deleteMany({ where: { id, shopDomain: session.shop } }); + return data({ ok: true }); + } + + const method = formData.get("method") as Method; + const name = String(formData.get("name") || "").trim(); + const keyedBy = String(formData.get("keyedBy") || "zone"); + const zoneId = String(formData.get("zoneId") || "") || null; + const priceRaw = String(formData.get("price") || ""); + const minDistanceRaw = String(formData.get("minDistanceKm") || ""); + const maxDistanceRaw = String(formData.get("maxDistanceKm") || ""); + + const errors: Record = {}; + if (!name) errors.name = "Name is required"; + if (!priceRaw || Number.isNaN(Number(priceRaw))) errors.price = "Price is required"; + if (keyedBy === "zone" && !zoneId) errors.zoneId = "Choose a zone"; + if (Object.keys(errors).length > 0) { + return data({ errors }); + } + + await db.rate.create({ + data: { + shopDomain: session.shop, + method, + name, + keyedBy, + zoneId: keyedBy === "zone" ? zoneId : null, + priceCents: Math.round(Number(priceRaw) * 100), + minDistanceKm: keyedBy === "distance" && minDistanceRaw ? Number(minDistanceRaw) : null, + maxDistanceKm: keyedBy === "distance" && maxDistanceRaw ? Number(maxDistanceRaw) : null, + }, + }); + + return data({ ok: true }); +}; + +export default function RatesIndex() { + const { rates, zones } = useLoaderData(); + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + + if (zones.length === 0) { + return ( + + + + + Zone-keyed rates need at least one zone to attach to. + + + + ); + } + + return ( + + + + + {rates.length === 0 ? ( +
+ + No rates yet. Without a rate, checkout uses Shopify's own configured shipping rates. + +
+ ) : ( + + {rates.map((rate, index) => ( + + {rate.name} + {rate.method.replace("_", " ")} + {rate.keyedBy} + + {rate.keyedBy === "zone" + ? (rate.zone?.name ?? "—") + : `${rate.minDistanceKm ?? 0}–${rate.maxDistanceKm ?? "∞"} km`} + + {formatPriceLabel(rate.priceCents)} + +
+ + + +
+
+
+ ))} +
+ )} +
+ + +
+
+ ); +} + +function AddRateForm({ + zones, + isSubmitting, +}: { + zones: Array<{ id: string; name: string; location: { name: string } }>; + isSubmitting: boolean; +}) { + const [keyedBy, setKeyedBy] = useState("zone"); + const [name, setName] = useState(""); + const [price, setPrice] = useState(""); + const [minDistanceKm, setMinDistanceKm] = useState(""); + const [maxDistanceKm, setMaxDistanceKm] = useState(""); + + return ( + +
+ + + Add a rate + + + + + {keyedBy === "zone" ? ( + ({ label: l.name, value: l.id }))} + value={activeLocationId ?? undefined} + onChange={(value) => setSearchParams({ locationId: value })} + /> + + + + {zones.length === 0 ? ( +
+ + No delivery zones yet for this location. Without a zone, Local Delivery is offered everywhere. + +
+ ) : ( + + {zones.map((zone, index) => ( + + {zone.name} + {zone.type === "postal" ? "Postal/ZIP" : "Radius"} + + {zone.type === "postal" ? zone.postalCodes.join(", ") || "—" : `${zone.radiusKm ?? "—"} km`} + + {zone.minOrders ?? "—"} + + + + + + + + + ))} + + )} +
+ + +
+ + ); +} + +function AddZoneForm({ locationId, isSubmitting }: { locationId: string; isSubmitting: boolean }) { + const [type, setType] = useState("postal"); + const [name, setName] = useState(""); + const [postalCodes, setPostalCodes] = useState(""); + const [radiusKm, setRadiusKm] = useState(""); + const [minOrders, setMinOrders] = useState(""); + + return ( + +
+ + + + Add a delivery zone + + + +