feat: Phase 7 — POS + Checkout UI extensions
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled

Also fixes the [events] gate that was blocking ALL extension generation
(discovered while starting this phase).

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-08-24 09:11:34 -04:00
parent c5ec8f368c
commit 5b2207a397
33 changed files with 1316 additions and 227 deletions

View File

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

View File

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

View File

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

View File

@ -1,32 +1,18 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { DateTime } from "luxon";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { getAvailability } from "../services/scheduling.server";
import { findEligibleLocationsForDelivery, meetsDeliveryDensity } from "../services/zones.server";
import { resolveRate } from "../services/rates.server";
import { formatPriceLabel } from "../lib/currency";
import { resolveAvailabilityRequest } from "../services/availability-request.server";
// Public endpoint, reachable only through Shopify's App Proxy (signature
// verified by authenticate.public.appProxy) — this is what the storefront
// Theme App Extension calls. Requests to https://{shop}/apps/scheduling/*
// forward here because shopify.app.toml's [app_proxy].url already includes
// the /apps/scheduling prefix, so this file's path (apps.scheduling.*)
// mirrors the shop-facing URL exactly.
// mirrors the shop-facing URL exactly. The actual resolution logic lives in
// services/availability-request.server.ts, shared with the POS route
// (pos.scheduling.availability.tsx) — same pool, same code, different auth.
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
const MAX_DAYS = 60;
const DEFAULT_DAYS = 14;
function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
/** `getAvailability`'s consumed-capacity map key — must match scheduling.server.ts's slotKey() exactly. */
function slotKey(date: string, startMin: number): string {
return `${date}|${startMin}`;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.public.appProxy(request);
@ -36,155 +22,21 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const methodParam = url.searchParams.get("method");
const locationIdParam = url.searchParams.get("locationId");
const postalCode = url.searchParams.get("postalCode") || undefined;
const address = url.searchParams.get("address") || undefined;
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;
let location: Awaited<ReturnType<typeof db.location.findFirst>> = null;
let zoneId: string | null = null;
let distanceKm: number | null = null;
// Delivery-zone auto-assignment (IMPLEMENTATION_PLAN.md Phase 5): route to
// the nearest eligible, density-qualified zone for the shopper's address.
// Falls through to the plain location lookup below for PICKUP/SHIPPING,
// or when no address was supplied yet (e.g. the widget hasn't asked for
// one), so the picker can still show something before that input exists.
if (method === "LOCAL_DELIVERY" && (postalCode || address)) {
const matches = await findEligibleLocationsForDelivery(session.shop, { postalCode, address });
const zones = matches.length
? await db.zone.findMany({ where: { id: { in: matches.map((m) => m.zoneId) } } })
: [];
const zoneById = new Map(zones.map((z) => [z.id, z]));
for (const match of matches) {
if (locationIdParam && match.locationId !== locationIdParam) continue;
const zone = zoneById.get(match.zoneId);
if (!zone) continue;
// eslint-disable-next-line no-await-in-loop -- checked in nearest-first order; stop at the first that qualifies
if (!(await meetsDeliveryDensity(session.shop, zone))) continue;
zoneId = match.zoneId;
distanceKm = match.distanceKm;
location = await db.location.findFirst({ where: { id: match.locationId, shopDomain: session.shop, active: true } });
break;
}
if (!location) {
return Response.json({
locationId: null,
method,
dates: {},
zoneId: null,
rate: null,
error: "This address is outside our delivery area right now.",
});
}
} else {
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();
// Bookings are keyed by exact instant; widen the window by a day on each
// side so a slot near midnight UTC-offset boundaries isn't miscounted.
const bookingRangeStart = DateTime.fromISO(startDate, { zone: "utc" }).minus({ days: 1 }).toJSDate();
const bookingRangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate();
const [slotTemplates, overrides, blackouts, bookings, rates] = await Promise.all([
db.slotTemplate.findMany({
where: { shopDomain: 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 }] }],
},
}),
db.booking.findMany({
where: {
shopDomain: session.shop,
locationId: location.id,
method,
status: { in: ["confirmed", "fulfilled"] },
slotStart: { gte: bookingRangeStart, lte: bookingRangeEnd },
},
select: { slotStart: true },
}),
db.rate.findMany({ where: { shopDomain: session.shop, method } }),
]);
const consumed = new Map<string, number>();
for (const booking of bookings) {
const local = DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(location.timezone);
const key = slotKey(local.toISODate()!, local.hour * 60 + local.minute);
consumed.set(key, (consumed.get(key) ?? 0) + 1);
}
const availability = getAvailability({
timezone: location.timezone,
dateRange: { startDate, endDate },
slotTemplates: slotTemplates.map((t) => ({
weekday: t.weekday,
startMin: t.startMin,
endMin: t.endMin,
capacity: t.capacity,
cutoffMin: t.cutoffMin,
leadTimeMin: t.leadTimeMin,
})),
overrides: overrides.map((o) => ({
date: toIsoDate(o.date),
closed: o.closed,
startMin: o.startMin,
endMin: o.endMin,
capacity: o.capacity,
})),
blackoutDates: blackouts.map((b) => ({ date: toIsoDate(b.date) })),
now,
consumed,
const result = await resolveAvailabilityRequest(session.shop, {
method: methodParam as Method,
locationId: url.searchParams.get("locationId") || undefined,
postalCode: url.searchParams.get("postalCode") || undefined,
address: url.searchParams.get("address") || undefined,
days: Number(url.searchParams.get("days")) || undefined,
});
const matchedRate = resolveRate(rates, { method, zoneId: zoneId ?? undefined, distanceKm: distanceKm ?? undefined });
if (!result.locationId) {
return Response.json(result, { status: result.error === "No active location configured" ? 404 : 200 });
}
return Response.json({
locationId: location.id,
locationName: location.name,
locationLat: location.lat,
locationLng: location.lng,
timezone: location.timezone,
method,
dates: availability,
zoneId,
distanceKm,
rate: matchedRate ? { name: matchedRate.name, priceCents: matchedRate.priceCents, label: formatPriceLabel(matchedRate.priceCents) } : null,
});
return Response.json(result);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
{}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -73,3 +73,26 @@
border-radius: 8px;
overflow: hidden;
}
.dd-order-confirmation {
border: 1px solid currentColor;
border-radius: 8px;
padding: 0.75rem 1rem;
display: inline-flex;
flex-direction: column;
gap: 0.15rem;
}
.dd-order-confirmation__label {
font-size: 0.75rem;
opacity: 0.75;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.dd-order-confirmation__value {
font-size: 1rem;
font-weight: 600;
margin: 0;
}

View File

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

View File

@ -17,5 +17,10 @@
"postal_code": "Enter your postal/ZIP code",
"postal_code_submit": "Check availability",
"out_of_area": "Sorry, we don't deliver to this address."
},
"order_confirmation": {
"pickup_label": "Pickup",
"delivery_label": "Delivery",
"shipping_label": "Shipping"
}
}

View File

@ -12,5 +12,8 @@
"location_id_info": "Leave blank to use the shop's default location.",
"google_maps_api_key_label": "Google Maps API key",
"google_maps_api_key_info": "Optional — shows a map for Pickup locations. Restrict this key to your store's domain in Google Cloud Console."
},
"order_confirmation": {
"name": "Delivery Confirmation"
}
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

145
package-lock.json generated
View File

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

View File

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

View File

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