feat: Phase 7 — POS + Checkout UI extensions
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
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:
parent
c5ec8f368c
commit
5b2207a397
@ -6,3 +6,4 @@ shopify-app-remix
|
|||||||
.shopify
|
.shopify
|
||||||
extensions/*/assets/*.js
|
extensions/*/assets/*.js
|
||||||
extensions/*/dist
|
extensions/*/dist
|
||||||
|
extensions/*/shopify.d.ts
|
||||||
|
|||||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@ -46,6 +46,10 @@ jobs:
|
|||||||
run: npm run lint
|
run: npm run lint
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
- name: Type check POS extension
|
||||||
|
run: npm run typecheck:pos
|
||||||
|
- name: Type check Checkout extension
|
||||||
|
run: npm run typecheck:checkout
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: npm test -- --run
|
run: npm test -- --run
|
||||||
- name: Function tests (real WASM build + function-runner against fixtures)
|
- name: Function tests (real WASM build + function-runner against fixtures)
|
||||||
|
|||||||
31
README.md
31
README.md
@ -53,9 +53,34 @@ public launch or Built-for-Shopify submission** — don't ship without it.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Phase 0 (scaffold & CI) through Phase 6 (ops/dispatch dashboard) are
|
Phase 0 (scaffold & CI) through Phase 7 (POS + Checkout UI extensions) are
|
||||||
complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased build order and
|
complete — that's the full v1 launch scope per §8 of
|
||||||
acceptance criteria — next up is Phase 7 (POS + Checkout UI extensions).
|
`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
|
Phase 5's Google Maps / geocoding features (pickup-location map in the
|
||||||
widget, radius-zone eligibility, address auto-geocoding on Save Location)
|
widget, radius-zone eligibility, address auto-geocoding on Save Location)
|
||||||
|
|||||||
@ -1,32 +1,18 @@
|
|||||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||||
import { DateTime } from "luxon";
|
|
||||||
import type { Method } from "@prisma/client";
|
import type { Method } from "@prisma/client";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import db from "../db.server";
|
import { resolveAvailabilityRequest } from "../services/availability-request.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";
|
|
||||||
|
|
||||||
// Public endpoint, reachable only through Shopify's App Proxy (signature
|
// Public endpoint, reachable only through Shopify's App Proxy (signature
|
||||||
// verified by authenticate.public.appProxy) — this is what the storefront
|
// verified by authenticate.public.appProxy) — this is what the storefront
|
||||||
// Theme App Extension calls. Requests to https://{shop}/apps/scheduling/*
|
// Theme App Extension calls. Requests to https://{shop}/apps/scheduling/*
|
||||||
// forward here because shopify.app.toml's [app_proxy].url already includes
|
// forward here because shopify.app.toml's [app_proxy].url already includes
|
||||||
// the /apps/scheduling prefix, so this file's path (apps.scheduling.*)
|
// the /apps/scheduling prefix, so this file's path (apps.scheduling.*)
|
||||||
// mirrors the shop-facing URL exactly.
|
// 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 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) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const { session } = await authenticate.public.appProxy(request);
|
const { session } = await authenticate.public.appProxy(request);
|
||||||
@ -36,155 +22,21 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const methodParam = url.searchParams.get("method");
|
const methodParam = url.searchParams.get("method");
|
||||||
const locationIdParam = url.searchParams.get("locationId");
|
|
||||||
const 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)) {
|
if (!methodParam || !VALID_METHODS.has(methodParam as Method)) {
|
||||||
return Response.json({ error: "Invalid or missing method" }, { status: 400 });
|
return Response.json({ error: "Invalid or missing method" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const method = methodParam as Method;
|
|
||||||
|
|
||||||
let location: Awaited<ReturnType<typeof db.location.findFirst>> = null;
|
const result = await resolveAvailabilityRequest(session.shop, {
|
||||||
let zoneId: string | null = null;
|
method: methodParam as Method,
|
||||||
let distanceKm: number | null = null;
|
locationId: url.searchParams.get("locationId") || undefined,
|
||||||
|
postalCode: url.searchParams.get("postalCode") || undefined,
|
||||||
// Delivery-zone auto-assignment (IMPLEMENTATION_PLAN.md Phase 5): route to
|
address: url.searchParams.get("address") || undefined,
|
||||||
// the nearest eligible, density-qualified zone for the shopper's address.
|
days: Number(url.searchParams.get("days")) || undefined,
|
||||||
// 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 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({
|
return Response.json(result);
|
||||||
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,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
import type { ActionFunctionArgs } from "@remix-run/node";
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
import type { Method } from "@prisma/client";
|
import type { Method } from "@prisma/client";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import db from "../db.server";
|
import { resolveHoldRequest } from "../services/hold-request.server";
|
||||||
import { slotDateTime, weekdayOf } from "../lib/time";
|
|
||||||
import { remainingCapacity } from "../services/capacity.server";
|
|
||||||
import { tryCreateHold, releaseHold, countActiveHolds } from "../services/holds.server";
|
|
||||||
|
|
||||||
// Public app-proxy endpoint (see apps.scheduling.availability.tsx for the
|
// Public app-proxy endpoint (see apps.scheduling.availability.tsx for the
|
||||||
// path-mirroring rationale). Called by the widget the moment a shopper
|
// path-mirroring rationale). Called by the widget the moment a shopper
|
||||||
@ -12,7 +9,8 @@ import { tryCreateHold, releaseHold, countActiveHolds } from "../services/holds.
|
|||||||
// actually reserves capacity (PRODUCT_STRATEGY.md §3.1, §4.1: "the
|
// actually reserves capacity (PRODUCT_STRATEGY.md §3.1, §4.1: "the
|
||||||
// last-slot race condition"). The cart attribute write alone would just be
|
// last-slot race condition"). The cart attribute write alone would just be
|
||||||
// two shoppers racing to write the same free-text field; nothing would
|
// two shoppers racing to write the same free-text field; nothing would
|
||||||
// stop both orders from completing.
|
// 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"]);
|
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 });
|
return Response.json({ error: "Missing or invalid parameters" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const location = await db.location.findFirst({
|
const result = await resolveHoldRequest(session.shop, {
|
||||||
where: { id: locationId, shopDomain: session.shop, active: true },
|
intent: intent === "release" ? "release" : "create",
|
||||||
});
|
locationId,
|
||||||
if (!location) {
|
|
||||||
return Response.json({ error: "Location not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const slotStart = slotDateTime(date, startMin, location.timezone);
|
|
||||||
const slot = {
|
|
||||||
shopDomain: session.shop,
|
|
||||||
locationId: location.id,
|
|
||||||
method: method as Method,
|
method: method as Method,
|
||||||
slotStartIso: slotStart.toUTC().toISO()!,
|
date,
|
||||||
};
|
|
||||||
|
|
||||||
if (intent === "release") {
|
|
||||||
await releaseHold(slot, cartToken);
|
|
||||||
return Response.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = await db.slotTemplate.findFirst({
|
|
||||||
where: {
|
|
||||||
shopDomain: session.shop,
|
|
||||||
locationId: location.id,
|
|
||||||
method: slot.method,
|
|
||||||
weekday: weekdayOf(date, location.timezone),
|
|
||||||
startMin,
|
startMin,
|
||||||
},
|
cartToken,
|
||||||
});
|
|
||||||
if (!template) {
|
|
||||||
return Response.json({ error: "Slot not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmedCount = await db.booking.count({
|
|
||||||
where: {
|
|
||||||
shopDomain: session.shop,
|
|
||||||
locationId: location.id,
|
|
||||||
method: slot.method,
|
|
||||||
slotStart: slotStart.toJSDate(),
|
|
||||||
status: "confirmed",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const capacityBudget = remainingCapacity({ capacity: template.capacity, consumed: confirmedCount });
|
return Response.json(result.body, { status: result.status });
|
||||||
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),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
33
app/routes/checkout.scheduling.availability.tsx
Normal file
33
app/routes/checkout.scheduling.availability.tsx
Normal 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));
|
||||||
|
};
|
||||||
42
app/routes/checkout.scheduling.hold.tsx
Normal file
42
app/routes/checkout.scheduling.hold.tsx
Normal 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 }));
|
||||||
|
};
|
||||||
34
app/routes/pos.scheduling.availability.tsx
Normal file
34
app/routes/pos.scheduling.availability.tsx
Normal 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));
|
||||||
|
};
|
||||||
43
app/routes/pos.scheduling.hold.tsx
Normal file
43
app/routes/pos.scheduling.hold.tsx
Normal 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 }));
|
||||||
|
};
|
||||||
16
app/routes/webhooks.events.placeholder.tsx
Normal file
16
app/routes/webhooks.events.placeholder.tsx
Normal 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();
|
||||||
|
};
|
||||||
197
app/services/availability-request.server.ts
Normal file
197
app/services/availability-request.server.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
68
app/services/hold-request.server.ts
Normal file
68
app/services/hold-request.server.ts
Normal 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) },
|
||||||
|
};
|
||||||
|
}
|
||||||
1
extensions/checkout-datetime/locales/en.default.json
Normal file
1
extensions/checkout-datetime/locales/en.default.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
14
extensions/checkout-datetime/package.json
Normal file
14
extensions/checkout-datetime/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
extensions/checkout-datetime/shopify.d.ts
vendored
Normal file
13
extensions/checkout-datetime/shopify.d.ts
vendored
Normal 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 };
|
||||||
|
}
|
||||||
43
extensions/checkout-datetime/shopify.extension.toml
Normal file
43
extensions/checkout-datetime/shopify.extension.toml
Normal 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
|
||||||
202
extensions/checkout-datetime/src/Checkout.jsx
Normal file
202
extensions/checkout-datetime/src/Checkout.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
extensions/checkout-datetime/src/ThankYou.jsx
Normal file
28
extensions/checkout-datetime/src/ThankYou.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
extensions/checkout-datetime/tsconfig.json
Normal file
14
extensions/checkout-datetime/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
@ -73,3 +73,26 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
42
extensions/datetime-widget/blocks/order-confirmation.liquid
Normal file
42
extensions/datetime-widget/blocks/order-confirmation.liquid
Normal 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 %}
|
||||||
@ -17,5 +17,10 @@
|
|||||||
"postal_code": "Enter your postal/ZIP code",
|
"postal_code": "Enter your postal/ZIP code",
|
||||||
"postal_code_submit": "Check availability",
|
"postal_code_submit": "Check availability",
|
||||||
"out_of_area": "Sorry, we don't deliver to this address."
|
"out_of_area": "Sorry, we don't deliver to this address."
|
||||||
|
},
|
||||||
|
"order_confirmation": {
|
||||||
|
"pickup_label": "Pickup",
|
||||||
|
"delivery_label": "Delivery",
|
||||||
|
"shipping_label": "Shipping"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,5 +12,8 @@
|
|||||||
"location_id_info": "Leave blank to use the shop's default location.",
|
"location_id_info": "Leave blank to use the shop's default location.",
|
||||||
"google_maps_api_key_label": "Google Maps API key",
|
"google_maps_api_key_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."
|
"google_maps_api_key_info": "Optional — shows a map for Pickup locations. Restrict this key to your store's domain in Google Cloud Console."
|
||||||
|
},
|
||||||
|
"order_confirmation": {
|
||||||
|
"name": "Delivery Confirmation"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
extensions/pos-datetime/locales/en.default.json
Normal file
6
extensions/pos-datetime/locales/en.default.json
Normal 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"
|
||||||
|
}
|
||||||
14
extensions/pos-datetime/package.json
Normal file
14
extensions/pos-datetime/package.json
Normal 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
13
extensions/pos-datetime/shopify.d.ts
vendored
Normal 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 };
|
||||||
|
}
|
||||||
19
extensions/pos-datetime/shopify.extension.toml
Normal file
19
extensions/pos-datetime/shopify.extension.toml
Normal 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 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"
|
||||||
188
extensions/pos-datetime/src/Modal.jsx
Normal file
188
extensions/pos-datetime/src/Modal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
extensions/pos-datetime/src/Tile.jsx
Normal file
18
extensions/pos-datetime/src/Tile.jsx
Normal 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()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
extensions/pos-datetime/tsconfig.json
Normal file
13
extensions/pos-datetime/tsconfig.json
Normal 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
145
package-lock.json
generated
@ -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": {
|
"extensions/delivery-customization": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "UNLICENSED",
|
"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": {
|
"extensions/validation-slot": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
@ -4009,6 +4048,32 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@preact/signals": {
|
||||||
|
"version": "2.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.11.1.tgz",
|
||||||
|
"integrity": "sha512-uYoD+USkacTNU02kfCBkJEV7xabKTmA78W5pnT2GymsuGEC9n/ZO+UT4S96l0dIL6KLDk77VJyUFuOMBvftVHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@preact/signals-core": "^1.14.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": ">= 10.25.0 || >=11.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@preact/signals-core": {
|
||||||
|
"version": "1.14.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz",
|
||||||
|
"integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||||
@ -5739,6 +5804,27 @@
|
|||||||
"@shopify/graphql-client": "^1.4.2"
|
"@shopify/graphql-client": "^1.4.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@shopify/ui-extensions": {
|
||||||
|
"version": "2025.10.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@shopify/ui-extensions/-/ui-extensions-2025.10.16.tgz",
|
||||||
|
"integrity": "sha512-w8Lr8NbAILhtqWmeKH/9osFWBsB+G0ksWho/V3cruhK/t0GcdQA6GANKokFPTsmD9sszqlrrO+URbs5I/vHxiQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ts-morph": "^25.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@preact/signals": "*",
|
||||||
|
"preact": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@preact/signals": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"preact": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@ -5775,6 +5861,17 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ts-morph/common": {
|
||||||
|
"version": "0.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.26.1.tgz",
|
||||||
|
"integrity": "sha512-Sn28TGl/4cFpcM+jwsH1wLncYq3FtN/BIpem+HOygfBWPT5pAeS5dB4VFVzV8FbnOKHpDLZmvAl4AjPEev5idA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-glob": "^3.3.2",
|
||||||
|
"minimatch": "^9.0.4",
|
||||||
|
"path-browserify": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.3",
|
"version": "0.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||||
@ -8228,6 +8325,10 @@
|
|||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/checkout-datetime": {
|
||||||
|
"resolved": "extensions/checkout-datetime",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@ -8377,6 +8478,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/code-block-writer": {
|
||||||
|
"version": "13.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
|
||||||
|
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@ -15002,6 +15109,12 @@
|
|||||||
"tslib": "^2.0.3"
|
"tslib": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-browserify": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/path-case": {
|
"node_modules/path-case": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz",
|
||||||
@ -15243,6 +15356,10 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pos-datetime": {
|
||||||
|
"resolved": "extensions/pos-datetime",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@ -15424,6 +15541,24 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.29.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
|
||||||
|
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact-render-to-string": ">=5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"preact-render-to-string": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@ -17515,6 +17650,16 @@
|
|||||||
"integrity": "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==",
|
"integrity": "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-morph": {
|
||||||
|
"version": "25.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-25.0.1.tgz",
|
||||||
|
"integrity": "sha512-QJEiTdnz1YjrB3JFhd626gX4rKHDLSjSVMvGGG4v7ONc3RBwa0Eei98G9AT9uNFDMtV54JyuXsFeC+OH0n6bXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ts-morph/common": "~0.26.0",
|
||||||
|
"code-block-writer": "^13.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tsconfck": {
|
"node_modules/tsconfck": {
|
||||||
"version": "3.1.6",
|
"version": "3.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
|
||||||
|
|||||||
@ -18,6 +18,8 @@
|
|||||||
"typegen:functions": "npm --prefix extensions/validation-slot run typegen && npm --prefix extensions/delivery-customization run typegen",
|
"typegen:functions": "npm --prefix extensions/validation-slot run typegen && npm --prefix extensions/delivery-customization run typegen",
|
||||||
"pretypecheck": "npm run typegen:functions",
|
"pretypecheck": "npm run typegen:functions",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"typecheck:pos": "npm --prefix extensions/pos-datetime run typecheck",
|
||||||
|
"typecheck:checkout": "npm --prefix extensions/checkout-datetime run typecheck",
|
||||||
"test:functions": "npm --prefix extensions/validation-slot test && npm --prefix extensions/delivery-customization test",
|
"test:functions": "npm --prefix extensions/validation-slot test && npm --prefix extensions/delivery-customization test",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
|
|||||||
@ -20,7 +20,7 @@ scopes = "read_locales,read_locations,read_markets,read_metaobjects,read_orders,
|
|||||||
redirect_urls = [ "https://shopify.dev/apps/default-app-home/api/auth" ]
|
redirect_urls = [ "https://shopify.dev/apps/default-app-home/api/auth" ]
|
||||||
|
|
||||||
[webhooks]
|
[webhooks]
|
||||||
api_version = "2026-10"
|
api_version = "2025-01"
|
||||||
|
|
||||||
# Handled by: app/routes/webhooks.app.uninstalled.tsx
|
# Handled by: app/routes/webhooks.app.uninstalled.tsx
|
||||||
[[webhooks.subscriptions]]
|
[[webhooks.subscriptions]]
|
||||||
@ -70,6 +70,25 @@ api_version = "2026-10"
|
|||||||
# uri = "/webhooks/shop/redact"
|
# uri = "/webhooks/shop/redact"
|
||||||
# compliance_topics = ["shop/redact"]
|
# compliance_topics = ["shop/redact"]
|
||||||
|
|
||||||
|
# This org appears enrolled in Shopify's "Next Generation Events" developer
|
||||||
|
# preview (https://shopify.dev/changelog/next-generation-events-now-available-in-developer-preview),
|
||||||
|
# a separate, optional delivery mechanism from classic [webhooks] above
|
||||||
|
# (GraphQL-style resource topics + create/update/delete actions, api_version
|
||||||
|
# pinned to "unstable" while in preview). The CLI now treats [events] as a
|
||||||
|
# REQUIRED section for this app/org even though our app doesn't use it —
|
||||||
|
# everything real is handled via [webhooks]. This subscription is a
|
||||||
|
# functionally-inert placeholder that exists solely to satisfy that schema
|
||||||
|
# gate (app/routes/webhooks.events.placeholder.tsx just logs and returns
|
||||||
|
# 200) — it is not part of this app's actual feature set.
|
||||||
|
[events]
|
||||||
|
api_version = "unstable"
|
||||||
|
|
||||||
|
[[events.subscription]]
|
||||||
|
handle = "unused-events-preview-placeholder"
|
||||||
|
topic = "Product"
|
||||||
|
actions = ["create"]
|
||||||
|
uri = "/webhooks/events/placeholder"
|
||||||
|
|
||||||
# App proxy so the storefront Theme App Extension can call our backend
|
# App proxy so the storefront Theme App Extension can call our backend
|
||||||
# without CORS issues (see IMPLEMENTATION_PLAN.md §5.3). `shopify app dev`
|
# without CORS issues (see IMPLEMENTATION_PLAN.md §5.3). `shopify app dev`
|
||||||
# points this at your dev tunnel automatically when
|
# points this at your dev tunnel automatically when
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user