From 5b2207a397ce516fe6acf3bb8e9569c964039f25 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Mon, 24 Aug 2026 09:11:34 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=207=20=E2=80=94=20POS=20+=20Check?= =?UTF-8?q?out=20UI=20extensions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .eslintignore | 1 + .github/workflows/ci.yml | 4 + README.md | 31 ++- app/routes/apps.scheduling.availability.tsx | 176 ++------------- app/routes/apps.scheduling.hold.tsx | 71 +----- .../checkout.scheduling.availability.tsx | 33 +++ app/routes/checkout.scheduling.hold.tsx | 42 ++++ app/routes/pos.scheduling.availability.tsx | 34 +++ app/routes/pos.scheduling.hold.tsx | 43 ++++ app/routes/webhooks.events.placeholder.tsx | 16 ++ app/services/availability-request.server.ts | 197 +++++++++++++++++ app/services/hold-request.server.ts | 68 ++++++ .../checkout-datetime/locales/en.default.json | 1 + extensions/checkout-datetime/package.json | 14 ++ extensions/checkout-datetime/shopify.d.ts | 13 ++ .../checkout-datetime/shopify.extension.toml | 43 ++++ extensions/checkout-datetime/src/Checkout.jsx | 202 ++++++++++++++++++ extensions/checkout-datetime/src/ThankYou.jsx | 28 +++ extensions/checkout-datetime/tsconfig.json | 14 ++ .../assets/datetime-widget.css | 23 ++ .../blocks/order-confirmation.liquid | 42 ++++ .../datetime-widget/locales/en.default.json | 5 + .../locales/en.default.schema.json | 3 + .../pos-datetime/locales/en.default.json | 6 + extensions/pos-datetime/package.json | 14 ++ extensions/pos-datetime/shopify.d.ts | 13 ++ .../pos-datetime/shopify.extension.toml | 19 ++ extensions/pos-datetime/src/Modal.jsx | 188 ++++++++++++++++ extensions/pos-datetime/src/Tile.jsx | 18 ++ extensions/pos-datetime/tsconfig.json | 13 ++ package-lock.json | 145 +++++++++++++ package.json | 2 + shopify.app.toml | 21 +- 33 files changed, 1316 insertions(+), 227 deletions(-) create mode 100644 app/routes/checkout.scheduling.availability.tsx create mode 100644 app/routes/checkout.scheduling.hold.tsx create mode 100644 app/routes/pos.scheduling.availability.tsx create mode 100644 app/routes/pos.scheduling.hold.tsx create mode 100644 app/routes/webhooks.events.placeholder.tsx create mode 100644 app/services/availability-request.server.ts create mode 100644 app/services/hold-request.server.ts create mode 100644 extensions/checkout-datetime/locales/en.default.json create mode 100644 extensions/checkout-datetime/package.json create mode 100644 extensions/checkout-datetime/shopify.d.ts create mode 100644 extensions/checkout-datetime/shopify.extension.toml create mode 100644 extensions/checkout-datetime/src/Checkout.jsx create mode 100644 extensions/checkout-datetime/src/ThankYou.jsx create mode 100644 extensions/checkout-datetime/tsconfig.json create mode 100644 extensions/datetime-widget/blocks/order-confirmation.liquid create mode 100644 extensions/pos-datetime/locales/en.default.json create mode 100644 extensions/pos-datetime/package.json create mode 100644 extensions/pos-datetime/shopify.d.ts create mode 100644 extensions/pos-datetime/shopify.extension.toml create mode 100644 extensions/pos-datetime/src/Modal.jsx create mode 100644 extensions/pos-datetime/src/Tile.jsx create mode 100644 extensions/pos-datetime/tsconfig.json diff --git a/.eslintignore b/.eslintignore index 55fbf83..c31ba69 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,3 +6,4 @@ shopify-app-remix .shopify extensions/*/assets/*.js extensions/*/dist +extensions/*/shopify.d.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5c4fc2..716059e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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) diff --git a/README.md b/README.md index c47a77c..4257a96 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app/routes/apps.scheduling.availability.tsx b/app/routes/apps.scheduling.availability.tsx index 1fdd343..3879e9d 100644 --- a/app/routes/apps.scheduling.availability.tsx +++ b/app/routes/apps.scheduling.availability.tsx @@ -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(["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> = 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(); - 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); }; diff --git a/app/routes/apps.scheduling.hold.tsx b/app/routes/apps.scheduling.hold.tsx index e5e08aa..9724da2 100644 --- a/app/routes/apps.scheduling.hold.tsx +++ b/app/routes/apps.scheduling.hold.tsx @@ -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(["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), - 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", - }, + date, + startMin, + 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 }); }; diff --git a/app/routes/checkout.scheduling.availability.tsx b/app/routes/checkout.scheduling.availability.tsx new file mode 100644 index 0000000..10f02ca --- /dev/null +++ b/app/routes/checkout.scheduling.availability.tsx @@ -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(["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)); +}; diff --git a/app/routes/checkout.scheduling.hold.tsx b/app/routes/checkout.scheduling.hold.tsx new file mode 100644 index 0000000..c865943 --- /dev/null +++ b/app/routes/checkout.scheduling.hold.tsx @@ -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(["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 })); +}; diff --git a/app/routes/pos.scheduling.availability.tsx b/app/routes/pos.scheduling.availability.tsx new file mode 100644 index 0000000..93b1d99 --- /dev/null +++ b/app/routes/pos.scheduling.availability.tsx @@ -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(["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)); +}; diff --git a/app/routes/pos.scheduling.hold.tsx b/app/routes/pos.scheduling.hold.tsx new file mode 100644 index 0000000..201d29a --- /dev/null +++ b/app/routes/pos.scheduling.hold.tsx @@ -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(["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 })); +}; diff --git a/app/routes/webhooks.events.placeholder.tsx b/app/routes/webhooks.events.placeholder.tsx new file mode 100644 index 0000000..b66a54c --- /dev/null +++ b/app/routes/webhooks.events.placeholder.tsx @@ -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(); +}; diff --git a/app/services/availability-request.server.ts b/app/services/availability-request.server.ts new file mode 100644 index 0000000..064fd6c --- /dev/null +++ b/app/services/availability-request.server.ts @@ -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; + 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 { + 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> = 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(); + 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, + }; +} diff --git a/app/services/hold-request.server.ts b/app/services/hold-request.server.ts new file mode 100644 index 0000000..ad68950 --- /dev/null +++ b/app/services/hold-request.server.ts @@ -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 { + 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) }, + }; +} diff --git a/extensions/checkout-datetime/locales/en.default.json b/extensions/checkout-datetime/locales/en.default.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/extensions/checkout-datetime/locales/en.default.json @@ -0,0 +1 @@ +{} diff --git a/extensions/checkout-datetime/package.json b/extensions/checkout-datetime/package.json new file mode 100644 index 0000000..5c48c4b --- /dev/null +++ b/extensions/checkout-datetime/package.json @@ -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" + } +} diff --git a/extensions/checkout-datetime/shopify.d.ts b/extensions/checkout-datetime/shopify.d.ts new file mode 100644 index 0000000..73603c6 --- /dev/null +++ b/extensions/checkout-datetime/shopify.d.ts @@ -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 }; +} diff --git a/extensions/checkout-datetime/shopify.extension.toml b/extensions/checkout-datetime/shopify.extension.toml new file mode 100644 index 0000000..98303cf --- /dev/null +++ b/extensions/checkout-datetime/shopify.extension.toml @@ -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 diff --git a/extensions/checkout-datetime/src/Checkout.jsx b/extensions/checkout-datetime/src/Checkout.jsx new file mode 100644 index 0000000..4a866d0 --- /dev/null +++ b/extensions/checkout-datetime/src/Checkout.jsx @@ -0,0 +1,202 @@ +import "@shopify/ui-extensions/preact"; +import { render } from "preact"; +import { useEffect, useState } from "preact/hooks"; + +export default async () => { + render(, 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 ( + + Confirmed for {confirmed} + + ); + } + + return ( + + Choose your delivery date + + {error && ( + + {error} + + )} + + setMethod(selectedValue(e))} + > + {METHODS.map((m) => ( + + {m.label} + + ))} + + + {loading && Loading…} + + {!loading && availability?.locationId && availableDates.length === 0 && No dates available.} + + {!loading && availableDates.length > 0 && ( + setDate(selectedValue(e))}> + {availableDates.map((d) => ( + + {d} + + ))} + + )} + + {date && slots.length > 0 && ( + selectSlot(slots.find((s) => String(s.startMin) === selectedValue(e)))} + > + {slots.map((s) => ( + + {minutesToDisplayTime(s.startMin)}–{minutesToDisplayTime(s.endMin)} + + ))} + + )} + + ); +} diff --git a/extensions/checkout-datetime/src/ThankYou.jsx b/extensions/checkout-datetime/src/ThankYou.jsx new file mode 100644 index 0000000..bf6d5e2 --- /dev/null +++ b/extensions/checkout-datetime/src/ThankYou.jsx @@ -0,0 +1,28 @@ +import "@shopify/ui-extensions/preact"; +import { render } from "preact"; + +export default async () => { + render(, 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 ( + + {display} + + ); +} diff --git a/extensions/checkout-datetime/tsconfig.json b/extensions/checkout-datetime/tsconfig.json new file mode 100644 index 0000000..6dda8a3 --- /dev/null +++ b/extensions/checkout-datetime/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/extensions/datetime-widget/assets/datetime-widget.css b/extensions/datetime-widget/assets/datetime-widget.css index bf3d4f2..deb23e6 100644 --- a/extensions/datetime-widget/assets/datetime-widget.css +++ b/extensions/datetime-widget/assets/datetime-widget.css @@ -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; +} diff --git a/extensions/datetime-widget/blocks/order-confirmation.liquid b/extensions/datetime-widget/blocks/order-confirmation.liquid new file mode 100644 index 0000000..ba4f971 --- /dev/null +++ b/extensions/datetime-widget/blocks/order-confirmation.liquid @@ -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 -%} +
+

+ {%- 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 -%} +

+

{{ dd_display }}

+
+{%- endif -%} + +{% schema %} +{ + "name": "t:order_confirmation.name", + "target": "section", + "settings": [] +} +{% endschema %} diff --git a/extensions/datetime-widget/locales/en.default.json b/extensions/datetime-widget/locales/en.default.json index 5363616..910c405 100644 --- a/extensions/datetime-widget/locales/en.default.json +++ b/extensions/datetime-widget/locales/en.default.json @@ -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" } } diff --git a/extensions/datetime-widget/locales/en.default.schema.json b/extensions/datetime-widget/locales/en.default.schema.json index ace4bb7..aa2c432 100644 --- a/extensions/datetime-widget/locales/en.default.schema.json +++ b/extensions/datetime-widget/locales/en.default.schema.json @@ -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" } } diff --git a/extensions/pos-datetime/locales/en.default.json b/extensions/pos-datetime/locales/en.default.json new file mode 100644 index 0000000..2ddb9ec --- /dev/null +++ b/extensions/pos-datetime/locales/en.default.json @@ -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" +} diff --git a/extensions/pos-datetime/package.json b/extensions/pos-datetime/package.json new file mode 100644 index 0000000..a7ca2d9 --- /dev/null +++ b/extensions/pos-datetime/package.json @@ -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" + } +} diff --git a/extensions/pos-datetime/shopify.d.ts b/extensions/pos-datetime/shopify.d.ts new file mode 100644 index 0000000..7cbfcaa --- /dev/null +++ b/extensions/pos-datetime/shopify.d.ts @@ -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 }; +} diff --git a/extensions/pos-datetime/shopify.extension.toml b/extensions/pos-datetime/shopify.extension.toml new file mode 100644 index 0000000..bc8e4c0 --- /dev/null +++ b/extensions/pos-datetime/shopify.extension.toml @@ -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 extension’s source code +# target: location where your extension appears in POS +[[extensions.targeting]] +module = "./src/Tile.jsx" +target = "pos.home.tile.render" + +[[extensions.targeting]] +module = "./src/Modal.jsx" +target = "pos.home.modal.render" diff --git a/extensions/pos-datetime/src/Modal.jsx b/extensions/pos-datetime/src/Modal.jsx new file mode 100644 index 0000000..19ea423 --- /dev/null +++ b/extensions/pos-datetime/src/Modal.jsx @@ -0,0 +1,188 @@ +import "@shopify/ui-extensions/preact"; +import { render } from "preact"; +import { useEffect, useState } from "preact/hooks"; + +export default async () => { + render(, 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 ( + + + Confirmed for {confirmed} + + + ); + } + + return ( + + + + + setMethod(e.currentTarget.values[0])} + > + {METHODS.map((m) => ( + + {m.label} + + ))} + + + + {loading && Loading…} + + {!loading && availableDates.length === 0 && No dates available.} + + {!loading && availableDates.length > 0 && ( + + setDate(e.currentTarget.values[0])}> + {availableDates.map((d) => ( + + {d} + + ))} + + + )} + + {date && slots.length > 0 && ( + + selectSlot(slots.find((s) => String(s.startMin) === e.currentTarget.values[0]))} + > + {slots.map((s) => ( + + {minutesToDisplayTime(s.startMin)}–{minutesToDisplayTime(s.endMin)} + + ))} + + + )} + + + + ); +} diff --git a/extensions/pos-datetime/src/Tile.jsx b/extensions/pos-datetime/src/Tile.jsx new file mode 100644 index 0000000..28f789f --- /dev/null +++ b/extensions/pos-datetime/src/Tile.jsx @@ -0,0 +1,18 @@ +import "@shopify/ui-extensions/preact"; +import { render } from "preact"; + +export default async () => { + render(, document.body); +}; + +function Extension() { + const { i18n } = shopify; + + return ( + shopify.action.presentModal()} + /> + ); +} diff --git a/extensions/pos-datetime/tsconfig.json b/extensions/pos-datetime/tsconfig.json new file mode 100644 index 0000000..ce90da4 --- /dev/null +++ b/extensions/pos-datetime/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "target": "ES2020", + "checkJs": true, + "allowJs": true, + "moduleResolution": "node", + "esModuleInterop": true, + "noEmit": true, + "skipLibCheck": true + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f8c7330..adcd4e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5135e82..fcdb81e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/shopify.app.toml b/shopify.app.toml index 737ac9e..2db451f 100644 --- a/shopify.app.toml +++ b/shopify.app.toml @@ -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