Compare commits
No commits in common. "5b2207a397ce516fe6acf3bb8e9569c964039f25" and "4eebcc5c78e4c900cbb67887b62d6c67882a9650" have entirely different histories.
5b2207a397
...
4eebcc5c78
@ -6,4 +6,3 @@ shopify-app-remix
|
||||
.shopify
|
||||
extensions/*/assets/*.js
|
||||
extensions/*/dist
|
||||
extensions/*/shopify.d.ts
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@ -46,10 +46,6 @@ 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)
|
||||
|
||||
40
README.md
40
README.md
@ -53,42 +53,10 @@ public launch or Built-for-Shopify submission** — don't ship without it.
|
||||
|
||||
## Status
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
The storefront widget's TypeScript source lives in `widget-src/datetime-widget/`,
|
||||
**not** inside `extensions/datetime-widget/` — a Theme App Extension's
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
// 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}`;
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
// 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),
|
||||
);
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
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"`,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,341 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -14,7 +14,6 @@ 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);
|
||||
@ -44,7 +43,6 @@ 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";
|
||||
@ -53,21 +51,9 @@ 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,
|
||||
shopifyLocationId,
|
||||
...(coordinates ? { lat: coordinates.lat, lng: coordinates.lng } : {}),
|
||||
},
|
||||
data: { name, address, timezone, active },
|
||||
});
|
||||
|
||||
return { errors };
|
||||
@ -92,16 +78,7 @@ function LocationForm({
|
||||
errors,
|
||||
isSubmitting,
|
||||
}: {
|
||||
location: {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
timezone: string;
|
||||
active: boolean;
|
||||
shopifyLocationId: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
};
|
||||
location: { id: string; name: string; address: string; timezone: string; active: boolean };
|
||||
errors?: Record<string, string>;
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
@ -109,7 +86,6 @@ 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">
|
||||
@ -125,19 +101,7 @@ function LocationForm({
|
||||
error={errors?.name}
|
||||
requiredIndicator
|
||||
/>
|
||||
<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="Address" name="address" autoComplete="off" multiline={2} value={address} onChange={setAddress} />
|
||||
<TextField
|
||||
label="Timezone (IANA)"
|
||||
name="timezone"
|
||||
@ -147,14 +111,6 @@ 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}>
|
||||
@ -162,7 +118,6 @@ 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>
|
||||
|
||||
@ -1,229 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -27,9 +27,6 @@ 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>
|
||||
|
||||
@ -1,233 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,18 +1,28 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { DateTime } from "luxon";
|
||||
import type { Method } from "@prisma/client";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { resolveAvailabilityRequest } from "../services/availability-request.server";
|
||||
import db from "../db.server";
|
||||
import { getAvailability } from "../services/scheduling.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. 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.
|
||||
// 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.
|
||||
|
||||
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);
|
||||
@ -22,21 +32,82 @@ 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 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,
|
||||
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" },
|
||||
});
|
||||
|
||||
if (!result.locationId) {
|
||||
return Response.json(result, { status: result.error === "No active location configured" ? 404 : 200 });
|
||||
if (!location) {
|
||||
return Response.json({ error: "No active location configured" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(result);
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
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";
|
||||
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";
|
||||
|
||||
// Public app-proxy endpoint (see apps.scheduling.availability.tsx for the
|
||||
// path-mirroring rationale). Called by the widget the moment a shopper
|
||||
@ -9,8 +12,7 @@ import { resolveHoldRequest } from "../services/hold-request.server";
|
||||
// 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. Actual resolution lives in
|
||||
// services/hold-request.server.ts, shared with the POS route.
|
||||
// stop both orders from completing.
|
||||
|
||||
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
|
||||
|
||||
@ -34,14 +36,63 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
return Response.json({ error: "Missing or invalid parameters" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await resolveHoldRequest(session.shop, {
|
||||
intent: intent === "release" ? "release" : "create",
|
||||
locationId,
|
||||
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,
|
||||
method: method as Method,
|
||||
date,
|
||||
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),
|
||||
startMin,
|
||||
cartToken,
|
||||
},
|
||||
});
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json(result.body, { status: result.status });
|
||||
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),
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
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));
|
||||
};
|
||||
@ -1,42 +0,0 @@
|
||||
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 }));
|
||||
};
|
||||
@ -1,34 +0,0 @@
|
||||
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));
|
||||
};
|
||||
@ -1,43 +0,0 @@
|
||||
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 }));
|
||||
};
|
||||
@ -1,16 +0,0 @@
|
||||
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();
|
||||
};
|
||||
@ -1,197 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -16,16 +16,9 @@ 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;
|
||||
}
|
||||
@ -51,7 +44,6 @@ 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
|
||||
@ -72,13 +64,11 @@ 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
|
||||
});
|
||||
|
||||
@ -1,171 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
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) },
|
||||
};
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
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.
|
||||
@ -1,224 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
{}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
13
extensions/checkout-datetime/shopify.d.ts
vendored
13
extensions/checkout-datetime/shopify.d.ts
vendored
@ -1,13 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
# 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
|
||||
@ -1,202 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@ -55,44 +55,3 @@
|
||||
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
@ -18,11 +18,7 @@
|
||||
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>
|
||||
@ -62,12 +58,6 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
{%- 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 %}
|
||||
@ -13,14 +13,6 @@
|
||||
"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.",
|
||||
"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"
|
||||
"enable_js": "Please enable JavaScript to choose a delivery date and time."
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,11 +9,6 @@
|
||||
"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.",
|
||||
"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"
|
||||
"location_id_info": "Leave blank to use the shop's default location."
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,9 +6,6 @@ query CartDeliveryOptionsTransformRunInput {
|
||||
ddDate: attribute(key: "dd_date") {
|
||||
value
|
||||
}
|
||||
ddRateLabel: attribute(key: "dd_rate_label") {
|
||||
value
|
||||
}
|
||||
deliveryGroups {
|
||||
deliveryOptions {
|
||||
handle
|
||||
|
||||
@ -15,13 +15,13 @@ const NO_CHANGES = {
|
||||
|
||||
/**
|
||||
* Relabels every presented delivery option to the shopper's actual chosen
|
||||
* method + date (+ zone/distance rate, if the shop has Rate rows
|
||||
* configured — Phase 5), so checkout never shows a generic carrier label
|
||||
* method + date, 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. 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.
|
||||
* 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.
|
||||
* @param {CartDeliveryOptionsTransformRunInput} input
|
||||
* @returns {CartDeliveryOptionsTransformRunResult}
|
||||
*/
|
||||
@ -29,7 +29,6 @@ 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) {
|
||||
|
||||
@ -29,9 +29,5 @@ export function renameLabelFor(attributes) {
|
||||
}
|
||||
|
||||
const label = METHOD_LABEL[method];
|
||||
// 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}` };
|
||||
return { rename: true, title: `${label} — ${date}` };
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
"cart": {
|
||||
"ddMethod": null,
|
||||
"ddDate": null,
|
||||
"ddRateLabel": null,
|
||||
"deliveryGroups": [
|
||||
{
|
||||
"deliveryOptions": [{ "handle": "standard-shipping" }]
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
"cart": {
|
||||
"ddMethod": { "value": "PICKUP" },
|
||||
"ddDate": { "value": "2026-08-25" },
|
||||
"ddRateLabel": null,
|
||||
"deliveryGroups": [
|
||||
{
|
||||
"deliveryOptions": [{ "handle": "standard-shipping" }, { "handle": "express-shipping" }]
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
{
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Delivery Date & Time",
|
||||
"tile_heading": "Schedule delivery",
|
||||
"tile_subheading": "Pick a pickup/delivery slot for this sale",
|
||||
"modal_heading": "Delivery date & time"
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"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
13
extensions/pos-datetime/shopify.d.ts
vendored
@ -1,13 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
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 extension’s 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"
|
||||
@ -1,188 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
{
|
||||
"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
145
package-lock.json
generated
@ -54,36 +54,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@ -330,15 +300,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@ -4048,32 +4009,6 @@
|
||||
"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",
|
||||
@ -5804,27 +5739,6 @@
|
||||
"@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",
|
||||
@ -5861,17 +5775,6 @@
|
||||
"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",
|
||||
@ -8325,10 +8228,6 @@
|
||||
"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",
|
||||
@ -8478,12 +8377,6 @@
|
||||
"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",
|
||||
@ -15109,12 +15002,6 @@
|
||||
"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",
|
||||
@ -15356,10 +15243,6 @@
|
||||
"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",
|
||||
@ -15541,24 +15424,6 @@
|
||||
"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",
|
||||
@ -17650,16 +17515,6 @@
|
||||
"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",
|
||||
|
||||
@ -18,8 +18,6 @@
|
||||
"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",
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
-- 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;
|
||||
@ -1,5 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Booking" ADD COLUMN "zoneId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Booking_shopDomain_zoneId_idx" ON "Booking"("shopDomain", "zoneId");
|
||||
@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Booking" ADD COLUMN "totalPriceCents" INTEGER;
|
||||
@ -63,15 +63,10 @@ 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])
|
||||
@ -131,25 +126,16 @@ 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
|
||||
@ -157,47 +143,3 @@ 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())
|
||||
}
|
||||
|
||||
@ -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 = "2025-01"
|
||||
api_version = "2026-10"
|
||||
|
||||
# Handled by: app/routes/webhooks.app.uninstalled.tsx
|
||||
[[webhooks.subscriptions]]
|
||||
@ -70,25 +70,6 @@ api_version = "2025-01"
|
||||
# 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
|
||||
|
||||
@ -87,45 +87,4 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,166 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -1,146 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@ -31,14 +31,4 @@ 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" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -1,77 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@ -1,82 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -22,23 +22,12 @@ interface SlotDto {
|
||||
remainingCapacity: number;
|
||||
}
|
||||
|
||||
interface RateDto {
|
||||
name: string;
|
||||
priceCents: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface AvailabilityResponse {
|
||||
locationId: string | null;
|
||||
locationName?: string;
|
||||
locationLat?: number | null;
|
||||
locationLng?: number | null;
|
||||
timezone?: string;
|
||||
locationId: string;
|
||||
locationName: string;
|
||||
timezone: string;
|
||||
method: Method;
|
||||
dates: Record<string, SlotDto[]>;
|
||||
zoneId?: string | null;
|
||||
distanceKm?: number | null;
|
||||
rate?: RateDto | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@ -46,7 +35,6 @@ interface WidgetConfig {
|
||||
root: HTMLElement;
|
||||
heading: string;
|
||||
locationId: string | null;
|
||||
googleMapsApiKey: string | null;
|
||||
methods: Array<{ value: Method; label: string; attrLabel: string }>;
|
||||
labels: {
|
||||
chooseDate: string;
|
||||
@ -56,9 +44,6 @@ interface WidgetConfig {
|
||||
change: string;
|
||||
loading: string;
|
||||
error: string;
|
||||
postalCodeLabel: string;
|
||||
postalCodeSubmit: string;
|
||||
outOfArea: string;
|
||||
};
|
||||
}
|
||||
|
||||
@ -101,7 +86,6 @@ 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",
|
||||
@ -111,21 +95,13 @@ 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,
|
||||
postalCode?: string,
|
||||
): Promise<AvailabilityResponse> {
|
||||
async function fetchAvailability(method: Method, locationId: string | null): 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" },
|
||||
});
|
||||
@ -172,51 +148,11 @@ 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"),
|
||||
@ -225,9 +161,8 @@ 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; zoneId: string | null }
|
||||
| null = null;
|
||||
private heldSlot: { locationId: string; method: Method; date: string; startMin: number; cartToken: string } | null =
|
||||
null;
|
||||
|
||||
constructor(config: WidgetConfig) {
|
||||
this.config = config;
|
||||
@ -247,25 +182,13 @@ 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.postalRow,
|
||||
this.el.mapContainer,
|
||||
this.el.dateRow,
|
||||
this.el.timeRow,
|
||||
this.el.status,
|
||||
);
|
||||
root.append(this.el.confirmation, this.el.methodRow, this.el.dateRow, this.el.timeRow, this.el.status);
|
||||
|
||||
if (methods.length === 1) {
|
||||
this.selectMethod(methods[0]);
|
||||
@ -291,80 +214,20 @@ 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, 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.availability = await fetchAvailability(method.value, this.config.locationId);
|
||||
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 = "";
|
||||
@ -409,8 +272,7 @@ class DateTimeWidget {
|
||||
private async selectSlot(date: string, slot: SlotDto) {
|
||||
const method = this.selectedMethod!;
|
||||
const availability = this.availability!;
|
||||
const rateLabel = availability.rate ? ` (${availability.rate.label})` : "";
|
||||
const display = `${formatDateLabel(date)}, ${minutesToDisplayTime(slot.startMin)}–${minutesToDisplayTime(slot.endMin)}${rateLabel}`;
|
||||
const display = `${formatDateLabel(date)}, ${minutesToDisplayTime(slot.startMin)}–${minutesToDisplayTime(slot.endMin)}`;
|
||||
|
||||
this.el.status.textContent = this.config.labels.loading;
|
||||
|
||||
@ -424,7 +286,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,
|
||||
@ -438,31 +300,22 @@ class DateTimeWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
this.heldSlot = {
|
||||
locationId: availability.locationId!,
|
||||
method: method.value,
|
||||
date,
|
||||
startMin: slot.startMin,
|
||||
cartToken,
|
||||
zoneId: availability.zoneId ?? null,
|
||||
};
|
||||
this.heldSlot = { locationId: availability.locationId, method: method.value, date, startMin: slot.startMin, cartToken };
|
||||
|
||||
const machineAttrs: Record<string, string> = {
|
||||
await writeCartAttribute(
|
||||
method.attrLabel,
|
||||
{
|
||||
dd_method: method.value,
|
||||
dd_date: date,
|
||||
dd_start_min: String(slot.startMin),
|
||||
dd_end_min: String(slot.endMin),
|
||||
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);
|
||||
dd_location_id: availability.locationId,
|
||||
},
|
||||
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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user