Compare commits

...

3 Commits

Author SHA1 Message Date
metatroncubeswdev
5b2207a397 feat: Phase 7 — POS + Checkout UI extensions
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
Also fixes the [events] gate that was blocking ALL extension generation
(discovered while starting this phase).

- shopify.app.toml: this org appears enrolled in Shopify's "Next
  Generation Events" developer preview, which the CLI now treats as a
  REQUIRED top-level [events] section even though nothing in this app
  actually uses it (real webhook handling is entirely classic [webhooks],
  unaffected). Iteratively discovered the required shape from the CLI's
  own field-by-field validation errors, then found the real docs (Events
  is optional/developer-preview, api_version pinned to "unstable") to
  confirm rather than keep guessing. Added a functionally-inert
  [[events.subscription]] placeholder + a stub handler
  (webhooks.events.placeholder.tsx) solely to satisfy the gate.

- app/services/availability-request.server.ts +
  app/services/hold-request.server.ts: extracted the resolution logic that
  used to live directly in apps.scheduling.availability.tsx/hold.tsx into
  shared functions. This is what actually makes "same capacity pool feeds
  every surface" (CLAUDE.md) true by construction rather than by
  convention — the storefront, POS, and checkout routes now call the exact
  same code, not three copies that could quietly drift apart.

- extensions/pos-datetime (generated via `shopify app generate extension
  --template=pos_smart_grid` — pos_action's flavor requirement contradicted
  the CLI's own global --flavor validator, so smart_grid was used instead):
  a home-screen tile opening a modal where staff pick method -> date -> time
  against the same availability/hold endpoints (pos.scheduling.*.tsx,
  session-token authenticated), writing the same dd_* cart properties via
  CartApi.addCartProperties — booking.server.ts needed zero changes to
  handle POS-originated orders. Several API-shape guesses (toast isError
  option, ChoiceList's `value`/`label` props, a nonexistent
  action.dismissModal(), shopify.cart.cart.current) were wrong and caught
  by typechecking directly against @shopify/ui-extensions' own bundled
  .d.ts files (`npm run typecheck:pos`, now also in CI) — none of this was
  verified against a live POS session, which isn't possible in this
  environment.

- extensions/checkout-datetime (generated via `--template=checkout_ui`):
  the Plus-only native picker in checkout itself
  (purchase.checkout.block.render) plus a Thank You confirmation block
  (purchase.thank-you.block.render). Went looking for an order-status
  target too ("all plans show confirmed slot on thank-you/order-status" is
  the Phase 7 accept criterion) and confirmed via the installed package's
  own type definitions that purchase.order-status.block.render does not
  exist in this API version — checkout UI extensions' thank-you/order-status
  surfaces are Plus-only regardless. The actual "all plans" mechanism is
  extensions/datetime-widget/blocks/order-confirmation.liquid — a new Theme
  App Extension block reading order.note_attributes, which works on every
  plan since it's plain Liquid, not checkout extensibility.

Both new UI extensions share one real unverified assumption, called out in
code comments: process.env.APP_URL is expected to be substituted at build
time by the Shopify CLI to the app's backend origin, since these run in a
different origin than the app and need an absolute URL, unlike the
storefront widget's relative /apps/scheduling/* path. Needs confirming
against a live dev session.

Verified: lint, typecheck (root + both new extensions'
`npm run typecheck:pos`/`typecheck:checkout`, all now in CI), 102 unit +
18 integration tests (unchanged — this phase didn't touch pure business
logic, only added thin auth wrappers around already-tested services), both
admin/widget builds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 09:11:34 -04:00
metatroncubeswdev
c5ec8f368c feat: Phase 6 — ops/dispatch dashboard
- Prisma: Booking.totalPriceCents (parsed from the order webhook's
  total_price string), populated in booking.server.ts so "revenue by
  method" is real data, not a placeholder.
- app/services/dashboard.server.ts: pure aggregation over injected booking
  data (CLAUDE.md — no DB calls in the math) — bucketByLocalDate (each
  booking grouped under its own location's local calendar day, not a
  shared UTC day), revenueByMethod (confirmed/fulfilled only),
  utilizationByDateLocation (booked vs. summed SlotTemplate capacity for
  that weekday, capped at 100%), upcomingFulfillments, and a CSV
  writer/formatter with proper quote-escaping.
- /app/dashboard: filterable (date range, location, method, status) view
  with revenue-by-method cards, a capacity-utilization list (color-coded
  by load), an upcoming-fulfillments table with one-click
  confirmed->fulfilled/no_show status transitions, and a full by-day
  booking list.
- /app/dashboard/export: a resource route (loader only, no component)
  streaming the same filtered bookings as a downloadable CSV — kept
  separate from the dashboard route specifically so it can import
  dashboard.server.ts freely without the client-bundling constraint the
  main route has to respect (see below).

Fixed the same class of server/client bundling bug from the Phase 5 commit
before it could ship, this time by construction: dashboard.server.ts's
aggregation functions are called only inside app.dashboard.tsx's `loader`,
never referenced by the default-exported component (which only reads
useLoaderData() output) — verified this holds by actually running
`npm run build`, not just tsc/vitest, which both stay silent about this
class of error. Also hit (and fixed) the same "loader Dates arrive as
strings on the client" issue from Phase 1: swapped DateTime.fromJSDate for
DateTime.fromISO in the two places the component formats a booking's
slotStart.

Verified: lint, typecheck, 102 unit tests (+16 new for dashboard.server.ts,
+2 new for totalPriceCents parsing), 18 integration tests, both builds, and
a live script exercising the full aggregation pipeline (revenue exclusion
of cancelled bookings, utilization math, CSV output) against the Postgres
container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 03:33:21 -04:00
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
65 changed files with 3750 additions and 199 deletions

View File

@ -6,3 +6,4 @@ shopify-app-remix
.shopify
extensions/*/assets/*.js
extensions/*/dist
extensions/*/shopify.d.ts

View File

@ -46,6 +46,10 @@ jobs:
run: npm run lint
- name: Type check
run: npm run typecheck
- name: Type check POS extension
run: npm run typecheck:pos
- name: Type check Checkout extension
run: npm run typecheck:checkout
- name: Unit tests
run: npm test -- --run
- name: Function tests (real WASM build + function-runner against fixtures)

View File

@ -53,10 +53,42 @@ 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 7 (POS + Checkout UI extensions) are
complete — that's the full v1 launch scope per §8 of
`PRODUCT_STRATEGY.md`/§6 of `IMPLEMENTATION_PLAN.md`. Phase 8 (billing +
Built-for-Shopify hardening) is next; Phases 9-10 (v1.x fast-follow, v2) are
explicitly separate post-launch milestones in the plan, not part of v1.
**Every scheduling surface calls the same two service functions**
(`app/services/availability-request.server.ts`,
`app/services/hold-request.server.ts`) — the storefront widget (app-proxy
auth), POS (`extensions/pos-datetime`, session-token auth), and Plus
checkout (`extensions/checkout-datetime`, session-token auth) each have
their own thin route wrapper but share the exact same resolution logic and
Redis-backed capacity pool, so a slot booked from any one of them is
unavailable on the other two. `extensions/checkout-datetime`'s Thank You
block and `extensions/datetime-widget`'s new `order-confirmation.liquid`
block both show the confirmed slot after checkout — the Liquid block is
what actually satisfies "all plans," since Checkout UI Extensions'
thank-you/order-status targets are Plus-only; there's no
`purchase.order-status.block.render` target in this API version (verified
against `@shopify/ui-extensions`' own type definitions — an early guess
based on the target name pattern was wrong).
**Unverified without a live device/store to test against** (noted in-code
where relevant): `pos-datetime` and `checkout-datetime` both assume
`process.env.APP_URL` is substituted at build time to the app's backend
origin, and neither extension's actual runtime behavior has been exercised
outside of typechecking against `@shopify/ui-extensions`' bundled types
(which did catch several wrong API-shape guesses during development).
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

9
app/lib/currency.ts Normal file
View File

@ -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}`;
}

49
app/lib/geo.ts Normal file
View File

@ -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<T extends { coordinates: Coordinates }>(point: Coordinates, items: T[]): T[] {
return [...items].sort(
(a, b) => haversineDistanceKm(point, a.coordinates) - haversineDistanceKm(point, b.coordinates),
);
}

View File

@ -0,0 +1,58 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { DateTime } from "luxon";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { bookingsToCsv, type BookingSummary } from "../services/dashboard.server";
// Resource route (no default export/component) — safe to hit directly for
// a file download, and safe to import dashboard.server.ts freely since
// there's no client component here for Remix to worry about bundling it into.
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const url = new URL(request.url);
const locationIdFilter = url.searchParams.get("locationId") || undefined;
const methodFilter = (url.searchParams.get("method") as Method | null) || undefined;
const statusFilter = url.searchParams.get("status") || undefined;
const startDate = url.searchParams.get("startDate") || DateTime.now().toISODate()!;
const endDate = url.searchParams.get("endDate") || DateTime.now().plus({ days: 7 }).toISODate()!;
const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate();
const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate();
const bookings = await db.booking.findMany({
where: {
shopDomain: session.shop,
locationId: locationIdFilter,
method: methodFilter,
status: statusFilter,
slotStart: { gte: rangeStart, lt: rangeEnd },
},
include: { location: true },
orderBy: { slotStart: "asc" },
});
const summaries: BookingSummary[] = bookings.map((b) => ({
id: b.id,
orderName: b.orderName,
locationId: b.locationId,
locationName: b.location.name,
method: b.method,
slotStart: b.slotStart,
slotEnd: b.slotEnd,
timezone: b.location.timezone,
status: b.status,
totalPriceCents: b.totalPriceCents,
customerEmail: b.customerEmail,
}));
const csv = bookingsToCsv(summaries);
return new Response(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="dispatch-${startDate}-to-${endDate}.csv"`,
},
});
};

View File

@ -0,0 +1,341 @@
import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData, useNavigation, useSearchParams } from "@remix-run/react";
import {
Page,
Card,
BlockStack,
InlineStack,
Text,
Button,
Select,
TextField,
IndexTable,
Badge,
Box,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { DateTime } from "luxon";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import {
bucketByLocalDate,
revenueByMethod,
upcomingFulfillments,
utilizationByDateLocation,
type BookingSummary,
} from "../services/dashboard.server";
import { formatPriceLabel } from "../lib/currency";
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
const STATUSES = ["confirmed", "fulfilled", "no_show", "cancelled"];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const url = new URL(request.url);
const locationIdFilter = url.searchParams.get("locationId") || undefined;
const methodFilter = (url.searchParams.get("method") as Method | null) || undefined;
const statusFilter = url.searchParams.get("status") || undefined;
const startDate = url.searchParams.get("startDate") || DateTime.now().toISODate()!;
const endDate = url.searchParams.get("endDate") || DateTime.now().plus({ days: 7 }).toISODate()!;
const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate();
const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate();
const [locations, bookings, slotTemplates] = await Promise.all([
db.location.findMany({ where: { shopDomain: session.shop }, orderBy: { createdAt: "asc" } }),
db.booking.findMany({
where: {
shopDomain: session.shop,
locationId: locationIdFilter,
method: methodFilter,
status: statusFilter,
slotStart: { gte: rangeStart, lt: rangeEnd },
},
include: { location: true },
orderBy: { slotStart: "asc" },
}),
db.slotTemplate.findMany({
where: { shopDomain: session.shop, locationId: locationIdFilter, method: methodFilter },
}),
]);
const summaries: BookingSummary[] = bookings.map((b) => ({
id: b.id,
orderName: b.orderName,
locationId: b.locationId,
locationName: b.location.name,
method: b.method,
slotStart: b.slotStart,
slotEnd: b.slotEnd,
timezone: b.location.timezone,
status: b.status,
totalPriceCents: b.totalPriceCents,
customerEmail: b.customerEmail,
}));
const byDate = bucketByLocalDate(summaries);
const revenue = revenueByMethod(summaries);
const utilization = utilizationByDateLocation(
summaries,
slotTemplates.map((t) => ({ locationId: t.locationId, method: t.method, weekday: t.weekday, capacity: t.capacity })),
);
const upcoming = upcomingFulfillments(summaries);
return {
locations,
filters: { locationId: locationIdFilter ?? "", method: methodFilter ?? "", status: statusFilter ?? "", startDate, endDate },
byDate,
revenue,
utilization,
upcoming,
totalBookings: summaries.length,
};
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const bookingId = String(formData.get("bookingId") || "");
const status = String(formData.get("status") || "");
if (!bookingId || !STATUSES.includes(status)) {
return data({ error: "Invalid request" }, { status: 400 });
}
await db.booking.updateMany({ where: { id: bookingId, shopDomain: session.shop }, data: { status } });
return data({ ok: true });
};
export default function Dashboard() {
const { locations, filters, byDate, revenue, utilization, upcoming, totalBookings } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const setFilter = (key: string, value: string) => {
const next = new URLSearchParams(searchParams);
if (value) next.set(key, value);
else next.delete(key);
setSearchParams(next);
};
const dates = Object.keys(byDate).sort();
const exportQuery = new URLSearchParams(searchParams).toString();
return (
<Page>
<TitleBar title="Dispatch dashboard" />
<BlockStack gap="400">
<Card>
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Filters
</Text>
<InlineStack gap="300" wrap>
<TextField
label="Start date"
labelHidden={false}
type="date"
autoComplete="off"
value={filters.startDate}
onChange={(v) => setFilter("startDate", v)}
/>
<TextField
label="End date"
type="date"
autoComplete="off"
value={filters.endDate}
onChange={(v) => setFilter("endDate", v)}
/>
<Select
label="Location"
options={[{ label: "All locations", value: "" }, ...locations.map((l) => ({ label: l.name, value: l.id }))]}
value={filters.locationId}
onChange={(v) => setFilter("locationId", v)}
/>
<Select
label="Method"
options={[{ label: "All methods", value: "" }, ...METHODS.map((m) => ({ label: m.replace("_", " "), value: m }))]}
value={filters.method}
onChange={(v) => setFilter("method", v)}
/>
<Select
label="Status"
options={[{ label: "All statuses", value: "" }, ...STATUSES.map((s) => ({ label: s, value: s }))]}
value={filters.status}
onChange={(v) => setFilter("status", v)}
/>
</InlineStack>
<InlineStack gap="200">
<Button url={`/app/dashboard/export?${exportQuery}`} target="_blank">
Export CSV
</Button>
<Text as="span" tone="subdued">
{totalBookings} booking{totalBookings === 1 ? "" : "s"} in range
</Text>
</InlineStack>
</BlockStack>
</Card>
<InlineStack gap="400" wrap>
{METHODS.map((m) => (
<Card key={m}>
<BlockStack gap="100">
<Text as="span" tone="subdued">
{m.replace("_", " ")} revenue
</Text>
<Text as="span" variant="headingLg">
{formatPriceLabel(revenue[m])}
</Text>
</BlockStack>
</Card>
))}
</InlineStack>
<Card>
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Capacity utilization
</Text>
{utilization.length === 0 ? (
<Text as="p" tone="subdued">
No bookings in this range yet.
</Text>
) : (
<BlockStack gap="150">
{utilization.map((row) => (
<InlineStack key={`${row.date}-${row.locationId}-${row.method}`} align="space-between" blockAlign="center">
<Text as="span">
{row.date} · {row.locationName} · {row.method.replace("_", " ")}
</Text>
<InlineStack gap="200" blockAlign="center">
<Box
minWidth="120px"
background={row.utilizationPct >= 90 ? "bg-fill-critical" : row.utilizationPct >= 60 ? "bg-fill-caution" : "bg-fill-success"}
padding="100"
borderRadius="100"
>
<Text as="span" alignment="center">
{row.booked}/{row.capacity} ({row.utilizationPct}%)
</Text>
</Box>
</InlineStack>
</InlineStack>
))}
</BlockStack>
)}
</BlockStack>
</Card>
<Card padding="0">
<Box padding="400">
<Text as="h3" variant="headingSm">
Upcoming fulfillments
</Text>
</Box>
{upcoming.length === 0 ? (
<Box padding="400">
<Text as="p" tone="subdued">
Nothing confirmed and upcoming in this range.
</Text>
</Box>
) : (
<IndexTable
itemCount={upcoming.length}
headings={[
{ title: "Order" },
{ title: "When" },
{ title: "Method" },
{ title: "Location" },
{ title: "Customer" },
{ title: "" },
]}
selectable={false}
>
{upcoming.map((b, index) => {
const local = DateTime.fromISO(b.slotStart, { zone: "utc" }).setZone(b.timezone);
return (
<IndexTable.Row id={b.id} key={b.id} position={index}>
<IndexTable.Cell>{b.orderName ?? "—"}</IndexTable.Cell>
<IndexTable.Cell>{local.toFormat("ccc LLL d, h:mm a")}</IndexTable.Cell>
<IndexTable.Cell>{b.method.replace("_", " ")}</IndexTable.Cell>
<IndexTable.Cell>{b.locationName}</IndexTable.Cell>
<IndexTable.Cell>{b.customerEmail ?? "—"}</IndexTable.Cell>
<IndexTable.Cell>
<InlineStack gap="150">
<Form method="post">
<input type="hidden" name="bookingId" value={b.id} />
<input type="hidden" name="status" value="fulfilled" />
<Button submit size="slim" loading={isSubmitting}>
Fulfilled
</Button>
</Form>
<Form method="post">
<input type="hidden" name="bookingId" value={b.id} />
<input type="hidden" name="status" value="no_show" />
<Button submit size="slim" tone="critical" loading={isSubmitting}>
No-show
</Button>
</Form>
</InlineStack>
</IndexTable.Cell>
</IndexTable.Row>
);
})}
</IndexTable>
)}
</Card>
<Card padding="0">
<Box padding="400">
<Text as="h3" variant="headingSm">
All bookings by day
</Text>
</Box>
{dates.length === 0 ? (
<Box padding="400">
<Text as="p" tone="subdued">
No bookings in this range.
</Text>
</Box>
) : (
dates.map((date) => (
<Box key={date} padding="400" borderBlockStartWidth="025" borderColor="border">
<BlockStack gap="200">
<Text as="h4" variant="headingXs">
{date}
</Text>
{byDate[date].map((b) => {
const local = DateTime.fromISO(b.slotStart, { zone: "utc" }).setZone(b.timezone);
return (
<InlineStack key={b.id} align="space-between">
<Text as="span">
{local.toFormat("h:mm a")} · {b.orderName ?? "—"} · {b.method.replace("_", " ")} · {b.locationName}
</Text>
<Badge
tone={
b.status === "confirmed"
? "info"
: b.status === "fulfilled"
? "success"
: b.status === "no_show"
? "critical"
: "read-only"
}
>
{b.status}
</Badge>
</InlineStack>
);
})}
</BlockStack>
</Box>
))
)}
</Card>
</BlockStack>
</Page>
);
}

View File

@ -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<string, string> = {};
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<string, string>;
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 (
<BlockStack gap="400">
@ -101,7 +125,19 @@ function LocationForm({
error={errors?.name}
requiredIndicator
/>
<TextField label="Address" name="address" autoComplete="off" multiline={2} value={address} onChange={setAddress} />
<TextField
label="Address"
name="address"
autoComplete="off"
multiline={2}
value={address}
onChange={setAddress}
helpText={
location.lat != null
? `Geocoded: ${location.lat.toFixed(4)}, ${location.lng?.toFixed(4)}`
: "Saved without coordinates — set GOOGLE_MAPS_API_KEY to auto-geocode on save."
}
/>
<TextField
label="Timezone (IANA)"
name="timezone"
@ -111,6 +147,14 @@ function LocationForm({
error={errors?.timezone}
requiredIndicator
/>
<TextField
label="Shopify Location ID (advanced)"
name="shopifyLocationId"
autoComplete="off"
value={shopifyLocationId}
onChange={setShopifyLocationId}
helpText="gid://shopify/Location/… — only needed for inventory-based location exclusion."
/>
<Checkbox label="Active" name="active" value="true" checked={active} onChange={setActive} />
<InlineStack gap="200">
<Button submit variant="primary" loading={isSubmitting}>
@ -118,6 +162,7 @@ function LocationForm({
</Button>
<Button url={`/app/slots?locationId=${location.id}`}>Manage weekly slots</Button>
<Button url={`/app/blackouts?locationId=${location.id}`}>Manage blackout dates</Button>
<Button url={`/app/zones?locationId=${location.id}`}>Manage delivery zones</Button>
</InlineStack>
</FormLayout>
</Form>

View File

@ -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<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>
);
}

View File

@ -27,6 +27,9 @@ export default function App() {
<Link to="/app/locations">Locations</Link>
<Link to="/app/slots">Weekly slots</Link>
<Link to="/app/blackouts">Blackout dates</Link>
<Link to="/app/zones">Delivery zones</Link>
<Link to="/app/rates">Delivery rates</Link>
<Link to="/app/dashboard">Dispatch dashboard</Link>
</NavMenu>
<Outlet />
</AppProvider>

View File

@ -0,0 +1,233 @@
import { useState } from "react";
import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData, useNavigation, useSearchParams } 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 { authenticate } from "../shopify.server";
import db from "../db.server";
const ZONE_TYPES = [
{ label: "Postal / ZIP codes", value: "postal" },
{ label: "Radius (straight-line distance)", value: "radius" },
];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const url = new URL(request.url);
const locationId = url.searchParams.get("locationId");
const locations = await db.location.findMany({
where: { shopDomain: session.shop },
orderBy: { createdAt: "asc" },
});
const activeLocationId = locationId || locations[0]?.id || null;
const zones = activeLocationId
? await db.zone.findMany({
where: { shopDomain: session.shop, locationId: activeLocationId },
orderBy: { createdAt: "asc" },
})
: [];
return { locations, activeLocationId, 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.zone.deleteMany({ where: { id, shopDomain: session.shop } });
return data({ ok: true });
}
const locationId = String(formData.get("locationId") || "");
const name = String(formData.get("name") || "").trim();
const type = String(formData.get("type") || "postal");
const postalCodesRaw = String(formData.get("postalCodes") || "");
const radiusKmRaw = String(formData.get("radiusKm") || "");
const minOrdersRaw = String(formData.get("minOrders") || "");
const errors: Record<string, string> = {};
if (!locationId) errors.locationId = "Choose a location";
if (!name) errors.name = "Name is required";
if (type === "radius" && !radiusKmRaw) errors.radiusKm = "Radius is required for a radius zone";
if (Object.keys(errors).length > 0) {
return data({ errors });
}
const postalCodes = postalCodesRaw
.split(",")
.map((code) => code.trim())
.filter(Boolean);
await db.zone.create({
data: {
shopDomain: session.shop,
locationId,
name,
type,
postalCodes: type === "postal" ? postalCodes : [],
radiusKm: type === "radius" && radiusKmRaw ? Number(radiusKmRaw) : null,
minOrders: minOrdersRaw ? Number(minOrdersRaw) : null,
},
});
return data({ ok: true });
};
export default function ZonesIndex() {
const { locations, activeLocationId, zones } = useLoaderData<typeof loader>();
const [, setSearchParams] = useSearchParams();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (locations.length === 0) {
return (
<Page>
<TitleBar title="Delivery zones" />
<Card>
<EmptyState
heading="Add a location first"
action={{ content: "Add location", url: "/app/locations/new" }}
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<Text as="p">Delivery zones determine which addresses a location's Local Delivery covers.</Text>
</EmptyState>
</Card>
</Page>
);
}
return (
<Page>
<TitleBar title="Delivery zones" />
<BlockStack gap="400">
<Card>
<Select
label="Location"
options={locations.map((l) => ({ label: l.name, value: l.id }))}
value={activeLocationId ?? undefined}
onChange={(value) => setSearchParams({ locationId: value })}
/>
</Card>
<Card padding="0">
{zones.length === 0 ? (
<div style={{ padding: 16 }}>
<Text as="p" tone="subdued">
No delivery zones yet for this location. Without a zone, Local Delivery is offered everywhere.
</Text>
</div>
) : (
<IndexTable
itemCount={zones.length}
headings={[
{ title: "Name" },
{ title: "Type" },
{ title: "Coverage" },
{ title: "Min. orders" },
{ title: "" },
]}
selectable={false}
>
{zones.map((zone, index) => (
<IndexTable.Row id={zone.id} key={zone.id} position={index}>
<IndexTable.Cell>{zone.name}</IndexTable.Cell>
<IndexTable.Cell>{zone.type === "postal" ? "Postal/ZIP" : "Radius"}</IndexTable.Cell>
<IndexTable.Cell>
{zone.type === "postal" ? zone.postalCodes.join(", ") || "—" : `${zone.radiusKm ?? "—"} km`}
</IndexTable.Cell>
<IndexTable.Cell>{zone.minOrders ?? "—"}</IndexTable.Cell>
<IndexTable.Cell>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={zone.id} />
<Button submit variant="plain" tone="critical">
Remove
</Button>
</Form>
</IndexTable.Cell>
</IndexTable.Row>
))}
</IndexTable>
)}
</Card>
<AddZoneForm key={activeLocationId} locationId={activeLocationId ?? ""} isSubmitting={isSubmitting} />
</BlockStack>
</Page>
);
}
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 (
<Card>
<Form method="post">
<input type="hidden" name="locationId" value={locationId} />
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Add a delivery zone
</Text>
<InlineStack gap="300" wrap>
<TextField label="Name" name="name" autoComplete="off" value={name} onChange={setName} />
<Select label="Type" name="type" options={ZONE_TYPES} value={type} onChange={setType} />
{type === "postal" ? (
<TextField
label="Postal/ZIP codes (comma-separated)"
name="postalCodes"
autoComplete="off"
value={postalCodes}
onChange={setPostalCodes}
helpText="e.g. M5V 3A8, M4B 1B3"
/>
) : (
<TextField
label="Radius (km)"
name="radiusKm"
type="number"
autoComplete="off"
value={radiusKm}
onChange={setRadiusKm}
/>
)}
<TextField
label="Min. orders before this zone unlocks (optional)"
name="minOrders"
type="number"
autoComplete="off"
value={minOrders}
onChange={setMinOrders}
helpText="Delivery-density threshold — leave blank for no minimum."
/>
</InlineStack>
<div>
<Button submit variant="primary" loading={isSubmitting}>
Add zone
</Button>
</div>
</BlockStack>
</Form>
</Card>
);
}

View File

@ -1,28 +1,18 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { DateTime } from "luxon";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { getAvailability } from "../services/scheduling.server";
import { resolveAvailabilityRequest } from "../services/availability-request.server";
// Public endpoint, reachable only through Shopify's App Proxy (signature
// verified by authenticate.public.appProxy) — this is what the storefront
// Theme App Extension calls. Requests to https://{shop}/apps/scheduling/*
// forward here because shopify.app.toml's [app_proxy].url already includes
// the /apps/scheduling prefix, so this file's path (apps.scheduling.*)
// mirrors the shop-facing URL exactly.
//
// No capacity consumption is wired up yet (Booking/SlotHold don't exist
// until Phase 4), so every slot's `consumed` is implicitly 0 here — that's
// expected for Phase 3, not a bug to fix in this file.
// mirrors the shop-facing URL exactly. The actual resolution logic lives in
// services/availability-request.server.ts, shared with the POS route
// (pos.scheduling.availability.tsx) — same pool, same code, different auth.
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
const MAX_DAYS = 60;
const DEFAULT_DAYS = 14;
function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.public.appProxy(request);
@ -32,82 +22,21 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const methodParam = url.searchParams.get("method");
const locationIdParam = url.searchParams.get("locationId");
const daysParam = Number(url.searchParams.get("days") ?? DEFAULT_DAYS);
const days = Number.isFinite(daysParam) && daysParam > 0 ? Math.min(daysParam, MAX_DAYS) : DEFAULT_DAYS;
if (!methodParam || !VALID_METHODS.has(methodParam as Method)) {
return Response.json({ error: "Invalid or missing method" }, { status: 400 });
}
const method = methodParam as Method;
const location = locationIdParam
? await db.location.findFirst({
where: { id: locationIdParam, shopDomain: session.shop, active: true },
})
: await db.location.findFirst({
where: { shopDomain: session.shop, active: true },
orderBy: { createdAt: "asc" },
const result = await resolveAvailabilityRequest(session.shop, {
method: methodParam as Method,
locationId: url.searchParams.get("locationId") || undefined,
postalCode: url.searchParams.get("postalCode") || undefined,
address: url.searchParams.get("address") || undefined,
days: Number(url.searchParams.get("days")) || undefined,
});
if (!location) {
return Response.json({ error: "No active location configured" }, { status: 404 });
if (!result.locationId) {
return Response.json(result, { status: result.error === "No active location configured" ? 404 : 200 });
}
const now = DateTime.now().setZone(location.timezone);
const startDate = now.toISODate()!;
const endDate = now.plus({ days }).toISODate()!;
const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate();
const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).toJSDate();
const [slotTemplates, overrides, blackouts] = await Promise.all([
db.slotTemplate.findMany({
where: { shopDomain: session.shop, locationId: location.id, method },
}),
db.slotOverride.findMany({
where: {
shopDomain: session.shop,
locationId: location.id,
method,
date: { gte: rangeStart, lte: rangeEnd },
},
}),
db.blackoutDate.findMany({
where: {
shopDomain: session.shop,
date: { gte: rangeStart, lte: rangeEnd },
AND: [{ OR: [{ locationId: location.id }, { locationId: null }] }, { OR: [{ method }, { method: null }] }],
},
}),
]);
const availability = getAvailability({
timezone: location.timezone,
dateRange: { startDate, endDate },
slotTemplates: slotTemplates.map((t) => ({
weekday: t.weekday,
startMin: t.startMin,
endMin: t.endMin,
capacity: t.capacity,
cutoffMin: t.cutoffMin,
leadTimeMin: t.leadTimeMin,
})),
overrides: overrides.map((o) => ({
date: toIsoDate(o.date),
closed: o.closed,
startMin: o.startMin,
endMin: o.endMin,
capacity: o.capacity,
})),
blackoutDates: blackouts.map((b) => ({ date: toIsoDate(b.date) })),
now,
});
return Response.json({
locationId: location.id,
locationName: location.name,
timezone: location.timezone,
method,
dates: availability,
});
return Response.json(result);
};

View File

@ -1,10 +1,7 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { slotDateTime, weekdayOf } from "../lib/time";
import { remainingCapacity } from "../services/capacity.server";
import { tryCreateHold, releaseHold, countActiveHolds } from "../services/holds.server";
import { resolveHoldRequest } from "../services/hold-request.server";
// Public app-proxy endpoint (see apps.scheduling.availability.tsx for the
// path-mirroring rationale). Called by the widget the moment a shopper
@ -12,7 +9,8 @@ import { tryCreateHold, releaseHold, countActiveHolds } from "../services/holds.
// actually reserves capacity (PRODUCT_STRATEGY.md §3.1, §4.1: "the
// last-slot race condition"). The cart attribute write alone would just be
// two shoppers racing to write the same free-text field; nothing would
// stop both orders from completing.
// stop both orders from completing. Actual resolution lives in
// services/hold-request.server.ts, shared with the POS route.
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
@ -36,63 +34,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return Response.json({ error: "Missing or invalid parameters" }, { status: 400 });
}
const location = await db.location.findFirst({
where: { id: locationId, shopDomain: session.shop, active: true },
});
if (!location) {
return Response.json({ error: "Location not found" }, { status: 404 });
}
const slotStart = slotDateTime(date, startMin, location.timezone);
const slot = {
shopDomain: session.shop,
locationId: location.id,
const result = await resolveHoldRequest(session.shop, {
intent: intent === "release" ? "release" : "create",
locationId,
method: method as Method,
slotStartIso: slotStart.toUTC().toISO()!,
};
if (intent === "release") {
await releaseHold(slot, cartToken);
return Response.json({ ok: true });
}
const template = await db.slotTemplate.findFirst({
where: {
shopDomain: session.shop,
locationId: location.id,
method: slot.method,
weekday: weekdayOf(date, location.timezone),
date,
startMin,
},
});
if (!template) {
return Response.json({ error: "Slot not found" }, { status: 404 });
}
const confirmedCount = await db.booking.count({
where: {
shopDomain: session.shop,
locationId: location.id,
method: slot.method,
slotStart: slotStart.toJSDate(),
status: "confirmed",
},
cartToken,
});
const capacityBudget = remainingCapacity({ capacity: template.capacity, consumed: confirmedCount });
if (capacityBudget <= 0) {
return Response.json({ success: false, error: "Slot is full" }, { status: 409 });
}
const result = await tryCreateHold(slot, cartToken, capacityBudget);
if (!result.success) {
return Response.json({ success: false, error: "Slot was just taken" }, { status: 409 });
}
const activeHolds = await countActiveHolds(slot);
return Response.json({
success: true,
expiresAt: result.expiresAt,
remaining: Math.max(0, capacityBudget - activeHolds),
});
return Response.json(result.body, { status: result.status });
};

View File

@ -0,0 +1,33 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import { resolveAvailabilityRequest } from "../services/availability-request.server";
// Checkout UI Extension endpoint (extensions/checkout-datetime, Plus-only
// native picker) — session-token authenticated, CORS-enabled. Calls the
// exact same resolveAvailabilityRequest() as the storefront and POS
// routes — CLAUDE.md: "the same capacity pool feeds every surface...
// behavior must never diverge between channels."
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { sessionToken, cors } = await authenticate.public.checkout(request);
const shopDomain = sessionToken.dest.replace(/^https?:\/\//, "");
const url = new URL(request.url);
const methodParam = url.searchParams.get("method");
if (!methodParam || !VALID_METHODS.has(methodParam as Method)) {
return cors(Response.json({ error: "Invalid or missing method" }, { status: 400 }));
}
const result = await resolveAvailabilityRequest(shopDomain, {
method: methodParam as Method,
locationId: url.searchParams.get("locationId") || undefined,
postalCode: url.searchParams.get("postalCode") || undefined,
address: url.searchParams.get("address") || undefined,
days: Number(url.searchParams.get("days")) || undefined,
});
return cors(Response.json(result));
};

View File

@ -0,0 +1,42 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import { resolveHoldRequest } from "../services/hold-request.server";
// Checkout UI Extension endpoint — see checkout.scheduling.availability.tsx
// for the session-token/CORS rationale. Calls the exact same
// resolveHoldRequest() as the storefront and POS routes, so a Plus
// checkout booking competes for the same Redis-backed capacity as every
// other surface (IMPLEMENTATION_PLAN.md Phase 7 accept criteria).
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
export const action = async ({ request }: ActionFunctionArgs) => {
const { sessionToken, cors } = await authenticate.public.checkout(request);
const shopDomain = sessionToken.dest.replace(/^https?:\/\//, "");
const body = await request.json();
const { intent, locationId, method, date, startMin, cartToken } = body as {
intent?: "create" | "release";
locationId?: string;
method?: string;
date?: string;
startMin?: number;
cartToken?: string;
};
if (!locationId || !method || !VALID_METHODS.has(method as Method) || !date || typeof startMin !== "number" || !cartToken) {
return cors(Response.json({ error: "Missing or invalid parameters" }, { status: 400 }));
}
const result = await resolveHoldRequest(shopDomain, {
intent: intent === "release" ? "release" : "create",
locationId,
method: method as Method,
date,
startMin,
cartToken,
});
return cors(Response.json(result.body, { status: result.status }));
};

View File

@ -0,0 +1,34 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import { resolveAvailabilityRequest } from "../services/availability-request.server";
// POS UI Extension endpoint — session-token authenticated (POS extensions
// can't use the storefront's app-proxy signature scheme), CORS-enabled
// since POS calls this cross-origin. Calls the exact same
// resolveAvailabilityRequest() as the storefront's
// apps.scheduling.availability.tsx — CLAUDE.md: "the same capacity pool
// feeds every surface... behavior must never diverge between channels."
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { sessionToken, cors } = await authenticate.public.pos(request);
const shopDomain = sessionToken.dest.replace(/^https?:\/\//, "");
const url = new URL(request.url);
const methodParam = url.searchParams.get("method");
if (!methodParam || !VALID_METHODS.has(methodParam as Method)) {
return cors(Response.json({ error: "Invalid or missing method" }, { status: 400 }));
}
const result = await resolveAvailabilityRequest(shopDomain, {
method: methodParam as Method,
locationId: url.searchParams.get("locationId") || undefined,
postalCode: url.searchParams.get("postalCode") || undefined,
address: url.searchParams.get("address") || undefined,
days: Number(url.searchParams.get("days")) || undefined,
});
return cors(Response.json(result));
};

View File

@ -0,0 +1,43 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import { resolveHoldRequest } from "../services/hold-request.server";
// POS UI Extension endpoint — see pos.scheduling.availability.tsx for the
// session-token/CORS rationale. Calls the exact same resolveHoldRequest()
// as the storefront's apps.scheduling.hold.tsx, so a staff-booked slot and
// a shopper-booked slot compete for the same Redis-backed capacity — a POS
// order can't double-book a slot an online shopper already holds, or vice
// versa (IMPLEMENTATION_PLAN.md Phase 7 accept criteria).
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
export const action = async ({ request }: ActionFunctionArgs) => {
const { sessionToken, cors } = await authenticate.public.pos(request);
const shopDomain = sessionToken.dest.replace(/^https?:\/\//, "");
const body = await request.json();
const { intent, locationId, method, date, startMin, cartToken } = body as {
intent?: "create" | "release";
locationId?: string;
method?: string;
date?: string;
startMin?: number;
cartToken?: string;
};
if (!locationId || !method || !VALID_METHODS.has(method as Method) || !date || typeof startMin !== "number" || !cartToken) {
return cors(Response.json({ error: "Missing or invalid parameters" }, { status: 400 }));
}
const result = await resolveHoldRequest(shopDomain, {
intent: intent === "release" ? "release" : "create",
locationId,
method: method as Method,
date,
startMin,
cartToken,
});
return cors(Response.json(result.body, { status: result.status }));
};

View File

@ -0,0 +1,16 @@
import type { ActionFunctionArgs } from "@remix-run/node";
// Inert placeholder — see the [events] section of shopify.app.toml for why
// this exists at all: this org appears enrolled in Shopify's "Next
// Generation Events" developer preview, which the CLI now treats as a
// REQUIRED shopify.app.toml section for this app even though nothing here
// actually uses it (all real webhook handling goes through the classic
// webhooks.*.tsx routes). Deliberately does NOT call authenticate.webhook()
// — that verifies the classic webhook HMAC scheme, and this preview
// delivery mechanism may use a different one our shopify-app-remix version
// doesn't yet support verifying. No real functionality depends on this
// route ever actually being hit.
export const action = async ({ request }: ActionFunctionArgs) => {
console.log("Received Next-Gen Events preview delivery (unused placeholder)", request.url);
return new Response();
};

View File

@ -0,0 +1,197 @@
import { DateTime } from "luxon";
import type { Method } from "@prisma/client";
import db from "../db.server";
import { getAvailability, type AvailableSlot } from "./scheduling.server";
import { findEligibleLocationsForDelivery, meetsDeliveryDensity } from "./zones.server";
import { resolveRate } from "./rates.server";
import { formatPriceLabel } from "../lib/currency";
// The single availability resolver every surface calls — storefront widget
// (via apps.scheduling.availability.tsx, app-proxy auth), POS (via
// pos.scheduling.availability.tsx, session-token auth), and eventually
// Checkout UI. CLAUDE.md's non-negotiable: "One Scheduling Service + one
// capacity pool feeds every surface... behavior must never diverge between
// channels" — this is that single point, not just a shared convention each
// route re-implements.
const MAX_DAYS = 60;
const DEFAULT_DAYS = 14;
export interface AvailabilityRequestParams {
method: Method;
locationId?: string;
postalCode?: string;
address?: string;
days?: number;
}
export interface AvailabilityResult {
locationId: string | null;
locationName?: string;
locationLat?: number | null;
locationLng?: number | null;
timezone?: string;
method: Method;
dates: Record<string, AvailableSlot[]>;
zoneId: string | null;
distanceKm: number | null;
rate: { name: string; priceCents: number; label: string } | null;
error?: string;
}
function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
/** getAvailability's consumed-capacity map key — must match scheduling.server.ts's slotKey() exactly. */
function slotKey(date: string, startMin: number): string {
return `${date}|${startMin}`;
}
export async function resolveAvailabilityRequest(
shopDomain: string,
params: AvailabilityRequestParams,
): Promise<AvailabilityResult> {
const { method, locationId: locationIdParam, postalCode, address } = params;
const days = params.days && params.days > 0 ? Math.min(params.days, MAX_DAYS) : DEFAULT_DAYS;
let location: Awaited<ReturnType<typeof db.location.findFirst>> = null;
let zoneId: string | null = null;
let distanceKm: number | null = null;
// Delivery-zone auto-assignment (IMPLEMENTATION_PLAN.md Phase 5): route to
// the nearest eligible, density-qualified zone for the shopper's address.
// Falls through to the plain location lookup below for PICKUP/SHIPPING,
// or when no address was supplied yet, so the picker can still show
// something before that input exists.
if (method === "LOCAL_DELIVERY" && (postalCode || address)) {
const matches = await findEligibleLocationsForDelivery(shopDomain, { postalCode, address });
const zones = matches.length
? await db.zone.findMany({ where: { id: { in: matches.map((m) => m.zoneId) } } })
: [];
const zoneById = new Map(zones.map((z) => [z.id, z]));
for (const match of matches) {
if (locationIdParam && match.locationId !== locationIdParam) continue;
const zone = zoneById.get(match.zoneId);
if (!zone) continue;
// eslint-disable-next-line no-await-in-loop -- checked in nearest-first order; stop at the first that qualifies
if (!(await meetsDeliveryDensity(shopDomain, zone))) continue;
zoneId = match.zoneId;
distanceKm = match.distanceKm;
location = await db.location.findFirst({ where: { id: match.locationId, shopDomain, active: true } });
break;
}
if (!location) {
return {
locationId: null,
method,
dates: {},
zoneId: null,
distanceKm: null,
rate: null,
error: "This address is outside our delivery area right now.",
};
}
} else {
location = locationIdParam
? await db.location.findFirst({ where: { id: locationIdParam, shopDomain, active: true } })
: await db.location.findFirst({ where: { shopDomain, active: true }, orderBy: { createdAt: "asc" } });
}
if (!location) {
return {
locationId: null,
method,
dates: {},
zoneId: null,
distanceKm: null,
rate: null,
error: "No active location configured",
};
}
const now = DateTime.now().setZone(location.timezone);
const startDate = now.toISODate()!;
const endDate = now.plus({ days }).toISODate()!;
const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate();
const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).toJSDate();
// Bookings are keyed by exact instant; widen the window by a day on each
// side so a slot near midnight UTC-offset boundaries isn't miscounted.
const bookingRangeStart = DateTime.fromISO(startDate, { zone: "utc" }).minus({ days: 1 }).toJSDate();
const bookingRangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate();
const [slotTemplates, overrides, blackouts, bookings, rates] = await Promise.all([
db.slotTemplate.findMany({ where: { shopDomain, locationId: location.id, method } }),
db.slotOverride.findMany({
where: { shopDomain, locationId: location.id, method, date: { gte: rangeStart, lte: rangeEnd } },
}),
db.blackoutDate.findMany({
where: {
shopDomain,
date: { gte: rangeStart, lte: rangeEnd },
AND: [{ OR: [{ locationId: location.id }, { locationId: null }] }, { OR: [{ method }, { method: null }] }],
},
}),
db.booking.findMany({
where: {
shopDomain,
locationId: location.id,
method,
status: { in: ["confirmed", "fulfilled"] },
slotStart: { gte: bookingRangeStart, lte: bookingRangeEnd },
},
select: { slotStart: true },
}),
db.rate.findMany({ where: { shopDomain, method } }),
]);
const consumed = new Map<string, number>();
for (const booking of bookings) {
const local = DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(location.timezone);
const key = slotKey(local.toISODate()!, local.hour * 60 + local.minute);
consumed.set(key, (consumed.get(key) ?? 0) + 1);
}
const availability = getAvailability({
timezone: location.timezone,
dateRange: { startDate, endDate },
slotTemplates: slotTemplates.map((t) => ({
weekday: t.weekday,
startMin: t.startMin,
endMin: t.endMin,
capacity: t.capacity,
cutoffMin: t.cutoffMin,
leadTimeMin: t.leadTimeMin,
})),
overrides: overrides.map((o) => ({
date: toIsoDate(o.date),
closed: o.closed,
startMin: o.startMin,
endMin: o.endMin,
capacity: o.capacity,
})),
blackoutDates: blackouts.map((b) => ({ date: toIsoDate(b.date) })),
now,
consumed,
});
const matchedRate = resolveRate(rates, { method, zoneId: zoneId ?? undefined, distanceKm: distanceKm ?? undefined });
return {
locationId: location.id,
locationName: location.name,
locationLat: location.lat,
locationLng: location.lng,
timezone: location.timezone,
method,
dates: availability,
zoneId,
distanceKm,
rate: matchedRate
? { name: matchedRate.name, priceCents: matchedRate.priceCents, label: formatPriceLabel(matchedRate.priceCents) }
: null,
};
}

View File

@ -16,9 +16,16 @@ export interface OrderWebhookPayload {
cart_token?: string | null;
email?: string | null;
phone?: string | null;
total_price?: string | null; // e.g. "49.99" — Shopify sends this as a decimal string, not cents
note_attributes?: OrderNoteAttribute[];
}
function parsePriceCents(totalPrice: string | null | undefined): number | undefined {
if (!totalPrice) return undefined;
const value = Number(totalPrice);
return Number.isFinite(value) ? Math.round(value * 100) : undefined;
}
function readAttr(attrs: OrderNoteAttribute[], key: string): string | undefined {
return attrs.find((a) => a.name === key)?.value;
}
@ -44,6 +51,7 @@ export async function createBookingFromOrder(shopDomain: string, order: OrderWeb
const startMinRaw = readAttr(attrs, "dd_start_min");
const endMinRaw = readAttr(attrs, "dd_end_min");
const locationId = readAttr(attrs, "dd_location_id");
const zoneId = readAttr(attrs, "dd_zone_id") || null; // LOCAL_DELIVERY orders matched by zones.server.ts; absent for PICKUP/SHIPPING
if (!method || !VALID_METHODS.has(method as Method) || !date || !startMinRaw || !endMinRaw || !locationId) {
return; // no scheduling selection on this order — nothing to book
@ -64,11 +72,13 @@ export async function createBookingFromOrder(shopDomain: string, order: OrderWeb
orderId: order.admin_graphql_api_id,
orderName: order.name,
locationId: location.id,
zoneId,
method: method as Method,
slotStart: slotStart.toJSDate(),
slotEnd: slotEnd.toJSDate(),
customerEmail: order.email ?? undefined,
customerPhone: order.phone ?? undefined,
totalPriceCents: parsePriceCents(order.total_price),
},
update: {}, // redelivered webhook — the booking already exists, nothing to change
});

View File

@ -0,0 +1,171 @@
import { DateTime } from "luxon";
import type { Method } from "@prisma/client";
// Pure aggregation for the ops/dispatch dashboard (IMPLEMENTATION_PLAN.md
// Phase 6). No DB calls — the route loader fetches Bookings/SlotTemplates
// and passes plain data in here, per CLAUDE.md's "inject data" rule.
export interface BookingSummary {
id: string;
orderName: string | null;
locationId: string;
locationName: string;
method: Method;
slotStart: Date; // UTC instant
slotEnd: Date;
timezone: string; // the booking's location's timezone, for local-date bucketing
status: string;
totalPriceCents: number | null;
customerEmail: string | null;
}
export interface CapacitySummary {
locationId: string;
method: Method;
weekday: number;
capacity: number;
}
export interface DailyLocationUtilization {
date: string; // location-local ISO date
locationId: string;
locationName: string;
method: Method;
booked: number;
capacity: number;
utilizationPct: number; // 0-100, capped even if overbooked past capacity
}
function localDate(booking: BookingSummary): string {
return DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(booking.timezone).toISODate()!;
}
function localWeekday(booking: BookingSummary): number {
return DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(booking.timezone).weekday % 7;
}
/** Groups bookings by their own location's local calendar date — the basis for "bookings by day/slot/location". */
export function bucketByLocalDate(bookings: BookingSummary[]): Record<string, BookingSummary[]> {
const buckets: Record<string, BookingSummary[]> = {};
for (const booking of bookings) {
const key = localDate(booking);
(buckets[key] ??= []).push(booking);
}
for (const list of Object.values(buckets)) {
list.sort((a, b) => a.slotStart.getTime() - b.slotStart.getTime());
}
return buckets;
}
/** Confirmed/fulfilled revenue, summed per method — cancelled/no_show bookings never counted. */
export function revenueByMethod(bookings: BookingSummary[]): Record<Method, number> {
const totals: Record<Method, number> = { SHIPPING: 0, LOCAL_DELIVERY: 0, PICKUP: 0 };
for (const booking of bookings) {
if (booking.status !== "confirmed" && booking.status !== "fulfilled") continue;
totals[booking.method] += booking.totalPriceCents ?? 0;
}
return totals;
}
/**
* Capacity-utilization view: for each date/location/method that has at
* least one booking, what fraction of that weekday's total slot capacity
* (summed across every matching SlotTemplate) is booked. Dates/locations
* with zero bookings are omitted there's nothing to show a merchant for
* an empty day.
*/
export function utilizationByDateLocation(
bookings: BookingSummary[],
capacities: CapacitySummary[],
): DailyLocationUtilization[] {
const capacityByKey = new Map<string, number>();
for (const c of capacities) {
const key = `${c.locationId}|${c.method}|${c.weekday}`;
capacityByKey.set(key, (capacityByKey.get(key) ?? 0) + c.capacity);
}
const bookedByKey = new Map<
string,
{ date: string; locationId: string; locationName: string; method: Method; weekday: number; count: number }
>();
for (const booking of bookings) {
if (booking.status === "cancelled") continue;
const date = localDate(booking);
const weekday = localWeekday(booking);
const key = `${date}|${booking.locationId}|${booking.method}`;
const existing = bookedByKey.get(key);
if (existing) {
existing.count += 1;
} else {
bookedByKey.set(key, {
date,
locationId: booking.locationId,
locationName: booking.locationName,
method: booking.method,
weekday,
count: 1,
});
}
}
return [...bookedByKey.values()]
.map((entry) => {
const capacity = capacityByKey.get(`${entry.locationId}|${entry.method}|${entry.weekday}`) ?? 0;
const utilizationPct = capacity > 0 ? Math.min(100, Math.round((entry.count / capacity) * 100)) : 0;
return {
date: entry.date,
locationId: entry.locationId,
locationName: entry.locationName,
method: entry.method,
booked: entry.count,
capacity,
utilizationPct,
};
})
.sort((a, b) => a.date.localeCompare(b.date) || a.locationName.localeCompare(b.locationName));
}
/** Nearest-first list of not-yet-fulfilled bookings, for the "upcoming fulfillment" panel. */
export function upcomingFulfillments(bookings: BookingSummary[], limit = 25): BookingSummary[] {
return bookings
.filter((b) => b.status === "confirmed")
.sort((a, b) => a.slotStart.getTime() - b.slotStart.getTime())
.slice(0, limit);
}
export function toCsvRow(values: Array<string | number>): string {
return values
.map((v) => {
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
})
.join(",");
}
export function bookingsToCsv(bookings: BookingSummary[]): string {
const header = toCsvRow([
"Order",
"Date",
"Time",
"Method",
"Location",
"Status",
"Customer email",
"Total",
]);
const rows = bookings.map((b) => {
const local = DateTime.fromJSDate(b.slotStart, { zone: "utc" }).setZone(b.timezone);
return toCsvRow([
b.orderName ?? "",
local.toISODate() ?? "",
local.toFormat("h:mm a"),
b.method,
b.locationName,
b.status,
b.customerEmail ?? "",
b.totalPriceCents != null ? (b.totalPriceCents / 100).toFixed(2) : "",
]);
});
return [header, ...rows].join("\n");
}

View File

@ -0,0 +1,68 @@
import type { Method } from "@prisma/client";
import db from "../db.server";
import { slotDateTime, weekdayOf } from "../lib/time";
import { remainingCapacity } from "./capacity.server";
import { tryCreateHold, releaseHold, countActiveHolds } from "./holds.server";
// Shared by every surface that reserves capacity — storefront widget (via
// apps.scheduling.hold.tsx) and POS (via pos.scheduling.hold.tsx). Same
// pool, same code, different auth wrapper. See holds.server.ts for why
// creating a hold has to be a single atomic Redis operation.
export interface HoldRequestParams {
intent: "create" | "release";
locationId: string;
method: Method;
date: string;
startMin: number;
cartToken: string;
}
export interface HoldRequestResult {
status: number;
body: { ok?: true; success?: boolean; expiresAt?: number; remaining?: number; error?: string };
}
export async function resolveHoldRequest(shopDomain: string, params: HoldRequestParams): Promise<HoldRequestResult> {
const { intent, locationId, method, date, startMin, cartToken } = params;
const location = await db.location.findFirst({ where: { id: locationId, shopDomain, active: true } });
if (!location) {
return { status: 404, body: { error: "Location not found" } };
}
const slotStart = slotDateTime(date, startMin, location.timezone);
const slot = { shopDomain, locationId: location.id, method, slotStartIso: slotStart.toUTC().toISO()! };
if (intent === "release") {
await releaseHold(slot, cartToken);
return { status: 200, body: { ok: true } };
}
const template = await db.slotTemplate.findFirst({
where: { shopDomain, locationId: location.id, method, weekday: weekdayOf(date, location.timezone), startMin },
});
if (!template) {
return { status: 404, body: { error: "Slot not found" } };
}
const confirmedCount = await db.booking.count({
where: { shopDomain, locationId: location.id, method, slotStart: slotStart.toJSDate(), status: "confirmed" },
});
const capacityBudget = remainingCapacity({ capacity: template.capacity, consumed: confirmedCount });
if (capacityBudget <= 0) {
return { status: 409, body: { success: false, error: "Slot is full" } };
}
const result = await tryCreateHold(slot, cartToken, capacityBudget);
if (!result.success) {
return { status: 409, body: { success: false, error: "Slot was just taken" } };
}
const activeHolds = await countActiveHolds(slot);
return {
status: 200,
body: { success: true, expiresAt: result.expiresAt, remaining: Math.max(0, capacityBudget - activeHolds) },
};
}

View File

@ -0,0 +1,58 @@
import type { Method } from "@prisma/client";
// Pure rate resolution — no DB calls (CLAUDE.md: inject data). The caller
// (apps.scheduling.availability.tsx) fetches the shop's Rate rows for the
// method and passes them in here.
export interface RateLike {
id: string;
method: Method;
zoneId: string | null;
name: string;
priceCents: number;
keyedBy: string; // "zone" | "distance"
minDistanceKm: number | null;
maxDistanceKm: number | null;
}
export interface RateContext {
method: Method;
zoneId?: string;
distanceKm?: number;
}
/**
* Picks the rate that applies to a given method/zone/distance. Zone-keyed
* rates are matched by exact zoneId; distance-keyed rates by falling within
* [minDistanceKm, maxDistanceKm) (open-ended bounds are unbounded on that
* side). When more than one rate matches, the cheapest wins a merchant
* with overlapping bands almost certainly wants the shopper quoted the best
* available price, not an arbitrary one.
*/
export function resolveRate(rates: RateLike[], context: RateContext): RateLike | null {
const candidates = rates.filter((rate) => {
if (rate.method !== context.method) return false;
if (rate.keyedBy === "zone") {
return context.zoneId !== undefined && rate.zoneId === context.zoneId;
}
if (rate.keyedBy === "distance") {
if (context.distanceKm === undefined) return false;
const min = rate.minDistanceKm ?? 0;
const max = rate.maxDistanceKm ?? Infinity;
return context.distanceKm >= min && context.distanceKm < max;
}
return false;
});
if (candidates.length === 0) return null;
return candidates.reduce((cheapest, candidate) => (candidate.priceCents < cheapest.priceCents ? candidate : cheapest));
}
// formatPriceLabel moved to app/lib/currency.ts (2026-08-24): it's pure/
// no-I/O, but a route *component* needs it (app.rates._index.tsx), and
// Remix refuses to bundle anything imported from a .server.ts path for the
// client — import it from lib/currency directly, not re-exported here.

View File

@ -0,0 +1,224 @@
import db from "../db.server";
import { haversineDistanceKm, isPostalCodeListed, type Coordinates } from "../lib/geo";
// I/O orchestration around the pure math in lib/geo.ts. Geocoding results
// are cached permanently per IMPLEMENTATION_PLAN.md §9 ("cache geocode
// results per address; don't call the maps API on every availability
// request") — addresses don't move, so there's no cache invalidation to
// worry about, only cache growth, which is fine for this volume.
export interface ZoneLike {
id: string;
locationId: string;
type: string; // "postal" | "radius"
postalCodes: string[];
radiusKm: number | null;
minOrders: number | null;
active: boolean;
}
function normalizeAddressKey(address: string): string {
return address.trim().toLowerCase().replace(/\s+/g, " ");
}
/**
* Geocodes an address via the Google Maps Geocoding API, using
* GOOGLE_MAPS_API_KEY. Returns null (rather than throwing) when no key is
* configured or the address can't be resolved callers should treat that
* as "can't determine eligibility," not a hard error, since a merchant may
* not have set up Maps yet.
*/
export async function geocodeAddress(address: string): Promise<Coordinates | null> {
const key = normalizeAddressKey(address);
const cached = await db.geocodeCache.findUnique({ where: { normalizedKey: key } });
if (cached) return { lat: cached.lat, lng: cached.lng };
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) return null;
const url = new URL("https://maps.googleapis.com/maps/api/geocode/json");
url.searchParams.set("address", address);
url.searchParams.set("key", apiKey);
const res = await fetch(url.toString());
if (!res.ok) return null;
const body = (await res.json()) as {
status: string;
results: Array<{ geometry: { location: { lat: number; lng: number } } }>;
};
if (body.status !== "OK" || body.results.length === 0) return null;
const { lat, lng } = body.results[0].geometry.location;
await db.geocodeCache.upsert({
where: { normalizedKey: key },
create: { normalizedKey: key, lat, lng },
update: { lat, lng },
});
return { lat, lng };
}
/**
* Radius zones use the location's own lat/lng as the center postal zones
* need no coordinates at all. This is why isZoneEligible takes the
* location's coordinates separately rather than assuming the zone carries
* its own center point.
*/
export function isZoneEligible(
zone: ZoneLike,
locationCoordinates: Coordinates | null,
customer: { coordinates?: Coordinates; postalCode?: string },
): boolean {
if (!zone.active) return false;
if (zone.type === "postal") {
return customer.postalCode ? isPostalCodeListed(customer.postalCode, zone.postalCodes) : false;
}
if (zone.type === "radius") {
if (!locationCoordinates || !customer.coordinates || zone.radiusKm == null) return false;
return haversineDistanceKm(customer.coordinates, locationCoordinates) <= zone.radiusKm;
}
return false;
}
export interface EligibleLocationMatch {
locationId: string;
zoneId: string;
distanceKm: number | null;
}
/**
* Nearest-location auto-assign (PRODUCT_STRATEGY.md §2 "Auto location
* assignment"): given a shopper's address, finds every active zone across
* the shop's locations that covers it, ranked nearest-first when distance
* is known (radius zones) postal zones without location coordinates sort
* after distance-ranked ones, in the order returned by the query.
*/
export async function findEligibleLocationsForDelivery(
shopDomain: string,
customer: { address?: string; postalCode?: string },
): Promise<EligibleLocationMatch[]> {
const customerCoordinates = customer.address ? await geocodeAddress(customer.address) : null;
const locations = await db.location.findMany({
where: { shopDomain, active: true },
include: { zones: { where: { active: true } } },
});
const matches: EligibleLocationMatch[] = [];
for (const location of locations) {
const locationCoordinates = location.lat != null && location.lng != null ? { lat: location.lat, lng: location.lng } : null;
for (const zone of location.zones) {
const eligible = isZoneEligible(zone, locationCoordinates, {
coordinates: customerCoordinates ?? undefined,
postalCode: customer.postalCode,
});
if (!eligible) continue;
const distanceKm =
locationCoordinates && customerCoordinates ? haversineDistanceKm(customerCoordinates, locationCoordinates) : null;
matches.push({ locationId: location.id, zoneId: zone.id, distanceKm });
}
}
return matches.sort((a, b) => {
if (a.distanceKm == null && b.distanceKm == null) return 0;
if (a.distanceKm == null) return 1;
if (b.distanceKm == null) return -1;
return a.distanceKm - b.distanceKm;
});
}
/**
* Delivery-density threshold (PRODUCT_STRATEGY.md §3.2): a sparse zone
* shouldn't unlock delivery capacity until `minOrders` bookings have
* already routed through it cuts delivery cost on routes that wouldn't
* be worth a driver trip for just one or two orders. A zone with no
* minOrders set (or 0) always passes.
*/
export async function meetsDeliveryDensity(shopDomain: string, zone: ZoneLike): Promise<boolean> {
if (!zone.minOrders || zone.minOrders <= 0) return true;
const count = await db.booking.count({
where: { shopDomain, zoneId: zone.id, status: { in: ["confirmed", "fulfilled"] } },
});
return count >= zone.minOrders;
}
interface AdminGraphQLClient {
graphql(query: string, options?: { variables?: Record<string, unknown> }): Promise<Response>;
}
/**
* Inventory-based location exclusion (PRODUCT_STRATEGY.md §2): drops any
* candidate location that doesn't stock at least one of the cart's
* products, per Shopify's InventoryLevel API. Locations without a
* shopifyLocationId mapping are left in (can't check what we can't query
* excluding them would be a false negative, not a safe default) and this
* whole check is a no-op when productIds is empty (nothing to check stock
* for, e.g. availability requests made before a cart exists).
*/
export async function excludeLocationsWithoutStock(
admin: AdminGraphQLClient,
locationIds: string[],
productVariantGids: string[],
): Promise<Set<string>> {
if (productVariantGids.length === 0) return new Set(locationIds);
const locations = await db.location.findMany({
where: { id: { in: locationIds } },
select: { id: true, shopifyLocationId: true },
});
const inStock = new Set<string>();
for (const location of locations) {
if (!location.shopifyLocationId) {
inStock.add(location.id); // unmapped — can't verify, don't exclude
continue;
}
const response = await admin.graphql(
`#graphql
query LocationStock($locationId: ID!, $variantIds: [ID!]!) {
location(id: $locationId) {
id
}
nodes(ids: $variantIds) {
... on ProductVariant {
inventoryItem {
inventoryLevel(locationId: $locationId) {
quantities(names: ["available"]) {
quantity
}
}
}
}
}
}`,
{ variables: { locationId: location.shopifyLocationId, variantIds: productVariantGids } },
);
const body = (await response.json()) as {
data?: {
nodes: Array<{ inventoryItem?: { inventoryLevel?: { quantities: Array<{ quantity: number }> } | null } } | null>;
};
};
const hasStock = (body.data?.nodes ?? []).some((node) =>
(node?.inventoryItem?.inventoryLevel?.quantities ?? []).some((q) => q.quantity > 0),
);
if (hasStock) inStock.add(location.id);
}
return inStock;
}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,14 @@
{
"name": "checkout-datetime",
"private": true,
"version": "1.0.0",
"license": "UNLICENSED",
"scripts": {
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"preact": "^10.10.x",
"@preact/signals": "^2.3.x",
"@shopify/ui-extensions": "2026.7.x"
}
}

View File

@ -0,0 +1,13 @@
import '@shopify/ui-extensions';
//@ts-ignore
declare module './src/Checkout.jsx' {
const shopify: import('@shopify/ui-extensions/purchase.checkout.block.render').Api;
const globalThis: { shopify: typeof shopify };
}
//@ts-ignore
declare module './src/ThankYou.jsx' {
const shopify: import('@shopify/ui-extensions/purchase.thank-you.block.render').Api;
const globalThis: { shopify: typeof shopify };
}

View File

@ -0,0 +1,43 @@
# Learn more about configuring your checkout UI extension:
# https://shopify.dev/docs/api/checkout-ui-extensions/latest/configuration
# The version of APIs your extension will receive. Learn more:
# https://shopify.dev/docs/api/usage/versioning
api_version = "2026-07"
[[extensions]]
name = "Delivery Date & Time (Checkout)"
handle = "checkout-datetime"
type = "ui_extension"
uid = "14165501-9e99-cb7d-e2bf-2e25f3028ca218bc5e8a"
description = "Plus-only native picker in checkout, plus a Thank You page confirmation of the scheduled slot."
# purchase.checkout.block.render: the native picker inside checkout itself
# (Plus only — non-Plus checkout can't host a custom block; those stores
# rely entirely on the storefront widget + Validation Function instead, per
# IMPLEMENTATION_PLAN.md §9's "no checkout.liquid fallback" risk note).
[[extensions.targeting]]
module = "./src/Checkout.jsx"
target = "purchase.checkout.block.render"
# purchase.thank-you.block.render: read-only confirmation of the slot
# selected above. NOTE: there is no purchase.order-status.block.render in
# this API version — order-status-page display for ALL plans (not just
# Plus) is handled separately, by a Theme App Extension block reading the
# order's note_attributes (see extensions/datetime-widget/blocks/
# order-confirmation.liquid), since that works on every plan via Liquid,
# not just where checkout extensibility is available.
[[extensions.targeting]]
module = "./src/ThankYou.jsx"
target = "purchase.thank-you.block.render"
[extensions.capabilities]
# Gives your extension access to directly query Shopify's storefront API.
# https://shopify.dev/docs/api/checkout-ui-extensions/latest/configuration#api-access
api_access = true
# Gives your extension access to make external network calls, using the
# JavaScript `fetch()` API. Required — this extension calls our own
# backend (checkout.scheduling.availability/hold) to share the same
# capacity pool as every other surface.
network_access = true

View File

@ -0,0 +1,202 @@
import "@shopify/ui-extensions/preact";
import { render } from "preact";
import { useEffect, useState } from "preact/hooks";
export default async () => {
render(<Extension />, document.body);
};
// NOTE: unverified against a live Plus checkout session this environment
// has no way to run checkout itself, only to generate the extension and
// typecheck it against @shopify/ui-extensions' own .d.ts files. Same
// APP_URL build-time-substitution assumption as the POS extension (see
// extensions/pos-datetime/src/Modal.jsx) needs live confirmation.
//
// This is the Plus-only native picker (IMPLEMENTATION_PLAN.md Phase 7):
// non-Plus stores rely entirely on the storefront widget + Validation
// Function, since checkout.liquid/Additional Scripts don't exist anymore
// and non-Plus checkout can't host a custom block. Where this extension
// IS available, it collects the same dd_* attributes the widget does, via
// applyAttributeChange instead of /cart/update.js same contract,
// booking.server.ts needs no changes to handle either source.
const APP_URL = process.env.APP_URL || "";
const METHODS = [
{ value: "PICKUP", label: "Pickup" },
{ value: "LOCAL_DELIVERY", label: "Local delivery" },
{ value: "SHIPPING", label: "Shipping" },
];
// TS infers `event.currentTarget` on an inline JSX onChange as the generic
// DOM `EventTarget` here rather than the choice-list-specific element type
// ChoiceListEvents declares this cast is just working around that
// inference gap, the runtime shape is exactly `{ values: string[] }`.
function selectedValue(event) {
return /** @type {{ values: string[] }} */ (event.currentTarget).values[0];
}
function minutesToDisplayTime(minutes) {
const h24 = Math.floor(minutes / 60);
const m = minutes % 60;
const period = h24 < 12 ? "AM" : "PM";
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
return `${h12}:${String(m).padStart(2, "0")} ${period}`;
}
function randomToken() {
return `checkout-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function Extension() {
const { sessionToken } = shopify;
const [cartToken] = useState(randomToken);
const [method, setMethod] = useState("PICKUP");
const [availability, setAvailability] = useState(null);
const [date, setDate] = useState("");
const [loading, setLoading] = useState(false);
const [confirmed, setConfirmed] = useState(null);
const [error, setError] = useState(null);
async function authedFetch(path, options = {}) {
const token = await sessionToken.get();
return fetch(`${APP_URL}${path}`, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
});
}
useEffect(() => {
let cancelled = false;
setLoading(true);
setDate("");
setAvailability(null);
setError(null);
authedFetch(`/checkout/scheduling/availability?method=${method}&days=14`)
.then((res) => res.json())
.then((body) => {
if (cancelled) return;
if (body.error && !body.locationId) setError(body.error);
setAvailability(body);
})
.catch(() => {
if (!cancelled) setError("Couldn't load available dates.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [method]);
const availableDates = availability ? Object.keys(availability.dates ?? {}) : [];
const slots = date && availability ? (availability.dates[date] ?? []) : [];
async function setAttribute(key, value) {
await shopify.applyAttributeChange({ type: "updateAttribute", key, value });
}
async function selectSlot(slot) {
if (!availability?.locationId) return;
setLoading(true);
setError(null);
try {
const holdRes = await authedFetch("/checkout/scheduling/hold", {
method: "POST",
body: JSON.stringify({
intent: "create",
locationId: availability.locationId,
method,
date,
startMin: slot.startMin,
cartToken,
}),
});
const hold = await holdRes.json();
if (!hold.success) {
setError(hold.error || "That slot was just taken.");
return;
}
const display = `${date}, ${minutesToDisplayTime(slot.startMin)}${minutesToDisplayTime(slot.endMin)}`;
const attrLabel = method === "PICKUP" ? "Pickup date" : method === "LOCAL_DELIVERY" ? "Delivery date" : "Shipping date";
await Promise.all([
setAttribute(attrLabel, display),
setAttribute("dd_method", method),
setAttribute("dd_date", date),
setAttribute("dd_start_min", String(slot.startMin)),
setAttribute("dd_end_min", String(slot.endMin)),
setAttribute("dd_location_id", availability.locationId),
]);
setConfirmed(display);
} catch {
setError("Couldn't reserve that slot.");
} finally {
setLoading(false);
}
}
if (confirmed) {
return (
<s-banner heading="Delivery date & time" tone="success">
<s-text>Confirmed for {confirmed}</s-text>
</s-banner>
);
}
return (
<s-stack gap="base">
<s-heading>Choose your delivery date</s-heading>
{error && (
<s-banner tone="critical">
<s-text>{error}</s-text>
</s-banner>
)}
<s-choice-list
label="Method"
values={[method]}
onChange={(e) => setMethod(selectedValue(e))}
>
{METHODS.map((m) => (
<s-choice key={m.value} value={m.value}>
{m.label}
</s-choice>
))}
</s-choice-list>
{loading && <s-text>Loading</s-text>}
{!loading && availability?.locationId && availableDates.length === 0 && <s-text>No dates available.</s-text>}
{!loading && availableDates.length > 0 && (
<s-choice-list label="Date" values={date ? [date] : []} onChange={(e) => setDate(selectedValue(e))}>
{availableDates.map((d) => (
<s-choice key={d} value={d}>
{d}
</s-choice>
))}
</s-choice-list>
)}
{date && slots.length > 0 && (
<s-choice-list
label="Time"
onChange={(e) => selectSlot(slots.find((s) => String(s.startMin) === selectedValue(e)))}
>
{slots.map((s) => (
<s-choice key={s.startMin} value={String(s.startMin)}>
{minutesToDisplayTime(s.startMin)}{minutesToDisplayTime(s.endMin)}
</s-choice>
))}
</s-choice-list>
)}
</s-stack>
);
}

View File

@ -0,0 +1,28 @@
import "@shopify/ui-extensions/preact";
import { render } from "preact";
export default async () => {
render(<Extension />, document.body);
};
// Read-only the order is already placed by the time this renders, so
// there's nothing to collect, just to confirm. Reads the same dd_* keys
// Checkout.jsx (or the storefront widget, for non-Plus orders) wrote.
function Extension() {
const attributes = shopify.attributes.value ?? [];
const get = (key) => attributes.find((a) => a.key === key)?.value;
const method = get("dd_method");
const date = get("dd_date");
if (!method || !date) return null; // no scheduling selection on this order render nothing
const label = method === "PICKUP" ? "Pickup" : method === "LOCAL_DELIVERY" ? "Local delivery" : "Shipping";
const display = [get("Pickup date"), get("Delivery date"), get("Shipping date")].find(Boolean) ?? date;
return (
<s-banner heading={`${label} scheduled`} tone="success">
<s-text>{display}</s-text>
</s-banner>
);
}

View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"target": "ES2020",
"checkJs": true,
"allowJs": true,
"moduleResolution": "node",
"esModuleInterop": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["./src", "./shopify.d.ts"]
}

View File

@ -55,3 +55,44 @@
color: inherit;
font: inherit;
}
.dd-widget__input {
border: 1px solid currentColor;
border-radius: 4px;
background: transparent;
padding: 0.4rem 0.75rem;
font-size: 0.875rem;
color: inherit;
font-family: inherit;
min-width: 10rem;
}
.dd-widget__map {
width: 100%;
height: 220px;
border-radius: 8px;
overflow: hidden;
}
.dd-order-confirmation {
border: 1px solid currentColor;
border-radius: 8px;
padding: 0.75rem 1rem;
display: inline-flex;
flex-direction: column;
gap: 0.15rem;
}
.dd-order-confirmation__label {
font-size: 0.75rem;
opacity: 0.75;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.dd-order-confirmation__value {
font-size: 1rem;
font-weight: 600;
margin: 0;
}

File diff suppressed because one or more lines are too long

View File

@ -18,7 +18,11 @@
data-label-change="{{ 'widget.change' | t | escape }}"
data-label-loading="{{ 'widget.loading' | t | escape }}"
data-label-error="{{ 'widget.error' | t | escape }}"
data-label-postal-code="{{ 'widget.postal_code' | t | escape }}"
data-label-postal-code-submit="{{ 'widget.postal_code_submit' | t | escape }}"
data-label-out-of-area="{{ 'widget.out_of_area' | t | escape }}"
{% if block.settings.location_id != blank %}data-location-id="{{ block.settings.location_id | escape }}"{% endif %}
{% if block.settings.google_maps_api_key != blank %}data-google-maps-api-key="{{ block.settings.google_maps_api_key | escape }}"{% endif %}
{{ block.shopify_attributes }}
>
<noscript>{{ 'widget.enable_js' | t | escape }}</noscript>
@ -58,6 +62,12 @@
"id": "location_id",
"label": "t:datetime_picker.location_id_label",
"info": "t:datetime_picker.location_id_info"
},
{
"type": "text",
"id": "google_maps_api_key",
"label": "t:datetime_picker.google_maps_api_key_label",
"info": "t:datetime_picker.google_maps_api_key_info"
}
]
}

View File

@ -0,0 +1,42 @@
{%- comment -%}
Works on every plan (Order Status / Thank You are plain Liquid-rendered
pages outside checkout extensibility for non-Plus stores) — reads the
same dd_* attributes the storefront widget/POS/Plus checkout all write,
which Shopify carries from cart attributes onto order.note_attributes
automatically. This is the actual "all plans show confirmed slot on
thank-you/order-status" piece (IMPLEMENTATION_PLAN.md Phase 7); the
Checkout UI Extension's Thank You block (extensions/checkout-datetime) is
an additional, Plus-only nicety layered on top, not a substitute for this.
{%- endcomment -%}
{%- assign dd_method = blank -%}
{%- assign dd_display = blank -%}
{%- for attribute in order.note_attributes -%}
{%- if attribute.name == "dd_method" -%}
{%- assign dd_method = attribute.value -%}
{%- endif -%}
{%- if attribute.name == "Pickup date" or attribute.name == "Delivery date" or attribute.name == "Shipping date" -%}
{%- assign dd_display = attribute.value -%}
{%- endif -%}
{%- endfor -%}
{%- if dd_method != blank and dd_display != blank -%}
<div class="dd-order-confirmation" {{ block.shopify_attributes }}>
<p class="dd-order-confirmation__label">
{%- case dd_method -%}
{%- when "PICKUP" -%}{{ 'order_confirmation.pickup_label' | t }}
{%- when "LOCAL_DELIVERY" -%}{{ 'order_confirmation.delivery_label' | t }}
{%- else -%}{{ 'order_confirmation.shipping_label' | t }}
{%- endcase -%}
</p>
<p class="dd-order-confirmation__value">{{ dd_display }}</p>
</div>
{%- endif -%}
{% schema %}
{
"name": "t:order_confirmation.name",
"target": "section",
"settings": []
}
{% endschema %}

View File

@ -13,6 +13,14 @@
"change": "Change",
"loading": "Loading available dates…",
"error": "Couldn't load available dates. Please try again.",
"enable_js": "Please enable JavaScript to choose a delivery date and time."
"enable_js": "Please enable JavaScript to choose a delivery date and time.",
"postal_code": "Enter your postal/ZIP code",
"postal_code_submit": "Check availability",
"out_of_area": "Sorry, we don't deliver to this address."
},
"order_confirmation": {
"pickup_label": "Pickup",
"delivery_label": "Delivery",
"shipping_label": "Shipping"
}
}

View File

@ -9,6 +9,11 @@
"show_local_delivery_label": "Show Local Delivery",
"show_pickup_label": "Show Pickup",
"location_id_label": "Location ID (advanced)",
"location_id_info": "Leave blank to use the shop's default location."
"location_id_info": "Leave blank to use the shop's default location.",
"google_maps_api_key_label": "Google Maps API key",
"google_maps_api_key_info": "Optional — shows a map for Pickup locations. Restrict this key to your store's domain in Google Cloud Console."
},
"order_confirmation": {
"name": "Delivery Confirmation"
}
}

View File

@ -6,6 +6,9 @@ query CartDeliveryOptionsTransformRunInput {
ddDate: attribute(key: "dd_date") {
value
}
ddRateLabel: attribute(key: "dd_rate_label") {
value
}
deliveryGroups {
deliveryOptions {
handle

View File

@ -15,13 +15,13 @@ const NO_CHANGES = {
/**
* Relabels every presented delivery option to the shopper's actual chosen
* method + date, so checkout never shows a generic carrier label
* method + date (+ zone/distance rate, if the shop has Rate rows
* configured Phase 5), so checkout never shows a generic carrier label
* ("Standard", "Economy") that could be mistaken for a shipping ETA on
* what's actually a pickup or local-delivery order. Phase 4 doesn't yet
* have a per-option method mapping (that needs Phase 5's zones/rates
* work), so every option in the cart gets the same clarified label
* reasonable since a single order only has one chosen fulfillment method
* today.
* what's actually a pickup or local-delivery order. There's still no
* per-option method mapping, so every option in the cart gets the same
* clarified label reasonable since a single order only has one chosen
* fulfillment method today.
* @param {CartDeliveryOptionsTransformRunInput} input
* @returns {CartDeliveryOptionsTransformRunResult}
*/
@ -29,6 +29,7 @@ export function cartDeliveryOptionsTransformRun(input) {
const decision = renameLabelFor({
dd_method: input.cart.ddMethod?.value,
dd_date: input.cart.ddDate?.value,
dd_rate_label: input.cart.ddRateLabel?.value,
});
if (!decision.rename) {

View File

@ -29,5 +29,9 @@ export function renameLabelFor(attributes) {
}
const label = METHOD_LABEL[method];
return { rename: true, title: `${label}${date}` };
// dd_rate_label comes from Phase 5's zone/distance rate resolution
// (rates.server.ts, surfaced by apps.scheduling.availability.tsx) —
// absent when the shop hasn't configured any Rate rows yet.
const rateSuffix = attributes.dd_rate_label ? ` (${attributes.dd_rate_label})` : "";
return { rename: true, title: `${label}${date}${rateSuffix}` };
}

View File

@ -6,6 +6,7 @@
"cart": {
"ddMethod": null,
"ddDate": null,
"ddRateLabel": null,
"deliveryGroups": [
{
"deliveryOptions": [{ "handle": "standard-shipping" }]

View File

@ -6,6 +6,7 @@
"cart": {
"ddMethod": { "value": "PICKUP" },
"ddDate": { "value": "2026-08-25" },
"ddRateLabel": null,
"deliveryGroups": [
{
"deliveryOptions": [{ "handle": "standard-shipping" }, { "handle": "express-shipping" }]

View File

@ -0,0 +1,28 @@
{
"payload": {
"export": "cart-delivery-options-transform-run",
"target": "cart.delivery-options.transform.run",
"input": {
"cart": {
"ddMethod": { "value": "LOCAL_DELIVERY" },
"ddDate": { "value": "2026-08-25" },
"ddRateLabel": { "value": "$5.00" },
"deliveryGroups": [
{
"deliveryOptions": [{ "handle": "local-delivery" }]
}
]
}
},
"output": {
"operations": [
{
"deliveryOptionRename": {
"deliveryOptionHandle": "local-delivery",
"title": "Local delivery — 2026-08-25 ($5.00)"
}
}
]
}
}
}

View File

@ -0,0 +1,6 @@
{
"name": "Delivery Date & Time",
"tile_heading": "Schedule delivery",
"tile_subheading": "Pick a pickup/delivery slot for this sale",
"modal_heading": "Delivery date & time"
}

View File

@ -0,0 +1,14 @@
{
"name": "pos-datetime",
"private": true,
"version": "1.0.0",
"license": "UNLICENSED",
"scripts": {
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@shopify/ui-extensions": "2025.10.x",
"preact": "^10.10.x",
"@preact/signals": "^2.3.x"
}
}

13
extensions/pos-datetime/shopify.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
import '@shopify/ui-extensions';
//@ts-ignore
declare module './src/Tile.jsx' {
const shopify: import('@shopify/ui-extensions/pos.home.tile.render').Api;
const globalThis: { shopify: typeof shopify };
}
//@ts-ignore
declare module './src/Modal.jsx' {
const shopify: import('@shopify/ui-extensions/pos.home.modal.render').Api;
const globalThis: { shopify: typeof shopify };
}

View File

@ -0,0 +1,19 @@
api_version = "2026-01"
[[extensions]]
type = "ui_extension"
# Change the merchant-facing name of the extension in locales/en.default.json
name = "t:name"
uid = "619919cc-b3ef-a888-33c4-e35d43b7c028675e2c92"
handle = "pos-datetime"
description = "Schedule a pickup/delivery date and time for the current sale, against the same capacity pool as the storefront widget."
# module: file that contains your extensions source code
# target: location where your extension appears in POS
[[extensions.targeting]]
module = "./src/Tile.jsx"
target = "pos.home.tile.render"
[[extensions.targeting]]
module = "./src/Modal.jsx"
target = "pos.home.modal.render"

View File

@ -0,0 +1,188 @@
import "@shopify/ui-extensions/preact";
import { render } from "preact";
import { useEffect, useState } from "preact/hooks";
export default async () => {
render(<Extension />, document.body);
};
// NOTE: unverified against a live POS session this environment has no
// way to run the POS app itself, only to generate the extension and
// typecheck it against @shopify/ui-extensions' own .d.ts files (which did
// catch several wrong API guesses during development see git history).
// The one real assumption worth double-checking first: process.env.APP_URL
// below is expected to be substituted at build time by the Shopify CLI
// (the same way it's set for the Remix app itself during `shopify app dev`
// / `deploy`) to the app's backend origin, since a POS extension runs in a
// completely different origin than the app and has to call it absolutely,
// unlike the storefront widget's relative /apps/scheduling/* path.
const APP_URL = process.env.APP_URL || "";
const METHODS = [
{ value: "PICKUP", label: "Pickup" },
{ value: "LOCAL_DELIVERY", label: "Local delivery" },
{ value: "SHIPPING", label: "Shipping" },
];
function minutesToDisplayTime(minutes) {
const h24 = Math.floor(minutes / 60);
const m = minutes % 60;
const period = h24 < 12 ? "AM" : "PM";
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
return `${h12}:${String(m).padStart(2, "0")} ${period}`;
}
function randomToken() {
return `pos-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function Extension() {
const { session, cart, toast } = shopify;
// POS carts don't expose a stable client-visible id/token the way a
// storefront cart.token does a random per-session id is enough here,
// since its only job is letting this same modal release the hold it
// created if the staff member picks a different slot before checking out.
const [cartToken] = useState(randomToken);
const [method, setMethod] = useState("PICKUP");
const [availability, setAvailability] = useState(null);
const [date, setDate] = useState("");
const [loading, setLoading] = useState(false);
const [confirmed, setConfirmed] = useState(null);
async function authedFetch(path, options = {}) {
const token = await session.getSessionToken();
return fetch(`${APP_URL}${path}`, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
});
}
useEffect(() => {
let cancelled = false;
setLoading(true);
setDate("");
setAvailability(null);
authedFetch(`/pos/scheduling/availability?method=${method}&days=14`)
.then((res) => res.json())
.then((body) => {
if (!cancelled) setAvailability(body);
})
.catch(() => {
if (!cancelled) toast.show("Couldn't load availability");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [method]);
const availableDates = availability ? Object.keys(availability.dates ?? {}) : [];
const slots = date && availability ? (availability.dates[date] ?? []) : [];
async function selectSlot(slot) {
if (!availability?.locationId) return;
setLoading(true);
try {
const holdRes = await authedFetch("/pos/scheduling/hold", {
method: "POST",
body: JSON.stringify({
intent: "create",
locationId: availability.locationId,
method,
date,
startMin: slot.startMin,
cartToken,
}),
});
const hold = await holdRes.json();
if (!hold.success) {
toast.show(hold.error || "That slot was just taken");
return;
}
const display = `${date}, ${minutesToDisplayTime(slot.startMin)}${minutesToDisplayTime(slot.endMin)}`;
const attrLabel = method === "PICKUP" ? "Pickup date" : method === "LOCAL_DELIVERY" ? "Delivery date" : "Shipping date";
await cart.addCartProperties({
[attrLabel]: display,
dd_method: method,
dd_date: date,
dd_start_min: String(slot.startMin),
dd_end_min: String(slot.endMin),
dd_location_id: availability.locationId,
});
setConfirmed(display);
toast.show("Slot reserved for this sale");
} catch {
toast.show("Couldn't reserve that slot");
} finally {
setLoading(false);
}
}
if (confirmed) {
return (
<s-page heading="Delivery date & time">
<s-box padding="base">
<s-text>Confirmed for {confirmed}</s-text>
</s-box>
</s-page>
);
}
return (
<s-page heading="Delivery date & time">
<s-scroll-box>
<s-box padding="base">
<s-section heading="Method">
<s-choice-list
values={[method]}
onChange={(e) => setMethod(e.currentTarget.values[0])}
>
{METHODS.map((m) => (
<s-choice key={m.value} value={m.value}>
{m.label}
</s-choice>
))}
</s-choice-list>
</s-section>
{loading && <s-text>Loading</s-text>}
{!loading && availableDates.length === 0 && <s-text>No dates available.</s-text>}
{!loading && availableDates.length > 0 && (
<s-section heading="Date">
<s-choice-list values={date ? [date] : []} onChange={(e) => setDate(e.currentTarget.values[0])}>
{availableDates.map((d) => (
<s-choice key={d} value={d}>
{d}
</s-choice>
))}
</s-choice-list>
</s-section>
)}
{date && slots.length > 0 && (
<s-section heading="Time">
<s-choice-list
onChange={(e) => selectSlot(slots.find((s) => String(s.startMin) === e.currentTarget.values[0]))}
>
{slots.map((s) => (
<s-choice key={s.startMin} value={String(s.startMin)}>
{minutesToDisplayTime(s.startMin)}{minutesToDisplayTime(s.endMin)}
</s-choice>
))}
</s-choice-list>
</s-section>
)}
</s-box>
</s-scroll-box>
</s-page>
);
}

View File

@ -0,0 +1,18 @@
import "@shopify/ui-extensions/preact";
import { render } from "preact";
export default async () => {
render(<Extension />, document.body);
};
function Extension() {
const { i18n } = shopify;
return (
<s-tile
heading={i18n.translate("tile_heading")}
subheading={i18n.translate("tile_subheading")}
onClick={() => shopify.action.presentModal()}
/>
);
}

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"target": "ES2020",
"checkJs": true,
"allowJs": true,
"moduleResolution": "node",
"esModuleInterop": true,
"noEmit": true,
"skipLibCheck": true
}
}

145
package-lock.json generated
View File

@ -54,6 +54,36 @@
]
}
},
"extensions/checkout-datetime": {
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"@preact/signals": "^2.3.x",
"@shopify/ui-extensions": "2026.7.x",
"preact": "^10.10.x"
}
},
"extensions/checkout-datetime/node_modules/@shopify/ui-extensions": {
"version": "2026.7.0",
"resolved": "https://registry.npmjs.org/@shopify/ui-extensions/-/ui-extensions-2026.7.0.tgz",
"integrity": "sha512-xqcExD7d5yAmTZMd+W8yB5sfB587UKobY/fPeONImZLITsEPRbUYfH9MPUHoGJ3uL+5oBYy9ZMZofysQ3vP7eQ==",
"license": "MIT",
"dependencies": {
"ts-morph": "^25.0.1"
},
"peerDependencies": {
"@preact/signals": "*",
"preact": "*"
},
"peerDependenciesMeta": {
"@preact/signals": {
"optional": true
},
"preact": {
"optional": true
}
}
},
"extensions/delivery-customization": {
"version": "0.0.1",
"license": "UNLICENSED",
@ -300,6 +330,15 @@
}
}
},
"extensions/pos-datetime": {
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"@preact/signals": "^2.3.x",
"@shopify/ui-extensions": "2025.10.x",
"preact": "^10.10.x"
}
},
"extensions/validation-slot": {
"version": "0.0.1",
"license": "UNLICENSED",
@ -4009,6 +4048,32 @@
"node": ">=20"
}
},
"node_modules/@preact/signals": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.11.1.tgz",
"integrity": "sha512-uYoD+USkacTNU02kfCBkJEV7xabKTmA78W5pnT2GymsuGEC9n/ZO+UT4S96l0dIL6KLDk77VJyUFuOMBvftVHg==",
"license": "MIT",
"dependencies": {
"@preact/signals-core": "^1.14.4"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact": ">= 10.25.0 || >=11.0.0-0"
}
},
"node_modules/@preact/signals-core": {
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz",
"integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/@prisma/client": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
@ -5739,6 +5804,27 @@
"@shopify/graphql-client": "^1.4.2"
}
},
"node_modules/@shopify/ui-extensions": {
"version": "2025.10.16",
"resolved": "https://registry.npmjs.org/@shopify/ui-extensions/-/ui-extensions-2025.10.16.tgz",
"integrity": "sha512-w8Lr8NbAILhtqWmeKH/9osFWBsB+G0ksWho/V3cruhK/t0GcdQA6GANKokFPTsmD9sszqlrrO+URbs5I/vHxiQ==",
"license": "MIT",
"dependencies": {
"ts-morph": "^25.0.1"
},
"peerDependencies": {
"@preact/signals": "*",
"preact": "*"
},
"peerDependenciesMeta": {
"@preact/signals": {
"optional": true
},
"preact": {
"optional": true
}
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@ -5775,6 +5861,17 @@
"node": ">=6.9.0"
}
},
"node_modules/@ts-morph/common": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.26.1.tgz",
"integrity": "sha512-Sn28TGl/4cFpcM+jwsH1wLncYq3FtN/BIpem+HOygfBWPT5pAeS5dB4VFVzV8FbnOKHpDLZmvAl4AjPEev5idA==",
"license": "MIT",
"dependencies": {
"fast-glob": "^3.3.2",
"minimatch": "^9.0.4",
"path-browserify": "^1.0.1"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@ -8228,6 +8325,10 @@
"node": ">= 16"
}
},
"node_modules/checkout-datetime": {
"resolved": "extensions/checkout-datetime",
"link": true
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -8377,6 +8478,12 @@
"node": ">=0.10.0"
}
},
"node_modules/code-block-writer": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
"license": "MIT"
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -15002,6 +15109,12 @@
"tslib": "^2.0.3"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"license": "MIT"
},
"node_modules/path-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz",
@ -15243,6 +15356,10 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pos-datetime": {
"resolved": "extensions/pos-datetime",
"link": true
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -15424,6 +15541,24 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/preact": {
"version": "10.29.8",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact-render-to-string": ">=5"
},
"peerDependenciesMeta": {
"preact-render-to-string": {
"optional": true
}
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -17515,6 +17650,16 @@
"integrity": "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==",
"license": "MIT"
},
"node_modules/ts-morph": {
"version": "25.0.1",
"resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-25.0.1.tgz",
"integrity": "sha512-QJEiTdnz1YjrB3JFhd626gX4rKHDLSjSVMvGGG4v7ONc3RBwa0Eei98G9AT9uNFDMtV54JyuXsFeC+OH0n6bXQ==",
"license": "MIT",
"dependencies": {
"@ts-morph/common": "~0.26.0",
"code-block-writer": "^13.0.3"
}
},
"node_modules/tsconfck": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",

View File

@ -18,6 +18,8 @@
"typegen:functions": "npm --prefix extensions/validation-slot run typegen && npm --prefix extensions/delivery-customization run typegen",
"pretypecheck": "npm run typegen:functions",
"typecheck": "tsc --noEmit",
"typecheck:pos": "npm --prefix extensions/pos-datetime run typecheck",
"typecheck:checkout": "npm --prefix extensions/checkout-datetime run typecheck",
"test:functions": "npm --prefix extensions/validation-slot test && npm --prefix extensions/delivery-customization test",
"test": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts",

View File

@ -0,0 +1,60 @@
-- AlterTable
ALTER TABLE "Location" ADD COLUMN "shopifyLocationId" TEXT;
-- CreateTable
CREATE TABLE "Zone" (
"id" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"postalCodes" TEXT[],
"radiusKm" DOUBLE PRECISION,
"minOrders" INTEGER,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Zone_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Rate" (
"id" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"method" "Method" NOT NULL,
"zoneId" TEXT,
"name" TEXT NOT NULL,
"priceCents" INTEGER NOT NULL,
"keyedBy" TEXT NOT NULL,
"minDistanceKm" DOUBLE PRECISION,
"maxDistanceKm" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Rate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GeocodeCache" (
"id" TEXT NOT NULL,
"normalizedKey" TEXT NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"lng" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GeocodeCache_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Zone_shopDomain_locationId_idx" ON "Zone"("shopDomain", "locationId");
-- CreateIndex
CREATE INDEX "Rate_shopDomain_method_idx" ON "Rate"("shopDomain", "method");
-- CreateIndex
CREATE UNIQUE INDEX "GeocodeCache_normalizedKey_key" ON "GeocodeCache"("normalizedKey");
-- AddForeignKey
ALTER TABLE "Zone" ADD CONSTRAINT "Zone_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rate" ADD CONSTRAINT "Rate_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "zoneId" TEXT;
-- CreateIndex
CREATE INDEX "Booking_shopDomain_zoneId_idx" ON "Booking"("shopDomain", "zoneId");

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "totalPriceCents" INTEGER;

View File

@ -63,10 +63,15 @@ model Location {
lng Float?
timezone String
active Boolean @default(true)
// Maps this row to Shopify's own Location resource (gid://shopify/Location/…)
// so inventory-based exclusion (Phase 5) can query stock at the right
// Shopify location — optional since not every merchant needs it wired up.
shopifyLocationId String?
slotTemplates SlotTemplate[]
overrides SlotOverride[]
blackouts BlackoutDate[]
bookings Booking[]
zones Zone[]
createdAt DateTime @default(now())
@@index([shopDomain])
@ -126,16 +131,25 @@ model Booking {
orderName String? // e.g. "#1001", for display only
locationId String
location Location @relation(fields: [locationId], references: [id])
// Which delivery Zone the order was routed through, if any (LOCAL_DELIVERY
// orders matched by zones.server.ts). Nullable: PICKUP/SHIPPING orders and
// any LOCAL_DELIVERY order placed before zones existed have none. Not a
// relation (no onDelete behavior wanted if a zone is later removed) —
// deliberately just an id for the delivery-density threshold check in
// zones.server.ts to count "how many orders have already routed here."
zoneId String?
method Method
slotStart DateTime
slotEnd DateTime
status String @default("confirmed") // confirmed|cancelled|fulfilled|no_show
customerEmail String?
customerPhone String?
totalPriceCents Int? // order's total_price at booking time, for the dispatch dashboard's revenue-by-method view
createdAt DateTime @default(now())
@@index([shopDomain, locationId, method, slotStart])
@@index([shopDomain, status])
@@index([shopDomain, zoneId])
}
// SlotHold is intentionally NOT a Prisma model — per IMPLEMENTATION_PLAN.md
@ -143,3 +157,47 @@ model Booking {
// semantics a soft, time-limited reservation needs, so it's the sole source
// of truth for holds (app/services/holds.server.ts). Mirroring it into
// Postgres too would only add a sync-consistency burden with no benefit.
model Zone {
id String @id @default(cuid())
shopDomain String
locationId String
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
name String
type String // postal|radius
postalCodes String[] // used when type = postal
radiusKm Float? // used when type = radius (straight-line distance from the location)
minOrders Int? // delivery-density threshold: don't offer this zone's days until N orders already routed there
active Boolean @default(true)
rates Rate[]
createdAt DateTime @default(now())
@@index([shopDomain, locationId])
}
model Rate {
id String @id @default(cuid())
shopDomain String
method Method
zoneId String?
zone Zone? @relation(fields: [zoneId], references: [id], onDelete: Cascade)
name String // shown to the shopper, e.g. "Standard local delivery"
priceCents Int
keyedBy String // zone|distance — see rates.server.ts for resolution order
minDistanceKm Float? // used when keyedBy = distance
maxDistanceKm Float? // used when keyedBy = distance
createdAt DateTime @default(now())
@@index([shopDomain, method])
}
// Permanent cache of address -> lat/lng geocode results (IMPLEMENTATION_PLAN.md
// §9: "cache geocode results per address; don't call the maps API on every
// availability request"). Addresses don't move, so entries never expire.
model GeocodeCache {
id String @id @default(cuid())
normalizedKey String @unique // lowercased, whitespace-collapsed address string
lat Float
lng Float
createdAt DateTime @default(now())
}

View File

@ -20,7 +20,7 @@ scopes = "read_locales,read_locations,read_markets,read_metaobjects,read_orders,
redirect_urls = [ "https://shopify.dev/apps/default-app-home/api/auth" ]
[webhooks]
api_version = "2026-10"
api_version = "2025-01"
# Handled by: app/routes/webhooks.app.uninstalled.tsx
[[webhooks.subscriptions]]
@ -70,6 +70,25 @@ api_version = "2026-10"
# uri = "/webhooks/shop/redact"
# compliance_topics = ["shop/redact"]
# This org appears enrolled in Shopify's "Next Generation Events" developer
# preview (https://shopify.dev/changelog/next-generation-events-now-available-in-developer-preview),
# a separate, optional delivery mechanism from classic [webhooks] above
# (GraphQL-style resource topics + create/update/delete actions, api_version
# pinned to "unstable" while in preview). The CLI now treats [events] as a
# REQUIRED section for this app/org even though our app doesn't use it —
# everything real is handled via [webhooks]. This subscription is a
# functionally-inert placeholder that exists solely to satisfy that schema
# gate (app/routes/webhooks.events.placeholder.tsx just logs and returns
# 200) — it is not part of this app's actual feature set.
[events]
api_version = "unstable"
[[events.subscription]]
handle = "unused-events-preview-placeholder"
topic = "Product"
actions = ["create"]
uri = "/webhooks/events/placeholder"
# App proxy so the storefront Theme App Extension can call our backend
# without CORS issues (see IMPLEMENTATION_PLAN.md §5.3). `shopify app dev`
# points this at your dev tunnel automatically when

View File

@ -87,4 +87,45 @@ describe("createBookingFromOrder", () => {
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/3" } });
expect(count).toBe(0);
});
it("converts total_price to integer cents for the dashboard's revenue view", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
});
await createBookingFromOrder(shopDomain, {
admin_graphql_api_id: "gid://shopify/Order/4",
total_price: "49.99",
note_attributes: [
{ name: "dd_method", value: "PICKUP" },
{ name: "dd_date", value: "2026-08-25" },
{ name: "dd_start_min", value: "540" },
{ name: "dd_end_min", value: "600" },
{ name: "dd_location_id", value: location.id },
],
});
const booking = await db.booking.findUnique({ where: { orderId: "gid://shopify/Order/4" } });
expect(booking?.totalPriceCents).toBe(4999);
});
it("leaves totalPriceCents unset when total_price is missing", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
});
await createBookingFromOrder(shopDomain, {
admin_graphql_api_id: "gid://shopify/Order/5",
note_attributes: [
{ name: "dd_method", value: "PICKUP" },
{ name: "dd_date", value: "2026-08-25" },
{ name: "dd_start_min", value: "540" },
{ name: "dd_end_min", value: "600" },
{ name: "dd_location_id", value: location.id },
],
});
const booking = await db.booking.findUnique({ where: { orderId: "gid://shopify/Order/5" } });
expect(booking?.totalPriceCents).toBeNull();
});
});

View File

@ -0,0 +1,166 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import db from "../../app/db.server";
import { findEligibleLocationsForDelivery, geocodeAddress, meetsDeliveryDensity } from "../../app/services/zones.server";
const shopDomain = "zones-integration-test.myshopify.com";
async function cleanup() {
await db.booking.deleteMany({ where: { shopDomain } });
await db.zone.deleteMany({ where: { shopDomain } });
await db.location.deleteMany({ where: { shopDomain } });
await db.geocodeCache.deleteMany({ where: { normalizedKey: { contains: "123 test" } } });
}
describe("zones.server integration", () => {
beforeEach(cleanup);
afterAll(async () => {
await cleanup();
await db.$disconnect();
});
it("geocodeAddress caches a result and reuses it without a real API key on the second call", async () => {
// No GOOGLE_MAPS_API_KEY is set in this test environment, so the first
// call would normally return null — pre-seed the cache directly to
// prove the cache-read path works without needing live Maps access.
await db.geocodeCache.upsert({
where: { normalizedKey: "123 test street" },
create: { normalizedKey: "123 test street", lat: 43.65, lng: -79.38 },
update: {},
});
const result = await geocodeAddress("123 Test Street");
expect(result).toEqual({ lat: 43.65, lng: -79.38 });
});
it("returns null when there's no cache entry and no API key configured", async () => {
delete process.env.GOOGLE_MAPS_API_KEY;
const result = await geocodeAddress("456 Nowhere Cached Ave");
expect(result).toBeNull();
});
it("findEligibleLocationsForDelivery matches a postal-zone location by postal code alone (no geocoding needed)", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Postal Loc", address: "", timezone: "America/Toronto" },
});
const zone = await db.zone.create({
data: {
shopDomain,
locationId: location.id,
name: "Downtown",
type: "postal",
postalCodes: ["M5V 3A8"],
},
});
const matches = await findEligibleLocationsForDelivery(shopDomain, { postalCode: "M5V3A8" });
expect(matches).toHaveLength(1);
expect(matches[0]).toMatchObject({ locationId: location.id, zoneId: zone.id, distanceKm: null });
});
it("excludes an inactive location's zones", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Inactive Loc", address: "", timezone: "America/Toronto", active: false },
});
await db.zone.create({
data: { shopDomain, locationId: location.id, name: "Zone", type: "postal", postalCodes: ["M5V 3A8"] },
});
const matches = await findEligibleLocationsForDelivery(shopDomain, { postalCode: "M5V3A8" });
expect(matches).toHaveLength(0);
});
it("ranks radius-zone matches nearest-first using cached geocode coordinates", async () => {
await db.geocodeCache.upsert({
where: { normalizedKey: "123 test customer address" },
create: { normalizedKey: "123 test customer address", lat: 43.6532, lng: -79.3832 }, // Toronto
update: {},
});
const near = await db.location.create({
data: { shopDomain, name: "Near", address: "", timezone: "America/Toronto", lat: 43.65, lng: -79.4 },
});
const far = await db.location.create({
data: { shopDomain, name: "Far", address: "", timezone: "America/Toronto", lat: 45.4215, lng: -75.6972 }, // Ottawa
});
await db.zone.create({
data: { shopDomain, locationId: near.id, name: "Zone", type: "radius", radiusKm: 500 },
});
await db.zone.create({
data: { shopDomain, locationId: far.id, name: "Zone", type: "radius", radiusKm: 500 },
});
const matches = await findEligibleLocationsForDelivery(shopDomain, { address: "123 Test Customer Address" });
expect(matches).toHaveLength(2);
expect(matches[0].locationId).toBe(near.id);
expect(matches[1].locationId).toBe(far.id);
});
it("meetsDeliveryDensity passes when minOrders is unset", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Loc", address: "", timezone: "America/Toronto" },
});
const zone = await db.zone.create({
data: { shopDomain, locationId: location.id, name: "Zone", type: "postal", postalCodes: [] },
});
expect(await meetsDeliveryDensity(shopDomain, zone)).toBe(true);
});
it("meetsDeliveryDensity fails below threshold and passes once enough bookings exist", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Loc", address: "", timezone: "America/Toronto" },
});
const zone = await db.zone.create({
data: { shopDomain, locationId: location.id, name: "Sparse Zone", type: "postal", postalCodes: [], minOrders: 2 },
});
expect(await meetsDeliveryDensity(shopDomain, zone)).toBe(false);
await db.booking.create({
data: {
shopDomain,
orderId: "gid://shopify/Order/zone-density-1",
locationId: location.id,
zoneId: zone.id,
method: "LOCAL_DELIVERY",
slotStart: new Date(),
slotEnd: new Date(),
},
});
expect(await meetsDeliveryDensity(shopDomain, zone)).toBe(false); // still only 1 of 2
await db.booking.create({
data: {
shopDomain,
orderId: "gid://shopify/Order/zone-density-2",
locationId: location.id,
zoneId: zone.id,
method: "LOCAL_DELIVERY",
slotStart: new Date(),
slotEnd: new Date(),
},
});
expect(await meetsDeliveryDensity(shopDomain, zone)).toBe(true);
});
it("meetsDeliveryDensity does not count a cancelled booking toward the threshold", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Loc", address: "", timezone: "America/Toronto" },
});
const zone = await db.zone.create({
data: { shopDomain, locationId: location.id, name: "Zone", type: "postal", postalCodes: [], minOrders: 1 },
});
await db.booking.create({
data: {
shopDomain,
orderId: "gid://shopify/Order/zone-density-cancelled",
locationId: location.id,
zoneId: zone.id,
method: "LOCAL_DELIVERY",
slotStart: new Date(),
slotEnd: new Date(),
status: "cancelled",
},
});
expect(await meetsDeliveryDensity(shopDomain, zone)).toBe(false);
});
});

View File

@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import {
bookingsToCsv,
bucketByLocalDate,
revenueByMethod,
toCsvRow,
upcomingFulfillments,
utilizationByDateLocation,
type BookingSummary,
type CapacitySummary,
} from "../../app/services/dashboard.server";
const ZONE = "America/Toronto";
function booking(overrides: Partial<BookingSummary> = {}): BookingSummary {
return {
id: "b1",
orderName: "#1001",
locationId: "loc_1",
locationName: "Main Bakery",
method: "PICKUP",
slotStart: new Date("2026-08-25T13:00:00.000Z"), // 9 AM EDT, a Tuesday
slotEnd: new Date("2026-08-25T14:00:00.000Z"),
timezone: ZONE,
status: "confirmed",
totalPriceCents: 2500,
customerEmail: "shopper@example.com",
...overrides,
};
}
describe("bucketByLocalDate", () => {
it("groups bookings under their location-local calendar date", () => {
const buckets = bucketByLocalDate([booking()]);
expect(Object.keys(buckets)).toEqual(["2026-08-25"]);
});
it("sorts bookings within a day by slot start time", () => {
const late = booking({ id: "late", slotStart: new Date("2026-08-25T20:00:00.000Z") });
const early = booking({ id: "early", slotStart: new Date("2026-08-25T13:00:00.000Z") });
const buckets = bucketByLocalDate([late, early]);
expect(buckets["2026-08-25"].map((b) => b.id)).toEqual(["early", "late"]);
});
it("puts a booking near midnight into the correct location-local day, not the UTC day", () => {
// 11 PM EDT on Aug 25 is already Aug 26 in UTC.
const b = booking({ slotStart: new Date("2026-08-26T03:00:00.000Z") });
const buckets = bucketByLocalDate([b]);
expect(Object.keys(buckets)).toEqual(["2026-08-25"]);
});
});
describe("revenueByMethod", () => {
it("sums confirmed and fulfilled bookings per method", () => {
const bookings = [
booking({ method: "PICKUP", totalPriceCents: 1000, status: "confirmed" }),
booking({ method: "PICKUP", totalPriceCents: 500, status: "fulfilled" }),
booking({ method: "LOCAL_DELIVERY", totalPriceCents: 2000, status: "confirmed" }),
];
expect(revenueByMethod(bookings)).toEqual({ SHIPPING: 0, LOCAL_DELIVERY: 2000, PICKUP: 1500 });
});
it("excludes cancelled and no_show bookings", () => {
const bookings = [
booking({ totalPriceCents: 1000, status: "cancelled" }),
booking({ totalPriceCents: 1000, status: "no_show" }),
];
expect(revenueByMethod(bookings).PICKUP).toBe(0);
});
it("treats a missing totalPriceCents as 0, not an error", () => {
expect(revenueByMethod([booking({ totalPriceCents: null })]).PICKUP).toBe(0);
});
});
describe("utilizationByDateLocation", () => {
it("computes booked/capacity percentage for a date+location+method with bookings", () => {
// 2026-08-25 is a Tuesday -> weekday 2.
const capacities: CapacitySummary[] = [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 }];
const bookings = [booking(), booking({ id: "b2" })];
const result = utilizationByDateLocation(bookings, capacities);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ date: "2026-08-25", booked: 2, capacity: 4, utilizationPct: 50 });
});
it("sums capacity across multiple slot templates for the same weekday", () => {
const capacities: CapacitySummary[] = [
{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 },
{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 6 },
];
const result = utilizationByDateLocation([booking()], capacities);
expect(result[0].capacity).toBe(10);
});
it("caps utilization at 100% even if overbooked", () => {
const capacities: CapacitySummary[] = [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 1 }];
const bookings = [booking(), booking({ id: "b2" }), booking({ id: "b3" })];
expect(utilizationByDateLocation(bookings, capacities)[0].utilizationPct).toBe(100);
});
it("excludes cancelled bookings from the booked count", () => {
const capacities: CapacitySummary[] = [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 }];
const result = utilizationByDateLocation([booking({ status: "cancelled" })], capacities);
expect(result).toHaveLength(0);
});
it("omits a date/location/method with no bookings at all", () => {
expect(utilizationByDateLocation([], [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 }])).toEqual([]);
});
});
describe("upcomingFulfillments", () => {
it("returns only confirmed bookings, nearest-first", () => {
const bookings = [
booking({ id: "later", slotStart: new Date("2026-08-26T13:00:00.000Z") }),
booking({ id: "sooner", slotStart: new Date("2026-08-25T13:00:00.000Z") }),
booking({ id: "fulfilled-already", status: "fulfilled" }),
];
const result = upcomingFulfillments(bookings);
expect(result.map((b) => b.id)).toEqual(["sooner", "later"]);
});
it("respects the limit", () => {
const bookings = Array.from({ length: 5 }, (_, i) => booking({ id: `b${i}` }));
expect(upcomingFulfillments(bookings, 2)).toHaveLength(2);
});
});
describe("toCsvRow / bookingsToCsv", () => {
it("quotes a field containing a comma", () => {
expect(toCsvRow(["a,b", "c"])).toBe('"a,b",c');
});
it("escapes an embedded quote by doubling it", () => {
expect(toCsvRow(['say "hi"'])).toBe('"say ""hi"""');
});
it("produces a header row plus one row per booking", () => {
const csv = bookingsToCsv([booking()]);
const lines = csv.split("\n");
expect(lines).toHaveLength(2);
expect(lines[0]).toContain("Order");
expect(lines[1]).toContain("#1001");
expect(lines[1]).toContain("25.00");
});
});

View File

@ -31,4 +31,14 @@ describe("renameLabelFor", () => {
it("does nothing when the date is missing even if method is present", () => {
expect(renameLabelFor({ dd_method: "PICKUP" })).toEqual({ rename: false });
});
it("appends the resolved rate when a rate label is present", () => {
const result = renameLabelFor({ dd_method: "LOCAL_DELIVERY", dd_date: "2026-08-25", dd_rate_label: "$5.00" });
expect(result).toEqual({ rename: true, title: "Local delivery — 2026-08-25 ($5.00)" });
});
it("omits the rate suffix entirely when no rate label is present", () => {
const result = renameLabelFor({ dd_method: "LOCAL_DELIVERY", dd_date: "2026-08-25" });
expect(result).toEqual({ rename: true, title: "Local delivery — 2026-08-25" });
});
});

83
tests/unit/geo.test.ts Normal file
View File

@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
haversineDistanceKm,
isPostalCodeListed,
isWithinRadiusKm,
normalizePostalCode,
sortByDistance,
} from "../../app/lib/geo";
// Known reference distances (verifiable against any mapping service):
const TORONTO = { lat: 43.6532, lng: -79.3832 };
const OTTAWA = { lat: 45.4215, lng: -75.6972 };
const KITCHENER = { lat: 43.4516, lng: -80.4925 };
describe("haversineDistanceKm", () => {
it("is zero for the same point", () => {
expect(haversineDistanceKm(TORONTO, TORONTO)).toBeCloseTo(0, 5);
});
it("is symmetric", () => {
expect(haversineDistanceKm(TORONTO, OTTAWA)).toBeCloseTo(haversineDistanceKm(OTTAWA, TORONTO), 8);
});
it("matches the known Toronto-Ottawa distance within a reasonable tolerance", () => {
// Real-world straight-line distance is ~352 km.
expect(haversineDistanceKm(TORONTO, OTTAWA)).toBeGreaterThan(340);
expect(haversineDistanceKm(TORONTO, OTTAWA)).toBeLessThan(365);
});
it("Kitchener is closer to Toronto than Ottawa is", () => {
expect(haversineDistanceKm(TORONTO, KITCHENER)).toBeLessThan(haversineDistanceKm(TORONTO, OTTAWA));
});
});
describe("isWithinRadiusKm", () => {
it("is true when distance is under the radius", () => {
expect(isWithinRadiusKm(TORONTO, KITCHENER, 200)).toBe(true);
});
it("is false when distance exceeds the radius", () => {
expect(isWithinRadiusKm(TORONTO, OTTAWA, 100)).toBe(false);
});
it("is true exactly at the boundary", () => {
const d = haversineDistanceKm(TORONTO, OTTAWA);
expect(isWithinRadiusKm(TORONTO, OTTAWA, d)).toBe(true);
});
});
describe("normalizePostalCode / isPostalCodeListed", () => {
it("normalizes case and whitespace", () => {
expect(normalizePostalCode("v6b 1a1")).toBe("V6B1A1");
expect(normalizePostalCode(" M5V 3A8 ")).toBe("M5V3A8");
});
it("matches regardless of formatting differences", () => {
expect(isPostalCodeListed("m5v3a8", ["M5V 3A8", "N2G 1A1"])).toBe(true);
});
it("does not match a postal code outside the list", () => {
expect(isPostalCodeListed("K1A 0A6", ["M5V 3A8", "N2G 1A1"])).toBe(false);
});
});
describe("sortByDistance", () => {
it("orders items nearest-first", () => {
const items = [
{ name: "Ottawa", coordinates: OTTAWA },
{ name: "Kitchener", coordinates: KITCHENER },
];
expect(sortByDistance(TORONTO, items).map((i) => i.name)).toEqual(["Kitchener", "Ottawa"]);
});
it("does not mutate the input array", () => {
const items = [
{ name: "Ottawa", coordinates: OTTAWA },
{ name: "Kitchener", coordinates: KITCHENER },
];
const original = [...items];
sortByDistance(TORONTO, items);
expect(items).toEqual(original);
});
});

77
tests/unit/rates.test.ts Normal file
View File

@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { resolveRate, type RateLike } from "../../app/services/rates.server";
import { formatPriceLabel } from "../../app/lib/currency";
function zoneRate(overrides: Partial<RateLike> = {}): RateLike {
return {
id: "rate_1",
method: "LOCAL_DELIVERY",
zoneId: "zone_1",
name: "Zone A",
priceCents: 500,
keyedBy: "zone",
minDistanceKm: null,
maxDistanceKm: null,
...overrides,
};
}
describe("resolveRate", () => {
it("matches a zone-keyed rate by exact zoneId", () => {
const rates = [zoneRate({ id: "a", zoneId: "zone_1" }), zoneRate({ id: "b", zoneId: "zone_2" })];
const result = resolveRate(rates, { method: "LOCAL_DELIVERY", zoneId: "zone_1" });
expect(result?.id).toBe("a");
});
it("returns null when no zone matches", () => {
const rates = [zoneRate({ zoneId: "zone_1" })];
expect(resolveRate(rates, { method: "LOCAL_DELIVERY", zoneId: "zone_9" })).toBeNull();
});
it("does not match a different method", () => {
const rates = [zoneRate({ method: "PICKUP", zoneId: "zone_1" })];
expect(resolveRate(rates, { method: "LOCAL_DELIVERY", zoneId: "zone_1" })).toBeNull();
});
it("matches a distance-keyed rate within its band", () => {
const rates = [
zoneRate({ id: "near", keyedBy: "distance", zoneId: null, minDistanceKm: 0, maxDistanceKm: 5, priceCents: 300 }),
zoneRate({ id: "far", keyedBy: "distance", zoneId: null, minDistanceKm: 5, maxDistanceKm: 15, priceCents: 700 }),
];
expect(resolveRate(rates, { method: "LOCAL_DELIVERY", distanceKm: 3 })?.id).toBe("near");
expect(resolveRate(rates, { method: "LOCAL_DELIVERY", distanceKm: 10 })?.id).toBe("far");
});
it("treats an unbounded minDistanceKm as 0 and unbounded maxDistanceKm as infinite", () => {
const rates = [zoneRate({ id: "flat", keyedBy: "distance", zoneId: null, minDistanceKm: null, maxDistanceKm: null })];
expect(resolveRate(rates, { method: "LOCAL_DELIVERY", distanceKm: 999 })?.id).toBe("flat");
});
it("excludes the upper bound of a distance band (half-open interval)", () => {
const rates = [
zoneRate({ id: "near", keyedBy: "distance", zoneId: null, minDistanceKm: 0, maxDistanceKm: 5 }),
zoneRate({ id: "far", keyedBy: "distance", zoneId: null, minDistanceKm: 5, maxDistanceKm: 15 }),
];
expect(resolveRate(rates, { method: "LOCAL_DELIVERY", distanceKm: 5 })?.id).toBe("far");
});
it("returns the cheapest match when multiple rates overlap", () => {
const rates = [
zoneRate({ id: "expensive", zoneId: "zone_1", priceCents: 900 }),
zoneRate({ id: "cheap", zoneId: "zone_1", priceCents: 400 }),
];
expect(resolveRate(rates, { method: "LOCAL_DELIVERY", zoneId: "zone_1" })?.id).toBe("cheap");
});
it("returns null for an empty rate list", () => {
expect(resolveRate([], { method: "LOCAL_DELIVERY", zoneId: "zone_1" })).toBeNull();
});
});
describe("formatPriceLabel", () => {
it("formats cents as a dollar amount with 2 decimals", () => {
expect(formatPriceLabel(500)).toBe("$5.00");
expect(formatPriceLabel(1999)).toBe("$19.99");
expect(formatPriceLabel(0)).toBe("$0.00");
});
});

82
tests/unit/zones.test.ts Normal file
View File

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { isZoneEligible, type ZoneLike } from "../../app/services/zones.server";
const TORONTO = { lat: 43.6532, lng: -79.3832 };
const KITCHENER = { lat: 43.4516, lng: -80.4925 }; // ~100km from Toronto
function postalZone(overrides: Partial<ZoneLike> = {}): ZoneLike {
return {
id: "zone_1",
locationId: "loc_1",
type: "postal",
postalCodes: ["M5V 3A8", "M4B 1B3"],
radiusKm: null,
minOrders: null,
active: true,
...overrides,
};
}
function radiusZone(overrides: Partial<ZoneLike> = {}): ZoneLike {
return {
id: "zone_2",
locationId: "loc_1",
type: "radius",
postalCodes: [],
radiusKm: 25,
minOrders: null,
active: true,
...overrides,
};
}
describe("isZoneEligible", () => {
it("an inactive zone is never eligible, even with a matching postal code", () => {
const zone = postalZone({ active: false });
expect(isZoneEligible(zone, null, { postalCode: "M5V 3A8" })).toBe(false);
});
describe("postal zones", () => {
it("matches a listed postal code", () => {
expect(isZoneEligible(postalZone(), null, { postalCode: "m5v3a8" })).toBe(true);
});
it("rejects an unlisted postal code", () => {
expect(isZoneEligible(postalZone(), null, { postalCode: "K1A 0A6" })).toBe(false);
});
it("is ineligible when no postal code is provided", () => {
expect(isZoneEligible(postalZone(), null, {})).toBe(false);
});
it("ignores location coordinates entirely (postal zones don't need them)", () => {
expect(isZoneEligible(postalZone(), TORONTO, { postalCode: "M5V 3A8" })).toBe(true);
});
});
describe("radius zones", () => {
it("is eligible within the radius", () => {
expect(isZoneEligible(radiusZone({ radiusKm: 200 }), TORONTO, { coordinates: KITCHENER })).toBe(true);
});
it("is ineligible outside the radius", () => {
expect(isZoneEligible(radiusZone({ radiusKm: 10 }), TORONTO, { coordinates: KITCHENER })).toBe(false);
});
it("is ineligible when the location has no coordinates set", () => {
expect(isZoneEligible(radiusZone(), null, { coordinates: KITCHENER })).toBe(false);
});
it("is ineligible when the customer has no coordinates (couldn't geocode)", () => {
expect(isZoneEligible(radiusZone(), TORONTO, {})).toBe(false);
});
it("is ineligible when radiusKm isn't configured", () => {
expect(isZoneEligible(radiusZone({ radiusKm: null }), TORONTO, { coordinates: KITCHENER })).toBe(false);
});
});
it("an unrecognized zone type is never eligible", () => {
expect(isZoneEligible(postalZone({ type: "driving-time" }), null, { postalCode: "M5V 3A8" })).toBe(false);
});
});

View File

@ -22,12 +22,23 @@ interface SlotDto {
remainingCapacity: number;
}
interface RateDto {
name: string;
priceCents: number;
label: string;
}
interface AvailabilityResponse {
locationId: string;
locationName: string;
timezone: string;
locationId: string | null;
locationName?: string;
locationLat?: number | null;
locationLng?: number | null;
timezone?: string;
method: Method;
dates: Record<string, SlotDto[]>;
zoneId?: string | null;
distanceKm?: number | null;
rate?: RateDto | null;
error?: string;
}
@ -35,6 +46,7 @@ interface WidgetConfig {
root: HTMLElement;
heading: string;
locationId: string | null;
googleMapsApiKey: string | null;
methods: Array<{ value: Method; label: string; attrLabel: string }>;
labels: {
chooseDate: string;
@ -44,6 +56,9 @@ interface WidgetConfig {
change: string;
loading: string;
error: string;
postalCodeLabel: string;
postalCodeSubmit: string;
outOfArea: string;
};
}
@ -86,6 +101,7 @@ function readConfig(root: HTMLElement): WidgetConfig {
root,
heading: d.heading || "",
locationId: d.locationId || null,
googleMapsApiKey: d.googleMapsApiKey || null,
methods,
labels: {
chooseDate: d.labelChooseDate || "Choose a date",
@ -95,13 +111,21 @@ function readConfig(root: HTMLElement): WidgetConfig {
change: d.labelChange || "Change",
loading: d.labelLoading || "Loading available dates…",
error: d.labelError || "Couldn't load available dates. Please try again.",
postalCodeLabel: d.labelPostalCode || "Enter your postal/ZIP code",
postalCodeSubmit: d.labelPostalCodeSubmit || "Check availability",
outOfArea: d.labelOutOfArea || "Sorry, we don't deliver to this address.",
},
};
}
async function fetchAvailability(method: Method, locationId: string | null): Promise<AvailabilityResponse> {
async function fetchAvailability(
method: Method,
locationId: string | null,
postalCode?: string,
): Promise<AvailabilityResponse> {
const params = new URLSearchParams({ method, days: "14" });
if (locationId) params.set("locationId", locationId);
if (postalCode) params.set("postalCode", postalCode);
const res = await fetch(`${PROXY_BASE}/availability?${params.toString()}`, {
headers: { Accept: "application/json" },
});
@ -148,11 +172,51 @@ async function writeCartAttribute(key: string, machine: Record<string, string>,
});
}
// Google Maps JS API loads once per page and calls a global callback — the
// callback name has to be unique-ish and reachable on `window`.
let mapsLoadPromise: Promise<void> | null = null;
function loadGoogleMaps(apiKey: string): Promise<void> {
if (mapsLoadPromise) return mapsLoadPromise;
mapsLoadPromise = new Promise((resolve, reject) => {
const callbackName = "__ddMapsReady";
(window as unknown as Record<string, () => void>)[callbackName] = () => resolve();
const script = document.createElement("script");
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(apiKey)}&callback=${callbackName}`;
script.async = true;
script.onerror = () => reject(new Error("Failed to load Google Maps"));
document.head.appendChild(script);
});
return mapsLoadPromise;
}
interface GoogleMapsGlobal {
maps: {
Map: new (el: HTMLElement, options: { center: { lat: number; lng: number }; zoom: number }) => unknown;
Marker: new (options: { position: { lat: number; lng: number }; map: unknown; title?: string }) => unknown;
};
}
async function renderPickupMap(container: HTMLElement, apiKey: string, lat: number, lng: number, title: string) {
try {
await loadGoogleMaps(apiKey);
const google = (window as unknown as { google: GoogleMapsGlobal }).google;
const map = new google.maps.Map(container, { center: { lat, lng }, zoom: 13 });
new google.maps.Marker({ position: { lat, lng }, map, title });
} catch {
container.hidden = true; // no map, no crash — the date/time picker still works fine without it
}
}
class DateTimeWidget {
private config: WidgetConfig;
private el = {
heading: document.createElement("h3"),
methodRow: document.createElement("div"),
postalRow: document.createElement("div"),
mapContainer: document.createElement("div"),
dateRow: document.createElement("div"),
timeRow: document.createElement("div"),
status: document.createElement("p"),
@ -161,8 +225,9 @@ class DateTimeWidget {
private selectedMethod: WidgetConfig["methods"][number] | null = null;
private selectedDate: string | null = null;
private availability: AvailabilityResponse | null = null;
private heldSlot: { locationId: string; method: Method; date: string; startMin: number; cartToken: string } | null =
null;
private heldSlot:
| { locationId: string; method: Method; date: string; startMin: number; cartToken: string; zoneId: string | null }
| null = null;
constructor(config: WidgetConfig) {
this.config = config;
@ -182,13 +247,25 @@ class DateTimeWidget {
}
this.el.methodRow.className = "dd-widget__row dd-widget__methods";
this.el.postalRow.className = "dd-widget__row dd-widget__postal";
this.el.postalRow.hidden = true;
this.el.mapContainer.className = "dd-widget__map";
this.el.mapContainer.hidden = true;
this.el.dateRow.className = "dd-widget__row dd-widget__dates";
this.el.timeRow.className = "dd-widget__row dd-widget__times";
this.el.status.className = "dd-widget__status";
this.el.confirmation.className = "dd-widget__confirmation";
this.el.confirmation.hidden = true;
root.append(this.el.confirmation, this.el.methodRow, this.el.dateRow, this.el.timeRow, this.el.status);
root.append(
this.el.confirmation,
this.el.methodRow,
this.el.postalRow,
this.el.mapContainer,
this.el.dateRow,
this.el.timeRow,
this.el.status,
);
if (methods.length === 1) {
this.selectMethod(methods[0]);
@ -214,20 +291,80 @@ class DateTimeWidget {
this.selectedMethod = method;
this.selectedDate = null;
this.el.timeRow.innerHTML = "";
this.el.dateRow.innerHTML = "";
this.el.mapContainer.hidden = true;
this.el.confirmation.hidden = false;
this.el.confirmation.hidden = true;
if (this.config.methods.length > 1) this.renderMethods();
// Local delivery needs a postal/ZIP code first — availability depends
// on which zone (if any) the address falls into, and which location
// that zone routes to (PRODUCT_STRATEGY.md §2 "Auto location assignment").
if (method.value === "LOCAL_DELIVERY") {
this.renderPostalCodeInput(method);
return;
}
this.el.postalRow.hidden = true;
await this.loadAvailability(method);
}
private renderPostalCodeInput(method: WidgetConfig["methods"][number]) {
this.el.postalRow.hidden = false;
this.el.postalRow.innerHTML = "";
this.el.status.textContent = "";
const input = document.createElement("input");
input.type = "text";
input.className = "dd-widget__input";
input.placeholder = this.config.labels.postalCodeLabel;
input.setAttribute("aria-label", this.config.labels.postalCodeLabel);
const button = document.createElement("button");
button.type = "button";
button.className = "dd-widget__pill";
button.textContent = this.config.labels.postalCodeSubmit;
button.addEventListener("click", async () => {
const postalCode = input.value.trim();
if (!postalCode) return;
await this.loadAvailability(method, postalCode);
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") button.click();
});
this.el.postalRow.append(input, button);
}
private async loadAvailability(method: WidgetConfig["methods"][number], postalCode?: string) {
this.el.status.textContent = this.config.labels.loading;
this.el.dateRow.innerHTML = "";
try {
this.availability = await fetchAvailability(method.value, this.config.locationId);
this.availability = await fetchAvailability(method.value, this.config.locationId, postalCode);
if (!this.availability.locationId) {
this.el.status.textContent = this.availability.error || this.config.labels.outOfArea;
return;
}
if (method.value === "PICKUP" && this.config.googleMapsApiKey) {
this.showPickupMap();
}
this.renderDates();
} catch {
this.el.status.textContent = this.config.labels.error;
}
}
private showPickupMap() {
const a = this.availability;
if (!a?.locationLat || !a?.locationLng || !this.config.googleMapsApiKey) return;
this.el.mapContainer.hidden = false;
void renderPickupMap(this.el.mapContainer, this.config.googleMapsApiKey, a.locationLat, a.locationLng, a.locationName ?? "");
}
private renderDates() {
const dates = Object.keys(this.availability?.dates ?? {}).sort();
this.el.dateRow.innerHTML = "";
@ -272,7 +409,8 @@ class DateTimeWidget {
private async selectSlot(date: string, slot: SlotDto) {
const method = this.selectedMethod!;
const availability = this.availability!;
const display = `${formatDateLabel(date)}, ${minutesToDisplayTime(slot.startMin)}${minutesToDisplayTime(slot.endMin)}`;
const rateLabel = availability.rate ? ` (${availability.rate.label})` : "";
const display = `${formatDateLabel(date)}, ${minutesToDisplayTime(slot.startMin)}${minutesToDisplayTime(slot.endMin)}${rateLabel}`;
this.el.status.textContent = this.config.labels.loading;
@ -286,7 +424,7 @@ class DateTimeWidget {
// against at checkout.
const hold = await requestHold({
intent: "create",
locationId: availability.locationId,
locationId: availability.locationId!,
method: method.value,
date,
startMin: slot.startMin,
@ -300,22 +438,31 @@ class DateTimeWidget {
return;
}
this.heldSlot = { locationId: availability.locationId, method: method.value, date, startMin: slot.startMin, cartToken };
this.heldSlot = {
locationId: availability.locationId!,
method: method.value,
date,
startMin: slot.startMin,
cartToken,
zoneId: availability.zoneId ?? null,
};
await writeCartAttribute(
method.attrLabel,
{
const machineAttrs: Record<string, string> = {
dd_method: method.value,
dd_date: date,
dd_start_min: String(slot.startMin),
dd_end_min: String(slot.endMin),
dd_location_id: availability.locationId,
},
display,
);
dd_location_id: availability.locationId!,
};
if (availability.zoneId) machineAttrs.dd_zone_id = availability.zoneId;
if (availability.rate) machineAttrs.dd_rate_label = availability.rate.label;
await writeCartAttribute(method.attrLabel, machineAttrs, display);
this.el.status.textContent = "";
this.el.methodRow.hidden = true;
this.el.postalRow.hidden = true;
this.el.mapContainer.hidden = true;
this.el.dateRow.hidden = true;
this.el.timeRow.hidden = true;
this.el.confirmation.hidden = false;