Compare commits
10 Commits
a35a4f89be
...
4eebcc5c78
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eebcc5c78 | ||
|
|
0538980eb5 | ||
|
|
cc3303d318 | ||
|
|
e116a6c87a | ||
|
|
381c52d01a | ||
|
|
7a340ad135 | ||
|
|
039b1539ab | ||
|
|
6c633af598 | ||
|
|
24980b8e03 | ||
|
|
8c2b8a9e1c |
@ -2,7 +2,7 @@
|
|||||||
# Never commit the real .env — this file is the template only.
|
# Never commit the real .env — this file is the template only.
|
||||||
SHOPIFY_API_KEY=
|
SHOPIFY_API_KEY=
|
||||||
SHOPIFY_API_SECRET=
|
SHOPIFY_API_SECRET=
|
||||||
SCOPES=read_products,read_customers,read_orders,write_orders,read_locations,read_metaobjects,write_metaobjects,write_cart_transforms,write_delivery_customizations,write_payment_customizations,read_markets,read_locales
|
SCOPES=read_products,read_orders,write_orders,read_locations,read_metaobjects,write_metaobjects,write_cart_transforms,write_delivery_customizations,write_payment_customizations,read_markets,read_locales
|
||||||
SHOPIFY_APP_URL=https://replace-with-your-tunnel-url.example.com
|
SHOPIFY_APP_URL=https://replace-with-your-tunnel-url.example.com
|
||||||
SHOP_CUSTOM_DOMAIN=
|
SHOP_CUSTOM_DOMAIN=
|
||||||
|
|
||||||
|
|||||||
@ -4,3 +4,5 @@ public/build
|
|||||||
shopify-app-remix
|
shopify-app-remix
|
||||||
*/*.yml
|
*/*.yml
|
||||||
.shopify
|
.shopify
|
||||||
|
extensions/*/assets/*.js
|
||||||
|
extensions/*/dist
|
||||||
|
|||||||
40
.github/workflows/ci.yml
vendored
40
.github/workflows/ci.yml
vendored
@ -4,13 +4,39 @@ on: [push, pull_request]
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Lint & Unit Tests
|
name: Lint, Unit & Integration Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: app
|
||||||
|
POSTGRES_PASSWORD: app
|
||||||
|
POSTGRES_DB: delivery_datetime_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://app:app@localhost:5432/delivery_datetime_test
|
||||||
|
REDIS_URL: redis://127.0.0.1:6379
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@ -22,3 +48,13 @@ jobs:
|
|||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: npm test -- --run
|
run: npm test -- --run
|
||||||
|
- name: Function tests (real WASM build + function-runner against fixtures)
|
||||||
|
run: npm run test:functions
|
||||||
|
- name: Apply migrations
|
||||||
|
run: npx prisma migrate deploy
|
||||||
|
- name: Integration tests (Redis-backed slot-hold concurrency, Booking flow)
|
||||||
|
run: npm run test:integration
|
||||||
|
- name: Build admin app
|
||||||
|
run: npm run build
|
||||||
|
- name: Build storefront widget
|
||||||
|
run: npm run build:widget
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -26,3 +26,6 @@ dump.rdb
|
|||||||
# Ignore shopify files created during app dev
|
# Ignore shopify files created during app dev
|
||||||
.shopify/*
|
.shopify/*
|
||||||
.shopify.lock
|
.shopify.lock
|
||||||
|
|
||||||
|
# Local debug output (can contain bearer tokens from --verbose CLI runs)
|
||||||
|
dev-log.txt
|
||||||
|
|||||||
46
README.md
46
README.md
@ -34,9 +34,49 @@ runs the BullMQ worker (jobs/worker.ts) once Phase 4 makes it do anything.
|
|||||||
| `npx prisma migrate dev` | DB migrations |
|
| `npx prisma migrate dev` | DB migrations |
|
||||||
| `npm run deploy` | `shopify app deploy` — deploy extensions/functions |
|
| `npm run deploy` | `shopify app deploy` — deploy extensions/functions |
|
||||||
| `npm run worker` | BullMQ worker (hold-expiry, notifications) |
|
| `npm run worker` | BullMQ worker (hold-expiry, notifications) |
|
||||||
|
| `npm run test:integration` | Redis/Postgres-backed tests (slot-hold concurrency, booking flow) — needs `docker compose up -d` |
|
||||||
|
| `npm run test:functions` | Real WASM build + `function-runner` tests for both Shopify Functions, against fixtures |
|
||||||
|
| `npm run typegen:functions` | Regenerate `extensions/*/generated/api.ts` from each Function's `schema.graphql` + `.graphql` query (also runs automatically before `npm run typecheck`) |
|
||||||
|
|
||||||
|
## Before public launch
|
||||||
|
|
||||||
|
The 3 mandatory GDPR compliance webhooks (`customers/data_request`,
|
||||||
|
`customers/redact`, `shop/redact`) are commented out in `shopify.app.toml`
|
||||||
|
— Shopify refuses to push them until the org requests and is granted
|
||||||
|
**Protected customer data access** in the Partner Dashboard (Apps → this
|
||||||
|
app → API access → Protected customer data), which is a manual
|
||||||
|
questionnaire/approval step. The handlers already exist and are fully
|
||||||
|
wired (`app/routes/webhooks.customers.*.tsx`, `webhooks.shop.redact.tsx`) —
|
||||||
|
once that access is granted, uncomment the three `[[webhooks.subscriptions]]`
|
||||||
|
blocks near the bottom of the webhooks section. **Required before any
|
||||||
|
public launch or Built-for-Shopify submission** — don't ship without it.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Phase 0 (scaffold & CI) and Phase 1 (core data model & admin CRUD) complete.
|
Phase 0 (scaffold & CI) through Phase 4 (enforcement Functions +
|
||||||
See §6 of `IMPLEMENTATION_PLAN.md` for the phased build order and acceptance
|
slot-holds) are complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased
|
||||||
criteria — next up is Phase 2 (the scheduling engine).
|
build order and acceptance criteria — next up is Phase 5 (multi-location,
|
||||||
|
zones, rates, auto-assignment).
|
||||||
|
|
||||||
|
The storefront widget's TypeScript source lives in `widget-src/datetime-widget/`,
|
||||||
|
**not** inside `extensions/datetime-widget/` — a Theme App Extension's
|
||||||
|
directory may only contain `assets`, `blocks`, `snippets`, and `locales`
|
||||||
|
(the CLI hard-rejects anything else, e.g. a `src/` folder, with "Only
|
||||||
|
assets, blocks, snippets, locales directories are allowed"). Editing the
|
||||||
|
widget? Run `npm run build:widget` to bundle it into
|
||||||
|
`extensions/datetime-widget/assets/datetime-widget.js` — it also runs
|
||||||
|
automatically before `npm run dev` / `npm run deploy`.
|
||||||
|
|
||||||
|
**Functions are JavaScript, not Rust** (`extensions/validation-slot/`,
|
||||||
|
`extensions/delivery-customization/`) — no Rust toolchain was available in
|
||||||
|
the environment that built Phase 4, and `IMPLEMENTATION_PLAN.md` §1
|
||||||
|
explicitly allows JS as a fallback. Both were generated with
|
||||||
|
`shopify app generate extension` (once a real Partner login was available)
|
||||||
|
and their business logic (`src/evaluate.js` in each) is verified two ways:
|
||||||
|
plain Vitest unit tests at the repo root (`npm test`) and real
|
||||||
|
`function-runner` fixture tests that compile actual WASM
|
||||||
|
(`npm run test:functions`, also in CI). `extensions/*/generated/` and
|
||||||
|
`extensions/*/dist/` aren't committed (matching the CLI's own
|
||||||
|
`.gitignore` for these extensions) — `npm run typegen:functions`
|
||||||
|
regenerates the types from the committed `schema.graphql`, and building
|
||||||
|
runs automatically as part of `npm run dev` / `test:functions`.
|
||||||
|
|||||||
91
app/lib/time.ts
Normal file
91
app/lib/time.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
|
// All date math for scheduling lives here. Nothing in this file touches the
|
||||||
|
// DB, the clock, or Shopify — every function takes its inputs explicitly so
|
||||||
|
// it stays exhaustively unit-testable (CLAUDE.md: "pure functions... inject
|
||||||
|
// data, no I/O in the math").
|
||||||
|
|
||||||
|
const MINUTES_PER_DAY = 24 * 60;
|
||||||
|
|
||||||
|
export type IsoDate = string; // "YYYY-MM-DD", a calendar date with no timezone of its own
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact instant a "minutes from midnight" wall-clock offset (as stored
|
||||||
|
* on SlotTemplate/SlotOverride) refers to, on a given calendar date, in a
|
||||||
|
* given IANA timezone.
|
||||||
|
*
|
||||||
|
* Deliberately built via `DateTime.fromObject({ hour, minute }, { zone })`
|
||||||
|
* rather than `midnight.plus({ minutes })`: `.plus()` on time units adds
|
||||||
|
* *elapsed* real time, so on a spring-forward day it would land an hour
|
||||||
|
* later than the intended wall-clock time (e.g. "9:00 AM" computed as
|
||||||
|
* "540 elapsed minutes past local midnight" becomes 10:00 AM on the day the
|
||||||
|
* clocks jump). Building from the wall-clock fields directly keeps "9:00 AM"
|
||||||
|
* meaning 9:00 AM regardless of what the clocks did earlier that day.
|
||||||
|
*/
|
||||||
|
export function slotDateTime(date: IsoDate, minutesFromMidnight: number, timezone: string): DateTime {
|
||||||
|
if (minutesFromMidnight < 0 || minutesFromMidnight > MINUTES_PER_DAY) {
|
||||||
|
throw new RangeError(`minutesFromMidnight out of range: ${minutesFromMidnight}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = DateTime.fromISO(date, { zone: timezone });
|
||||||
|
if (!base.isValid) {
|
||||||
|
throw new RangeError(`Invalid date "${date}" for zone "${timezone}": ${base.invalidReason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hour = Math.floor(minutesFromMidnight / 60);
|
||||||
|
const minute = minutesFromMidnight % 60;
|
||||||
|
|
||||||
|
const result = base.set({ hour, minute, second: 0, millisecond: 0 });
|
||||||
|
|
||||||
|
// A wall-clock time that doesn't exist (spring-forward gap, e.g. 2:30 AM
|
||||||
|
// on the day clocks jump from 2:00 to 3:00) stays `isValid` in Luxon —
|
||||||
|
// `.set()` silently rolls it forward past the gap (2:30 -> 3:30) instead
|
||||||
|
// of rejecting it. Detect that by checking the fields actually landed
|
||||||
|
// where asked; surface it rather than silently returning a shifted time
|
||||||
|
// a merchant never configured.
|
||||||
|
if (result.hour !== hour || result.minute !== minute) {
|
||||||
|
throw new RangeError(
|
||||||
|
`${date} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} does not exist in ${timezone} (DST spring-forward gap)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0 (Sunday) .. 6 (Saturday), evaluated as a calendar date — independent of what time or timezone the server itself is running in. */
|
||||||
|
export function weekdayOf(date: IsoDate, timezone: string): number {
|
||||||
|
const dt = DateTime.fromISO(date, { zone: timezone });
|
||||||
|
// Luxon's weekday is 1 (Monday) .. 7 (Sunday); our schema uses 0 (Sunday) .. 6 (Saturday).
|
||||||
|
return dt.weekday % 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inclusive list of calendar dates from start to end (both "YYYY-MM-DD").
|
||||||
|
* Pinned to UTC — these are plain calendar dates, not instants, so stepping
|
||||||
|
* through them must not depend on the server process's own local timezone
|
||||||
|
* or DST (which would otherwise risk skipping/repeating a date on the
|
||||||
|
* server's own transition day).
|
||||||
|
*/
|
||||||
|
export function enumerateDates(startDate: IsoDate, endDate: IsoDate): IsoDate[] {
|
||||||
|
const start = DateTime.fromISO(startDate, { zone: "utc" });
|
||||||
|
const end = DateTime.fromISO(endDate, { zone: "utc" });
|
||||||
|
if (!start.isValid || !end.isValid) {
|
||||||
|
throw new RangeError(`Invalid date range: ${startDate}..${endDate}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates: IsoDate[] = [];
|
||||||
|
for (let d = start; d <= end; d = d.plus({ days: 1 })) {
|
||||||
|
dates.push(d.toISODate()!);
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minutes between `now` and a slot's start, in real elapsed time (correctly
|
||||||
|
* reflecting a DST change if `now` and the slot fall on opposite sides of
|
||||||
|
* one — e.g. a slot booked the evening before a fall-back has genuinely 60
|
||||||
|
* more minutes of lead time than the wall-clock difference would suggest).
|
||||||
|
*/
|
||||||
|
export function minutesUntil(now: DateTime, target: DateTime): number {
|
||||||
|
return target.diff(now, "minutes").minutes;
|
||||||
|
}
|
||||||
113
app/routes/apps.scheduling.availability.tsx
Normal file
113
app/routes/apps.scheduling.availability.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import type { Method } from "@prisma/client";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
import { getAvailability } from "../services/scheduling.server";
|
||||||
|
|
||||||
|
// Public endpoint, reachable only through Shopify's App Proxy (signature
|
||||||
|
// verified by authenticate.public.appProxy) — this is what the storefront
|
||||||
|
// Theme App Extension calls. Requests to https://{shop}/apps/scheduling/*
|
||||||
|
// forward here because shopify.app.toml's [app_proxy].url already includes
|
||||||
|
// the /apps/scheduling prefix, so this file's path (apps.scheduling.*)
|
||||||
|
// mirrors the shop-facing URL exactly.
|
||||||
|
//
|
||||||
|
// No capacity consumption is wired up yet (Booking/SlotHold don't exist
|
||||||
|
// until Phase 4), so every slot's `consumed` is implicitly 0 here — that's
|
||||||
|
// expected for Phase 3, not a bug to fix in this file.
|
||||||
|
|
||||||
|
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
|
||||||
|
const MAX_DAYS = 60;
|
||||||
|
const DEFAULT_DAYS = 14;
|
||||||
|
|
||||||
|
function toIsoDate(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const { session } = await authenticate.public.appProxy(request);
|
||||||
|
if (!session) {
|
||||||
|
return Response.json({ error: "Shop not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const methodParam = url.searchParams.get("method");
|
||||||
|
const locationIdParam = url.searchParams.get("locationId");
|
||||||
|
const daysParam = Number(url.searchParams.get("days") ?? DEFAULT_DAYS);
|
||||||
|
const days = Number.isFinite(daysParam) && daysParam > 0 ? Math.min(daysParam, MAX_DAYS) : DEFAULT_DAYS;
|
||||||
|
|
||||||
|
if (!methodParam || !VALID_METHODS.has(methodParam as Method)) {
|
||||||
|
return Response.json({ error: "Invalid or missing method" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const method = methodParam as Method;
|
||||||
|
|
||||||
|
const location = locationIdParam
|
||||||
|
? await db.location.findFirst({
|
||||||
|
where: { id: locationIdParam, shopDomain: session.shop, active: true },
|
||||||
|
})
|
||||||
|
: await db.location.findFirst({
|
||||||
|
where: { shopDomain: session.shop, active: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
return Response.json({ error: "No active location configured" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
98
app/routes/apps.scheduling.hold.tsx
Normal file
98
app/routes/apps.scheduling.hold.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
|
import type { Method } from "@prisma/client";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
import { slotDateTime, weekdayOf } from "../lib/time";
|
||||||
|
import { remainingCapacity } from "../services/capacity.server";
|
||||||
|
import { tryCreateHold, releaseHold, countActiveHolds } from "../services/holds.server";
|
||||||
|
|
||||||
|
// Public app-proxy endpoint (see apps.scheduling.availability.tsx for the
|
||||||
|
// path-mirroring rationale). Called by the widget the moment a shopper
|
||||||
|
// picks a slot, before it writes the cart attribute — this is what
|
||||||
|
// actually reserves capacity (PRODUCT_STRATEGY.md §3.1, §4.1: "the
|
||||||
|
// last-slot race condition"). The cart attribute write alone would just be
|
||||||
|
// two shoppers racing to write the same free-text field; nothing would
|
||||||
|
// stop both orders from completing.
|
||||||
|
|
||||||
|
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { session } = await authenticate.public.appProxy(request);
|
||||||
|
if (!session) {
|
||||||
|
return Response.json({ error: "Shop not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Response.json({ error: "Missing or invalid parameters" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await db.location.findFirst({
|
||||||
|
where: { id: locationId, shopDomain: session.shop, active: true },
|
||||||
|
});
|
||||||
|
if (!location) {
|
||||||
|
return Response.json({ error: "Location not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotStart = slotDateTime(date, startMin, location.timezone);
|
||||||
|
const slot = {
|
||||||
|
shopDomain: session.shop,
|
||||||
|
locationId: location.id,
|
||||||
|
method: method as Method,
|
||||||
|
slotStartIso: slotStart.toUTC().toISO()!,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (intent === "release") {
|
||||||
|
await releaseHold(slot, cartToken);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await db.slotTemplate.findFirst({
|
||||||
|
where: {
|
||||||
|
shopDomain: session.shop,
|
||||||
|
locationId: location.id,
|
||||||
|
method: slot.method,
|
||||||
|
weekday: weekdayOf(date, location.timezone),
|
||||||
|
startMin,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!template) {
|
||||||
|
return Response.json({ error: "Slot not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmedCount = await db.booking.count({
|
||||||
|
where: {
|
||||||
|
shopDomain: session.shop,
|
||||||
|
locationId: location.id,
|
||||||
|
method: slot.method,
|
||||||
|
slotStart: slotStart.toJSDate(),
|
||||||
|
status: "confirmed",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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,12 +1,19 @@
|
|||||||
import type { ActionFunctionArgs } from "@remix-run/node";
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
|
||||||
// TODO (Phase 4): mark the linked Booking cancelled and free its
|
// Marks the linked Booking cancelled, which frees its capacity for
|
||||||
// capacity/resources. See IMPLEMENTATION_PLAN.md §5.2.
|
// getAvailability()/the hold endpoint's confirmedCount check on the next
|
||||||
|
// request — see IMPLEMENTATION_PLAN.md §5.2.
|
||||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
const { shop, topic, payload } = await authenticate.webhook(request);
|
const { shop, topic, payload } = await authenticate.webhook(request);
|
||||||
|
console.log(`Received ${topic} webhook for ${shop}`);
|
||||||
|
|
||||||
console.log(`Received ${topic} webhook for ${shop}`, payload);
|
const order = payload as unknown as { admin_graphql_api_id: string };
|
||||||
|
await db.booking.updateMany({
|
||||||
|
where: { shopDomain: shop, orderId: order.admin_graphql_api_id },
|
||||||
|
data: { status: "cancelled" },
|
||||||
|
});
|
||||||
|
|
||||||
return new Response();
|
return new Response();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,13 +1,59 @@
|
|||||||
import type { ActionFunctionArgs } from "@remix-run/node";
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
|
import { createBookingFromOrder, type OrderWebhookPayload } from "../services/booking.server";
|
||||||
|
|
||||||
// TODO (Phase 4): convert the cart's SlotHold into a confirmed Booking,
|
// Converts the cart's dd_* attributes into a confirmed Booking, releases
|
||||||
// consume capacity/resources, write the slot back onto the order via
|
// the matching Redis hold, and writes the slot back onto the order via a
|
||||||
// metafield, and release the hold. See IMPLEMENTATION_PLAN.md §5.2.
|
// metafield so staff (and, later, POS) see it natively on the order record
|
||||||
|
// — IMPLEMENTATION_PLAN.md §5.2 / §2 data flow. Idempotent: Shopify
|
||||||
|
// redelivers webhooks, and booking.server.ts's upsert-on-orderId handles
|
||||||
|
// that safely.
|
||||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
const { shop, topic, payload } = await authenticate.webhook(request);
|
const { shop, topic, payload, admin } = await authenticate.webhook(request);
|
||||||
|
console.log(`Received ${topic} webhook for ${shop}`);
|
||||||
|
|
||||||
console.log(`Received ${topic} webhook for ${shop}`, payload);
|
const order = payload as unknown as OrderWebhookPayload;
|
||||||
|
await createBookingFromOrder(shop, order);
|
||||||
|
|
||||||
|
if (admin) {
|
||||||
|
const bookingSummary = summarizeBookingAttributes(order);
|
||||||
|
if (bookingSummary) {
|
||||||
|
await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
mutation setBookingMetafield($metafields: [MetafieldsSetInput!]!) {
|
||||||
|
metafieldsSet(metafields: $metafields) {
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
metafields: [
|
||||||
|
{
|
||||||
|
ownerId: order.admin_graphql_api_id,
|
||||||
|
namespace: "delivery_datetime",
|
||||||
|
key: "booking",
|
||||||
|
type: "json",
|
||||||
|
value: JSON.stringify(bookingSummary),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new Response();
|
return new Response();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function summarizeBookingAttributes(order: OrderWebhookPayload) {
|
||||||
|
const attrs = order.note_attributes ?? [];
|
||||||
|
const get = (key: string) => attrs.find((a) => a.name === key)?.value;
|
||||||
|
const method = get("dd_method");
|
||||||
|
const date = get("dd_date");
|
||||||
|
const startMin = get("dd_start_min");
|
||||||
|
const endMin = get("dd_end_min");
|
||||||
|
const locationId = get("dd_location_id");
|
||||||
|
|
||||||
|
if (!method || !date || !startMin || !endMin || !locationId) return null;
|
||||||
|
return { method, date, startMin: Number(startMin), endMin: Number(endMin), locationId };
|
||||||
|
}
|
||||||
|
|||||||
87
app/services/booking.server.ts
Normal file
87
app/services/booking.server.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import type { Method } from "@prisma/client";
|
||||||
|
import db from "../db.server";
|
||||||
|
import { slotDateTime } from "../lib/time";
|
||||||
|
import { releaseHold } from "./holds.server";
|
||||||
|
|
||||||
|
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
|
||||||
|
|
||||||
|
export interface OrderNoteAttribute {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderWebhookPayload {
|
||||||
|
admin_graphql_api_id: string;
|
||||||
|
name?: string;
|
||||||
|
cart_token?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
note_attributes?: OrderNoteAttribute[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAttr(attrs: OrderNoteAttribute[], key: string): string | undefined {
|
||||||
|
return attrs.find((a) => a.name === key)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a completed order's dd_* cart attributes (written by the
|
||||||
|
* storefront widget, see extensions/datetime-widget) into a confirmed
|
||||||
|
* Booking, and releases the Redis hold that reserved its capacity.
|
||||||
|
*
|
||||||
|
* Idempotent on orderId — webhooks can and do redeliver, so this must be
|
||||||
|
* safe to run twice for the same order without double-booking capacity.
|
||||||
|
*
|
||||||
|
* Deliberately does NOT re-check capacity here and reject the order if
|
||||||
|
* over budget: by the time an order exists, payment has been taken and the
|
||||||
|
* Validation Function already had its chance to block checkout with a
|
||||||
|
* fresh capacity snapshot. This function's job is to record what happened,
|
||||||
|
* not to re-litigate it.
|
||||||
|
*/
|
||||||
|
export async function createBookingFromOrder(shopDomain: string, order: OrderWebhookPayload): Promise<void> {
|
||||||
|
const attrs = order.note_attributes ?? [];
|
||||||
|
const method = readAttr(attrs, "dd_method");
|
||||||
|
const date = readAttr(attrs, "dd_date");
|
||||||
|
const startMinRaw = readAttr(attrs, "dd_start_min");
|
||||||
|
const endMinRaw = readAttr(attrs, "dd_end_min");
|
||||||
|
const locationId = readAttr(attrs, "dd_location_id");
|
||||||
|
|
||||||
|
if (!method || !VALID_METHODS.has(method as Method) || !date || !startMinRaw || !endMinRaw || !locationId) {
|
||||||
|
return; // no scheduling selection on this order — nothing to book
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await db.location.findFirst({ where: { id: locationId, shopDomain } });
|
||||||
|
if (!location) return;
|
||||||
|
|
||||||
|
const startMin = Number(startMinRaw);
|
||||||
|
const endMin = Number(endMinRaw);
|
||||||
|
const slotStart = slotDateTime(date, startMin, location.timezone);
|
||||||
|
const slotEnd = slotDateTime(date, endMin, location.timezone);
|
||||||
|
|
||||||
|
await db.booking.upsert({
|
||||||
|
where: { orderId: order.admin_graphql_api_id },
|
||||||
|
create: {
|
||||||
|
shopDomain,
|
||||||
|
orderId: order.admin_graphql_api_id,
|
||||||
|
orderName: order.name,
|
||||||
|
locationId: location.id,
|
||||||
|
method: method as Method,
|
||||||
|
slotStart: slotStart.toJSDate(),
|
||||||
|
slotEnd: slotEnd.toJSDate(),
|
||||||
|
customerEmail: order.email ?? undefined,
|
||||||
|
customerPhone: order.phone ?? undefined,
|
||||||
|
},
|
||||||
|
update: {}, // redelivered webhook — the booking already exists, nothing to change
|
||||||
|
});
|
||||||
|
|
||||||
|
if (order.cart_token) {
|
||||||
|
await releaseHold(
|
||||||
|
{
|
||||||
|
shopDomain,
|
||||||
|
locationId: location.id,
|
||||||
|
method: method as Method,
|
||||||
|
slotStartIso: slotStart.toUTC().toISO()!,
|
||||||
|
},
|
||||||
|
order.cart_token,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/services/capacity.server.ts
Normal file
18
app/services/capacity.server.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// Capacity math is intentionally trivial today: `consumed` is whatever the
|
||||||
|
// caller already looked up (Bookings + active Holds, once those models
|
||||||
|
// exist from Phase 4 on). Keeping it as an explicit input rather than a
|
||||||
|
// query inside this function is what lets scheduling.server.ts's
|
||||||
|
// getAvailability stay a pure, DB-free function while this still slots in
|
||||||
|
// cleanly once real consumption exists.
|
||||||
|
export interface CapacityInput {
|
||||||
|
capacity: number;
|
||||||
|
consumed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remainingCapacity({ capacity, consumed }: CapacityInput): number {
|
||||||
|
return Math.max(0, capacity - consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasCapacity(input: CapacityInput): boolean {
|
||||||
|
return remainingCapacity(input) > 0;
|
||||||
|
}
|
||||||
117
app/services/holds.server.ts
Normal file
117
app/services/holds.server.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import type { Method } from "@prisma/client";
|
||||||
|
import redis from "../lib/redis.server";
|
||||||
|
|
||||||
|
// Soft slot-holds: when a shopper picks a slot, we reserve one unit of its
|
||||||
|
// capacity for a short TTL so a second shopper can't grab the same last
|
||||||
|
// unit while the first is still filling out checkout (PRODUCT_STRATEGY.md
|
||||||
|
// §3.1 — "the last-slot race condition"). Redis is the sole source of
|
||||||
|
// truth for holds (see the schema.prisma comment on why there's no
|
||||||
|
// matching Postgres table); its native key expiry is exactly the semantics
|
||||||
|
// a time-limited reservation needs.
|
||||||
|
//
|
||||||
|
// Each slot's outstanding holds live in one Redis sorted set, scored by
|
||||||
|
// expiry timestamp (ms). Creating a hold has to be atomic — read the
|
||||||
|
// current count, compare to the capacity budget, and add the new member —
|
||||||
|
// or two concurrent requests can both read "one spot left" and both
|
||||||
|
// succeed. That's done with a Lua script (EVAL), which Redis runs
|
||||||
|
// single-threaded and atomically; there is no equivalent way to get this
|
||||||
|
// right with separate round-trips from Node.
|
||||||
|
|
||||||
|
export const DEFAULT_HOLD_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
const TRY_CREATE_HOLD_SCRIPT = `
|
||||||
|
local key = KEYS[1]
|
||||||
|
local now = tonumber(ARGV[1])
|
||||||
|
local expiresAt = tonumber(ARGV[2])
|
||||||
|
local member = ARGV[3]
|
||||||
|
local capacity = tonumber(ARGV[4])
|
||||||
|
|
||||||
|
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
||||||
|
|
||||||
|
-- The same cart re-confirming its own already-held slot (a retried
|
||||||
|
-- request, a back/forward navigation, a duplicate click) is a renewal, not
|
||||||
|
-- a new claim — it must not have to compete against the capacity gate a
|
||||||
|
-- second time, or a shopper can lock themselves out of the slot they
|
||||||
|
-- already hold.
|
||||||
|
if redis.call('ZSCORE', key, member) then
|
||||||
|
redis.call('ZADD', key, expiresAt, member)
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local count = redis.call('ZCARD', key)
|
||||||
|
|
||||||
|
if count < capacity then
|
||||||
|
redis.call('ZADD', key, expiresAt, member)
|
||||||
|
redis.call('PEXPIRE', key, 3600000)
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
`;
|
||||||
|
|
||||||
|
redis.defineCommand("tryCreateHoldScript", {
|
||||||
|
numberOfKeys: 1,
|
||||||
|
lua: TRY_CREATE_HOLD_SCRIPT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ioredis's defineCommand() adds the method at runtime without changing the
|
||||||
|
// client's static type, so this augments it for the one command we added.
|
||||||
|
declare module "ioredis" {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- must match ioredis's own generic arity to merge
|
||||||
|
interface RedisCommander<Context> {
|
||||||
|
tryCreateHoldScript(
|
||||||
|
key: string,
|
||||||
|
now: number,
|
||||||
|
expiresAt: number,
|
||||||
|
member: string,
|
||||||
|
capacity: number,
|
||||||
|
): Promise<number>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlotIdentity {
|
||||||
|
shopDomain: string;
|
||||||
|
locationId: string;
|
||||||
|
method: Method;
|
||||||
|
/** The slot's exact start instant, as an ISO string (stable, timezone-unambiguous cache key). */
|
||||||
|
slotStartIso: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function holdSetKey({ shopDomain, locationId, method, slotStartIso }: SlotIdentity): string {
|
||||||
|
return `hold:${shopDomain}:${locationId}:${method}:${slotStartIso}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TryCreateHoldResult {
|
||||||
|
success: boolean;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to reserve one unit of capacity for `cartToken` on the given
|
||||||
|
* slot. `capacityBudget` is the capacity still available to holds — i.e.
|
||||||
|
* template capacity minus already-confirmed Bookings, computed by the
|
||||||
|
* caller from Postgres immediately before calling this. Returns
|
||||||
|
* success: false if the slot's outstanding holds already meet that budget.
|
||||||
|
*/
|
||||||
|
export async function tryCreateHold(
|
||||||
|
slot: SlotIdentity,
|
||||||
|
cartToken: string,
|
||||||
|
capacityBudget: number,
|
||||||
|
ttlMs: number = DEFAULT_HOLD_TTL_MS,
|
||||||
|
): Promise<TryCreateHoldResult> {
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = now + ttlMs;
|
||||||
|
const result = await redis.tryCreateHoldScript(holdSetKey(slot), now, expiresAt, cartToken, capacityBudget);
|
||||||
|
return { success: result === 1, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseHold(slot: SlotIdentity, cartToken: string): Promise<void> {
|
||||||
|
await redis.zrem(holdSetKey(slot), cartToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active (non-expired) hold count for a slot — evicts expired members first. */
|
||||||
|
export async function countActiveHolds(slot: SlotIdentity): Promise<number> {
|
||||||
|
const key = holdSetKey(slot);
|
||||||
|
await redis.zremrangebyscore(key, "-inf", Date.now());
|
||||||
|
return redis.zcard(key);
|
||||||
|
}
|
||||||
150
app/services/scheduling.server.ts
Normal file
150
app/services/scheduling.server.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import type { DateTime } from "luxon";
|
||||||
|
import { enumerateDates, minutesUntil, slotDateTime, weekdayOf, type IsoDate } from "../lib/time";
|
||||||
|
import { remainingCapacity } from "./capacity.server";
|
||||||
|
|
||||||
|
export type Method = "SHIPPING" | "LOCAL_DELIVERY" | "PICKUP";
|
||||||
|
|
||||||
|
export interface SlotTemplateLike {
|
||||||
|
weekday: number; // 0-6, matches lib/time.ts#weekdayOf
|
||||||
|
startMin: number;
|
||||||
|
endMin: number;
|
||||||
|
capacity: number;
|
||||||
|
cutoffMin: number | null;
|
||||||
|
leadTimeMin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlotOverrideLike {
|
||||||
|
date: IsoDate;
|
||||||
|
closed: boolean;
|
||||||
|
startMin: number | null;
|
||||||
|
endMin: number | null;
|
||||||
|
capacity: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlackoutDateLike {
|
||||||
|
date: IsoDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableSlot {
|
||||||
|
date: IsoDate;
|
||||||
|
startMin: number;
|
||||||
|
endMin: number;
|
||||||
|
start: DateTime;
|
||||||
|
end: DateTime;
|
||||||
|
capacity: number;
|
||||||
|
remainingCapacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetAvailabilityInput {
|
||||||
|
timezone: string;
|
||||||
|
dateRange: { startDate: IsoDate; endDate: IsoDate };
|
||||||
|
/**
|
||||||
|
* One method, one location's worth of candidates. `SlotTemplate`,
|
||||||
|
* `SlotOverride`, and `BlackoutDate` are all scoped by (location, method)
|
||||||
|
* in the schema, plus method-agnostic rows (BlackoutDate.method === null)
|
||||||
|
* — the caller is responsible for resolving that down to the flat lists
|
||||||
|
* below before calling this function, so the availability math itself
|
||||||
|
* never has to branch on method.
|
||||||
|
*/
|
||||||
|
slotTemplates: SlotTemplateLike[];
|
||||||
|
overrides?: SlotOverrideLike[];
|
||||||
|
blackoutDates?: BlackoutDateLike[];
|
||||||
|
/** Injected "now" so this stays a pure function — never `DateTime.now()` internally. */
|
||||||
|
now: DateTime;
|
||||||
|
/**
|
||||||
|
* Capacity already consumed per slot, keyed by `${date}|${startMin}`.
|
||||||
|
* Omitted entirely until Phase 4 wires up real Booking/Hold counts —
|
||||||
|
* every slot is treated as unconsumed until then.
|
||||||
|
*/
|
||||||
|
consumed?: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotKey(date: IsoDate, startMin: number): string {
|
||||||
|
return `${date}|${startMin}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The availability engine: given a location's weekly slot templates,
|
||||||
|
* date-specific overrides, and blackout dates, returns the bookable slots
|
||||||
|
* per calendar date in the requested range — already filtered down to what
|
||||||
|
* a shopper should be shown (past-cutoff, under-lead-time, blacked-out, and
|
||||||
|
* fully-consumed slots are all excluded here, not just flagged, per
|
||||||
|
* PRODUCT_STRATEGY.md §2 "unavailable dates/slots hidden, not just
|
||||||
|
* rejected"). No DB or Shopify calls — every input is injected so this is
|
||||||
|
* exhaustively unit-testable (CLAUDE.md non-negotiable).
|
||||||
|
*/
|
||||||
|
export function getAvailability(input: GetAvailabilityInput): Record<IsoDate, AvailableSlot[]> {
|
||||||
|
const { timezone, dateRange, slotTemplates, now } = input;
|
||||||
|
const overrides = input.overrides ?? [];
|
||||||
|
const blackoutDates = input.blackoutDates ?? [];
|
||||||
|
const consumed = input.consumed ?? new Map<string, number>();
|
||||||
|
|
||||||
|
const blackoutSet = new Set(blackoutDates.map((b) => b.date));
|
||||||
|
const overridesByDate = new Map(overrides.map((o) => [o.date, o]));
|
||||||
|
const templatesByWeekday = new Map<number, SlotTemplateLike[]>();
|
||||||
|
for (const template of slotTemplates) {
|
||||||
|
const list = templatesByWeekday.get(template.weekday) ?? [];
|
||||||
|
list.push(template);
|
||||||
|
templatesByWeekday.set(template.weekday, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<IsoDate, AvailableSlot[]> = {};
|
||||||
|
|
||||||
|
for (const date of enumerateDates(dateRange.startDate, dateRange.endDate)) {
|
||||||
|
if (blackoutSet.has(date)) continue;
|
||||||
|
|
||||||
|
const override = overridesByDate.get(date);
|
||||||
|
if (override?.closed) continue;
|
||||||
|
|
||||||
|
const daySlotDefs: Array<Pick<SlotTemplateLike, "startMin" | "endMin" | "capacity" | "cutoffMin" | "leadTimeMin">> =
|
||||||
|
override
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
startMin: override.startMin ?? 0,
|
||||||
|
endMin: override.endMin ?? 24 * 60,
|
||||||
|
capacity: override.capacity ?? 0,
|
||||||
|
cutoffMin: null,
|
||||||
|
leadTimeMin: 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: (templatesByWeekday.get(weekdayOf(date, timezone)) ?? []);
|
||||||
|
|
||||||
|
const daySlots: AvailableSlot[] = [];
|
||||||
|
|
||||||
|
for (const def of daySlotDefs) {
|
||||||
|
const start = slotDateTime(date, def.startMin, timezone);
|
||||||
|
const end = slotDateTime(date, def.endMin, timezone);
|
||||||
|
|
||||||
|
// Both cutoffMin (a fixed "orders close N minutes before this slot")
|
||||||
|
// and leadTimeMin (a floor on how soon after ordering the slot can be)
|
||||||
|
// ultimately gate the same thing — the minimum real-time gap required
|
||||||
|
// between `now` and slot start — so the effective threshold is
|
||||||
|
// whichever is larger.
|
||||||
|
const minimumLeadMinutes = Math.max(def.cutoffMin ?? 0, def.leadTimeMin);
|
||||||
|
if (minutesUntil(now, start) < minimumLeadMinutes) continue;
|
||||||
|
|
||||||
|
const remaining = remainingCapacity({
|
||||||
|
capacity: def.capacity,
|
||||||
|
consumed: consumed.get(slotKey(date, def.startMin)) ?? 0,
|
||||||
|
});
|
||||||
|
if (remaining <= 0) continue;
|
||||||
|
|
||||||
|
daySlots.push({
|
||||||
|
date,
|
||||||
|
startMin: def.startMin,
|
||||||
|
endMin: def.endMin,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
capacity: def.capacity,
|
||||||
|
remainingCapacity: remaining,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
daySlots.sort((a, b) => a.startMin - b.startMin);
|
||||||
|
if (daySlots.length > 0) {
|
||||||
|
result[date] = daySlots;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
57
extensions/datetime-widget/assets/datetime-widget.css
Normal file
57
extensions/datetime-widget/assets/datetime-widget.css
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
.dd-widget {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-family: inherit;
|
||||||
|
min-height: 3.5rem; /* reserves space before JS renders content, to avoid layout shift */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-widget__heading {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-widget__row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-widget__pill {
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-widget__pill[aria-pressed="true"] {
|
||||||
|
background: currentColor;
|
||||||
|
color: Canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-widget__status {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-widget__confirmation {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-widget__link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
1
extensions/datetime-widget/assets/datetime-widget.js
Normal file
1
extensions/datetime-widget/assets/datetime-widget.js
Normal file
File diff suppressed because one or more lines are too long
10
extensions/datetime-widget/blocks/app-embed.liquid
Normal file
10
extensions/datetime-widget/blocks/app-embed.liquid
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{{ 'datetime-widget.css' | asset_url | stylesheet_tag }}
|
||||||
|
<script src="{{ 'datetime-widget.js' | asset_url }}" defer="defer"></script>
|
||||||
|
|
||||||
|
{% schema %}
|
||||||
|
{
|
||||||
|
"name": "t:app_embed.name",
|
||||||
|
"target": "body",
|
||||||
|
"settings": []
|
||||||
|
}
|
||||||
|
{% endschema %}
|
||||||
64
extensions/datetime-widget/blocks/datetime-picker.liquid
Normal file
64
extensions/datetime-widget/blocks/datetime-picker.liquid
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<div
|
||||||
|
class="dd-widget"
|
||||||
|
data-dd-widget
|
||||||
|
data-heading="{{ block.settings.heading | escape }}"
|
||||||
|
data-show-shipping="{{ block.settings.show_shipping }}"
|
||||||
|
data-show-local-delivery="{{ block.settings.show_local_delivery }}"
|
||||||
|
data-show-pickup="{{ block.settings.show_pickup }}"
|
||||||
|
data-label-shipping="{{ 'widget.method_shipping' | t | escape }}"
|
||||||
|
data-label-local-delivery="{{ 'widget.method_local_delivery' | t | escape }}"
|
||||||
|
data-label-pickup="{{ 'widget.method_pickup' | t | escape }}"
|
||||||
|
data-attr-label-shipping="{{ 'widget.date_label_shipping' | t | escape }}"
|
||||||
|
data-attr-label-local-delivery="{{ 'widget.date_label_local_delivery' | t | escape }}"
|
||||||
|
data-attr-label-pickup="{{ 'widget.date_label_pickup' | t | escape }}"
|
||||||
|
data-label-choose-date="{{ 'widget.choose_date' | t | escape }}"
|
||||||
|
data-label-choose-time="{{ 'widget.choose_time' | t | escape }}"
|
||||||
|
data-label-no-dates="{{ 'widget.no_dates' | t | escape }}"
|
||||||
|
data-label-confirmed="{{ 'widget.confirmed' | t | escape }}"
|
||||||
|
data-label-change="{{ 'widget.change' | t | escape }}"
|
||||||
|
data-label-loading="{{ 'widget.loading' | t | escape }}"
|
||||||
|
data-label-error="{{ 'widget.error' | t | escape }}"
|
||||||
|
{% if block.settings.location_id != blank %}data-location-id="{{ block.settings.location_id | escape }}"{% endif %}
|
||||||
|
{{ block.shopify_attributes }}
|
||||||
|
>
|
||||||
|
<noscript>{{ 'widget.enable_js' | t | escape }}</noscript>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% schema %}
|
||||||
|
{
|
||||||
|
"name": "t:datetime_picker.name",
|
||||||
|
"target": "section",
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "heading",
|
||||||
|
"label": "t:datetime_picker.heading_label",
|
||||||
|
"default": "Choose your delivery date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "checkbox",
|
||||||
|
"id": "show_shipping",
|
||||||
|
"label": "t:datetime_picker.show_shipping_label",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "checkbox",
|
||||||
|
"id": "show_local_delivery",
|
||||||
|
"label": "t:datetime_picker.show_local_delivery_label",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "checkbox",
|
||||||
|
"id": "show_pickup",
|
||||||
|
"label": "t:datetime_picker.show_pickup_label",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "location_id",
|
||||||
|
"label": "t:datetime_picker.location_id_label",
|
||||||
|
"info": "t:datetime_picker.location_id_info"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
{% endschema %}
|
||||||
18
extensions/datetime-widget/locales/en.default.json
Normal file
18
extensions/datetime-widget/locales/en.default.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"widget": {
|
||||||
|
"method_shipping": "Shipping",
|
||||||
|
"method_local_delivery": "Local delivery",
|
||||||
|
"method_pickup": "Pickup",
|
||||||
|
"date_label_shipping": "Shipping date",
|
||||||
|
"date_label_local_delivery": "Delivery date",
|
||||||
|
"date_label_pickup": "Pickup date",
|
||||||
|
"choose_date": "Choose a date",
|
||||||
|
"choose_time": "Choose a time",
|
||||||
|
"no_dates": "No dates are available right now.",
|
||||||
|
"confirmed": "Confirmed for",
|
||||||
|
"change": "Change",
|
||||||
|
"loading": "Loading available dates…",
|
||||||
|
"error": "Couldn't load available dates. Please try again.",
|
||||||
|
"enable_js": "Please enable JavaScript to choose a delivery date and time."
|
||||||
|
}
|
||||||
|
}
|
||||||
14
extensions/datetime-widget/locales/en.default.schema.json
Normal file
14
extensions/datetime-widget/locales/en.default.schema.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"app_embed": {
|
||||||
|
"name": "Delivery Date & Time"
|
||||||
|
},
|
||||||
|
"datetime_picker": {
|
||||||
|
"name": "Date & Time Picker",
|
||||||
|
"heading_label": "Heading",
|
||||||
|
"show_shipping_label": "Show Shipping",
|
||||||
|
"show_local_delivery_label": "Show Local Delivery",
|
||||||
|
"show_pickup_label": "Show Pickup",
|
||||||
|
"location_id_label": "Location ID (advanced)",
|
||||||
|
"location_id_info": "Leave blank to use the shop's default location."
|
||||||
|
}
|
||||||
|
}
|
||||||
3
extensions/datetime-widget/shopify.extension.toml
Normal file
3
extensions/datetime-widget/shopify.extension.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
name = "Delivery Date & Time"
|
||||||
|
type = "theme"
|
||||||
|
uid = "4935f147-fc17-827b-2629-385b36917fa08faa33de"
|
||||||
2
extensions/delivery-customization/.gitignore
vendored
Normal file
2
extensions/delivery-customization/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
dist
|
||||||
|
generated
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "Delivery Method Labeling",
|
||||||
|
"description": "Relabels delivery options at checkout with the shopper's chosen pickup/delivery date and time."
|
||||||
|
}
|
||||||
35
extensions/delivery-customization/package.json
Normal file
35
extensions/delivery-customization/package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "delivery-customization",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"shopify": "npm exec -- shopify",
|
||||||
|
"typegen": "npm exec -- shopify app function typegen",
|
||||||
|
"build": "npm exec -- shopify app function build",
|
||||||
|
"preview": "npm exec -- shopify app function run",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"codegen": {
|
||||||
|
"schema": "schema.graphql",
|
||||||
|
"documents": "src/*.graphql",
|
||||||
|
"generates": {
|
||||||
|
"./generated/api.ts": {
|
||||||
|
"plugins": [
|
||||||
|
"typescript",
|
||||||
|
"typescript-operations"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"omitOperationSuffix": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@shopify/shopify_function": "^2.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@shopify/shopify-function-test-helpers": "^1.0.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
5297
extensions/delivery-customization/schema.graphql
Normal file
5297
extensions/delivery-customization/schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
21
extensions/delivery-customization/shopify.extension.toml
Normal file
21
extensions/delivery-customization/shopify.extension.toml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
api_version = "2026-07"
|
||||||
|
|
||||||
|
[[extensions]]
|
||||||
|
name = "t:name"
|
||||||
|
handle = "delivery-customization"
|
||||||
|
type = "function"
|
||||||
|
uid = "7caef09d-bc92-c4fe-9c8c-5e43dc1b38bce5a7fa55"
|
||||||
|
description = "t:description"
|
||||||
|
|
||||||
|
[[extensions.targeting]]
|
||||||
|
target = "cart.delivery-options.transform.run"
|
||||||
|
input_query = "src/cart_delivery_options_transform_run.graphql"
|
||||||
|
export = "cart-delivery-options-transform-run"
|
||||||
|
|
||||||
|
[extensions.build]
|
||||||
|
command = ""
|
||||||
|
path = "dist/function.wasm"
|
||||||
|
|
||||||
|
[extensions.ui.paths]
|
||||||
|
create = "/"
|
||||||
|
details = "/"
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
query CartDeliveryOptionsTransformRunInput {
|
||||||
|
cart {
|
||||||
|
ddMethod: attribute(key: "dd_method") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
ddDate: attribute(key: "dd_date") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
deliveryGroups {
|
||||||
|
deliveryOptions {
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { renameLabelFor } from "./evaluate.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import("../generated/api").CartDeliveryOptionsTransformRunInput} CartDeliveryOptionsTransformRunInput
|
||||||
|
* @typedef {import("../generated/api").CartDeliveryOptionsTransformRunResult} CartDeliveryOptionsTransformRunResult
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {CartDeliveryOptionsTransformRunResult}
|
||||||
|
*/
|
||||||
|
const NO_CHANGES = {
|
||||||
|
operations: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relabels every presented delivery option to the shopper's actual chosen
|
||||||
|
* method + date, so checkout never shows a generic carrier label
|
||||||
|
* ("Standard", "Economy") that could be mistaken for a shipping ETA on
|
||||||
|
* what's actually a pickup or local-delivery order. Phase 4 doesn't yet
|
||||||
|
* have a per-option method mapping (that needs Phase 5's zones/rates
|
||||||
|
* work), so every option in the cart gets the same clarified label —
|
||||||
|
* reasonable since a single order only has one chosen fulfillment method
|
||||||
|
* today.
|
||||||
|
* @param {CartDeliveryOptionsTransformRunInput} input
|
||||||
|
* @returns {CartDeliveryOptionsTransformRunResult}
|
||||||
|
*/
|
||||||
|
export function cartDeliveryOptionsTransformRun(input) {
|
||||||
|
const decision = renameLabelFor({
|
||||||
|
dd_method: input.cart.ddMethod?.value,
|
||||||
|
dd_date: input.cart.ddDate?.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!decision.rename) {
|
||||||
|
return NO_CHANGES;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operations = input.cart.deliveryGroups.flatMap((group) =>
|
||||||
|
group.deliveryOptions.map((option) => ({
|
||||||
|
deliveryOptionRename: {
|
||||||
|
deliveryOptionHandle: option.handle,
|
||||||
|
title: decision.title,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { operations };
|
||||||
|
};
|
||||||
33
extensions/delivery-customization/src/evaluate.js
Normal file
33
extensions/delivery-customization/src/evaluate.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Pure decision logic, kept separate from the run.js adapter so it's
|
||||||
|
// unit-testable directly with plain Vitest — no WASM build needed to verify
|
||||||
|
// the actual rule (see tests/unit/delivery-customization.test.ts at the
|
||||||
|
// repo root).
|
||||||
|
//
|
||||||
|
// This directly targets the DS review complaint PRODUCT_STRATEGY.md §3.1
|
||||||
|
// names: "a stray 'estimated delivery date' on a pickup order." Renaming
|
||||||
|
// the delivery-method line at checkout to show the shopper's actual chosen
|
||||||
|
// method/date/time removes any ambiguity about what they're getting.
|
||||||
|
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const METHOD_LABEL = {
|
||||||
|
PICKUP: "Pickup",
|
||||||
|
LOCAL_DELIVERY: "Local delivery",
|
||||||
|
SHIPPING: "Shipping",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, string | null | undefined>} attributes
|
||||||
|
* @returns {{ rename: true, title: string } | { rename: false }}
|
||||||
|
*/
|
||||||
|
export function renameLabelFor(attributes) {
|
||||||
|
const method = attributes.dd_method;
|
||||||
|
const date = attributes.dd_date;
|
||||||
|
|
||||||
|
if (!method || !date || !(method in METHOD_LABEL)) {
|
||||||
|
return { rename: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = METHOD_LABEL[method];
|
||||||
|
return { rename: true, title: `${label} — ${date}` };
|
||||||
|
}
|
||||||
1
extensions/delivery-customization/src/index.js
Normal file
1
extensions/delivery-customization/src/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './cart_delivery_options_transform_run';
|
||||||
45
extensions/delivery-customization/tests/default.test.js
Normal file
45
extensions/delivery-customization/tests/default.test.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import { describe, beforeAll, test, expect } from "vitest";
|
||||||
|
import { buildFunction, getFunctionInfo, loadSchema, loadInputQuery, loadFixture, validateTestAssets, runFunction } from "@shopify/shopify-function-test-helpers";
|
||||||
|
|
||||||
|
describe("Default Integration Test", () => {
|
||||||
|
let schema;
|
||||||
|
let functionDir;
|
||||||
|
let functionInfo;
|
||||||
|
let schemaPath;
|
||||||
|
let targeting;
|
||||||
|
let functionRunnerPath;
|
||||||
|
let wasmPath;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
functionDir = path.dirname(__dirname);
|
||||||
|
await buildFunction(functionDir);
|
||||||
|
functionInfo = await getFunctionInfo(functionDir);
|
||||||
|
({ schemaPath, functionRunnerPath, wasmPath, targeting } = functionInfo);
|
||||||
|
schema = await loadSchema(schemaPath);
|
||||||
|
}, 45000);
|
||||||
|
|
||||||
|
const fixturesDir = path.join(__dirname, "fixtures");
|
||||||
|
const fixtureFiles = fs
|
||||||
|
.readdirSync(fixturesDir)
|
||||||
|
.filter((file) => file.endsWith(".json"))
|
||||||
|
.map((file) => path.join(fixturesDir, file));
|
||||||
|
|
||||||
|
fixtureFiles.forEach((fixtureFile) => {
|
||||||
|
test(`runs ${path.relative(fixturesDir, fixtureFile)}`, async () => {
|
||||||
|
const fixture = await loadFixture(fixtureFile);
|
||||||
|
const targetInputQueryPath = targeting[fixture.target].inputQueryPath;
|
||||||
|
const inputQueryAST = await loadInputQuery(targetInputQueryPath);
|
||||||
|
|
||||||
|
const validationResult = await validateTestAssets({ schema, fixture, inputQueryAST });
|
||||||
|
expect(validationResult.inputQuery.errors).toEqual([]);
|
||||||
|
expect(validationResult.inputFixture.errors).toEqual([]);
|
||||||
|
expect(validationResult.outputFixture.errors).toEqual([]);
|
||||||
|
|
||||||
|
const runResult = await runFunction(fixture, functionRunnerPath, wasmPath, targetInputQueryPath, schemaPath);
|
||||||
|
expect(runResult.error).toBeNull();
|
||||||
|
expect(runResult.result.output).toEqual(fixture.expectedOutput);
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
extensions/delivery-customization/tests/fixtures/no-operations.json
vendored
Normal file
20
extensions/delivery-customization/tests/fixtures/no-operations.json
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"payload": {
|
||||||
|
"export": "cart-delivery-options-transform-run",
|
||||||
|
"target": "cart.delivery-options.transform.run",
|
||||||
|
"input": {
|
||||||
|
"cart": {
|
||||||
|
"ddMethod": null,
|
||||||
|
"ddDate": null,
|
||||||
|
"deliveryGroups": [
|
||||||
|
{
|
||||||
|
"deliveryOptions": [{ "handle": "standard-shipping" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"operations": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
extensions/delivery-customization/tests/fixtures/renames-pickup-option.json
vendored
Normal file
33
extensions/delivery-customization/tests/fixtures/renames-pickup-option.json
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"payload": {
|
||||||
|
"export": "cart-delivery-options-transform-run",
|
||||||
|
"target": "cart.delivery-options.transform.run",
|
||||||
|
"input": {
|
||||||
|
"cart": {
|
||||||
|
"ddMethod": { "value": "PICKUP" },
|
||||||
|
"ddDate": { "value": "2026-08-25" },
|
||||||
|
"deliveryGroups": [
|
||||||
|
{
|
||||||
|
"deliveryOptions": [{ "handle": "standard-shipping" }, { "handle": "express-shipping" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"deliveryOptionRename": {
|
||||||
|
"deliveryOptionHandle": "standard-shipping",
|
||||||
|
"title": "Pickup — 2026-08-25"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"deliveryOptionRename": {
|
||||||
|
"deliveryOptionHandle": "express-shipping",
|
||||||
|
"title": "Pickup — 2026-08-25"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
extensions/delivery-customization/vite.config.js
Normal file
1
extensions/delivery-customization/vite.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
// Prevents inheritance from parent Remix project
|
||||||
8
extensions/delivery-customization/vitest.config.js
Normal file
8
extensions/delivery-customization/vitest.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export default {
|
||||||
|
test: {
|
||||||
|
forceRerunTriggers: [
|
||||||
|
'**/tests/fixtures/**',
|
||||||
|
'**/src/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
2
extensions/validation-slot/.gitignore
vendored
Normal file
2
extensions/validation-slot/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
dist
|
||||||
|
generated
|
||||||
4
extensions/validation-slot/locales/en.default.json
Normal file
4
extensions/validation-slot/locales/en.default.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "Delivery Slot Validation",
|
||||||
|
"description": "Blocks checkout if no delivery date/time slot was selected."
|
||||||
|
}
|
||||||
35
extensions/validation-slot/package.json
Normal file
35
extensions/validation-slot/package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "validation-slot",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"shopify": "npm exec -- shopify",
|
||||||
|
"typegen": "npm exec -- shopify app function typegen",
|
||||||
|
"build": "npm exec -- shopify app function build",
|
||||||
|
"preview": "npm exec -- shopify app function run",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"codegen": {
|
||||||
|
"schema": "schema.graphql",
|
||||||
|
"documents": "src/*.graphql",
|
||||||
|
"generates": {
|
||||||
|
"./generated/api.ts": {
|
||||||
|
"plugins": [
|
||||||
|
"typescript",
|
||||||
|
"typescript-operations"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"omitOperationSuffix": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@shopify/shopify_function": "^2.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@shopify/shopify-function-test-helpers": "^1.0.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
5437
extensions/validation-slot/schema.graphql
Normal file
5437
extensions/validation-slot/schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
17
extensions/validation-slot/shopify.extension.toml
Normal file
17
extensions/validation-slot/shopify.extension.toml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
api_version = "2026-07"
|
||||||
|
|
||||||
|
[[extensions]]
|
||||||
|
name = "t:name"
|
||||||
|
handle = "validation-slot"
|
||||||
|
type = "function"
|
||||||
|
uid = "f5265178-4064-010b-5acf-a7b68096a1d29be8d5da"
|
||||||
|
description = "t:description"
|
||||||
|
|
||||||
|
[[extensions.targeting]]
|
||||||
|
target = "cart.validations.generate.run"
|
||||||
|
input_query = "src/cart_validations_generate_run.graphql"
|
||||||
|
export = "cart-validations-generate-run"
|
||||||
|
|
||||||
|
[extensions.build]
|
||||||
|
command = ""
|
||||||
|
path = "dist/function.wasm"
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
query CartValidationsGenerateRunInput {
|
||||||
|
cart {
|
||||||
|
ddMethod: attribute(key: "dd_method") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
ddDate: attribute(key: "dd_date") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
ddStartMin: attribute(key: "dd_start_min") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
ddEndMin: attribute(key: "dd_end_min") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
ddLocationId: attribute(key: "dd_location_id") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { evaluateCartAttributes } from "./evaluate.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import("../generated/api").CartValidationsGenerateRunInput} CartValidationsGenerateRunInput
|
||||||
|
* @typedef {import("../generated/api").CartValidationsGenerateRunResult} CartValidationsGenerateRunResult
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ERROR_MESSAGE = {
|
||||||
|
no_slot_selected: "Please choose a delivery date and time before checking out.",
|
||||||
|
incomplete_slot_selection: "Your delivery date/time selection is incomplete — please choose it again.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cart & Checkout Validation Function — the server-side enforcement half of
|
||||||
|
* the widget/Function split (see evaluate.js for the full scope note).
|
||||||
|
* @param {CartValidationsGenerateRunInput} input
|
||||||
|
* @returns {CartValidationsGenerateRunResult}
|
||||||
|
*/
|
||||||
|
export function cartValidationsGenerateRun(input) {
|
||||||
|
const attributes = {
|
||||||
|
dd_method: input.cart.ddMethod?.value,
|
||||||
|
dd_date: input.cart.ddDate?.value,
|
||||||
|
dd_start_min: input.cart.ddStartMin?.value,
|
||||||
|
dd_end_min: input.cart.ddEndMin?.value,
|
||||||
|
dd_location_id: input.cart.ddLocationId?.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateCartAttributes(attributes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
validationAdd: {
|
||||||
|
errors: result.valid ? [] : [{ message: ERROR_MESSAGE[result.reason], target: "$.cart" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
42
extensions/validation-slot/src/evaluate.js
Normal file
42
extensions/validation-slot/src/evaluate.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Pure decision logic, kept separate from the run.js adapter so it can be
|
||||||
|
// unit-tested directly with plain Vitest (see
|
||||||
|
// tests/unit/validation-slot.test.ts at the repo root) as well as through
|
||||||
|
// the real function-runner fixtures in tests/fixtures/ here.
|
||||||
|
//
|
||||||
|
// This is the enforcement half of the "widget collects, Function enforces"
|
||||||
|
// split (CLAUDE.md non-negotiable): a shopper who clears the cart
|
||||||
|
// attribute, calls the cart API directly, or otherwise bypasses the
|
||||||
|
// storefront widget still cannot complete checkout, because this runs
|
||||||
|
// server-side inside Shopify's own checkout, not in the browser.
|
||||||
|
//
|
||||||
|
// Scope note (Phase 4): every order on shops that activate this Function is
|
||||||
|
// currently treated as requiring a schedule selection — there's no
|
||||||
|
// ProductRule yet (that's Phase 5) to scope enforcement to specific
|
||||||
|
// products/collections. Merchants who haven't finished configuring
|
||||||
|
// locations/slots simply shouldn't activate this Function in
|
||||||
|
// Settings > Checkout yet, the same way any other checkout validation
|
||||||
|
// works. Also out of scope for now: re-validating against a *live*
|
||||||
|
// capacity snapshot at the moment checkout completes ("has since been
|
||||||
|
// taken" in PRODUCT_STRATEGY.md §3.1) — Functions can't call our DB, and
|
||||||
|
// building a metafield-snapshot refresh pipeline for that is real,
|
||||||
|
// unscoped work. The 10-minute hold TTL (app/services/holds.server.ts) is
|
||||||
|
// the mitigation for that specific race in the meantime.
|
||||||
|
|
||||||
|
const REQUIRED_ATTRIBUTES = ["dd_method", "dd_date", "dd_start_min", "dd_end_min", "dd_location_id"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, string | null | undefined>} attributes
|
||||||
|
* @returns {{ valid: true } | { valid: false, reason: "no_slot_selected" | "incomplete_slot_selection", missing: string[] }}
|
||||||
|
*/
|
||||||
|
export function evaluateCartAttributes(attributes) {
|
||||||
|
const missing = REQUIRED_ATTRIBUTES.filter((key) => !attributes[key]);
|
||||||
|
|
||||||
|
if (missing.length === 0) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
if (missing.length === REQUIRED_ATTRIBUTES.length) {
|
||||||
|
return { valid: false, reason: "no_slot_selected", missing };
|
||||||
|
}
|
||||||
|
return { valid: false, reason: "incomplete_slot_selection", missing };
|
||||||
|
}
|
||||||
1
extensions/validation-slot/src/index.js
Normal file
1
extensions/validation-slot/src/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './cart_validations_generate_run';
|
||||||
45
extensions/validation-slot/tests/default.test.js
Normal file
45
extensions/validation-slot/tests/default.test.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import { describe, beforeAll, test, expect } from "vitest";
|
||||||
|
import { buildFunction, getFunctionInfo, loadSchema, loadInputQuery, loadFixture, validateTestAssets, runFunction } from "@shopify/shopify-function-test-helpers";
|
||||||
|
|
||||||
|
describe("Default Integration Test", () => {
|
||||||
|
let schema;
|
||||||
|
let functionDir;
|
||||||
|
let functionInfo;
|
||||||
|
let schemaPath;
|
||||||
|
let targeting;
|
||||||
|
let functionRunnerPath;
|
||||||
|
let wasmPath;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
functionDir = path.dirname(__dirname);
|
||||||
|
await buildFunction(functionDir);
|
||||||
|
functionInfo = await getFunctionInfo(functionDir);
|
||||||
|
({ schemaPath, functionRunnerPath, wasmPath, targeting } = functionInfo);
|
||||||
|
schema = await loadSchema(schemaPath);
|
||||||
|
}, 45000);
|
||||||
|
|
||||||
|
const fixturesDir = path.join(__dirname, "fixtures");
|
||||||
|
const fixtureFiles = fs
|
||||||
|
.readdirSync(fixturesDir)
|
||||||
|
.filter((file) => file.endsWith(".json"))
|
||||||
|
.map((file) => path.join(fixturesDir, file));
|
||||||
|
|
||||||
|
fixtureFiles.forEach((fixtureFile) => {
|
||||||
|
test(`runs ${path.relative(fixturesDir, fixtureFile)}`, async () => {
|
||||||
|
const fixture = await loadFixture(fixtureFile);
|
||||||
|
const targetInputQueryPath = targeting[fixture.target].inputQueryPath;
|
||||||
|
const inputQueryAST = await loadInputQuery(targetInputQueryPath);
|
||||||
|
|
||||||
|
const validationResult = await validateTestAssets({ schema, fixture, inputQueryAST });
|
||||||
|
expect(validationResult.inputQuery.errors).toEqual([]);
|
||||||
|
expect(validationResult.inputFixture.errors).toEqual([]);
|
||||||
|
expect(validationResult.outputFixture.errors).toEqual([]);
|
||||||
|
|
||||||
|
const runResult = await runFunction(fixture, functionRunnerPath, wasmPath, targetInputQueryPath, schemaPath);
|
||||||
|
expect(runResult.error).toBeNull();
|
||||||
|
expect(runResult.result.output).toEqual(fixture.expectedOutput);
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
extensions/validation-slot/tests/fixtures/complete-selection-passes.json
vendored
Normal file
24
extensions/validation-slot/tests/fixtures/complete-selection-passes.json
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"payload": {
|
||||||
|
"export": "cart-validations-generate-run",
|
||||||
|
"target": "cart.validations.generate.run",
|
||||||
|
"input": {
|
||||||
|
"cart": {
|
||||||
|
"ddMethod": { "value": "PICKUP" },
|
||||||
|
"ddDate": { "value": "2026-08-25" },
|
||||||
|
"ddStartMin": { "value": "540" },
|
||||||
|
"ddEndMin": { "value": "600" },
|
||||||
|
"ddLocationId": { "value": "loc_123" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"validationAdd": {
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
extensions/validation-slot/tests/fixtures/incomplete-selection-blocks-checkout.json
vendored
Normal file
29
extensions/validation-slot/tests/fixtures/incomplete-selection-blocks-checkout.json
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"payload": {
|
||||||
|
"export": "cart-validations-generate-run",
|
||||||
|
"target": "cart.validations.generate.run",
|
||||||
|
"input": {
|
||||||
|
"cart": {
|
||||||
|
"ddMethod": { "value": "PICKUP" },
|
||||||
|
"ddDate": { "value": "2026-08-25" },
|
||||||
|
"ddStartMin": null,
|
||||||
|
"ddEndMin": null,
|
||||||
|
"ddLocationId": { "value": "loc_123" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"validationAdd": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"message": "Your delivery date/time selection is incomplete — please choose it again.",
|
||||||
|
"target": "$.cart"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
extensions/validation-slot/tests/fixtures/missing-selection-blocks-checkout.json
vendored
Normal file
29
extensions/validation-slot/tests/fixtures/missing-selection-blocks-checkout.json
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"payload": {
|
||||||
|
"export": "cart-validations-generate-run",
|
||||||
|
"target": "cart.validations.generate.run",
|
||||||
|
"input": {
|
||||||
|
"cart": {
|
||||||
|
"ddMethod": null,
|
||||||
|
"ddDate": null,
|
||||||
|
"ddStartMin": null,
|
||||||
|
"ddEndMin": null,
|
||||||
|
"ddLocationId": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"validationAdd": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"message": "Please choose a delivery date and time before checking out.",
|
||||||
|
"target": "$.cart"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
extensions/validation-slot/vite.config.js
Normal file
1
extensions/validation-slot/vite.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
// Prevents inheritance from parent Remix project
|
||||||
8
extensions/validation-slot/vitest.config.js
Normal file
8
extensions/validation-slot/vitest.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export default {
|
||||||
|
test: {
|
||||||
|
forceRerunTriggers: [
|
||||||
|
'**/tests/fixtures/**',
|
||||||
|
'**/src/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
2159
package-lock.json
generated
2159
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "remix vite:build",
|
"build": "remix vite:build",
|
||||||
|
"predev": "npm run build:widget",
|
||||||
"dev": "shopify app dev",
|
"dev": "shopify app dev",
|
||||||
|
"predeploy": "npm run build:widget",
|
||||||
"config:link": "shopify app config link",
|
"config:link": "shopify app config link",
|
||||||
"generate": "shopify app generate",
|
"generate": "shopify app generate",
|
||||||
"deploy": "shopify app deploy",
|
"deploy": "shopify app deploy",
|
||||||
@ -13,9 +15,14 @@
|
|||||||
"docker-start": "npm run setup && npm run start",
|
"docker-start": "npm run setup && npm run start",
|
||||||
"setup": "prisma generate && prisma migrate deploy",
|
"setup": "prisma generate && prisma migrate deploy",
|
||||||
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
|
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
|
||||||
|
"typegen:functions": "npm --prefix extensions/validation-slot run typegen && npm --prefix extensions/delivery-customization run typegen",
|
||||||
|
"pretypecheck": "npm run typegen:functions",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"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:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
"build:widget": "esbuild widget-src/datetime-widget/datetime-widget.ts --bundle --minify --target=es2019 --outfile=extensions/datetime-widget/assets/datetime-widget.js",
|
||||||
"worker": "tsx jobs/worker.ts",
|
"worker": "tsx jobs/worker.ts",
|
||||||
"shopify": "shopify",
|
"shopify": "shopify",
|
||||||
"prisma": "prisma",
|
"prisma": "prisma",
|
||||||
@ -56,6 +63,8 @@
|
|||||||
"@types/node": "^22.2.0",
|
"@types/node": "^22.2.0",
|
||||||
"@types/react": "^18.2.31",
|
"@types/react": "^18.2.31",
|
||||||
"@types/react-dom": "^18.2.14",
|
"@types/react-dom": "^18.2.14",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"esbuild": "^0.24.2",
|
||||||
"eslint": "^8.42.0",
|
"eslint": "^8.42.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"prettier": "^3.2.4",
|
"prettier": "^3.2.4",
|
||||||
|
|||||||
29
prisma/migrations/20260823221020_add_booking/migration.sql
Normal file
29
prisma/migrations/20260823221020_add_booking/migration.sql
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Booking" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"shopDomain" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"orderName" TEXT,
|
||||||
|
"locationId" TEXT NOT NULL,
|
||||||
|
"method" "Method" NOT NULL,
|
||||||
|
"slotStart" TIMESTAMP(3) NOT NULL,
|
||||||
|
"slotEnd" TIMESTAMP(3) NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'confirmed',
|
||||||
|
"customerEmail" TEXT,
|
||||||
|
"customerPhone" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Booking_orderId_key" ON "Booking"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Booking_shopDomain_locationId_method_slotStart_idx" ON "Booking"("shopDomain", "locationId", "method", "slotStart");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Booking_shopDomain_status_idx" ON "Booking"("shopDomain", "status");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@ -66,6 +66,7 @@ model Location {
|
|||||||
slotTemplates SlotTemplate[]
|
slotTemplates SlotTemplate[]
|
||||||
overrides SlotOverride[]
|
overrides SlotOverride[]
|
||||||
blackouts BlackoutDate[]
|
blackouts BlackoutDate[]
|
||||||
|
bookings Booking[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([shopDomain])
|
@@index([shopDomain])
|
||||||
@ -117,3 +118,28 @@ model BlackoutDate {
|
|||||||
|
|
||||||
@@index([shopDomain, date])
|
@@index([shopDomain, date])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Booking {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
shopDomain String
|
||||||
|
orderId String @unique // Shopify order GID — also our idempotency key for webhook retries
|
||||||
|
orderName String? // e.g. "#1001", for display only
|
||||||
|
locationId String
|
||||||
|
location Location @relation(fields: [locationId], references: [id])
|
||||||
|
method Method
|
||||||
|
slotStart DateTime
|
||||||
|
slotEnd DateTime
|
||||||
|
status String @default("confirmed") // confirmed|cancelled|fulfilled|no_show
|
||||||
|
customerEmail String?
|
||||||
|
customerPhone String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([shopDomain, locationId, method, slotStart])
|
||||||
|
@@index([shopDomain, status])
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlotHold is intentionally NOT a Prisma model — per IMPLEMENTATION_PLAN.md
|
||||||
|
// §4 it's "App DB (Redis-backed)": Redis's native TTL/expiry is exactly the
|
||||||
|
// semantics a soft, time-limited reservation needs, so it's the sole source
|
||||||
|
// of truth for holds (app/services/holds.server.ts). Mirroring it into
|
||||||
|
// Postgres too would only add a sync-consistency burden with no benefit.
|
||||||
|
|||||||
@ -1,25 +1,26 @@
|
|||||||
# This file stores configurations for your Shopify app.
|
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||||
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration
|
|
||||||
|
|
||||||
client_id = ""
|
client_id = "890f611da9f31c1a8e3183b5300b2f53"
|
||||||
name = "delivery-datetime-app"
|
name = "Metatron-delivery"
|
||||||
application_url = "https://replace-with-your-tunnel-url.example.com"
|
application_url = "https://shopify.dev/apps/default-app-home"
|
||||||
embedded = true
|
embedded = true
|
||||||
|
|
||||||
[access_scopes]
|
[access_scopes]
|
||||||
# Minimum scopes for the v1 (Phase 0-4) feature set. Add more only when a
|
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
|
||||||
# feature in IMPLEMENTATION_PLAN.md actually needs it.
|
# read_customers deliberately omitted: no feature currently calls the
|
||||||
scopes = "read_products,read_customers,read_orders,write_orders,read_locations,read_metaobjects,write_metaobjects,write_cart_transforms,write_delivery_customizations,write_payment_customizations,read_markets,read_locales"
|
# Customers API (Booking.customerEmail/customerPhone come straight off the
|
||||||
|
# orders/create webhook payload, covered by read_orders) — requesting it
|
||||||
|
# unused would also gate this app behind Shopify's Protected Customer Data
|
||||||
|
# Access approval for no reason. Add it back only when a feature (e.g. the
|
||||||
|
# "recognize returning customers" parity item) actually needs it, and
|
||||||
|
# expect to need that approval granted at that point.
|
||||||
|
scopes = "read_locales,read_locations,read_markets,read_metaobjects,read_orders,read_products,write_cart_transforms,write_delivery_customizations,write_metaobjects,write_orders,write_payment_customizations"
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
redirect_urls = [
|
redirect_urls = [ "https://shopify.dev/apps/default-app-home/api/auth" ]
|
||||||
"https://replace-with-your-tunnel-url.example.com/auth/callback",
|
|
||||||
"https://replace-with-your-tunnel-url.example.com/auth/shopify/callback",
|
|
||||||
"https://replace-with-your-tunnel-url.example.com/api/auth/callback"
|
|
||||||
]
|
|
||||||
|
|
||||||
[webhooks]
|
[webhooks]
|
||||||
api_version = "2024-10"
|
api_version = "2026-10"
|
||||||
|
|
||||||
# Handled by: app/routes/webhooks.app.uninstalled.tsx
|
# Handled by: app/routes/webhooks.app.uninstalled.tsx
|
||||||
[[webhooks.subscriptions]]
|
[[webhooks.subscriptions]]
|
||||||
@ -31,39 +32,51 @@ api_version = "2024-10"
|
|||||||
uri = "/webhooks/app/scopes_update"
|
uri = "/webhooks/app/scopes_update"
|
||||||
topics = ["app/scopes_update"]
|
topics = ["app/scopes_update"]
|
||||||
|
|
||||||
# Handled by: app/routes/webhooks.orders.create.tsx (Phase 4)
|
# Handled by: app/routes/webhooks.orders.create.tsx
|
||||||
[[webhooks.subscriptions]]
|
[[webhooks.subscriptions]]
|
||||||
uri = "/webhooks/orders/create"
|
uri = "/webhooks/orders/create"
|
||||||
topics = ["orders/create"]
|
topics = ["orders/create"]
|
||||||
|
|
||||||
# Handled by: app/routes/webhooks.orders.updated.tsx (Phase 4)
|
# Handled by: app/routes/webhooks.orders.updated.tsx
|
||||||
[[webhooks.subscriptions]]
|
[[webhooks.subscriptions]]
|
||||||
uri = "/webhooks/orders/updated"
|
uri = "/webhooks/orders/updated"
|
||||||
topics = ["orders/updated"]
|
topics = ["orders/updated"]
|
||||||
|
|
||||||
# Handled by: app/routes/webhooks.orders.cancelled.tsx (Phase 4)
|
# Handled by: app/routes/webhooks.orders.cancelled.tsx
|
||||||
[[webhooks.subscriptions]]
|
[[webhooks.subscriptions]]
|
||||||
uri = "/webhooks/orders/cancelled"
|
uri = "/webhooks/orders/cancelled"
|
||||||
topics = ["orders/cancelled"]
|
topics = ["orders/cancelled"]
|
||||||
|
|
||||||
# Mandatory GDPR compliance topics — required for Built-for-Shopify / public app review.
|
# Mandatory GDPR compliance topics — required for Built-for-Shopify /
|
||||||
# Handled by: app/routes/webhooks.customers.data_request.tsx
|
# public app review. TEMPORARILY DISABLED: `shopify app dev`/`deploy`
|
||||||
[[webhooks.subscriptions]]
|
# refuses to push these until the org has requested and been granted
|
||||||
uri = "/webhooks/customers/data_request"
|
# "Protected customer data access" in the Partner Dashboard (Apps ->
|
||||||
compliance_topics = ["customers/data_request"]
|
# this app -> API access -> Protected customer data) — that's a manual
|
||||||
|
# questionnaire/approval step, not something the CLI or config can
|
||||||
|
# bypass. RE-ENABLE these three blocks (handlers already exist and are
|
||||||
|
# wired: app/routes/webhooks.customers.data_request.tsx,
|
||||||
|
# webhooks.customers.redact.tsx, webhooks.shop.redact.tsx) once that
|
||||||
|
# access is granted, and before any public launch/BfS submission.
|
||||||
|
|
||||||
# Handled by: app/routes/webhooks.customers.redact.tsx
|
# [[webhooks.subscriptions]]
|
||||||
[[webhooks.subscriptions]]
|
# uri = "/webhooks/customers/data_request"
|
||||||
uri = "/webhooks/customers/redact"
|
# compliance_topics = ["customers/data_request"]
|
||||||
compliance_topics = ["customers/redact"]
|
|
||||||
|
|
||||||
# Handled by: app/routes/webhooks.shop.redact.tsx
|
# [[webhooks.subscriptions]]
|
||||||
[[webhooks.subscriptions]]
|
# uri = "/webhooks/customers/redact"
|
||||||
uri = "/webhooks/shop/redact"
|
# compliance_topics = ["customers/redact"]
|
||||||
compliance_topics = ["shop/redact"]
|
|
||||||
|
# [[webhooks.subscriptions]]
|
||||||
|
# uri = "/webhooks/shop/redact"
|
||||||
|
# compliance_topics = ["shop/redact"]
|
||||||
|
|
||||||
# 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).
|
# without CORS issues (see IMPLEMENTATION_PLAN.md §5.3). `shopify app dev`
|
||||||
|
# points this at your dev tunnel automatically when
|
||||||
|
# automatically_update_urls_on_dev is true (see [build] below); if it
|
||||||
|
# doesn't, set it manually to `<your-tunnel-url>/apps/scheduling` — the
|
||||||
|
# Remix routes it forwards to (apps.scheduling.*.tsx) assume that exact
|
||||||
|
# prefix.
|
||||||
[app_proxy]
|
[app_proxy]
|
||||||
url = "https://replace-with-your-tunnel-url.example.com/apps/scheduling"
|
url = "https://replace-with-your-tunnel-url.example.com/apps/scheduling"
|
||||||
subpath = "scheduling"
|
subpath = "scheduling"
|
||||||
@ -71,3 +84,4 @@ prefix = "apps"
|
|||||||
|
|
||||||
[build]
|
[build]
|
||||||
include_config_on_deploy = true
|
include_config_on_deploy = true
|
||||||
|
automatically_update_urls_on_dev = true
|
||||||
|
|||||||
90
tests/integration/booking.test.ts
Normal file
90
tests/integration/booking.test.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import db from "../../app/db.server";
|
||||||
|
import redis from "../../app/lib/redis.server";
|
||||||
|
import { createBookingFromOrder } from "../../app/services/booking.server";
|
||||||
|
import { countActiveHolds, tryCreateHold } from "../../app/services/holds.server";
|
||||||
|
|
||||||
|
const shopDomain = "booking-integration-test.myshopify.com";
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await db.booking.deleteMany({ where: { shopDomain } });
|
||||||
|
await db.location.deleteMany({ where: { shopDomain } });
|
||||||
|
await db.shop.deleteMany({ where: { shopDomain } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createBookingFromOrder", () => {
|
||||||
|
beforeEach(cleanup);
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await db.$disconnect();
|
||||||
|
await redis.quit();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a Booking from an order's dd_* attributes and releases the matching hold", async () => {
|
||||||
|
const location = await db.location.create({
|
||||||
|
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const slot = {
|
||||||
|
shopDomain,
|
||||||
|
locationId: location.id,
|
||||||
|
method: "PICKUP" as const,
|
||||||
|
slotStartIso: "2026-08-25T13:00:00.000Z",
|
||||||
|
};
|
||||||
|
await tryCreateHold(slot, "cart_abc123", 5);
|
||||||
|
expect(await countActiveHolds(slot)).toBe(1);
|
||||||
|
|
||||||
|
await createBookingFromOrder(shopDomain, {
|
||||||
|
admin_graphql_api_id: "gid://shopify/Order/1",
|
||||||
|
name: "#1001",
|
||||||
|
cart_token: "cart_abc123",
|
||||||
|
email: "shopper@example.com",
|
||||||
|
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/1" } });
|
||||||
|
expect(booking).not.toBeNull();
|
||||||
|
expect(booking?.method).toBe("PICKUP");
|
||||||
|
expect(booking?.status).toBe("confirmed");
|
||||||
|
expect(booking?.customerEmail).toBe("shopper@example.com");
|
||||||
|
expect(booking?.slotStart.toISOString()).toBe("2026-08-25T13:00:00.000Z"); // 9 AM EDT
|
||||||
|
|
||||||
|
// The hold this order consumed should now be released.
|
||||||
|
expect(await countActiveHolds(slot)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent — a redelivered webhook does not create a second Booking", async () => {
|
||||||
|
const location = await db.location.create({
|
||||||
|
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = {
|
||||||
|
admin_graphql_api_id: "gid://shopify/Order/2",
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await createBookingFromOrder(shopDomain, order);
|
||||||
|
await createBookingFromOrder(shopDomain, order); // redelivery
|
||||||
|
|
||||||
|
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/2" } });
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing for an order with no scheduling attributes", async () => {
|
||||||
|
await createBookingFromOrder(shopDomain, { admin_graphql_api_id: "gid://shopify/Order/3", note_attributes: [] });
|
||||||
|
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/3" } });
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
98
tests/integration/holds.concurrency.test.ts
Normal file
98
tests/integration/holds.concurrency.test.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
import redis from "../../app/lib/redis.server";
|
||||||
|
import { countActiveHolds, releaseHold, tryCreateHold, type SlotIdentity } from "../../app/services/holds.server";
|
||||||
|
|
||||||
|
// The concurrency test CLAUDE.md calls out as non-negotiable: "Slot-holds
|
||||||
|
// (Redis, TTL) prevent last-slot double-booking — this has a dedicated
|
||||||
|
// concurrency test that must pass." This needs a real Redis — the
|
||||||
|
// atomicity guarantee comes from a Lua script Redis runs single-threaded,
|
||||||
|
// which a mock can't meaningfully exercise. Run via `npm run test:integration`
|
||||||
|
// against the docker-compose Redis (or CI's redis service).
|
||||||
|
|
||||||
|
function uniqueSlot(): SlotIdentity {
|
||||||
|
return {
|
||||||
|
shopDomain: "concurrency-test.myshopify.com",
|
||||||
|
locationId: "loc_test",
|
||||||
|
method: "PICKUP",
|
||||||
|
slotStartIso: `2026-08-25T13:00:00.000Z#${Math.random().toString(36).slice(2)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tryCreateHold concurrency", () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await redis.quit();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets exactly one of many concurrent requests claim the last unit of capacity", async () => {
|
||||||
|
const slot = uniqueSlot();
|
||||||
|
const capacity = 1;
|
||||||
|
const contenders = 20;
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: contenders }, (_, i) => tryCreateHold(slot, `cart-${i}`, capacity)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const successes = results.filter((r) => r.success);
|
||||||
|
expect(successes).toHaveLength(1);
|
||||||
|
|
||||||
|
const activeCount = await countActiveHolds(slot);
|
||||||
|
expect(activeCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows exactly `capacity` concurrent holds, no more, no fewer", async () => {
|
||||||
|
const slot = uniqueSlot();
|
||||||
|
const capacity = 5;
|
||||||
|
const contenders = 30;
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: contenders }, (_, i) => tryCreateHold(slot, `cart-${i}`, capacity)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(results.filter((r) => r.success)).toHaveLength(capacity);
|
||||||
|
expect(await countActiveHolds(slot)).toBe(capacity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releasing a hold frees capacity for a subsequent request", async () => {
|
||||||
|
const slot = uniqueSlot();
|
||||||
|
const capacity = 1;
|
||||||
|
|
||||||
|
const first = await tryCreateHold(slot, "cart-a", capacity);
|
||||||
|
expect(first.success).toBe(true);
|
||||||
|
|
||||||
|
const blocked = await tryCreateHold(slot, "cart-b", capacity);
|
||||||
|
expect(blocked.success).toBe(false);
|
||||||
|
|
||||||
|
await releaseHold(slot, "cart-a");
|
||||||
|
|
||||||
|
const afterRelease = await tryCreateHold(slot, "cart-b", capacity);
|
||||||
|
expect(afterRelease.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an expired hold no longer counts against capacity", async () => {
|
||||||
|
const slot = uniqueSlot();
|
||||||
|
const capacity = 1;
|
||||||
|
|
||||||
|
// A negative TTL means it's already expired the instant it's created.
|
||||||
|
const first = await tryCreateHold(slot, "cart-expired", capacity, -1000);
|
||||||
|
expect(first.success).toBe(true);
|
||||||
|
|
||||||
|
const second = await tryCreateHold(slot, "cart-fresh", capacity);
|
||||||
|
expect(second.success).toBe(true);
|
||||||
|
expect(await countActiveHolds(slot)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the same cart re-requesting the same slot does not consume a second unit", async () => {
|
||||||
|
const slot = uniqueSlot();
|
||||||
|
const capacity = 1;
|
||||||
|
|
||||||
|
const first = await tryCreateHold(slot, "cart-repeat", capacity);
|
||||||
|
expect(first.success).toBe(true);
|
||||||
|
|
||||||
|
// ZADD on an existing member updates its score rather than adding a
|
||||||
|
// second entry, so a shopper re-confirming the same slot (e.g. a retried
|
||||||
|
// request) doesn't burn extra capacity against themselves.
|
||||||
|
const second = await tryCreateHold(slot, "cart-repeat", capacity);
|
||||||
|
expect(second.success).toBe(true);
|
||||||
|
expect(await countActiveHolds(slot)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
tests/unit/capacity.test.ts
Normal file
30
tests/unit/capacity.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { hasCapacity, remainingCapacity } from "../../app/services/capacity.server";
|
||||||
|
|
||||||
|
describe("remainingCapacity", () => {
|
||||||
|
it("subtracts consumed from capacity", () => {
|
||||||
|
expect(remainingCapacity({ capacity: 10, consumed: 3 })).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors at 0 rather than going negative (overbooking never surfaces as negative capacity)", () => {
|
||||||
|
expect(remainingCapacity({ capacity: 10, consumed: 15 })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats zero consumed as full capacity", () => {
|
||||||
|
expect(remainingCapacity({ capacity: 10, consumed: 0 })).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasCapacity", () => {
|
||||||
|
it("is true when remaining capacity is positive", () => {
|
||||||
|
expect(hasCapacity({ capacity: 10, consumed: 9 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when exactly full", () => {
|
||||||
|
expect(hasCapacity({ capacity: 10, consumed: 10 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when over capacity", () => {
|
||||||
|
expect(hasCapacity({ capacity: 10, consumed: 11 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
tests/unit/delivery-customization.test.ts
Normal file
34
tests/unit/delivery-customization.test.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { renameLabelFor } from "../../extensions/delivery-customization/src/evaluate.js";
|
||||||
|
|
||||||
|
describe("renameLabelFor", () => {
|
||||||
|
it("relabels a pickup order unambiguously", () => {
|
||||||
|
const result = renameLabelFor({ dd_method: "PICKUP", dd_date: "2026-08-25" });
|
||||||
|
expect(result).toEqual({ rename: true, title: "Pickup — 2026-08-25" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relabels a local delivery order distinctly from pickup", () => {
|
||||||
|
const result = renameLabelFor({ dd_method: "LOCAL_DELIVERY", dd_date: "2026-08-25" });
|
||||||
|
expect(result.rename).toBe(true);
|
||||||
|
if (!result.rename) throw new Error("unreachable");
|
||||||
|
expect(result.title).toContain("Local delivery");
|
||||||
|
expect(result.title).not.toContain("Pickup");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relabels shipping too, for consistency across all three methods", () => {
|
||||||
|
const result = renameLabelFor({ dd_method: "SHIPPING", dd_date: "2026-08-25" });
|
||||||
|
expect(result).toEqual({ rename: true, title: "Shipping — 2026-08-25" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when no method was selected", () => {
|
||||||
|
expect(renameLabelFor({})).toEqual({ rename: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing for an unrecognized method value", () => {
|
||||||
|
expect(renameLabelFor({ dd_method: "TELEPORT", dd_date: "2026-08-25" })).toEqual({ rename: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the date is missing even if method is present", () => {
|
||||||
|
expect(renameLabelFor({ dd_method: "PICKUP" })).toEqual({ rename: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
162
tests/unit/scheduling.test.ts
Normal file
162
tests/unit/scheduling.test.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { slotDateTime } from "../../app/lib/time";
|
||||||
|
import { getAvailability, type SlotTemplateLike } from "../../app/services/scheduling.server";
|
||||||
|
|
||||||
|
const ZONE = "America/Toronto";
|
||||||
|
|
||||||
|
function now(date: string, minutesFromMidnight: number) {
|
||||||
|
return slotDateTime(date, minutesFromMidnight, ZONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A simple weekday-only (Mon-Fri) 9-5 template, 1hr cutoff, no extra lead time.
|
||||||
|
const WEEKDAY_TEMPLATE: SlotTemplateLike[] = [1, 2, 3, 4, 5].map((weekday) => ({
|
||||||
|
weekday,
|
||||||
|
startMin: 9 * 60,
|
||||||
|
endMin: 17 * 60,
|
||||||
|
capacity: 5,
|
||||||
|
cutoffMin: 60,
|
||||||
|
leadTimeMin: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("getAvailability", () => {
|
||||||
|
it("returns a slot for each weekday template date in range, none for weekends", () => {
|
||||||
|
// 2024-03-04 (Mon) .. 2024-03-10 (Sun)
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-10" },
|
||||||
|
slotTemplates: WEEKDAY_TEMPLATE,
|
||||||
|
now: now("2024-03-01", 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Object.keys(result).sort()).toEqual(["2024-03-04", "2024-03-05", "2024-03-06", "2024-03-07", "2024-03-08"]);
|
||||||
|
expect(result["2024-03-04"]).toHaveLength(1);
|
||||||
|
expect(result["2024-03-04"][0]).toMatchObject({ startMin: 9 * 60, endMin: 17 * 60, remainingCapacity: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides a slot once now is within its cutoff window", () => {
|
||||||
|
// Slot is 2024-03-04 09:00, cutoff 60 min -> unavailable from 08:00 on.
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
||||||
|
slotTemplates: WEEKDAY_TEMPLATE,
|
||||||
|
now: now("2024-03-04", 8 * 60 + 1),
|
||||||
|
});
|
||||||
|
expect(result["2024-03-04"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a slot exactly at the cutoff boundary", () => {
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
||||||
|
slotTemplates: WEEKDAY_TEMPLATE,
|
||||||
|
now: now("2024-03-04", 8 * 60), // exactly 60 min before 9:00
|
||||||
|
});
|
||||||
|
expect(result["2024-03-04"]).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces leadTimeMin even when it exceeds cutoffMin", () => {
|
||||||
|
const template: SlotTemplateLike[] = [
|
||||||
|
{ weekday: 1, startMin: 9 * 60, endMin: 17 * 60, capacity: 5, cutoffMin: 60, leadTimeMin: 24 * 60 },
|
||||||
|
];
|
||||||
|
// Only 2 hours before slot start — passes the 60-min cutoff but fails the 24h lead time.
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
||||||
|
slotTemplates: template,
|
||||||
|
now: now("2024-03-04", 7 * 60),
|
||||||
|
});
|
||||||
|
expect(result["2024-03-04"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes a blacked-out date entirely, even if a template would otherwise apply", () => {
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-05" },
|
||||||
|
slotTemplates: WEEKDAY_TEMPLATE,
|
||||||
|
blackoutDates: [{ date: "2024-03-04" }],
|
||||||
|
now: now("2024-03-01", 0),
|
||||||
|
});
|
||||||
|
expect(result["2024-03-04"]).toBeUndefined();
|
||||||
|
expect(result["2024-03-05"]).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a closed override removes the date even though a template exists", () => {
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
||||||
|
slotTemplates: WEEKDAY_TEMPLATE,
|
||||||
|
overrides: [{ date: "2024-03-04", closed: true, startMin: null, endMin: null, capacity: null }],
|
||||||
|
now: now("2024-03-01", 0),
|
||||||
|
});
|
||||||
|
expect(result["2024-03-04"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a non-closed override replaces the day's window/capacity instead of the template's", () => {
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
||||||
|
slotTemplates: WEEKDAY_TEMPLATE,
|
||||||
|
overrides: [
|
||||||
|
{ date: "2024-03-04", closed: false, startMin: 12 * 60, endMin: 14 * 60, capacity: 2 },
|
||||||
|
],
|
||||||
|
now: now("2024-03-01", 0),
|
||||||
|
});
|
||||||
|
expect(result["2024-03-04"]).toHaveLength(1);
|
||||||
|
expect(result["2024-03-04"][0]).toMatchObject({ startMin: 12 * 60, endMin: 14 * 60, capacity: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides a slot once consumed capacity reaches the template capacity", () => {
|
||||||
|
const consumed = new Map([[`2024-03-04|${9 * 60}`, 5]]);
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
||||||
|
slotTemplates: WEEKDAY_TEMPLATE,
|
||||||
|
consumed,
|
||||||
|
now: now("2024-03-01", 0),
|
||||||
|
});
|
||||||
|
expect(result["2024-03-04"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reduces remainingCapacity but keeps the slot visible when partially consumed", () => {
|
||||||
|
const consumed = new Map([[`2024-03-04|${9 * 60}`, 3]]);
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
||||||
|
slotTemplates: WEEKDAY_TEMPLATE,
|
||||||
|
consumed,
|
||||||
|
now: now("2024-03-01", 0),
|
||||||
|
});
|
||||||
|
expect(result["2024-03-04"][0].remainingCapacity).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes correct instants for a range spanning the spring-forward DST transition", () => {
|
||||||
|
// 2024-03-08 (Fri) and 2024-03-11 (Mon) bracket the 2024-03-10 transition.
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-08", endDate: "2024-03-11" },
|
||||||
|
slotTemplates: WEEKDAY_TEMPLATE,
|
||||||
|
now: now("2024-03-01", 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const friday = result["2024-03-08"][0];
|
||||||
|
const monday = result["2024-03-11"][0];
|
||||||
|
expect(friday.start.toUTC().hour).toBe(14); // EST, UTC-5
|
||||||
|
expect(monday.start.toUTC().hour).toBe(13); // EDT, UTC-4 — offset already changed
|
||||||
|
expect(friday.start.hour).toBe(9);
|
||||||
|
expect(monday.start.hour).toBe(9); // still wall-clock 9 AM despite the offset shift
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns multiple slots per day sorted by start time when several templates match", () => {
|
||||||
|
const templates: SlotTemplateLike[] = [
|
||||||
|
{ weekday: 1, startMin: 14 * 60, endMin: 16 * 60, capacity: 3, cutoffMin: 0, leadTimeMin: 0 },
|
||||||
|
{ weekday: 1, startMin: 9 * 60, endMin: 11 * 60, capacity: 3, cutoffMin: 0, leadTimeMin: 0 },
|
||||||
|
];
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
||||||
|
slotTemplates: templates,
|
||||||
|
now: now("2024-03-01", 0),
|
||||||
|
});
|
||||||
|
expect(result["2024-03-04"].map((s) => s.startMin)).toEqual([9 * 60, 14 * 60]);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
tests/unit/time.test.ts
Normal file
123
tests/unit/time.test.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { enumerateDates, minutesUntil, slotDateTime, weekdayOf } from "../../app/lib/time";
|
||||||
|
|
||||||
|
// North American DST transitions used throughout (2nd Sunday of March /
|
||||||
|
// 1st Sunday of November — fixed historical facts, safe to hardcode):
|
||||||
|
// 2024-03-10: spring forward, 2:00 AM -> 3:00 AM (that hour doesn't exist)
|
||||||
|
// 2024-11-03: fall back, 2:00 AM -> 1:00 AM (1:00-2:00 AM happens twice)
|
||||||
|
const ZONE = "America/Toronto";
|
||||||
|
|
||||||
|
describe("slotDateTime", () => {
|
||||||
|
it("computes the correct UTC offset in winter (EST, UTC-5)", () => {
|
||||||
|
const dt = slotDateTime("2024-01-15", 9 * 60, ZONE);
|
||||||
|
expect(dt.hour).toBe(9);
|
||||||
|
expect(dt.toUTC().hour).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes the correct UTC offset in summer (EDT, UTC-4)", () => {
|
||||||
|
const dt = slotDateTime("2024-07-15", 9 * 60, ZONE);
|
||||||
|
expect(dt.hour).toBe(9);
|
||||||
|
expect(dt.toUTC().hour).toBe(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps 9:00 AM meaning 9:00 AM on the spring-forward day itself", () => {
|
||||||
|
const dt = slotDateTime("2024-03-10", 9 * 60, ZONE);
|
||||||
|
expect(dt.hour).toBe(9);
|
||||||
|
expect(dt.toUTC().hour).toBe(13); // already EDT (UTC-4) by 9 AM
|
||||||
|
|
||||||
|
// Regression check: naively adding elapsed minutes to local midnight
|
||||||
|
// instead of setting wall-clock fields directly would land an hour
|
||||||
|
// late on this exact day, because the 2-3 AM hour never happened.
|
||||||
|
const naive = DateTime.fromISO("2024-03-10", { zone: ZONE }).startOf("day").plus({ minutes: 9 * 60 });
|
||||||
|
expect(naive.hour).toBe(10);
|
||||||
|
expect(naive.hour).not.toBe(dt.hour);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps 9:00 AM meaning 9:00 AM on the fall-back day itself", () => {
|
||||||
|
const dt = slotDateTime("2024-11-03", 9 * 60, ZONE);
|
||||||
|
expect(dt.hour).toBe(9);
|
||||||
|
expect(dt.toUTC().hour).toBe(14); // already back to EST (UTC-5) by 9 AM
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for a wall-clock time that doesn't exist (spring-forward gap)", () => {
|
||||||
|
// 2:30 AM on 2024-03-10 was skipped entirely (clocks jumped 2:00->3:00).
|
||||||
|
expect(() => slotDateTime("2024-03-10", 2 * 60 + 30, ZONE)).toThrow(/does not exist/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves an ambiguous wall-clock time on the fall-back day without throwing", () => {
|
||||||
|
// 1:30 AM on 2024-11-03 happened twice; either resolution is acceptable,
|
||||||
|
// it just must not throw and must report the wall-clock hour asked for.
|
||||||
|
const dt = slotDateTime("2024-11-03", 1 * 60 + 30, ZONE);
|
||||||
|
expect(dt.isValid).toBe(true);
|
||||||
|
expect(dt.hour).toBe(1);
|
||||||
|
expect(dt.minute).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for an invalid date string", () => {
|
||||||
|
expect(() => slotDateTime("not-a-date", 0, ZONE)).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for out-of-range minutes", () => {
|
||||||
|
expect(() => slotDateTime("2024-06-01", -1, ZONE)).toThrow(RangeError);
|
||||||
|
expect(() => slotDateTime("2024-06-01", 24 * 60 + 1, ZONE)).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("weekdayOf", () => {
|
||||||
|
it("matches known calendar weekdays (0 = Sunday .. 6 = Saturday)", () => {
|
||||||
|
expect(weekdayOf("2024-03-10", ZONE)).toBe(0); // Sunday
|
||||||
|
expect(weekdayOf("2024-01-01", ZONE)).toBe(1); // Monday
|
||||||
|
expect(weekdayOf("2024-01-06", ZONE)).toBe(6); // Saturday
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is purely calendar-date based, independent of the timezone passed", () => {
|
||||||
|
expect(weekdayOf("2024-03-10", "America/Toronto")).toBe(weekdayOf("2024-03-10", "Pacific/Auckland"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("enumerateDates", () => {
|
||||||
|
it("returns an inclusive, contiguous range across a DST transition", () => {
|
||||||
|
expect(enumerateDates("2024-03-08", "2024-03-11")).toEqual([
|
||||||
|
"2024-03-08",
|
||||||
|
"2024-03-09",
|
||||||
|
"2024-03-10",
|
||||||
|
"2024-03-11",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a single date when start equals end", () => {
|
||||||
|
expect(enumerateDates("2024-06-01", "2024-06-01")).toEqual(["2024-06-01"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spans a month boundary correctly", () => {
|
||||||
|
expect(enumerateDates("2024-01-30", "2024-02-02")).toEqual([
|
||||||
|
"2024-01-30",
|
||||||
|
"2024-01-31",
|
||||||
|
"2024-02-01",
|
||||||
|
"2024-02-02",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("minutesUntil", () => {
|
||||||
|
it("returns real elapsed minutes across a fall-back (an extra hour occurs)", () => {
|
||||||
|
const now = slotDateTime("2024-11-02", 23 * 60, ZONE); // 11 PM, day before
|
||||||
|
const target = slotDateTime("2024-11-03", 9 * 60, ZONE); // 9 AM, after fall-back
|
||||||
|
// Wall-clock difference looks like 10h, but 11 real hours passed.
|
||||||
|
expect(minutesUntil(now, target)).toBe(11 * 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns real elapsed minutes across a spring-forward (an hour is lost)", () => {
|
||||||
|
const now = slotDateTime("2024-03-09", 23 * 60, ZONE); // 11 PM, day before
|
||||||
|
const target = slotDateTime("2024-03-10", 9 * 60, ZONE); // 9 AM, after spring-forward
|
||||||
|
// Wall-clock difference looks like 10h, but only 9 real hours passed.
|
||||||
|
expect(minutesUntil(now, target)).toBe(9 * 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for the same instant and negative for a past target", () => {
|
||||||
|
const t = slotDateTime("2024-06-01", 12 * 60, ZONE);
|
||||||
|
expect(minutesUntil(t, t)).toBe(0);
|
||||||
|
expect(minutesUntil(t, slotDateTime("2024-06-01", 11 * 60, ZONE))).toBe(-60);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
tests/unit/validation-slot.test.ts
Normal file
40
tests/unit/validation-slot.test.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { evaluateCartAttributes } from "../../extensions/validation-slot/src/evaluate.js";
|
||||||
|
|
||||||
|
const COMPLETE = {
|
||||||
|
dd_method: "PICKUP",
|
||||||
|
dd_date: "2026-08-25",
|
||||||
|
dd_start_min: "540",
|
||||||
|
dd_end_min: "600",
|
||||||
|
dd_location_id: "loc_123",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("evaluateCartAttributes", () => {
|
||||||
|
it("is valid when every required attribute is present", () => {
|
||||||
|
expect(evaluateCartAttributes(COMPLETE)).toEqual({ valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a cart with no scheduling attributes at all", () => {
|
||||||
|
const result = evaluateCartAttributes({});
|
||||||
|
expect(result).toMatchObject({ valid: false, reason: "no_slot_selected" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a cart missing just one required attribute", () => {
|
||||||
|
const { dd_start_min, ...rest } = COMPLETE;
|
||||||
|
const result = evaluateCartAttributes(rest);
|
||||||
|
expect(result).toMatchObject({ valid: false, reason: "incomplete_slot_selection", missing: ["dd_start_min"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an empty-string attribute the same as missing", () => {
|
||||||
|
const result = evaluateCartAttributes({ ...COMPLETE, dd_date: "" });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats null/undefined attribute values as missing", () => {
|
||||||
|
const result = evaluateCartAttributes({ ...COMPLETE, dd_location_id: null, dd_method: undefined });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
if (result.valid) throw new Error("unreachable");
|
||||||
|
expect(result.reason).toBe("incomplete_slot_selection");
|
||||||
|
expect([...result.missing].sort()).toEqual(["dd_location_id", "dd_method"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
16
vitest.integration.config.ts
Normal file
16
vitest.integration.config.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
|
// Separate from vitest.config.ts on purpose: these tests hit a real Redis
|
||||||
|
// (and, later, Postgres) instance rather than pure functions, so they're
|
||||||
|
// kept out of the fast `npm test` unit loop and run explicitly via
|
||||||
|
// `npm run test:integration` (locally: docker compose up -d; in CI: a
|
||||||
|
// redis service — see .github/workflows/ci.yml).
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tsconfigPaths()],
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["tests/integration/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
359
widget-src/datetime-widget/datetime-widget.ts
Normal file
359
widget-src/datetime-widget/datetime-widget.ts
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
// Storefront widget for the Theme App Extension. Vanilla TS, no framework
|
||||||
|
// (IMPLEMENTATION_PLAN.md §1 sanctions "Preact or vanilla TS" — vanilla
|
||||||
|
// keeps the bundle tiny and avoids a runtime dependency for something this
|
||||||
|
// small). Bundled to ../assets/datetime-widget.js via esbuild
|
||||||
|
// (`npm run build:widget` at the repo root) — Theme App Extensions ship
|
||||||
|
// static assets as-is, there's no CLI build step for this extension type.
|
||||||
|
//
|
||||||
|
// The widget only *collects* a selection and writes it to cart attributes.
|
||||||
|
// It never enforces anything — that's the Validation Function's job
|
||||||
|
// (Phase 4), per CLAUDE.md's non-negotiable that enforcement is
|
||||||
|
// server-side. Losing network, JS, or an ad-blocker here should degrade to
|
||||||
|
// "no slot picked" (which the Function then rejects at checkout), not to a
|
||||||
|
// bypass.
|
||||||
|
|
||||||
|
type Method = "SHIPPING" | "LOCAL_DELIVERY" | "PICKUP";
|
||||||
|
|
||||||
|
interface SlotDto {
|
||||||
|
date: string;
|
||||||
|
startMin: number;
|
||||||
|
endMin: number;
|
||||||
|
capacity: number;
|
||||||
|
remainingCapacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvailabilityResponse {
|
||||||
|
locationId: string;
|
||||||
|
locationName: string;
|
||||||
|
timezone: string;
|
||||||
|
method: Method;
|
||||||
|
dates: Record<string, SlotDto[]>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WidgetConfig {
|
||||||
|
root: HTMLElement;
|
||||||
|
heading: string;
|
||||||
|
locationId: string | null;
|
||||||
|
methods: Array<{ value: Method; label: string; attrLabel: string }>;
|
||||||
|
labels: {
|
||||||
|
chooseDate: string;
|
||||||
|
chooseTime: string;
|
||||||
|
noDates: string;
|
||||||
|
confirmed: string;
|
||||||
|
change: string;
|
||||||
|
loading: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROXY_BASE = "/apps/scheduling";
|
||||||
|
|
||||||
|
function minutesToDisplayTime(minutes: number): string {
|
||||||
|
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}:${m.toString().padStart(2, "0")} ${period}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLabel(dateIso: string): string {
|
||||||
|
// Parsed as a plain calendar date (no timezone conversion) — this string
|
||||||
|
// already represents the location-local calendar day from the API.
|
||||||
|
const [year, month, day] = dateIso.split("-").map(Number);
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
return date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfig(root: HTMLElement): WidgetConfig {
|
||||||
|
const d = root.dataset;
|
||||||
|
const methods: WidgetConfig["methods"] = [];
|
||||||
|
if (d.showShipping === "true") {
|
||||||
|
methods.push({ value: "SHIPPING", label: d.labelShipping || "Shipping", attrLabel: d.attrLabelShipping || "Shipping date" });
|
||||||
|
}
|
||||||
|
if (d.showLocalDelivery === "true") {
|
||||||
|
methods.push({
|
||||||
|
value: "LOCAL_DELIVERY",
|
||||||
|
label: d.labelLocalDelivery || "Local delivery",
|
||||||
|
attrLabel: d.attrLabelLocalDelivery || "Delivery date",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (d.showPickup === "true") {
|
||||||
|
methods.push({ value: "PICKUP", label: d.labelPickup || "Pickup", attrLabel: d.attrLabelPickup || "Pickup date" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
heading: d.heading || "",
|
||||||
|
locationId: d.locationId || null,
|
||||||
|
methods,
|
||||||
|
labels: {
|
||||||
|
chooseDate: d.labelChooseDate || "Choose a date",
|
||||||
|
chooseTime: d.labelChooseTime || "Choose a time",
|
||||||
|
noDates: d.labelNoDates || "No dates are available right now.",
|
||||||
|
confirmed: d.labelConfirmed || "Confirmed for",
|
||||||
|
change: d.labelChange || "Change",
|
||||||
|
loading: d.labelLoading || "Loading available dates…",
|
||||||
|
error: d.labelError || "Couldn't load available dates. Please try again.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAvailability(method: Method, locationId: string | null): Promise<AvailabilityResponse> {
|
||||||
|
const params = new URLSearchParams({ method, days: "14" });
|
||||||
|
if (locationId) params.set("locationId", locationId);
|
||||||
|
const res = await fetch(`${PROXY_BASE}/availability?${params.toString()}`, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
const body = (await res.json()) as AvailabilityResponse;
|
||||||
|
if (!res.ok) throw new Error(body.error || `Request failed (${res.status})`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HoldResponse {
|
||||||
|
success: boolean;
|
||||||
|
expiresAt?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCartToken(): Promise<string> {
|
||||||
|
const res = await fetch("/cart.js", { headers: { Accept: "application/json" } });
|
||||||
|
const cart = (await res.json()) as { token: string };
|
||||||
|
return cart.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestHold(params: {
|
||||||
|
intent: "create" | "release";
|
||||||
|
locationId: string;
|
||||||
|
method: Method;
|
||||||
|
date: string;
|
||||||
|
startMin: number;
|
||||||
|
cartToken: string;
|
||||||
|
}): Promise<HoldResponse> {
|
||||||
|
const res = await fetch(`${PROXY_BASE}/hold`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
const body = (await res.json()) as HoldResponse;
|
||||||
|
if (!res.ok && params.intent === "create") return { success: false, error: body.error || "Slot unavailable" };
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeCartAttribute(key: string, machine: Record<string, string>, display: string): Promise<void> {
|
||||||
|
await fetch("/cart/update.js", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ attributes: { [key]: display, ...machine } }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class DateTimeWidget {
|
||||||
|
private config: WidgetConfig;
|
||||||
|
private el = {
|
||||||
|
heading: document.createElement("h3"),
|
||||||
|
methodRow: document.createElement("div"),
|
||||||
|
dateRow: document.createElement("div"),
|
||||||
|
timeRow: document.createElement("div"),
|
||||||
|
status: document.createElement("p"),
|
||||||
|
confirmation: document.createElement("div"),
|
||||||
|
};
|
||||||
|
private selectedMethod: WidgetConfig["methods"][number] | null = null;
|
||||||
|
private selectedDate: string | null = null;
|
||||||
|
private availability: AvailabilityResponse | null = null;
|
||||||
|
private heldSlot: { locationId: string; method: Method; date: string; startMin: number; cartToken: string } | null =
|
||||||
|
null;
|
||||||
|
|
||||||
|
constructor(config: WidgetConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
const { root, heading, methods } = this.config;
|
||||||
|
root.classList.add("dd-widget--ready");
|
||||||
|
root.innerHTML = "";
|
||||||
|
|
||||||
|
if (methods.length === 0) return; // merchant disabled every method — render nothing
|
||||||
|
|
||||||
|
if (heading) {
|
||||||
|
this.el.heading.className = "dd-widget__heading";
|
||||||
|
this.el.heading.textContent = heading;
|
||||||
|
root.appendChild(this.el.heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.el.methodRow.className = "dd-widget__row dd-widget__methods";
|
||||||
|
this.el.dateRow.className = "dd-widget__row dd-widget__dates";
|
||||||
|
this.el.timeRow.className = "dd-widget__row dd-widget__times";
|
||||||
|
this.el.status.className = "dd-widget__status";
|
||||||
|
this.el.confirmation.className = "dd-widget__confirmation";
|
||||||
|
this.el.confirmation.hidden = true;
|
||||||
|
|
||||||
|
root.append(this.el.confirmation, this.el.methodRow, this.el.dateRow, this.el.timeRow, this.el.status);
|
||||||
|
|
||||||
|
if (methods.length === 1) {
|
||||||
|
this.selectMethod(methods[0]);
|
||||||
|
} else {
|
||||||
|
this.renderMethods();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderMethods() {
|
||||||
|
this.el.methodRow.innerHTML = "";
|
||||||
|
for (const method of this.config.methods) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "dd-widget__pill";
|
||||||
|
button.textContent = method.label;
|
||||||
|
button.setAttribute("aria-pressed", String(this.selectedMethod?.value === method.value));
|
||||||
|
button.addEventListener("click", () => this.selectMethod(method));
|
||||||
|
this.el.methodRow.appendChild(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async selectMethod(method: WidgetConfig["methods"][number]) {
|
||||||
|
this.selectedMethod = method;
|
||||||
|
this.selectedDate = null;
|
||||||
|
this.el.timeRow.innerHTML = "";
|
||||||
|
this.el.confirmation.hidden = true;
|
||||||
|
if (this.config.methods.length > 1) this.renderMethods();
|
||||||
|
|
||||||
|
this.el.status.textContent = this.config.labels.loading;
|
||||||
|
this.el.dateRow.innerHTML = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.availability = await fetchAvailability(method.value, this.config.locationId);
|
||||||
|
this.renderDates();
|
||||||
|
} catch {
|
||||||
|
this.el.status.textContent = this.config.labels.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDates() {
|
||||||
|
const dates = Object.keys(this.availability?.dates ?? {}).sort();
|
||||||
|
this.el.dateRow.innerHTML = "";
|
||||||
|
|
||||||
|
if (dates.length === 0) {
|
||||||
|
this.el.status.textContent = this.config.labels.noDates;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.el.status.textContent = this.config.labels.chooseDate;
|
||||||
|
for (const date of dates) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "dd-widget__pill";
|
||||||
|
button.textContent = formatDateLabel(date);
|
||||||
|
button.setAttribute("aria-pressed", String(this.selectedDate === date));
|
||||||
|
button.addEventListener("click", () => this.selectDate(date));
|
||||||
|
this.el.dateRow.appendChild(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectDate(date: string) {
|
||||||
|
this.selectedDate = date;
|
||||||
|
for (const child of Array.from(this.el.dateRow.children)) {
|
||||||
|
child.setAttribute("aria-pressed", String(child.textContent === formatDateLabel(date)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const slots = this.availability?.dates[date] ?? [];
|
||||||
|
this.el.timeRow.innerHTML = "";
|
||||||
|
this.el.status.textContent = this.config.labels.chooseTime;
|
||||||
|
|
||||||
|
for (const slot of slots) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "dd-widget__pill";
|
||||||
|
button.textContent = `${minutesToDisplayTime(slot.startMin)}–${minutesToDisplayTime(slot.endMin)}`;
|
||||||
|
button.addEventListener("click", () => this.selectSlot(date, slot));
|
||||||
|
this.el.timeRow.appendChild(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async selectSlot(date: string, slot: SlotDto) {
|
||||||
|
const method = this.selectedMethod!;
|
||||||
|
const availability = this.availability!;
|
||||||
|
const display = `${formatDateLabel(date)}, ${minutesToDisplayTime(slot.startMin)}–${minutesToDisplayTime(slot.endMin)}`;
|
||||||
|
|
||||||
|
this.el.status.textContent = this.config.labels.loading;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cartToken = await getCartToken();
|
||||||
|
|
||||||
|
// Reserve capacity FIRST. Writing the cart attribute alone would just
|
||||||
|
// be two shoppers racing to write the same free-text field — nothing
|
||||||
|
// would stop both checkouts from completing for the last slot. The
|
||||||
|
// hold is what the Validation Function (Phase 4) actually enforces
|
||||||
|
// against at checkout.
|
||||||
|
const hold = await requestHold({
|
||||||
|
intent: "create",
|
||||||
|
locationId: availability.locationId,
|
||||||
|
method: method.value,
|
||||||
|
date,
|
||||||
|
startMin: slot.startMin,
|
||||||
|
cartToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hold.success) {
|
||||||
|
this.el.status.textContent = this.config.labels.error;
|
||||||
|
// The slot we just tried is gone — refresh so the list reflects reality.
|
||||||
|
await this.selectMethod(method);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.heldSlot = { locationId: availability.locationId, method: method.value, date, startMin: slot.startMin, cartToken };
|
||||||
|
|
||||||
|
await writeCartAttribute(
|
||||||
|
method.attrLabel,
|
||||||
|
{
|
||||||
|
dd_method: method.value,
|
||||||
|
dd_date: date,
|
||||||
|
dd_start_min: String(slot.startMin),
|
||||||
|
dd_end_min: String(slot.endMin),
|
||||||
|
dd_location_id: availability.locationId,
|
||||||
|
},
|
||||||
|
display,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.el.status.textContent = "";
|
||||||
|
this.el.methodRow.hidden = true;
|
||||||
|
this.el.dateRow.hidden = true;
|
||||||
|
this.el.timeRow.hidden = true;
|
||||||
|
this.el.confirmation.hidden = false;
|
||||||
|
this.el.confirmation.innerHTML = "";
|
||||||
|
|
||||||
|
const summary = document.createElement("p");
|
||||||
|
summary.textContent = `${this.config.labels.confirmed} ${display}`;
|
||||||
|
const changeButton = document.createElement("button");
|
||||||
|
changeButton.type = "button";
|
||||||
|
changeButton.className = "dd-widget__link";
|
||||||
|
changeButton.textContent = this.config.labels.change;
|
||||||
|
changeButton.addEventListener("click", () => {
|
||||||
|
if (this.heldSlot) {
|
||||||
|
void requestHold({ intent: "release", ...this.heldSlot });
|
||||||
|
this.heldSlot = null;
|
||||||
|
}
|
||||||
|
this.el.methodRow.hidden = false;
|
||||||
|
this.el.dateRow.hidden = false;
|
||||||
|
this.el.timeRow.hidden = false;
|
||||||
|
this.el.confirmation.hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.el.confirmation.append(summary, changeButton);
|
||||||
|
} catch {
|
||||||
|
this.el.status.textContent = this.config.labels.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const roots = document.querySelectorAll<HTMLElement>("[data-dd-widget]");
|
||||||
|
roots.forEach((root) => {
|
||||||
|
new DateTimeWidget(readConfig(root)).mount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user