Compare commits
No commits in common. "5b2207a397ce516fe6acf3bb8e9569c964039f25" and "4eebcc5c78e4c900cbb67887b62d6c67882a9650" have entirely different histories.
5b2207a397
...
4eebcc5c78
@ -6,4 +6,3 @@ shopify-app-remix
|
|||||||
.shopify
|
.shopify
|
||||||
extensions/*/assets/*.js
|
extensions/*/assets/*.js
|
||||||
extensions/*/dist
|
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
|
run: npm run lint
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npm run typecheck
|
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
|
- name: Unit tests
|
||||||
run: npm test -- --run
|
run: npm test -- --run
|
||||||
- name: Function tests (real WASM build + function-runner against fixtures)
|
- 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
|
## Status
|
||||||
|
|
||||||
Phase 0 (scaffold & CI) through Phase 7 (POS + Checkout UI extensions) are
|
Phase 0 (scaffold & CI) through Phase 4 (enforcement Functions +
|
||||||
complete — that's the full v1 launch scope per §8 of
|
slot-holds) are complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased
|
||||||
`PRODUCT_STRATEGY.md`/§6 of `IMPLEMENTATION_PLAN.md`. Phase 8 (billing +
|
build order and acceptance criteria — next up is Phase 5 (multi-location,
|
||||||
Built-for-Shopify hardening) is next; Phases 9-10 (v1.x fast-follow, v2) are
|
zones, rates, auto-assignment).
|
||||||
explicitly separate post-launch milestones in the plan, not part of v1.
|
|
||||||
|
|
||||||
**Every scheduling surface calls the same two service functions**
|
|
||||||
(`app/services/availability-request.server.ts`,
|
|
||||||
`app/services/hold-request.server.ts`) — the storefront widget (app-proxy
|
|
||||||
auth), POS (`extensions/pos-datetime`, session-token auth), and Plus
|
|
||||||
checkout (`extensions/checkout-datetime`, session-token auth) each have
|
|
||||||
their own thin route wrapper but share the exact same resolution logic and
|
|
||||||
Redis-backed capacity pool, so a slot booked from any one of them is
|
|
||||||
unavailable on the other two. `extensions/checkout-datetime`'s Thank You
|
|
||||||
block and `extensions/datetime-widget`'s new `order-confirmation.liquid`
|
|
||||||
block both show the confirmed slot after checkout — the Liquid block is
|
|
||||||
what actually satisfies "all plans," since Checkout UI Extensions'
|
|
||||||
thank-you/order-status targets are Plus-only; there's no
|
|
||||||
`purchase.order-status.block.render` target in this API version (verified
|
|
||||||
against `@shopify/ui-extensions`' own type definitions — an early guess
|
|
||||||
based on the target name pattern was wrong).
|
|
||||||
|
|
||||||
**Unverified without a live device/store to test against** (noted in-code
|
|
||||||
where relevant): `pos-datetime` and `checkout-datetime` both assume
|
|
||||||
`process.env.APP_URL` is substituted at build time to the app's backend
|
|
||||||
origin, and neither extension's actual runtime behavior has been exercised
|
|
||||||
outside of typechecking against `@shopify/ui-extensions`' bundled types
|
|
||||||
(which did catch several wrong API-shape guesses during development).
|
|
||||||
|
|
||||||
Phase 5's Google Maps / geocoding features (pickup-location map in the
|
|
||||||
widget, radius-zone eligibility, address auto-geocoding on Save Location)
|
|
||||||
are only live if `GOOGLE_MAPS_API_KEY` is set — either as an env var for
|
|
||||||
server-side geocoding, or as the "Google Maps API key" block setting in the
|
|
||||||
theme editor for the storefront map. Without a key, everything else in
|
|
||||||
Phase 5 (postal-code zones, distance-band rates, delivery-density
|
|
||||||
thresholds) still works — those don't need Maps at all.
|
|
||||||
|
|
||||||
The storefront widget's TypeScript source lives in `widget-src/datetime-widget/`,
|
The storefront widget's TypeScript source lives in `widget-src/datetime-widget/`,
|
||||||
**not** inside `extensions/datetime-widget/` — a Theme App Extension's
|
**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 { TitleBar } from "@shopify/app-bridge-react";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import db from "../db.server";
|
import db from "../db.server";
|
||||||
import { geocodeAddress } from "../services/zones.server";
|
|
||||||
|
|
||||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||||
const { session } = await authenticate.admin(request);
|
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 address = String(formData.get("address") || "").trim();
|
||||||
const timezone = String(formData.get("timezone") || "").trim();
|
const timezone = String(formData.get("timezone") || "").trim();
|
||||||
const active = formData.get("active") === "true";
|
const active = formData.get("active") === "true";
|
||||||
const shopifyLocationId = String(formData.get("shopifyLocationId") || "").trim() || null;
|
|
||||||
|
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
if (!name) errors.name = "Name is required";
|
if (!name) errors.name = "Name is required";
|
||||||
@ -53,21 +51,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||||||
return { errors };
|
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({
|
await db.location.updateMany({
|
||||||
where: { id: params.id, shopDomain: session.shop },
|
where: { id: params.id, shopDomain: session.shop },
|
||||||
data: {
|
data: { name, address, timezone, active },
|
||||||
name,
|
|
||||||
address,
|
|
||||||
timezone,
|
|
||||||
active,
|
|
||||||
shopifyLocationId,
|
|
||||||
...(coordinates ? { lat: coordinates.lat, lng: coordinates.lng } : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { errors };
|
return { errors };
|
||||||
@ -92,16 +78,7 @@ function LocationForm({
|
|||||||
errors,
|
errors,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
}: {
|
}: {
|
||||||
location: {
|
location: { id: string; name: string; address: string; timezone: string; active: boolean };
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
timezone: string;
|
|
||||||
active: boolean;
|
|
||||||
shopifyLocationId: string | null;
|
|
||||||
lat: number | null;
|
|
||||||
lng: number | null;
|
|
||||||
};
|
|
||||||
errors?: Record<string, string>;
|
errors?: Record<string, string>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
}) {
|
}) {
|
||||||
@ -109,7 +86,6 @@ function LocationForm({
|
|||||||
const [address, setAddress] = useState(location.address);
|
const [address, setAddress] = useState(location.address);
|
||||||
const [timezone, setTimezone] = useState(location.timezone);
|
const [timezone, setTimezone] = useState(location.timezone);
|
||||||
const [active, setActive] = useState(location.active);
|
const [active, setActive] = useState(location.active);
|
||||||
const [shopifyLocationId, setShopifyLocationId] = useState(location.shopifyLocationId ?? "");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BlockStack gap="400">
|
<BlockStack gap="400">
|
||||||
@ -125,19 +101,7 @@ function LocationForm({
|
|||||||
error={errors?.name}
|
error={errors?.name}
|
||||||
requiredIndicator
|
requiredIndicator
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField label="Address" name="address" autoComplete="off" multiline={2} value={address} onChange={setAddress} />
|
||||||
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
|
<TextField
|
||||||
label="Timezone (IANA)"
|
label="Timezone (IANA)"
|
||||||
name="timezone"
|
name="timezone"
|
||||||
@ -147,14 +111,6 @@ function LocationForm({
|
|||||||
error={errors?.timezone}
|
error={errors?.timezone}
|
||||||
requiredIndicator
|
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} />
|
<Checkbox label="Active" name="active" value="true" checked={active} onChange={setActive} />
|
||||||
<InlineStack gap="200">
|
<InlineStack gap="200">
|
||||||
<Button submit variant="primary" loading={isSubmitting}>
|
<Button submit variant="primary" loading={isSubmitting}>
|
||||||
@ -162,7 +118,6 @@ function LocationForm({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button url={`/app/slots?locationId=${location.id}`}>Manage weekly slots</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/blackouts?locationId=${location.id}`}>Manage blackout dates</Button>
|
||||||
<Button url={`/app/zones?locationId=${location.id}`}>Manage delivery zones</Button>
|
|
||||||
</InlineStack>
|
</InlineStack>
|
||||||
</FormLayout>
|
</FormLayout>
|
||||||
</Form>
|
</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/locations">Locations</Link>
|
||||||
<Link to="/app/slots">Weekly slots</Link>
|
<Link to="/app/slots">Weekly slots</Link>
|
||||||
<Link to="/app/blackouts">Blackout dates</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>
|
</NavMenu>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppProvider>
|
</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 type { LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
import type { Method } from "@prisma/client";
|
import type { Method } from "@prisma/client";
|
||||||
import { authenticate } from "../shopify.server";
|
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
|
// Public endpoint, reachable only through Shopify's App Proxy (signature
|
||||||
// verified by authenticate.public.appProxy) — this is what the storefront
|
// verified by authenticate.public.appProxy) — this is what the storefront
|
||||||
// Theme App Extension calls. Requests to https://{shop}/apps/scheduling/*
|
// Theme App Extension calls. Requests to https://{shop}/apps/scheduling/*
|
||||||
// forward here because shopify.app.toml's [app_proxy].url already includes
|
// forward here because shopify.app.toml's [app_proxy].url already includes
|
||||||
// the /apps/scheduling prefix, so this file's path (apps.scheduling.*)
|
// the /apps/scheduling prefix, so this file's path (apps.scheduling.*)
|
||||||
// mirrors the shop-facing URL exactly. The actual resolution logic lives in
|
// mirrors the shop-facing URL exactly.
|
||||||
// services/availability-request.server.ts, shared with the POS route
|
//
|
||||||
// (pos.scheduling.availability.tsx) — same pool, same code, different auth.
|
// 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 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) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const { session } = await authenticate.public.appProxy(request);
|
const { session } = await authenticate.public.appProxy(request);
|
||||||
@ -22,21 +32,82 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const methodParam = url.searchParams.get("method");
|
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)) {
|
if (!methodParam || !VALID_METHODS.has(methodParam as Method)) {
|
||||||
return Response.json({ error: "Invalid or missing method" }, { status: 400 });
|
return Response.json({ error: "Invalid or missing method" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const method = methodParam as Method;
|
||||||
|
|
||||||
const result = await resolveAvailabilityRequest(session.shop, {
|
const location = locationIdParam
|
||||||
method: methodParam as Method,
|
? await db.location.findFirst({
|
||||||
locationId: url.searchParams.get("locationId") || undefined,
|
where: { id: locationIdParam, shopDomain: session.shop, active: true },
|
||||||
postalCode: url.searchParams.get("postalCode") || undefined,
|
})
|
||||||
address: url.searchParams.get("address") || undefined,
|
: await db.location.findFirst({
|
||||||
days: Number(url.searchParams.get("days")) || undefined,
|
where: { shopDomain: session.shop, active: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.locationId) {
|
if (!location) {
|
||||||
return Response.json(result, { status: result.error === "No active location configured" ? 404 : 200 });
|
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 { ActionFunctionArgs } from "@remix-run/node";
|
||||||
import type { Method } from "@prisma/client";
|
import type { Method } from "@prisma/client";
|
||||||
import { authenticate } from "../shopify.server";
|
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
|
// Public app-proxy endpoint (see apps.scheduling.availability.tsx for the
|
||||||
// path-mirroring rationale). Called by the widget the moment a shopper
|
// 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
|
// actually reserves capacity (PRODUCT_STRATEGY.md §3.1, §4.1: "the
|
||||||
// last-slot race condition"). The cart attribute write alone would just be
|
// last-slot race condition"). The cart attribute write alone would just be
|
||||||
// two shoppers racing to write the same free-text field; nothing would
|
// two shoppers racing to write the same free-text field; nothing would
|
||||||
// stop both orders from completing. Actual resolution lives in
|
// stop both orders from completing.
|
||||||
// services/hold-request.server.ts, shared with the POS route.
|
|
||||||
|
|
||||||
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
|
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 });
|
return Response.json({ error: "Missing or invalid parameters" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await resolveHoldRequest(session.shop, {
|
const location = await db.location.findFirst({
|
||||||
intent: intent === "release" ? "release" : "create",
|
where: { id: locationId, shopDomain: session.shop, active: true },
|
||||||
locationId,
|
});
|
||||||
|
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,
|
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,
|
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;
|
cart_token?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
phone?: 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[];
|
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 {
|
function readAttr(attrs: OrderNoteAttribute[], key: string): string | undefined {
|
||||||
return attrs.find((a) => a.name === key)?.value;
|
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 startMinRaw = readAttr(attrs, "dd_start_min");
|
||||||
const endMinRaw = readAttr(attrs, "dd_end_min");
|
const endMinRaw = readAttr(attrs, "dd_end_min");
|
||||||
const locationId = readAttr(attrs, "dd_location_id");
|
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) {
|
if (!method || !VALID_METHODS.has(method as Method) || !date || !startMinRaw || !endMinRaw || !locationId) {
|
||||||
return; // no scheduling selection on this order — nothing to book
|
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,
|
orderId: order.admin_graphql_api_id,
|
||||||
orderName: order.name,
|
orderName: order.name,
|
||||||
locationId: location.id,
|
locationId: location.id,
|
||||||
zoneId,
|
|
||||||
method: method as Method,
|
method: method as Method,
|
||||||
slotStart: slotStart.toJSDate(),
|
slotStart: slotStart.toJSDate(),
|
||||||
slotEnd: slotEnd.toJSDate(),
|
slotEnd: slotEnd.toJSDate(),
|
||||||
customerEmail: order.email ?? undefined,
|
customerEmail: order.email ?? undefined,
|
||||||
customerPhone: order.phone ?? undefined,
|
customerPhone: order.phone ?? undefined,
|
||||||
totalPriceCents: parsePriceCents(order.total_price),
|
|
||||||
},
|
},
|
||||||
update: {}, // redelivered webhook — the booking already exists, nothing to change
|
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;
|
color: inherit;
|
||||||
font: 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-change="{{ 'widget.change' | t | escape }}"
|
||||||
data-label-loading="{{ 'widget.loading' | t | escape }}"
|
data-label-loading="{{ 'widget.loading' | t | escape }}"
|
||||||
data-label-error="{{ 'widget.error' | 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.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 }}
|
{{ block.shopify_attributes }}
|
||||||
>
|
>
|
||||||
<noscript>{{ 'widget.enable_js' | t | escape }}</noscript>
|
<noscript>{{ 'widget.enable_js' | t | escape }}</noscript>
|
||||||
@ -62,12 +58,6 @@
|
|||||||
"id": "location_id",
|
"id": "location_id",
|
||||||
"label": "t:datetime_picker.location_id_label",
|
"label": "t:datetime_picker.location_id_label",
|
||||||
"info": "t:datetime_picker.location_id_info"
|
"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",
|
"change": "Change",
|
||||||
"loading": "Loading available dates…",
|
"loading": "Loading available dates…",
|
||||||
"error": "Couldn't load available dates. Please try again.",
|
"error": "Couldn't load available dates. Please try again.",
|
||||||
"enable_js": "Please enable JavaScript to choose a delivery date and time.",
|
"enable_js": "Please enable JavaScript to choose a delivery date and time."
|
||||||
"postal_code": "Enter your postal/ZIP code",
|
|
||||||
"postal_code_submit": "Check availability",
|
|
||||||
"out_of_area": "Sorry, we don't deliver to this address."
|
|
||||||
},
|
|
||||||
"order_confirmation": {
|
|
||||||
"pickup_label": "Pickup",
|
|
||||||
"delivery_label": "Delivery",
|
|
||||||
"shipping_label": "Shipping"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,11 +9,6 @@
|
|||||||
"show_local_delivery_label": "Show Local Delivery",
|
"show_local_delivery_label": "Show Local Delivery",
|
||||||
"show_pickup_label": "Show Pickup",
|
"show_pickup_label": "Show Pickup",
|
||||||
"location_id_label": "Location ID (advanced)",
|
"location_id_label": "Location ID (advanced)",
|
||||||
"location_id_info": "Leave blank to use the shop's default location.",
|
"location_id_info": "Leave blank to use the shop's default location."
|
||||||
"google_maps_api_key_label": "Google Maps API key",
|
|
||||||
"google_maps_api_key_info": "Optional — shows a map for Pickup locations. Restrict this key to your store's domain in Google Cloud Console."
|
|
||||||
},
|
|
||||||
"order_confirmation": {
|
|
||||||
"name": "Delivery Confirmation"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,6 @@ query CartDeliveryOptionsTransformRunInput {
|
|||||||
ddDate: attribute(key: "dd_date") {
|
ddDate: attribute(key: "dd_date") {
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
ddRateLabel: attribute(key: "dd_rate_label") {
|
|
||||||
value
|
|
||||||
}
|
|
||||||
deliveryGroups {
|
deliveryGroups {
|
||||||
deliveryOptions {
|
deliveryOptions {
|
||||||
handle
|
handle
|
||||||
|
|||||||
@ -15,13 +15,13 @@ const NO_CHANGES = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Relabels every presented delivery option to the shopper's actual chosen
|
* Relabels every presented delivery option to the shopper's actual chosen
|
||||||
* method + date (+ zone/distance rate, if the shop has Rate rows
|
* method + date, so checkout never shows a generic carrier label
|
||||||
* configured — Phase 5), so checkout never shows a generic carrier label
|
|
||||||
* ("Standard", "Economy") that could be mistaken for a shipping ETA on
|
* ("Standard", "Economy") that could be mistaken for a shipping ETA on
|
||||||
* what's actually a pickup or local-delivery order. There's still no
|
* what's actually a pickup or local-delivery order. Phase 4 doesn't yet
|
||||||
* per-option method mapping, so every option in the cart gets the same
|
* have a per-option method mapping (that needs Phase 5's zones/rates
|
||||||
* clarified label — reasonable since a single order only has one chosen
|
* work), so every option in the cart gets the same clarified label —
|
||||||
* fulfillment method today.
|
* reasonable since a single order only has one chosen fulfillment method
|
||||||
|
* today.
|
||||||
* @param {CartDeliveryOptionsTransformRunInput} input
|
* @param {CartDeliveryOptionsTransformRunInput} input
|
||||||
* @returns {CartDeliveryOptionsTransformRunResult}
|
* @returns {CartDeliveryOptionsTransformRunResult}
|
||||||
*/
|
*/
|
||||||
@ -29,7 +29,6 @@ export function cartDeliveryOptionsTransformRun(input) {
|
|||||||
const decision = renameLabelFor({
|
const decision = renameLabelFor({
|
||||||
dd_method: input.cart.ddMethod?.value,
|
dd_method: input.cart.ddMethod?.value,
|
||||||
dd_date: input.cart.ddDate?.value,
|
dd_date: input.cart.ddDate?.value,
|
||||||
dd_rate_label: input.cart.ddRateLabel?.value,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!decision.rename) {
|
if (!decision.rename) {
|
||||||
|
|||||||
@ -29,9 +29,5 @@ export function renameLabelFor(attributes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const label = METHOD_LABEL[method];
|
const label = METHOD_LABEL[method];
|
||||||
// dd_rate_label comes from Phase 5's zone/distance rate resolution
|
return { rename: true, title: `${label} — ${date}` };
|
||||||
// (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}` };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
"cart": {
|
"cart": {
|
||||||
"ddMethod": null,
|
"ddMethod": null,
|
||||||
"ddDate": null,
|
"ddDate": null,
|
||||||
"ddRateLabel": null,
|
|
||||||
"deliveryGroups": [
|
"deliveryGroups": [
|
||||||
{
|
{
|
||||||
"deliveryOptions": [{ "handle": "standard-shipping" }]
|
"deliveryOptions": [{ "handle": "standard-shipping" }]
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
"cart": {
|
"cart": {
|
||||||
"ddMethod": { "value": "PICKUP" },
|
"ddMethod": { "value": "PICKUP" },
|
||||||
"ddDate": { "value": "2026-08-25" },
|
"ddDate": { "value": "2026-08-25" },
|
||||||
"ddRateLabel": null,
|
|
||||||
"deliveryGroups": [
|
"deliveryGroups": [
|
||||||
{
|
{
|
||||||
"deliveryOptions": [{ "handle": "standard-shipping" }, { "handle": "express-shipping" }]
|
"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": {
|
"extensions/delivery-customization": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "UNLICENSED",
|
"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": {
|
"extensions/validation-slot": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
@ -4048,32 +4009,6 @@
|
|||||||
"node": ">=20"
|
"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": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||||
@ -5804,27 +5739,6 @@
|
|||||||
"@shopify/graphql-client": "^1.4.2"
|
"@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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@ -5861,17 +5775,6 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.3",
|
"version": "0.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||||
@ -8325,10 +8228,6 @@
|
|||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/checkout-datetime": {
|
|
||||||
"resolved": "extensions/checkout-datetime",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@ -8478,12 +8377,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@ -15109,12 +15002,6 @@
|
|||||||
"tslib": "^2.0.3"
|
"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": {
|
"node_modules/path-case": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz",
|
"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": "^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": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"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==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@ -17650,16 +17515,6 @@
|
|||||||
"integrity": "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==",
|
"integrity": "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tsconfck": {
|
||||||
"version": "3.1.6",
|
"version": "3.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
|
"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",
|
"typegen:functions": "npm --prefix extensions/validation-slot run typegen && npm --prefix extensions/delivery-customization run typegen",
|
||||||
"pretypecheck": "npm run typegen:functions",
|
"pretypecheck": "npm run typegen:functions",
|
||||||
"typecheck": "tsc --noEmit",
|
"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:functions": "npm --prefix extensions/validation-slot test && npm --prefix extensions/delivery-customization test",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
"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?
|
lng Float?
|
||||||
timezone String
|
timezone String
|
||||||
active Boolean @default(true)
|
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[]
|
slotTemplates SlotTemplate[]
|
||||||
overrides SlotOverride[]
|
overrides SlotOverride[]
|
||||||
blackouts BlackoutDate[]
|
blackouts BlackoutDate[]
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
zones Zone[]
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([shopDomain])
|
@@index([shopDomain])
|
||||||
@ -131,25 +126,16 @@ model Booking {
|
|||||||
orderName String? // e.g. "#1001", for display only
|
orderName String? // e.g. "#1001", for display only
|
||||||
locationId String
|
locationId String
|
||||||
location Location @relation(fields: [locationId], references: [id])
|
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
|
method Method
|
||||||
slotStart DateTime
|
slotStart DateTime
|
||||||
slotEnd DateTime
|
slotEnd DateTime
|
||||||
status String @default("confirmed") // confirmed|cancelled|fulfilled|no_show
|
status String @default("confirmed") // confirmed|cancelled|fulfilled|no_show
|
||||||
customerEmail String?
|
customerEmail String?
|
||||||
customerPhone 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())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([shopDomain, locationId, method, slotStart])
|
@@index([shopDomain, locationId, method, slotStart])
|
||||||
@@index([shopDomain, status])
|
@@index([shopDomain, status])
|
||||||
@@index([shopDomain, zoneId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SlotHold is intentionally NOT a Prisma model — per IMPLEMENTATION_PLAN.md
|
// 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
|
// 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
|
// of truth for holds (app/services/holds.server.ts). Mirroring it into
|
||||||
// Postgres too would only add a sync-consistency burden with no benefit.
|
// 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" ]
|
redirect_urls = [ "https://shopify.dev/apps/default-app-home/api/auth" ]
|
||||||
|
|
||||||
[webhooks]
|
[webhooks]
|
||||||
api_version = "2025-01"
|
api_version = "2026-10"
|
||||||
|
|
||||||
# Handled by: app/routes/webhooks.app.uninstalled.tsx
|
# Handled by: app/routes/webhooks.app.uninstalled.tsx
|
||||||
[[webhooks.subscriptions]]
|
[[webhooks.subscriptions]]
|
||||||
@ -70,25 +70,6 @@ api_version = "2025-01"
|
|||||||
# uri = "/webhooks/shop/redact"
|
# uri = "/webhooks/shop/redact"
|
||||||
# compliance_topics = ["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
|
# App proxy so the storefront Theme App Extension can call our backend
|
||||||
# without CORS issues (see IMPLEMENTATION_PLAN.md §5.3). `shopify app dev`
|
# without CORS issues (see IMPLEMENTATION_PLAN.md §5.3). `shopify app dev`
|
||||||
# points this at your dev tunnel automatically when
|
# 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" } });
|
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/3" } });
|
||||||
expect(count).toBe(0);
|
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", () => {
|
it("does nothing when the date is missing even if method is present", () => {
|
||||||
expect(renameLabelFor({ dd_method: "PICKUP" })).toEqual({ rename: false });
|
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;
|
remainingCapacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RateDto {
|
|
||||||
name: string;
|
|
||||||
priceCents: number;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AvailabilityResponse {
|
interface AvailabilityResponse {
|
||||||
locationId: string | null;
|
locationId: string;
|
||||||
locationName?: string;
|
locationName: string;
|
||||||
locationLat?: number | null;
|
timezone: string;
|
||||||
locationLng?: number | null;
|
|
||||||
timezone?: string;
|
|
||||||
method: Method;
|
method: Method;
|
||||||
dates: Record<string, SlotDto[]>;
|
dates: Record<string, SlotDto[]>;
|
||||||
zoneId?: string | null;
|
|
||||||
distanceKm?: number | null;
|
|
||||||
rate?: RateDto | null;
|
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,7 +35,6 @@ interface WidgetConfig {
|
|||||||
root: HTMLElement;
|
root: HTMLElement;
|
||||||
heading: string;
|
heading: string;
|
||||||
locationId: string | null;
|
locationId: string | null;
|
||||||
googleMapsApiKey: string | null;
|
|
||||||
methods: Array<{ value: Method; label: string; attrLabel: string }>;
|
methods: Array<{ value: Method; label: string; attrLabel: string }>;
|
||||||
labels: {
|
labels: {
|
||||||
chooseDate: string;
|
chooseDate: string;
|
||||||
@ -56,9 +44,6 @@ interface WidgetConfig {
|
|||||||
change: string;
|
change: string;
|
||||||
loading: string;
|
loading: string;
|
||||||
error: string;
|
error: string;
|
||||||
postalCodeLabel: string;
|
|
||||||
postalCodeSubmit: string;
|
|
||||||
outOfArea: string;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,7 +86,6 @@ function readConfig(root: HTMLElement): WidgetConfig {
|
|||||||
root,
|
root,
|
||||||
heading: d.heading || "",
|
heading: d.heading || "",
|
||||||
locationId: d.locationId || null,
|
locationId: d.locationId || null,
|
||||||
googleMapsApiKey: d.googleMapsApiKey || null,
|
|
||||||
methods,
|
methods,
|
||||||
labels: {
|
labels: {
|
||||||
chooseDate: d.labelChooseDate || "Choose a date",
|
chooseDate: d.labelChooseDate || "Choose a date",
|
||||||
@ -111,21 +95,13 @@ function readConfig(root: HTMLElement): WidgetConfig {
|
|||||||
change: d.labelChange || "Change",
|
change: d.labelChange || "Change",
|
||||||
loading: d.labelLoading || "Loading available dates…",
|
loading: d.labelLoading || "Loading available dates…",
|
||||||
error: d.labelError || "Couldn't load available dates. Please try again.",
|
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(
|
async function fetchAvailability(method: Method, locationId: string | null): Promise<AvailabilityResponse> {
|
||||||
method: Method,
|
|
||||||
locationId: string | null,
|
|
||||||
postalCode?: string,
|
|
||||||
): Promise<AvailabilityResponse> {
|
|
||||||
const params = new URLSearchParams({ method, days: "14" });
|
const params = new URLSearchParams({ method, days: "14" });
|
||||||
if (locationId) params.set("locationId", locationId);
|
if (locationId) params.set("locationId", locationId);
|
||||||
if (postalCode) params.set("postalCode", postalCode);
|
|
||||||
const res = await fetch(`${PROXY_BASE}/availability?${params.toString()}`, {
|
const res = await fetch(`${PROXY_BASE}/availability?${params.toString()}`, {
|
||||||
headers: { Accept: "application/json" },
|
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 {
|
class DateTimeWidget {
|
||||||
private config: WidgetConfig;
|
private config: WidgetConfig;
|
||||||
private el = {
|
private el = {
|
||||||
heading: document.createElement("h3"),
|
heading: document.createElement("h3"),
|
||||||
methodRow: document.createElement("div"),
|
methodRow: document.createElement("div"),
|
||||||
postalRow: document.createElement("div"),
|
|
||||||
mapContainer: document.createElement("div"),
|
|
||||||
dateRow: document.createElement("div"),
|
dateRow: document.createElement("div"),
|
||||||
timeRow: document.createElement("div"),
|
timeRow: document.createElement("div"),
|
||||||
status: document.createElement("p"),
|
status: document.createElement("p"),
|
||||||
@ -225,9 +161,8 @@ class DateTimeWidget {
|
|||||||
private selectedMethod: WidgetConfig["methods"][number] | null = null;
|
private selectedMethod: WidgetConfig["methods"][number] | null = null;
|
||||||
private selectedDate: string | null = null;
|
private selectedDate: string | null = null;
|
||||||
private availability: AvailabilityResponse | null = null;
|
private availability: AvailabilityResponse | null = null;
|
||||||
private heldSlot:
|
private heldSlot: { locationId: string; method: Method; date: string; startMin: number; cartToken: string } | null =
|
||||||
| { locationId: string; method: Method; date: string; startMin: number; cartToken: string; zoneId: string | null }
|
null;
|
||||||
| null = null;
|
|
||||||
|
|
||||||
constructor(config: WidgetConfig) {
|
constructor(config: WidgetConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@ -247,25 +182,13 @@ class DateTimeWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.el.methodRow.className = "dd-widget__row dd-widget__methods";
|
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.dateRow.className = "dd-widget__row dd-widget__dates";
|
||||||
this.el.timeRow.className = "dd-widget__row dd-widget__times";
|
this.el.timeRow.className = "dd-widget__row dd-widget__times";
|
||||||
this.el.status.className = "dd-widget__status";
|
this.el.status.className = "dd-widget__status";
|
||||||
this.el.confirmation.className = "dd-widget__confirmation";
|
this.el.confirmation.className = "dd-widget__confirmation";
|
||||||
this.el.confirmation.hidden = true;
|
this.el.confirmation.hidden = true;
|
||||||
|
|
||||||
root.append(
|
root.append(this.el.confirmation, this.el.methodRow, this.el.dateRow, this.el.timeRow, this.el.status);
|
||||||
this.el.confirmation,
|
|
||||||
this.el.methodRow,
|
|
||||||
this.el.postalRow,
|
|
||||||
this.el.mapContainer,
|
|
||||||
this.el.dateRow,
|
|
||||||
this.el.timeRow,
|
|
||||||
this.el.status,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (methods.length === 1) {
|
if (methods.length === 1) {
|
||||||
this.selectMethod(methods[0]);
|
this.selectMethod(methods[0]);
|
||||||
@ -291,80 +214,20 @@ class DateTimeWidget {
|
|||||||
this.selectedMethod = method;
|
this.selectedMethod = method;
|
||||||
this.selectedDate = null;
|
this.selectedDate = null;
|
||||||
this.el.timeRow.innerHTML = "";
|
this.el.timeRow.innerHTML = "";
|
||||||
this.el.dateRow.innerHTML = "";
|
|
||||||
this.el.mapContainer.hidden = true;
|
|
||||||
this.el.confirmation.hidden = false;
|
|
||||||
this.el.confirmation.hidden = true;
|
this.el.confirmation.hidden = true;
|
||||||
if (this.config.methods.length > 1) this.renderMethods();
|
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.status.textContent = this.config.labels.loading;
|
||||||
this.el.dateRow.innerHTML = "";
|
this.el.dateRow.innerHTML = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.availability = await fetchAvailability(method.value, this.config.locationId, postalCode);
|
this.availability = await fetchAvailability(method.value, this.config.locationId);
|
||||||
|
|
||||||
if (!this.availability.locationId) {
|
|
||||||
this.el.status.textContent = this.availability.error || this.config.labels.outOfArea;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method.value === "PICKUP" && this.config.googleMapsApiKey) {
|
|
||||||
this.showPickupMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.renderDates();
|
this.renderDates();
|
||||||
} catch {
|
} catch {
|
||||||
this.el.status.textContent = this.config.labels.error;
|
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() {
|
private renderDates() {
|
||||||
const dates = Object.keys(this.availability?.dates ?? {}).sort();
|
const dates = Object.keys(this.availability?.dates ?? {}).sort();
|
||||||
this.el.dateRow.innerHTML = "";
|
this.el.dateRow.innerHTML = "";
|
||||||
@ -409,8 +272,7 @@ class DateTimeWidget {
|
|||||||
private async selectSlot(date: string, slot: SlotDto) {
|
private async selectSlot(date: string, slot: SlotDto) {
|
||||||
const method = this.selectedMethod!;
|
const method = this.selectedMethod!;
|
||||||
const availability = this.availability!;
|
const availability = this.availability!;
|
||||||
const rateLabel = availability.rate ? ` (${availability.rate.label})` : "";
|
const display = `${formatDateLabel(date)}, ${minutesToDisplayTime(slot.startMin)}–${minutesToDisplayTime(slot.endMin)}`;
|
||||||
const display = `${formatDateLabel(date)}, ${minutesToDisplayTime(slot.startMin)}–${minutesToDisplayTime(slot.endMin)}${rateLabel}`;
|
|
||||||
|
|
||||||
this.el.status.textContent = this.config.labels.loading;
|
this.el.status.textContent = this.config.labels.loading;
|
||||||
|
|
||||||
@ -424,7 +286,7 @@ class DateTimeWidget {
|
|||||||
// against at checkout.
|
// against at checkout.
|
||||||
const hold = await requestHold({
|
const hold = await requestHold({
|
||||||
intent: "create",
|
intent: "create",
|
||||||
locationId: availability.locationId!,
|
locationId: availability.locationId,
|
||||||
method: method.value,
|
method: method.value,
|
||||||
date,
|
date,
|
||||||
startMin: slot.startMin,
|
startMin: slot.startMin,
|
||||||
@ -438,31 +300,22 @@ class DateTimeWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.heldSlot = {
|
this.heldSlot = { locationId: availability.locationId, method: method.value, date, startMin: slot.startMin, cartToken };
|
||||||
locationId: availability.locationId!,
|
|
||||||
method: method.value,
|
|
||||||
date,
|
|
||||||
startMin: slot.startMin,
|
|
||||||
cartToken,
|
|
||||||
zoneId: availability.zoneId ?? null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const machineAttrs: Record<string, string> = {
|
await writeCartAttribute(
|
||||||
|
method.attrLabel,
|
||||||
|
{
|
||||||
dd_method: method.value,
|
dd_method: method.value,
|
||||||
dd_date: date,
|
dd_date: date,
|
||||||
dd_start_min: String(slot.startMin),
|
dd_start_min: String(slot.startMin),
|
||||||
dd_end_min: String(slot.endMin),
|
dd_end_min: String(slot.endMin),
|
||||||
dd_location_id: availability.locationId!,
|
dd_location_id: availability.locationId,
|
||||||
};
|
},
|
||||||
if (availability.zoneId) machineAttrs.dd_zone_id = availability.zoneId;
|
display,
|
||||||
if (availability.rate) machineAttrs.dd_rate_label = availability.rate.label;
|
);
|
||||||
|
|
||||||
await writeCartAttribute(method.attrLabel, machineAttrs, display);
|
|
||||||
|
|
||||||
this.el.status.textContent = "";
|
this.el.status.textContent = "";
|
||||||
this.el.methodRow.hidden = true;
|
this.el.methodRow.hidden = true;
|
||||||
this.el.postalRow.hidden = true;
|
|
||||||
this.el.mapContainer.hidden = true;
|
|
||||||
this.el.dateRow.hidden = true;
|
this.el.dateRow.hidden = true;
|
||||||
this.el.timeRow.hidden = true;
|
this.el.timeRow.hidden = true;
|
||||||
this.el.confirmation.hidden = false;
|
this.el.confirmation.hidden = false;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user