metatrondelivery/app/routes/app.rates._index.tsx
metatroncubeswdev 6598691372 feat: Phase 5 — multi-location, zones, rates, auto-assignment
- 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 <noreply@anthropic.com>
2026-08-24 03:27:21 -04:00

230 lines
7.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.

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<string, string> = {};
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<typeof loader>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (zones.length === 0) {
return (
<Page>
<TitleBar title="Delivery rates" />
<Card>
<EmptyState
heading="Add a delivery zone first"
action={{ content: "Add a zone", url: "/app/zones" }}
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<Text as="p">Zone-keyed rates need at least one zone to attach to.</Text>
</EmptyState>
</Card>
</Page>
);
}
return (
<Page>
<TitleBar title="Delivery rates" />
<BlockStack gap="400">
<Card padding="0">
{rates.length === 0 ? (
<div style={{ padding: 16 }}>
<Text as="p" tone="subdued">
No rates yet. Without a rate, checkout uses Shopify's own configured shipping rates.
</Text>
</div>
) : (
<IndexTable
itemCount={rates.length}
headings={[
{ title: "Name" },
{ title: "Method" },
{ title: "Keyed by" },
{ title: "Coverage" },
{ title: "Price" },
{ title: "" },
]}
selectable={false}
>
{rates.map((rate, index) => (
<IndexTable.Row id={rate.id} key={rate.id} position={index}>
<IndexTable.Cell>{rate.name}</IndexTable.Cell>
<IndexTable.Cell>{rate.method.replace("_", " ")}</IndexTable.Cell>
<IndexTable.Cell>{rate.keyedBy}</IndexTable.Cell>
<IndexTable.Cell>
{rate.keyedBy === "zone"
? (rate.zone?.name ?? "—")
: `${rate.minDistanceKm ?? 0}${rate.maxDistanceKm ?? "∞"} km`}
</IndexTable.Cell>
<IndexTable.Cell>{formatPriceLabel(rate.priceCents)}</IndexTable.Cell>
<IndexTable.Cell>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={rate.id} />
<Button submit variant="plain" tone="critical">
Remove
</Button>
</Form>
</IndexTable.Cell>
</IndexTable.Row>
))}
</IndexTable>
)}
</Card>
<AddRateForm zones={zones} isSubmitting={isSubmitting} />
</BlockStack>
</Page>
);
}
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 (
<Card>
<Form method="post">
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Add a rate
</Text>
<InlineStack gap="300" wrap>
<TextField label="Name" name="name" autoComplete="off" value={name} onChange={setName} />
<Select label="Method" name="method" options={METHODS.map((m) => ({ label: m.replace("_", " "), value: m }))} />
<Select label="Keyed by" name="keyedBy" options={KEYED_BY_OPTIONS} value={keyedBy} onChange={setKeyedBy} />
{keyedBy === "zone" ? (
<Select
label="Zone"
name="zoneId"
options={zones.map((z) => ({ label: `${z.name} (${z.location.name})`, value: z.id }))}
/>
) : (
<>
<TextField
label="Min distance (km)"
name="minDistanceKm"
type="number"
autoComplete="off"
value={minDistanceKm}
onChange={setMinDistanceKm}
/>
<TextField
label="Max distance (km)"
name="maxDistanceKm"
type="number"
autoComplete="off"
value={maxDistanceKm}
onChange={setMaxDistanceKm}
/>
</>
)}
<TextField
label="Price"
name="price"
type="number"
autoComplete="off"
value={price}
onChange={setPrice}
prefix="$"
/>
</InlineStack>
<div>
<Button submit variant="primary" loading={isSubmitting}>
Add rate
</Button>
</div>
</BlockStack>
</Form>
</Card>
);
}