feat: close DS study coverage gaps (inventory exclusion, live checkout re-validation, per-day cap, payment fn, checkout ext)
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
Audited the implementation against DS_Delivery_Date_Time_App_Study.docx and closed the actionable gaps (see IMPLEMENTATION_REVIEW_2026-09-04.md). Core (code + unit tests, 156 green): - Wire excludeLocationsWithoutStock into resolveAvailabilityRequest; widget now sends variantIds so inventory-based location exclusion actually runs. - Live slot re-validation at checkout: new checkout-snapshot.server.ts writes a shop-metafield capacity snapshot; validation-slot's evaluateCheckout rejects a complete selection that has since filled / blacked out / closed / hit the daily cap / left the schedule. Refreshed on order webhooks and slot/blackout/location/enforcement edits. - Scopable checkout enforcement: Shop.enforcementMode (all|tagged|off) + enforcementTag, new app.settings.tsx admin page, honoured via the snapshot. - Per-day order cap: Location.dailyOrderCap threaded through getAvailability (dailyCap + consumedPerDate); admin field on the location screen. - Product-rule slot blocking: ProductRule.blockedStartMins, unioned in resolveProductRuleConstraints, enforced in the engine and resolveHoldRequest; admin field on the product rules screen. - Product-page placement: product-availability.liquid block + widget data-mode="preview" (read-only earliest-date line). - Second locale: datetime-widget fr.json / fr.schema.json. - Migration 20260904120000_review_gaps (apply with prisma migrate deploy). New Functions (source + unit tests; need `shopify app deploy` to ship): - extensions/payment-customization: cart.payment-methods.transform.run — hides cash-on-delivery / pay-in-store gateways on SHIPPING orders. - extensions/checkout-datetime/src: restored from a gitignored dist-only state — Plus native picker + Thank you / Order status confirmation blocks, all calling the existing checkout.scheduling.* routes (one capacity pool). tsconfig ships checkJs:false pending reconciliation with live checkout types. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
dbf91e1515
commit
a2c78d703f
195
IMPLEMENTATION_REVIEW_2026-09-04.md
Normal file
195
IMPLEMENTATION_REVIEW_2026-09-04.md
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
# Implementation Review — 2026-09-04
|
||||||
|
|
||||||
|
**Scope:** Coverage audit of the current implementation against
|
||||||
|
`DS_Delivery_Date_Time_App_Study.docx` (the "DS Pickup Delivery Date & Time"
|
||||||
|
teardown by C2C).
|
||||||
|
|
||||||
|
**Reviewed:** `app/` (routes, services, lib), `extensions/`, `prisma/schema.prisma`,
|
||||||
|
`widget-src/`, `shopify.app.toml`, `tests/`.
|
||||||
|
|
||||||
|
**Verdict:** The scheduling core, concurrency safety, multi-surface consistency,
|
||||||
|
zones/auto-assignment, and all-plan checkout enforcement + confirmation — the hard
|
||||||
|
parts and the competitive moat — are solid and tested. The meaningful gaps are
|
||||||
|
inventory-based location exclusion (dead code), payment customization (absent),
|
||||||
|
rate *application* vs. *display*, product-page placement, and live slot
|
||||||
|
re-validation at checkout. Several gaps are consciously documented deferrals; the
|
||||||
|
inventory one looks like an actual wiring bug.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fully covered
|
||||||
|
|
||||||
|
| Study requirement | Where |
|
||||||
|
|---|---|
|
||||||
|
| 3 independent fulfillment methods (Shipping / Local Delivery / Pickup) with own rules, calendars | `Method` enum; per-method `SlotTemplate` / `SlotOverride` / `BlackoutDate` |
|
||||||
|
| Date→time slot picker; unavailable dates/slots **hidden**, not rejected | `app/services/scheduling.server.ts:89` filters past-cutoff / under-lead / blacked-out / full |
|
||||||
|
| Cut-off times + preparation/lead time (incl. per-product) | `cutoffMin`, `leadTimeMin` on template; `ProductRule.leadTimeMin` folded in at `app/services/availability-request.server.ts:210` |
|
||||||
|
| Blackout dates; per-weekday / per-method / per-location enable-disable | `BlackoutDate`, `SlotTemplate` per weekday, `SlotOverride.closed` |
|
||||||
|
| Per-slot capacity limit | `capacity` + `remainingCapacity()` in `app/services/capacity.server.ts` |
|
||||||
|
| Multi-location; radius **and** driving-distance zones; postal zones; nearest-location auto-assign; delivery-density threshold | `app/services/zones.server.ts` |
|
||||||
|
| Product / collection / vendor / type / tag rules → allowed methods, lead time, allowed locations | `app/services/product-rules.server.ts` |
|
||||||
|
| Rate resolution by zone / distance per method | `app/services/rates.server.ts` |
|
||||||
|
| Cart + cart-drawer placement; cross-theme auto-inject before checkout button | `widget-src/datetime-widget/datetime-widget.ts:570` |
|
||||||
|
| Thank-you / order-status confirmation on **all plans** | `extensions/datetime-widget/blocks/order-confirmation.liquid` |
|
||||||
|
| POS scheduling against the **same** capacity pool | `app/routes/pos.scheduling.*` reuse `resolveAvailabilityRequest` |
|
||||||
|
| Last-slot race prevention | atomic Lua hold in `app/services/holds.server.ts:22` + `tests/integration/holds.concurrency.test.ts` |
|
||||||
|
| Server-side checkout enforcement on non-Plus | `extensions/validation-slot` Function |
|
||||||
|
| Single availability service across surfaces; multi-tenant `shopDomain` scoping; DST-safe Luxon math | ✅ |
|
||||||
|
| Order write-back (metafield + note attributes) | `app/routes/webhooks.orders.create.tsx` |
|
||||||
|
| Ops dashboard: bookings by day/slot/location, utilization, revenue by method, CSV export | `app/services/dashboard.server.ts` |
|
||||||
|
| DS review complaint (pickup vs "estimated delivery date" mismatch) | explicitly fixed via method-specific labels + `delivery-customization` rename |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Partial / deviations from the study
|
||||||
|
|
||||||
|
1. **Inventory-based location exclusion is coded but never called.**
|
||||||
|
`excludeLocationsWithoutStock` (`app/services/zones.server.ts:241`) has **zero
|
||||||
|
callers** — `resolveAvailabilityRequest` never invokes it. Study §3.4
|
||||||
|
"product-inventory-based location choice" is effectively absent at runtime.
|
||||||
|
*Looks like a wiring bug, not a deliberate deferral.*
|
||||||
|
|
||||||
|
2. **Rates are displayed, not applied.** `delivery-customization` only *renames*
|
||||||
|
the delivery option to append a price label
|
||||||
|
(`extensions/delivery-customization/src/evaluate.js:23`). Nothing adjusts the
|
||||||
|
actual charged shipping price or feeds a Shopify rate. Study §3.6 / the DS core
|
||||||
|
value prop ("location-based shipping/delivery/pickup rates") is only half there.
|
||||||
|
|
||||||
|
3. **Checkout re-validation checks presence only.** `validation-slot`
|
||||||
|
(`extensions/validation-slot/src/evaluate.js`) verifies the `dd_*` attributes
|
||||||
|
exist; it does **not** re-check that the slot is still open
|
||||||
|
(capacity / blackout / cutoff). Study §5.2 explicitly names "or has since become
|
||||||
|
invalid." Mitigation is the 10-minute hold TTL, which the code comments
|
||||||
|
acknowledge.
|
||||||
|
|
||||||
|
4. **Validation Function can't be scoped by product.** Every order on an activated
|
||||||
|
shop is treated as requiring a slot — no ProductRule-scoped enforcement
|
||||||
|
(Functions can't read the app DB). A store mixing schedulable and
|
||||||
|
non-schedulable items can't use it selectively.
|
||||||
|
|
||||||
|
5. **"Date range" selection reinterpreted.** The schema/engine implement a
|
||||||
|
SHIPPING **arrival-range display** (`transitMinDays` / `transitMaxDays`), not a
|
||||||
|
shopper-selectable multi-day range. Study §3.2 "support for picking a range
|
||||||
|
rather than a single date."
|
||||||
|
|
||||||
|
6. **Product-rule "block a specific slot based on cart contents"** (§3.5, "fragile
|
||||||
|
item that can't go on the earliest slot") is only expressible as a lead-time
|
||||||
|
push. The planned `blockSlotRule Json?` field was dropped from `schema.prisma`.
|
||||||
|
|
||||||
|
7. **Pickup map is single-location.** The widget maps the one resolved location
|
||||||
|
(`widget-src/datetime-widget/datetime-widget.ts:427`); there's no multi-pin
|
||||||
|
pickup-location chooser. Study §3.4 "pickup locations shown visually on Google
|
||||||
|
Maps."
|
||||||
|
|
||||||
|
8. **Multi-language is structure-only.** Locale keys exist but only
|
||||||
|
`en.default.json` ships — no second translation.
|
||||||
|
|
||||||
|
9. **Per-day order cap not modeled** — only per-slot `capacity`. Study §3.3 "in a
|
||||||
|
given slot **or day**."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Not implemented
|
||||||
|
|
||||||
|
- **Payment customization** (§5.2 — "hide cash-on-delivery for a shipped order").
|
||||||
|
`write_payment_customizations` scope is requested but no such Function exists.
|
||||||
|
- **Product-page placement** (§3.7). No product-page block — only cart / drawer /
|
||||||
|
thank-you / order-status / POS.
|
||||||
|
- **Checkout UI Extension source** (`extensions/checkout-datetime/`) — only a
|
||||||
|
gitignored `dist/` bundle; no `src/` or `shopify.extension.toml` tracked. The
|
||||||
|
Plus native picker is not a maintained deliverable. (The all-plan Liquid
|
||||||
|
confirmation block does cover thank-you / order-status.)
|
||||||
|
- **Returning-customer recognition** (§3.8) — `read_customers` deliberately
|
||||||
|
dropped; documented as deferred.
|
||||||
|
- **Cart Transform** (deposits / fees) — scope requested, marked v2.
|
||||||
|
- **Named capacity resources** (oven / driver / picker pools) — capacity is a
|
||||||
|
single integer; `CapacityResource` from the plan was never added.
|
||||||
|
- **GDPR compliance webhooks** are commented out in `shopify.app.toml` pending
|
||||||
|
Shopify's Protected Customer Data approval (handlers exist). Blocks a
|
||||||
|
Built-for-Shopify submission.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation follow-up — 2026-09-04 (same day)
|
||||||
|
|
||||||
|
Actioned in this pass ("Core + new Functions" scope; Cart Transform / deposits,
|
||||||
|
returning-customer recognition, and GDPR re-enable stay deferred).
|
||||||
|
|
||||||
|
### Done — code + passing unit tests (`npm test`, 156 green)
|
||||||
|
|
||||||
|
1. **Inventory-based location exclusion is now wired.**
|
||||||
|
`resolveAvailabilityRequest` calls `excludeLocationsWithoutStock` when the
|
||||||
|
app-proxy surface supplies an Admin client + cart variant GIDs; the widget
|
||||||
|
now sends `variantIds`. Out-of-stock locations are dropped from both the
|
||||||
|
single-location and delivery-zone paths, with a dedicated
|
||||||
|
"no location stocks every item" message.
|
||||||
|
2. **Live slot re-validation at checkout.** New
|
||||||
|
`app/services/checkout-snapshot.server.ts` writes a shop-metafield capacity
|
||||||
|
snapshot; the `validation-slot` Function (`evaluateCheckout`) now rejects a
|
||||||
|
complete selection when the snapshot shows the slot is full, blacked out,
|
||||||
|
closed, past the daily cap, or gone from the schedule. Snapshot is refreshed
|
||||||
|
on `orders/create|updated|cancelled` and on slot / blackout / location /
|
||||||
|
enforcement edits.
|
||||||
|
3. **Scopable checkout enforcement.** `Shop.enforcementMode` (`all` / `tagged`
|
||||||
|
/ `off`) + `enforcementTag`, new **Checkout enforcement** admin page
|
||||||
|
(`app.settings.tsx`), honoured by the Function via the snapshot's
|
||||||
|
`enforcement` block and `schedulableProductIds`.
|
||||||
|
4. **Per-day order cap.** `Location.dailyOrderCap`, threaded through
|
||||||
|
`getAvailability` (`dailyCap` + `consumedPerDate`) — the whole date drops
|
||||||
|
once its bookings reach the cap, and per-slot `remainingCapacity` is clamped
|
||||||
|
to the day's remaining budget. Admin field on the location edit screen.
|
||||||
|
5. **Product-rule slot blocking (§3.5).** `ProductRule.blockedStartMins`,
|
||||||
|
unioned in `resolveProductRuleConstraints`, applied in `getAvailability` and
|
||||||
|
re-enforced server-side in `resolveHoldRequest`. Admin field on the product
|
||||||
|
rules screen.
|
||||||
|
6. **Product-page placement.** New `blocks/product-availability.liquid` +
|
||||||
|
widget `data-mode="preview"` — a read-only "earliest available date per
|
||||||
|
method" line that collects nothing.
|
||||||
|
7. **Second locale.** `extensions/datetime-widget/locales/fr.json` +
|
||||||
|
`fr.schema.json` (French), proving the i18n path end-to-end.
|
||||||
|
|
||||||
|
New migration: `prisma/migrations/20260904120000_review_gaps` (hand-written;
|
||||||
|
apply with `prisma migrate deploy` — the dev DB was unreachable here).
|
||||||
|
|
||||||
|
### Done — source + unit tests, needs `shopify app deploy` / CLI to ship
|
||||||
|
|
||||||
|
8. **payment-customization Function** — new `extensions/payment-customization/`
|
||||||
|
(`cart.payment-methods.transform.run`): hides cash-on-delivery / pay-in-store
|
||||||
|
gateways on `SHIPPING` orders. Pure logic unit-tested
|
||||||
|
(`tests/unit/payment-customization.test.ts`). Run
|
||||||
|
`npm --prefix extensions/payment-customization run typegen` then
|
||||||
|
`shopify app deploy`; merchant activates it under Settings → Payments.
|
||||||
|
9. **checkout-datetime Checkout UI Extension** — `src/` restored (was a
|
||||||
|
gitignored `dist/` only): `Checkout.jsx` (Plus native picker →
|
||||||
|
`checkout.scheduling.*` routes, same capacity pool), `ThankYou.jsx`,
|
||||||
|
`OrderStatus.jsx`. `tsconfig` ships `checkJs:false` until the `<s-*>` props
|
||||||
|
are reconciled against the live `@shopify/ui-extensions` checkout types
|
||||||
|
(see its README) — the server side it calls is already tested.
|
||||||
|
|
||||||
|
### Not done (unchanged from above)
|
||||||
|
|
||||||
|
- Cart Transform / deposits — v2.
|
||||||
|
- Returning-customer recognition — needs `read_customers` + Protected Customer
|
||||||
|
Data approval.
|
||||||
|
- GDPR compliance webhooks — still blocked on Shopify approval.
|
||||||
|
- Multi-pin pickup-location map picker — still single-location.
|
||||||
|
- Rates remain display-only (delivery-customization renames the option; it
|
||||||
|
doesn't set the charged price). Left as a product decision.
|
||||||
|
- Worker periodic snapshot sweep — event-driven refresh covers the real cases;
|
||||||
|
the 30-day horizon sweep needs offline-session storage first (noted in
|
||||||
|
`jobs/worker.ts`).
|
||||||
|
|
||||||
|
## Suggested priority order
|
||||||
|
|
||||||
|
1. Wire `excludeLocationsWithoutStock` into `resolveAvailabilityRequest` (or
|
||||||
|
delete it if intentionally cut). — *bug*
|
||||||
|
2. Decide rate strategy: real `delivery-customization` pricing / Shopify rate
|
||||||
|
integration vs. accept display-only and document it.
|
||||||
|
3. Live slot re-validation at checkout (metafield capacity snapshot the
|
||||||
|
Validation Function can read) — closes the §5.2 "since become invalid" gap.
|
||||||
|
4. Product-page placement block.
|
||||||
|
5. Payment customization Function (COD-on-shipping etc.).
|
||||||
|
6. Product-scoped enforcement toggle for the Validation Function.
|
||||||
|
7. Second locale + per-day cap + multi-pin pickup map as smaller follow-ups.
|
||||||
|
8. Re-enable GDPR webhooks once Protected Customer Data access is granted (launch
|
||||||
|
blocker for BfS).
|
||||||
@ -26,6 +26,11 @@ export function parseCartLinesParam(raw: string | null | undefined): CartLinePar
|
|||||||
|
|
||||||
/** `productIds` is a comma-separated list of Shopify Product GIDs. */
|
/** `productIds` is a comma-separated list of Shopify Product GIDs. */
|
||||||
export function parseProductIdsParam(raw: string | null | undefined): string[] {
|
export function parseProductIdsParam(raw: string | null | undefined): string[] {
|
||||||
|
return parseGidListParam(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic comma-separated Shopify GID list (products, variants, …). */
|
||||||
|
export function parseGidListParam(raw: string | null | undefined): string[] {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
return raw
|
return raw
|
||||||
.split(",")
|
.split(",")
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { TitleBar } from "@shopify/app-bridge-react";
|
|||||||
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 db from "../db.server";
|
||||||
|
import { writeCheckoutSnapshot } from "../services/checkout-snapshot.server";
|
||||||
|
|
||||||
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
|
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
|
||||||
|
|
||||||
@ -36,13 +37,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
const { session } = await authenticate.admin(request);
|
const { session, admin } = await authenticate.admin(request);
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const intent = formData.get("intent");
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
if (intent === "delete") {
|
if (intent === "delete") {
|
||||||
const id = String(formData.get("id") || "");
|
const id = String(formData.get("id") || "");
|
||||||
await db.blackoutDate.deleteMany({ where: { id, shopDomain: session.shop } });
|
await db.blackoutDate.deleteMany({ where: { id, shopDomain: session.shop } });
|
||||||
|
await writeCheckoutSnapshot(admin, session.shop);
|
||||||
return data({ ok: true });
|
return data({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,6 +67,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await writeCheckoutSnapshot(admin, session.shop);
|
||||||
return data({ ok: true });
|
return data({ ok: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { TitleBar } from "@shopify/app-bridge-react";
|
|||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import db from "../db.server";
|
import db from "../db.server";
|
||||||
import { geocodeAddress } from "../services/zones.server";
|
import { geocodeAddress } from "../services/zones.server";
|
||||||
|
import { writeCheckoutSnapshot } from "../services/checkout-snapshot.server";
|
||||||
|
|
||||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||||
const { session } = await authenticate.admin(request);
|
const { session } = await authenticate.admin(request);
|
||||||
@ -31,12 +32,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||||
const { session } = await authenticate.admin(request);
|
const { session, admin } = await authenticate.admin(request);
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const intent = formData.get("intent");
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
if (intent === "delete") {
|
if (intent === "delete") {
|
||||||
await db.location.deleteMany({ where: { id: params.id, shopDomain: session.shop } });
|
await db.location.deleteMany({ where: { id: params.id, shopDomain: session.shop } });
|
||||||
|
await writeCheckoutSnapshot(admin, session.shop);
|
||||||
return redirect("/app/locations");
|
return redirect("/app/locations");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,10 +47,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||||||
const timezone = String(formData.get("timezone") || "").trim();
|
const timezone = String(formData.get("timezone") || "").trim();
|
||||||
const active = formData.get("active") === "true";
|
const active = formData.get("active") === "true";
|
||||||
const shopifyLocationId = String(formData.get("shopifyLocationId") || "").trim() || null;
|
const shopifyLocationId = String(formData.get("shopifyLocationId") || "").trim() || null;
|
||||||
|
const dailyOrderCapRaw = String(formData.get("dailyOrderCap") || "").trim();
|
||||||
|
const dailyOrderCap = dailyOrderCapRaw ? Number(dailyOrderCapRaw) : null;
|
||||||
|
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
if (!name) errors.name = "Name is required";
|
if (!name) errors.name = "Name is required";
|
||||||
if (!timezone) errors.timezone = "Timezone is required";
|
if (!timezone) errors.timezone = "Timezone is required";
|
||||||
|
if (dailyOrderCap != null && (!Number.isFinite(dailyOrderCap) || dailyOrderCap < 1)) {
|
||||||
|
errors.dailyOrderCap = "Leave blank for no cap, or enter a whole number ≥ 1";
|
||||||
|
}
|
||||||
if (Object.keys(errors).length > 0) {
|
if (Object.keys(errors).length > 0) {
|
||||||
return { errors };
|
return { errors };
|
||||||
}
|
}
|
||||||
@ -66,10 +73,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||||||
timezone,
|
timezone,
|
||||||
active,
|
active,
|
||||||
shopifyLocationId,
|
shopifyLocationId,
|
||||||
|
dailyOrderCap,
|
||||||
...(coordinates ? { lat: coordinates.lat, lng: coordinates.lng } : {}),
|
...(coordinates ? { lat: coordinates.lat, lng: coordinates.lng } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await writeCheckoutSnapshot(admin, session.shop);
|
||||||
return { errors };
|
return { errors };
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -99,6 +108,7 @@ function LocationForm({
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
shopifyLocationId: string | null;
|
shopifyLocationId: string | null;
|
||||||
|
dailyOrderCap: number | null;
|
||||||
lat: number | null;
|
lat: number | null;
|
||||||
lng: number | null;
|
lng: number | null;
|
||||||
};
|
};
|
||||||
@ -110,6 +120,9 @@ function LocationForm({
|
|||||||
const [timezone, setTimezone] = useState(location.timezone);
|
const [timezone, setTimezone] = useState(location.timezone);
|
||||||
const [active, setActive] = useState(location.active);
|
const [active, setActive] = useState(location.active);
|
||||||
const [shopifyLocationId, setShopifyLocationId] = useState(location.shopifyLocationId ?? "");
|
const [shopifyLocationId, setShopifyLocationId] = useState(location.shopifyLocationId ?? "");
|
||||||
|
const [dailyOrderCap, setDailyOrderCap] = useState(
|
||||||
|
location.dailyOrderCap != null ? String(location.dailyOrderCap) : "",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BlockStack gap="400">
|
<BlockStack gap="400">
|
||||||
@ -155,6 +168,16 @@ function LocationForm({
|
|||||||
onChange={setShopifyLocationId}
|
onChange={setShopifyLocationId}
|
||||||
helpText="gid://shopify/Location/… — only needed for inventory-based location exclusion."
|
helpText="gid://shopify/Location/… — only needed for inventory-based location exclusion."
|
||||||
/>
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Daily order cap (optional)"
|
||||||
|
name="dailyOrderCap"
|
||||||
|
type="number"
|
||||||
|
autoComplete="off"
|
||||||
|
value={dailyOrderCap}
|
||||||
|
onChange={setDailyOrderCap}
|
||||||
|
error={errors?.dailyOrderCap}
|
||||||
|
helpText="Once this many orders are booked for a calendar day (across every method and slot), that whole day disappears from the picker. Blank = no daily cap."
|
||||||
|
/>
|
||||||
<Checkbox label="Active" name="active" value="true" checked={active} onChange={setActive} />
|
<Checkbox label="Active" name="active" value="true" checked={active} onChange={setActive} />
|
||||||
<InlineStack gap="200">
|
<InlineStack gap="200">
|
||||||
<Button submit variant="primary" loading={isSubmitting}>
|
<Button submit variant="primary" loading={isSubmitting}>
|
||||||
|
|||||||
@ -37,6 +37,29 @@ const SCOPE_LABELS: Record<string, string> = {
|
|||||||
tag: "Tag",
|
tag: "Tag",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** "09:00, 13:30" -> [540, 810]; ignores blank/garbage entries. */
|
||||||
|
function parseBlockedStartMins(raw: string): number[] {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
raw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((s) => {
|
||||||
|
const [h, m] = s.split(":").map(Number);
|
||||||
|
return Number.isFinite(h) && Number.isFinite(m) ? h * 60 + m : NaN;
|
||||||
|
})
|
||||||
|
.filter((n) => Number.isFinite(n) && n >= 0 && n < 24 * 60),
|
||||||
|
),
|
||||||
|
].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBlockedStartMins(mins: number[]): string {
|
||||||
|
return mins
|
||||||
|
.map((n) => `${String(Math.floor(n / 60)).padStart(2, "0")}:${String(n % 60).padStart(2, "0")}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const { session } = await authenticate.admin(request);
|
const { session } = await authenticate.admin(request);
|
||||||
|
|
||||||
@ -75,6 +98,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
const allowedMethods = formData.getAll("allowedMethods").map(String) as Method[];
|
const allowedMethods = formData.getAll("allowedMethods").map(String) as Method[];
|
||||||
const allowedLocationIds = formData.getAll("allowedLocationIds").map(String);
|
const allowedLocationIds = formData.getAll("allowedLocationIds").map(String);
|
||||||
const leadTimeMinRaw = String(formData.get("leadTimeMin") || "");
|
const leadTimeMinRaw = String(formData.get("leadTimeMin") || "");
|
||||||
|
const blockedStartMins = parseBlockedStartMins(String(formData.get("blockedStartMins") || ""));
|
||||||
|
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
if (!scopeValue) errors.scopeValue = "This field is required";
|
if (!scopeValue) errors.scopeValue = "This field is required";
|
||||||
@ -90,6 +114,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
allowedMethods,
|
allowedMethods,
|
||||||
allowedLocationIds,
|
allowedLocationIds,
|
||||||
leadTimeMin: leadTimeMinRaw ? Number(leadTimeMinRaw) : null,
|
leadTimeMin: leadTimeMinRaw ? Number(leadTimeMinRaw) : null,
|
||||||
|
blockedStartMins,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -138,6 +163,7 @@ export default function ProductRulesIndex() {
|
|||||||
{ title: "Extra lead time" },
|
{ title: "Extra lead time" },
|
||||||
{ title: "Allowed methods" },
|
{ title: "Allowed methods" },
|
||||||
{ title: "Allowed locations" },
|
{ title: "Allowed locations" },
|
||||||
|
{ title: "Blocked slots" },
|
||||||
{ title: "" },
|
{ title: "" },
|
||||||
]}
|
]}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
@ -154,6 +180,9 @@ export default function ProductRulesIndex() {
|
|||||||
? rule.allowedLocationIds.map((id) => locationNameById.get(id) ?? id).join(", ")
|
? rule.allowedLocationIds.map((id) => locationNameById.get(id) ?? id).join(", ")
|
||||||
: "Any"}
|
: "Any"}
|
||||||
</IndexTable.Cell>
|
</IndexTable.Cell>
|
||||||
|
<IndexTable.Cell>
|
||||||
|
{rule.blockedStartMins.length > 0 ? formatBlockedStartMins(rule.blockedStartMins) : "—"}
|
||||||
|
</IndexTable.Cell>
|
||||||
<IndexTable.Cell>
|
<IndexTable.Cell>
|
||||||
<Form method="post">
|
<Form method="post">
|
||||||
<input type="hidden" name="intent" value="delete" />
|
<input type="hidden" name="intent" value="delete" />
|
||||||
@ -185,6 +214,7 @@ function AddRuleForm({
|
|||||||
const [scopeType, setScopeType] = useState("vendor");
|
const [scopeType, setScopeType] = useState("vendor");
|
||||||
const [scopeValue, setScopeValue] = useState("");
|
const [scopeValue, setScopeValue] = useState("");
|
||||||
const [leadTimeMin, setLeadTimeMin] = useState("");
|
const [leadTimeMin, setLeadTimeMin] = useState("");
|
||||||
|
const [blockedStartMins, setBlockedStartMins] = useState("");
|
||||||
const [allowedMethods, setAllowedMethods] = useState<string[]>([]);
|
const [allowedMethods, setAllowedMethods] = useState<string[]>([]);
|
||||||
const [allowedLocationIds, setAllowedLocationIds] = useState<string[]>([]);
|
const [allowedLocationIds, setAllowedLocationIds] = useState<string[]>([]);
|
||||||
|
|
||||||
@ -224,6 +254,14 @@ function AddRuleForm({
|
|||||||
onChange={setLeadTimeMin}
|
onChange={setLeadTimeMin}
|
||||||
helpText="Added on top of the slot's own lead time when this rule matches something in the cart."
|
helpText="Added on top of the slot's own lead time when this rule matches something in the cart."
|
||||||
/>
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Blocked time slots (optional)"
|
||||||
|
name="blockedStartMins"
|
||||||
|
autoComplete="off"
|
||||||
|
value={blockedStartMins}
|
||||||
|
onChange={setBlockedStartMins}
|
||||||
|
helpText="Comma-separated start times (e.g. 09:00, 13:30) this cart's contents can't be scheduled into — the earliest run, say, for a fragile item."
|
||||||
|
/>
|
||||||
</InlineStack>
|
</InlineStack>
|
||||||
<InlineStack gap="400" wrap>
|
<InlineStack gap="400" wrap>
|
||||||
<ChoiceList
|
<ChoiceList
|
||||||
|
|||||||
120
app/routes/app.settings.tsx
Normal file
120
app/routes/app.settings.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
|
||||||
|
import { Page, Card, BlockStack, Text, Button, ChoiceList, TextField, Banner } from "@shopify/polaris";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
import { writeCheckoutSnapshot } from "../services/checkout-snapshot.server";
|
||||||
|
|
||||||
|
// Checkout enforcement scope for the Cart/Checkout Validation Function
|
||||||
|
// (study §3.5 — enforcement should be scopable, not all-or-nothing). The
|
||||||
|
// Function can't read this DB, so every save re-writes the shop-metafield
|
||||||
|
// snapshot it does read (checkout-snapshot.server.ts).
|
||||||
|
|
||||||
|
const MODE_CHOICES = [
|
||||||
|
{ label: "Every order must pick a slot", value: "all" },
|
||||||
|
{ label: "Only orders containing a tagged product", value: "tagged" },
|
||||||
|
{ label: "Never block checkout (widget still collects)", value: "off" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
const shop = await db.shop.findUnique({
|
||||||
|
where: { shopDomain: session.shop },
|
||||||
|
select: { enforcementMode: true, enforcementTag: true },
|
||||||
|
});
|
||||||
|
return { mode: shop?.enforcementMode ?? "all", tag: shop?.enforcementTag ?? "" };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { session, admin } = await authenticate.admin(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const mode = String(formData.get("mode") || "all");
|
||||||
|
const tag = String(formData.get("enforcementTag") || "").trim() || null;
|
||||||
|
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
if (!["all", "tagged", "off"].includes(mode)) errors.mode = "Pick a valid option";
|
||||||
|
if (mode === "tagged" && !tag) errors.enforcementTag = "Enter the product tag that requires scheduling";
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
return { ok: false as const, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.shop.upsert({
|
||||||
|
where: { shopDomain: session.shop },
|
||||||
|
create: { shopDomain: session.shop, enforcementMode: mode, enforcementTag: tag },
|
||||||
|
update: { enforcementMode: mode, enforcementTag: tag },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push the new config straight into the metafield the Function reads.
|
||||||
|
await writeCheckoutSnapshot(admin, session.shop);
|
||||||
|
|
||||||
|
return { ok: true as const, errors: {} as Record<string, string> };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EnforcementSettings() {
|
||||||
|
const { mode: initialMode, tag: initialTag } = useLoaderData<typeof loader>();
|
||||||
|
const actionData = useActionData<typeof action>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const isSubmitting = navigation.state === "submitting";
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<string>(initialMode);
|
||||||
|
const [tag, setTag] = useState(initialTag);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Checkout enforcement" />
|
||||||
|
<BlockStack gap="400">
|
||||||
|
{actionData?.ok && (
|
||||||
|
<Banner tone="success" title="Saved — the checkout snapshot has been refreshed." />
|
||||||
|
)}
|
||||||
|
<Card>
|
||||||
|
<Form method="post">
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<Text as="p" tone="subdued">
|
||||||
|
The storefront widget only <em>collects</em> a slot. The Cart & Checkout Validation Function is what
|
||||||
|
actually blocks an order from completing without a valid one. This controls when it blocks.
|
||||||
|
</Text>
|
||||||
|
<ChoiceList
|
||||||
|
title="When should checkout be blocked without a slot?"
|
||||||
|
choices={MODE_CHOICES}
|
||||||
|
selected={[mode]}
|
||||||
|
onChange={(v) => setMode(v[0])}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="mode" value={mode} />
|
||||||
|
{mode === "tagged" && (
|
||||||
|
<TextField
|
||||||
|
label="Product tag that requires scheduling"
|
||||||
|
name="enforcementTag"
|
||||||
|
autoComplete="off"
|
||||||
|
value={tag}
|
||||||
|
onChange={setTag}
|
||||||
|
error={actionData?.errors?.enforcementTag}
|
||||||
|
helpText="Orders are only blocked when a cart line's product carries this tag. Others check out normally."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Button submit variant="primary" loading={isSubmitting}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</BlockStack>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text as="h3" variant="headingSm">
|
||||||
|
Live slot re-validation
|
||||||
|
</Text>
|
||||||
|
<Text as="p" tone="subdued">
|
||||||
|
Every save here, plus every order and every slot/blackout edit, refreshes a capacity snapshot the Function
|
||||||
|
reads at checkout. If a slot has filled up or been blacked out since the shopper picked it, checkout is
|
||||||
|
blocked with a "no longer available" message — not just when the slot attribute is missing.
|
||||||
|
</Text>
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
</BlockStack>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ import { TitleBar } from "@shopify/app-bridge-react";
|
|||||||
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 db from "../db.server";
|
||||||
|
import { writeCheckoutSnapshot } from "../services/checkout-snapshot.server";
|
||||||
|
|
||||||
const WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
const WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
|
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
|
||||||
@ -57,7 +58,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
const { session } = await authenticate.admin(request);
|
const { session, admin } = await authenticate.admin(request);
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const intent = formData.get("intent");
|
const intent = formData.get("intent");
|
||||||
const locationId = String(formData.get("locationId") || "");
|
const locationId = String(formData.get("locationId") || "");
|
||||||
@ -65,6 +66,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
if (intent === "delete") {
|
if (intent === "delete") {
|
||||||
const id = String(formData.get("id") || "");
|
const id = String(formData.get("id") || "");
|
||||||
await db.slotTemplate.deleteMany({ where: { id, shopDomain: session.shop } });
|
await db.slotTemplate.deleteMany({ where: { id, shopDomain: session.shop } });
|
||||||
|
await writeCheckoutSnapshot(admin, session.shop);
|
||||||
return data({ ok: true });
|
return data({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,6 +110,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await writeCheckoutSnapshot(admin, session.shop);
|
||||||
return data({ ok: true });
|
return data({ ok: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export default function App() {
|
|||||||
<Link to="/app/rates">Delivery rates</Link>
|
<Link to="/app/rates">Delivery rates</Link>
|
||||||
<Link to="/app/rules">Product rules</Link>
|
<Link to="/app/rules">Product rules</Link>
|
||||||
<Link to="/app/dashboard">Dispatch dashboard</Link>
|
<Link to="/app/dashboard">Dispatch dashboard</Link>
|
||||||
|
<Link to="/app/settings">Checkout enforcement</Link>
|
||||||
<Link to="/app/billing">Billing</Link>
|
<Link to="/app/billing">Billing</Link>
|
||||||
<Link to="/app/help">Help</Link>
|
<Link to="/app/help">Help</Link>
|
||||||
</NavMenu>
|
</NavMenu>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { Method } from "@prisma/client";
|
|||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import { resolveAvailabilityRequest } from "../services/availability-request.server";
|
import { resolveAvailabilityRequest } from "../services/availability-request.server";
|
||||||
import { resolveProductRefs } from "../services/product-rules.server";
|
import { resolveProductRefs } from "../services/product-rules.server";
|
||||||
import { parseCartLinesParam, parseProductIdsParam } from "../lib/cart-rule-params";
|
import { parseCartLinesParam, parseProductIdsParam, parseGidListParam } from "../lib/cart-rule-params";
|
||||||
|
|
||||||
// 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
|
||||||
@ -44,6 +44,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
days: Number(url.searchParams.get("days")) || undefined,
|
days: Number(url.searchParams.get("days")) || undefined,
|
||||||
cartLines: parseCartLinesParam(url.searchParams.get("cartLines")),
|
cartLines: parseCartLinesParam(url.searchParams.get("cartLines")),
|
||||||
productRefs,
|
productRefs,
|
||||||
|
// Inventory-based location exclusion (study §3.4) — only the app-proxy
|
||||||
|
// surface has an Admin client, so it's the only one that can run it.
|
||||||
|
productVariantGids: parseGidListParam(url.searchParams.get("variantIds")),
|
||||||
|
admin: admin ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.locationId) {
|
if (!result.locationId) {
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import type { ActionFunctionArgs } from "@remix-run/node";
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import db from "../db.server";
|
import db from "../db.server";
|
||||||
|
import { writeCheckoutSnapshot } from "../services/checkout-snapshot.server";
|
||||||
|
|
||||||
// Marks the linked Booking cancelled, which frees its capacity for
|
// Marks the linked Booking cancelled, which frees its capacity for
|
||||||
// getAvailability()/the hold endpoint's confirmedCount check on the next
|
// getAvailability()/the hold endpoint's confirmedCount check on the next
|
||||||
// request — see IMPLEMENTATION_PLAN.md §5.2.
|
// request — see IMPLEMENTATION_PLAN.md §5.2.
|
||||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
const { shop, topic, payload } = await authenticate.webhook(request);
|
const { shop, topic, payload, admin } = await authenticate.webhook(request);
|
||||||
console.log(`Received ${topic} webhook for ${shop}`);
|
console.log(`Received ${topic} webhook for ${shop}`);
|
||||||
|
|
||||||
const order = payload as unknown as { admin_graphql_api_id: string };
|
const order = payload as unknown as { admin_graphql_api_id: string };
|
||||||
@ -15,5 +16,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
data: { status: "cancelled" },
|
data: { status: "cancelled" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Capacity just freed up — the checkout snapshot must reflect it so the
|
||||||
|
// Validation Function stops blocking a slot that's now open again.
|
||||||
|
if (admin) {
|
||||||
|
await writeCheckoutSnapshot(admin, shop);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response();
|
return new Response();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { ActionFunctionArgs } from "@remix-run/node";
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import { createBookingFromOrder, type OrderWebhookPayload } from "../services/booking.server";
|
import { createBookingFromOrder, type OrderWebhookPayload } from "../services/booking.server";
|
||||||
|
import { writeCheckoutSnapshot } from "../services/checkout-snapshot.server";
|
||||||
|
|
||||||
// Converts the cart's dd_* attributes into a confirmed Booking, releases
|
// Converts the cart's dd_* attributes into a confirmed Booking, releases
|
||||||
// the matching Redis hold, and writes the slot back onto the order via a
|
// the matching Redis hold, and writes the slot back onto the order via a
|
||||||
@ -15,6 +16,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
const order = payload as unknown as OrderWebhookPayload;
|
const order = payload as unknown as OrderWebhookPayload;
|
||||||
await createBookingFromOrder(shop, order);
|
await createBookingFromOrder(shop, order);
|
||||||
|
|
||||||
|
// This order just consumed capacity — refresh the snapshot the Cart/
|
||||||
|
// Checkout Validation Function reads so the next shopper can't book a
|
||||||
|
// slot this one filled (study §5.2 "has since become invalid").
|
||||||
|
if (admin) {
|
||||||
|
await writeCheckoutSnapshot(admin, shop);
|
||||||
|
}
|
||||||
|
|
||||||
if (admin) {
|
if (admin) {
|
||||||
const bookingSummary = summarizeBookingAttributes(order);
|
const bookingSummary = summarizeBookingAttributes(order);
|
||||||
if (bookingSummary) {
|
if (bookingSummary) {
|
||||||
|
|||||||
@ -1,12 +1,37 @@
|
|||||||
import type { ActionFunctionArgs } from "@remix-run/node";
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
import { writeCheckoutSnapshot } from "../services/checkout-snapshot.server";
|
||||||
|
|
||||||
// TODO (Phase 4): sync Booking.status changes (e.g. fulfillment status)
|
// Keeps Booking.status roughly in step with the order's fulfillment/cancel
|
||||||
// back from order updates. See IMPLEMENTATION_PLAN.md §5.2.
|
// state, and refreshes the checkout capacity snapshot the Validation
|
||||||
|
// Function reads. See IMPLEMENTATION_PLAN.md §5.2.
|
||||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
const { shop, topic, payload } = await authenticate.webhook(request);
|
const { shop, topic, payload, admin } = await authenticate.webhook(request);
|
||||||
|
console.log(`Received ${topic} webhook for ${shop}`);
|
||||||
|
|
||||||
console.log(`Received ${topic} webhook for ${shop}`, payload);
|
const order = payload as unknown as {
|
||||||
|
admin_graphql_api_id: string;
|
||||||
|
cancelled_at?: string | null;
|
||||||
|
fulfillment_status?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = order.cancelled_at
|
||||||
|
? "cancelled"
|
||||||
|
: order.fulfillment_status === "fulfilled"
|
||||||
|
? "fulfilled"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
await db.booking.updateMany({
|
||||||
|
where: { shopDomain: shop, orderId: order.admin_graphql_api_id, status: { notIn: ["cancelled"] } },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (admin) {
|
||||||
|
await writeCheckoutSnapshot(admin, shop);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response();
|
return new Response();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,7 +2,12 @@ import { DateTime } from "luxon";
|
|||||||
import type { Method } from "@prisma/client";
|
import type { Method } from "@prisma/client";
|
||||||
import db from "../db.server";
|
import db from "../db.server";
|
||||||
import { getAvailability, type AvailableSlot } from "./scheduling.server";
|
import { getAvailability, type AvailableSlot } from "./scheduling.server";
|
||||||
import { findEligibleLocationsForDelivery, meetsDeliveryDensity } from "./zones.server";
|
import {
|
||||||
|
findEligibleLocationsForDelivery,
|
||||||
|
meetsDeliveryDensity,
|
||||||
|
excludeLocationsWithoutStock,
|
||||||
|
type AdminGraphQLClient,
|
||||||
|
} from "./zones.server";
|
||||||
import { resolveRate } from "./rates.server";
|
import { resolveRate } from "./rates.server";
|
||||||
import { formatPriceLabel } from "../lib/currency";
|
import { formatPriceLabel } from "../lib/currency";
|
||||||
import { resolveProductRuleConstraints, productRefsFromCartLines, type ProductRef } from "./product-rules.server";
|
import { resolveProductRuleConstraints, productRefsFromCartLines, type ProductRef } from "./product-rules.server";
|
||||||
@ -35,6 +40,15 @@ export interface AvailabilityRequestParams {
|
|||||||
*/
|
*/
|
||||||
cartLines?: Array<{ vendor?: string; productType?: string }>;
|
cartLines?: Array<{ vendor?: string; productType?: string }>;
|
||||||
productRefs?: ProductRef[];
|
productRefs?: ProductRef[];
|
||||||
|
/**
|
||||||
|
* Cart variant GIDs + an Admin API client — when both are supplied, any
|
||||||
|
* candidate location that stocks none of them is dropped from selection
|
||||||
|
* (study §3.4 "product-inventory-based location choice"). Only the
|
||||||
|
* app-proxy surface has an Admin client; POS/checkout pass neither and
|
||||||
|
* skip the check.
|
||||||
|
*/
|
||||||
|
productVariantGids?: string[];
|
||||||
|
admin?: AdminGraphQLClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AvailabilityResult {
|
export interface AvailabilityResult {
|
||||||
@ -91,6 +105,15 @@ export async function resolveAvailabilityRequest(
|
|||||||
let zoneId: string | null = null;
|
let zoneId: string | null = null;
|
||||||
let distanceKm: number | null = null;
|
let distanceKm: number | null = null;
|
||||||
|
|
||||||
|
// Inventory-based location exclusion (study §3.4): only runs on the
|
||||||
|
// app-proxy surface, which is the only one that hands us an Admin client
|
||||||
|
// plus the cart's variant GIDs. `inStock` is the identity set otherwise.
|
||||||
|
const stockCheckEnabled = Boolean(params.admin) && (params.productVariantGids?.length ?? 0) > 0;
|
||||||
|
const inStock = async (locationIds: string[]): Promise<Set<string>> =>
|
||||||
|
stockCheckEnabled
|
||||||
|
? excludeLocationsWithoutStock(params.admin!, locationIds, params.productVariantGids!)
|
||||||
|
: new Set(locationIds);
|
||||||
|
|
||||||
// Delivery-zone auto-assignment (IMPLEMENTATION_PLAN.md Phase 5): route to
|
// Delivery-zone auto-assignment (IMPLEMENTATION_PLAN.md Phase 5): route to
|
||||||
// the nearest eligible, density-qualified zone for the shopper's address.
|
// the nearest eligible, density-qualified zone for the shopper's address.
|
||||||
// Falls through to the plain location lookup below for PICKUP/SHIPPING,
|
// Falls through to the plain location lookup below for PICKUP/SHIPPING,
|
||||||
@ -102,10 +125,12 @@ export async function resolveAvailabilityRequest(
|
|||||||
? await db.zone.findMany({ where: { id: { in: matches.map((m) => m.zoneId) } } })
|
? await db.zone.findMany({ where: { id: { in: matches.map((m) => m.zoneId) } } })
|
||||||
: [];
|
: [];
|
||||||
const zoneById = new Map(zones.map((z) => [z.id, z]));
|
const zoneById = new Map(zones.map((z) => [z.id, z]));
|
||||||
|
const stockedLocationIds = await inStock([...new Set(matches.map((m) => m.locationId))]);
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
if (locationIdParam && match.locationId !== locationIdParam) continue;
|
if (locationIdParam && match.locationId !== locationIdParam) continue;
|
||||||
if (ruleConstraints.allowedLocationIds != null && !ruleConstraints.allowedLocationIds.includes(match.locationId)) continue;
|
if (ruleConstraints.allowedLocationIds != null && !ruleConstraints.allowedLocationIds.includes(match.locationId)) continue;
|
||||||
|
if (!stockedLocationIds.has(match.locationId)) continue; // location doesn't stock the cart
|
||||||
const zone = zoneById.get(match.zoneId);
|
const zone = zoneById.get(match.zoneId);
|
||||||
if (!zone) continue;
|
if (!zone) continue;
|
||||||
// eslint-disable-next-line no-await-in-loop -- checked in nearest-first order; stop at the first that qualifies
|
// eslint-disable-next-line no-await-in-loop -- checked in nearest-first order; stop at the first that qualifies
|
||||||
@ -131,9 +156,14 @@ export async function resolveAvailabilityRequest(
|
|||||||
} else {
|
} else {
|
||||||
const locationWhere =
|
const locationWhere =
|
||||||
ruleConstraints.allowedLocationIds != null ? { id: { in: ruleConstraints.allowedLocationIds } } : {};
|
ruleConstraints.allowedLocationIds != null ? { id: { in: ruleConstraints.allowedLocationIds } } : {};
|
||||||
location = locationIdParam
|
const candidates = await db.location.findMany({
|
||||||
? await db.location.findFirst({ where: { id: locationIdParam, shopDomain, active: true, ...locationWhere } })
|
where: locationIdParam
|
||||||
: await db.location.findFirst({ where: { shopDomain, active: true, ...locationWhere }, orderBy: { createdAt: "asc" } });
|
? { id: locationIdParam, shopDomain, active: true, ...locationWhere }
|
||||||
|
: { shopDomain, active: true, ...locationWhere },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
const stocked = await inStock(candidates.map((c) => c.id));
|
||||||
|
location = candidates.find((c) => stocked.has(c.id)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!location) {
|
if (!location) {
|
||||||
@ -147,7 +177,9 @@ export async function resolveAvailabilityRequest(
|
|||||||
error:
|
error:
|
||||||
ruleConstraints.allowedLocationIds != null
|
ruleConstraints.allowedLocationIds != null
|
||||||
? "One or more items in your cart aren't available at this location."
|
? "One or more items in your cart aren't available at this location."
|
||||||
: "No active location configured",
|
: stockCheckEnabled
|
||||||
|
? "No location currently stocks every item in your cart."
|
||||||
|
: "No active location configured",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,19 +209,26 @@ export async function resolveAvailabilityRequest(
|
|||||||
where: {
|
where: {
|
||||||
shopDomain,
|
shopDomain,
|
||||||
locationId: location.id,
|
locationId: location.id,
|
||||||
method,
|
// Per-day cap (study §3.3) counts every method at the location, so the
|
||||||
|
// daily tally can't be filtered to one method — the per-slot tally
|
||||||
|
// below still keys by exact start instant, which is method-specific
|
||||||
|
// enough on its own.
|
||||||
status: { in: ["confirmed", "fulfilled"] },
|
status: { in: ["confirmed", "fulfilled"] },
|
||||||
slotStart: { gte: bookingRangeStart, lte: bookingRangeEnd },
|
slotStart: { gte: bookingRangeStart, lte: bookingRangeEnd },
|
||||||
},
|
},
|
||||||
select: { slotStart: true },
|
select: { slotStart: true, method: true },
|
||||||
}),
|
}),
|
||||||
db.rate.findMany({ where: { shopDomain, method } }),
|
db.rate.findMany({ where: { shopDomain, method } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const consumed = new Map<string, number>();
|
const consumed = new Map<string, number>();
|
||||||
|
const consumedPerDate = new Map<string, number>();
|
||||||
for (const booking of bookings) {
|
for (const booking of bookings) {
|
||||||
const local = DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(location.timezone);
|
const local = DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(location.timezone);
|
||||||
const key = slotKey(local.toISODate()!, local.hour * 60 + local.minute);
|
const isoDate = local.toISODate()!;
|
||||||
|
consumedPerDate.set(isoDate, (consumedPerDate.get(isoDate) ?? 0) + 1);
|
||||||
|
if (booking.method !== method) continue; // per-slot capacity is for this method's picker only
|
||||||
|
const key = slotKey(isoDate, local.hour * 60 + local.minute);
|
||||||
consumed.set(key, (consumed.get(key) ?? 0) + 1);
|
consumed.set(key, (consumed.get(key) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,6 +260,9 @@ export async function resolveAvailabilityRequest(
|
|||||||
blackoutDates: blackouts.map((b) => ({ date: toIsoDate(b.date) })),
|
blackoutDates: blackouts.map((b) => ({ date: toIsoDate(b.date) })),
|
||||||
now,
|
now,
|
||||||
consumed,
|
consumed,
|
||||||
|
dailyCap: location.dailyOrderCap,
|
||||||
|
consumedPerDate,
|
||||||
|
blockedStartMins: ruleConstraints.blockedStartMins,
|
||||||
});
|
});
|
||||||
|
|
||||||
const matchedRate = resolveRate(rates, { method, zoneId: zoneId ?? undefined, distanceKm: distanceKm ?? undefined });
|
const matchedRate = resolveRate(rates, { method, zoneId: zoneId ?? undefined, distanceKm: distanceKm ?? undefined });
|
||||||
|
|||||||
241
app/services/checkout-snapshot.server.ts
Normal file
241
app/services/checkout-snapshot.server.ts
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import { DateTime } from "luxon";
|
||||||
|
import type { Method } from "@prisma/client";
|
||||||
|
import db from "../db.server";
|
||||||
|
import { getAvailability } from "./scheduling.server";
|
||||||
|
import type { AdminGraphQLClient } from "./zones.server";
|
||||||
|
|
||||||
|
// Denormalized capacity + enforcement snapshot that the Cart/Checkout
|
||||||
|
// Validation Function reads from a shop metafield. Shopify Functions can't
|
||||||
|
// query this app's database, so anything the Function needs to enforce
|
||||||
|
// "the slot has since become invalid" (study §5.2) or "only enforce for
|
||||||
|
// tagged products" (study §3.5) has to be pushed into a metafield it can
|
||||||
|
// read directly. This module builds and writes that metafield; it's
|
||||||
|
// refreshed whenever bookings or scheduling config change (order webhooks,
|
||||||
|
// slot/blackout admin edits) and on a periodic worker sweep so the rolling
|
||||||
|
// horizon keeps advancing.
|
||||||
|
|
||||||
|
export const SNAPSHOT_NAMESPACE = "delivery_datetime";
|
||||||
|
export const SNAPSHOT_KEY = "checkout_snapshot";
|
||||||
|
|
||||||
|
const HORIZON_DAYS = 30;
|
||||||
|
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
|
||||||
|
|
||||||
|
// A fixed instant far in the past, so getAvailability's cutoff/lead-time
|
||||||
|
// filters never hide a slot here — the snapshot captures *capacity and
|
||||||
|
// calendar* state (blackout, closed override, daily cap, bookings), not the
|
||||||
|
// time-of-day cutoff, which is inherently second-sensitive and already
|
||||||
|
// covered by the hold TTL and the widget.
|
||||||
|
const EPOCH = DateTime.fromISO("1970-01-01T00:00:00Z");
|
||||||
|
|
||||||
|
export interface CheckoutSnapshot {
|
||||||
|
version: 1;
|
||||||
|
generatedAt: string;
|
||||||
|
/** Slots whose date is after this (UTC calendar date) aren't covered — the Function stays permissive past the horizon. */
|
||||||
|
horizonDate: string;
|
||||||
|
enforcement: { mode: "all" | "tagged" | "off" };
|
||||||
|
/** Product GIDs that trigger enforcement when mode = "tagged". Ignored otherwise. */
|
||||||
|
schedulableProductIds: string[];
|
||||||
|
/** `${locationId}|${METHOD}|${YYYY-MM-DD}|${startMin}` -> remaining capacity. */
|
||||||
|
slots: Record<string, number>;
|
||||||
|
/** `${locationId}|${METHOD}|${YYYY-MM-DD}` -> true when that day has no bookable slot (blackout / closed / daily cap / no template). */
|
||||||
|
closedDates: Record<string, true>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slotSnapshotKey(locationId: string, method: Method, date: string, startMin: number): string {
|
||||||
|
return `${locationId}|${method}|${date}|${startMin}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closedDateKey(locationId: string, method: Method, date: string): string {
|
||||||
|
return `${locationId}|${method}|${date}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoDate(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the capacity/calendar half of the snapshot from the database.
|
||||||
|
* Enforcement config and tagged-product resolution are layered on by
|
||||||
|
* writeCheckoutSnapshot (they need the Admin API / are cheap to add there).
|
||||||
|
*/
|
||||||
|
export async function buildCheckoutSnapshot(shopDomain: string): Promise<Omit<CheckoutSnapshot, "enforcement" | "schedulableProductIds">> {
|
||||||
|
const nowUtc = DateTime.utc();
|
||||||
|
const startDate = nowUtc.toISODate()!;
|
||||||
|
const endDate = nowUtc.plus({ days: HORIZON_DAYS }).toISODate()!;
|
||||||
|
const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).minus({ days: 1 }).toJSDate();
|
||||||
|
const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate();
|
||||||
|
|
||||||
|
const locations = await db.location.findMany({ where: { shopDomain, active: true } });
|
||||||
|
|
||||||
|
const slots: Record<string, number> = {};
|
||||||
|
const closedDates: Record<string, true> = {};
|
||||||
|
|
||||||
|
for (const location of locations) {
|
||||||
|
const [templates, overrides, blackouts, bookings] = await Promise.all([
|
||||||
|
db.slotTemplate.findMany({ where: { shopDomain, locationId: location.id } }),
|
||||||
|
db.slotOverride.findMany({
|
||||||
|
where: { shopDomain, locationId: location.id, date: { gte: rangeStart, lte: rangeEnd } },
|
||||||
|
}),
|
||||||
|
db.blackoutDate.findMany({
|
||||||
|
where: { shopDomain, date: { gte: rangeStart, lte: rangeEnd }, OR: [{ locationId: location.id }, { locationId: null }] },
|
||||||
|
}),
|
||||||
|
db.booking.findMany({
|
||||||
|
where: {
|
||||||
|
shopDomain,
|
||||||
|
locationId: location.id,
|
||||||
|
status: { in: ["confirmed", "fulfilled"] },
|
||||||
|
slotStart: { gte: rangeStart, lte: rangeEnd },
|
||||||
|
},
|
||||||
|
select: { slotStart: true, method: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Per-date booking tally (all methods) drives the daily cap; per-slot
|
||||||
|
// tally is method-specific.
|
||||||
|
const consumedPerDate = new Map<string, number>();
|
||||||
|
const consumedBySlot = new Map<string, Map<string, number>>(); // method -> (date|startMin -> n)
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const local = DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(location.timezone);
|
||||||
|
const isoDate = local.toISODate()!;
|
||||||
|
consumedPerDate.set(isoDate, (consumedPerDate.get(isoDate) ?? 0) + 1);
|
||||||
|
const perMethod = consumedBySlot.get(booking.method) ?? new Map<string, number>();
|
||||||
|
const key = `${isoDate}|${local.hour * 60 + local.minute}`;
|
||||||
|
perMethod.set(key, (perMethod.get(key) ?? 0) + 1);
|
||||||
|
consumedBySlot.set(booking.method, perMethod);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const method of METHODS) {
|
||||||
|
const availability = getAvailability({
|
||||||
|
timezone: location.timezone,
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
slotTemplates: templates
|
||||||
|
.filter((t) => t.method === method)
|
||||||
|
.map((t) => ({
|
||||||
|
weekday: t.weekday,
|
||||||
|
startMin: t.startMin,
|
||||||
|
endMin: t.endMin,
|
||||||
|
capacity: t.capacity,
|
||||||
|
cutoffMin: null, // deliberately ignored here — see EPOCH note
|
||||||
|
leadTimeMin: 0,
|
||||||
|
transitMinDays: t.transitMinDays,
|
||||||
|
transitMaxDays: t.transitMaxDays,
|
||||||
|
})),
|
||||||
|
overrides: overrides
|
||||||
|
.filter((o) => o.method === method)
|
||||||
|
.map((o) => ({ date: toIsoDate(o.date), closed: o.closed, startMin: o.startMin, endMin: o.endMin, capacity: o.capacity })),
|
||||||
|
blackoutDates: blackouts
|
||||||
|
.filter((b) => b.method === null || b.method === method)
|
||||||
|
.map((b) => ({ date: toIsoDate(b.date) })),
|
||||||
|
now: EPOCH,
|
||||||
|
consumed: consumedBySlot.get(method) ?? new Map(),
|
||||||
|
dailyCap: location.dailyOrderCap,
|
||||||
|
consumedPerDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Walk every calendar day in the horizon: present -> record each slot's
|
||||||
|
// remaining capacity; absent -> the day is closed for this method.
|
||||||
|
let cursor = DateTime.fromISO(startDate, { zone: "utc" });
|
||||||
|
const last = DateTime.fromISO(endDate, { zone: "utc" });
|
||||||
|
for (; cursor <= last; cursor = cursor.plus({ days: 1 })) {
|
||||||
|
const date = cursor.toISODate()!;
|
||||||
|
const daySlots = availability[date];
|
||||||
|
if (!daySlots || daySlots.length === 0) {
|
||||||
|
closedDates[closedDateKey(location.id, method, date)] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const slot of daySlots) {
|
||||||
|
slots[slotSnapshotKey(location.id, method, date, slot.startMin)] = slot.remainingCapacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { version: 1, generatedAt: nowUtc.toISO()!, horizonDate: endDate, slots, closedDates };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the Product GIDs carrying `tag` via the Admin API (paged). */
|
||||||
|
async function resolveTaggedProductIds(admin: AdminGraphQLClient, tag: string): Promise<string[]> {
|
||||||
|
const ids: string[] = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
// Cap the sweep so a huge catalog can't stall a webhook — 2000 tagged
|
||||||
|
// products is already an extreme case for this feature.
|
||||||
|
for (let page = 0; page < 8; page += 1) {
|
||||||
|
const response: Response = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
query TaggedProducts($query: String!, $after: String) {
|
||||||
|
products(first: 250, query: $query, after: $after) {
|
||||||
|
nodes { id }
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ variables: { query: `tag:'${tag.replace(/'/g, "")}'`, after: cursor } },
|
||||||
|
);
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
data?: { products: { nodes: Array<{ id: string }>; pageInfo: { hasNextPage: boolean; endCursor: string | null } } };
|
||||||
|
};
|
||||||
|
const products = body.data?.products;
|
||||||
|
if (!products) break;
|
||||||
|
for (const node of products.nodes) ids.push(node.id);
|
||||||
|
if (!products.pageInfo.hasNextPage) break;
|
||||||
|
cursor = products.pageInfo.endCursor;
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the snapshot and writes it to the shop metafield the Validation
|
||||||
|
* Function reads. Safe to call from a webhook handler — failures are logged,
|
||||||
|
* not thrown, so a metafield hiccup never fails order processing.
|
||||||
|
*/
|
||||||
|
export async function writeCheckoutSnapshot(admin: AdminGraphQLClient, shopDomain: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const shop = await db.shop.findUnique({
|
||||||
|
where: { shopDomain },
|
||||||
|
select: { enforcementMode: true, enforcementTag: true },
|
||||||
|
});
|
||||||
|
const mode = (shop?.enforcementMode as CheckoutSnapshot["enforcement"]["mode"]) ?? "all";
|
||||||
|
|
||||||
|
const base = await buildCheckoutSnapshot(shopDomain);
|
||||||
|
|
||||||
|
const schedulableProductIds =
|
||||||
|
mode === "tagged" && shop?.enforcementTag ? await resolveTaggedProductIds(admin, shop.enforcementTag) : [];
|
||||||
|
|
||||||
|
const snapshot: CheckoutSnapshot = { ...base, enforcement: { mode }, schedulableProductIds };
|
||||||
|
|
||||||
|
const response = await admin.graphql(
|
||||||
|
`#graphql
|
||||||
|
mutation WriteCheckoutSnapshot($metafields: [MetafieldsSetInput!]!) {
|
||||||
|
metafieldsSet(metafields: $metafields) {
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
metafields: [
|
||||||
|
{
|
||||||
|
ownerId: await currentShopGid(admin),
|
||||||
|
namespace: SNAPSHOT_NAMESPACE,
|
||||||
|
key: SNAPSHOT_KEY,
|
||||||
|
type: "json",
|
||||||
|
value: JSON.stringify(snapshot),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const body = (await response.json()) as { data?: { metafieldsSet?: { userErrors: Array<{ message: string }> } } };
|
||||||
|
const errors = body.data?.metafieldsSet?.userErrors ?? [];
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error("writeCheckoutSnapshot userErrors", errors);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("writeCheckoutSnapshot failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function currentShopGid(admin: AdminGraphQLClient): Promise<string> {
|
||||||
|
const response = await admin.graphql(`#graphql
|
||||||
|
query CurrentShopId { shop { id } }`);
|
||||||
|
const body = (await response.json()) as { data?: { shop?: { id: string } } };
|
||||||
|
return body.data?.shop?.id ?? "";
|
||||||
|
}
|
||||||
@ -72,6 +72,9 @@ export async function resolveHoldRequest(shopDomain: string, params: HoldRequest
|
|||||||
return { status: 400, body: { success: false, error: "One or more items in your cart need more preparation time than this slot allows." } };
|
return { status: 400, body: { success: false, error: "One or more items in your cart need more preparation time than this slot allows." } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (ruleConstraints.blockedStartMins.includes(startMin)) {
|
||||||
|
return { status: 400, body: { success: false, error: "One or more items in your cart can't be scheduled for this time slot." } };
|
||||||
|
}
|
||||||
|
|
||||||
const confirmedCount = await db.booking.count({
|
const confirmedCount = await db.booking.count({
|
||||||
where: { shopDomain, locationId: location.id, method, slotStart: slotStart.toJSDate(), status: "confirmed" },
|
where: { shopDomain, locationId: location.id, method, slotStart: slotStart.toJSDate(), status: "confirmed" },
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export interface ProductRuleLike {
|
|||||||
allowedMethods: Method[]; // empty = unrestricted
|
allowedMethods: Method[]; // empty = unrestricted
|
||||||
leadTimeMin: number | null;
|
leadTimeMin: number | null;
|
||||||
allowedLocationIds: string[]; // empty = unrestricted
|
allowedLocationIds: string[]; // empty = unrestricted
|
||||||
|
blockedStartMins?: number[]; // slot start-minutes this rule forbids for a matching cart
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,6 +55,8 @@ export interface ProductRuleConstraints {
|
|||||||
allowedMethods: Method[] | null;
|
allowedMethods: Method[] | null;
|
||||||
/** Same null/empty-array convention as allowedMethods, for locations. */
|
/** Same null/empty-array convention as allowedMethods, for locations. */
|
||||||
allowedLocationIds: string[] | null;
|
allowedLocationIds: string[] | null;
|
||||||
|
/** Union of every matching rule's forbidden slot start-minutes (study §3.5). Empty = nothing blocked. */
|
||||||
|
blockedStartMins: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -71,12 +74,15 @@ export function resolveProductRuleConstraints(
|
|||||||
let minLeadTimeMin = 0;
|
let minLeadTimeMin = 0;
|
||||||
let allowedMethods: Method[] | null = null;
|
let allowedMethods: Method[] | null = null;
|
||||||
let allowedLocationIds: string[] | null = null;
|
let allowedLocationIds: string[] | null = null;
|
||||||
|
const blockedStartMins = new Set<number>();
|
||||||
|
|
||||||
for (const rule of matched) {
|
for (const rule of matched) {
|
||||||
if (rule.leadTimeMin != null) {
|
if (rule.leadTimeMin != null) {
|
||||||
minLeadTimeMin = Math.max(minLeadTimeMin, rule.leadTimeMin);
|
minLeadTimeMin = Math.max(minLeadTimeMin, rule.leadTimeMin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const min of rule.blockedStartMins ?? []) blockedStartMins.add(min);
|
||||||
|
|
||||||
if (rule.allowedMethods.length > 0) {
|
if (rule.allowedMethods.length > 0) {
|
||||||
allowedMethods = allowedMethods == null ? rule.allowedMethods : allowedMethods.filter((m) => rule.allowedMethods.includes(m));
|
allowedMethods = allowedMethods == null ? rule.allowedMethods : allowedMethods.filter((m) => rule.allowedMethods.includes(m));
|
||||||
}
|
}
|
||||||
@ -87,7 +93,7 @@ export function resolveProductRuleConstraints(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { minLeadTimeMin, allowedMethods, allowedLocationIds };
|
return { minLeadTimeMin, allowedMethods, allowedLocationIds, blockedStartMins: [...blockedStartMins].sort((a, b) => a - b) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -70,6 +70,25 @@ export interface GetAvailabilityInput {
|
|||||||
* every slot is treated as unconsumed until then.
|
* every slot is treated as unconsumed until then.
|
||||||
*/
|
*/
|
||||||
consumed?: Map<string, number>;
|
consumed?: Map<string, number>;
|
||||||
|
/**
|
||||||
|
* Per-day order cap (study §3.3 "a maximum number of orders … allowed in a
|
||||||
|
* given slot **or day**"): once a calendar date's own booking count reaches
|
||||||
|
* this, *every* slot on that date is hidden regardless of individual slot
|
||||||
|
* capacity. Null/undefined = no daily cap.
|
||||||
|
*/
|
||||||
|
dailyCap?: number | null;
|
||||||
|
/**
|
||||||
|
* Bookings already taken per calendar date, keyed by `IsoDate`. Only
|
||||||
|
* consulted when `dailyCap` is set. Omitted = nothing booked yet.
|
||||||
|
*/
|
||||||
|
consumedPerDate?: Map<IsoDate, number>;
|
||||||
|
/**
|
||||||
|
* Slot start-minutes the current cart's contents may not use (study §3.5
|
||||||
|
* "rules can block a specific date or time slot at checkout based on which
|
||||||
|
* products are in the cart"). Resolved from ProductRule.blockedStartMins by
|
||||||
|
* the caller. Empty/undefined = nothing blocked.
|
||||||
|
*/
|
||||||
|
blockedStartMins?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function slotKey(date: IsoDate, startMin: number): string {
|
function slotKey(date: IsoDate, startMin: number): string {
|
||||||
@ -91,6 +110,9 @@ export function getAvailability(input: GetAvailabilityInput): Record<IsoDate, Av
|
|||||||
const overrides = input.overrides ?? [];
|
const overrides = input.overrides ?? [];
|
||||||
const blackoutDates = input.blackoutDates ?? [];
|
const blackoutDates = input.blackoutDates ?? [];
|
||||||
const consumed = input.consumed ?? new Map<string, number>();
|
const consumed = input.consumed ?? new Map<string, number>();
|
||||||
|
const consumedPerDate = input.consumedPerDate ?? new Map<IsoDate, number>();
|
||||||
|
const dailyCap = input.dailyCap ?? null;
|
||||||
|
const blockedStartMins = new Set(input.blockedStartMins ?? []);
|
||||||
|
|
||||||
const blackoutSet = new Set(blackoutDates.map((b) => b.date));
|
const blackoutSet = new Set(blackoutDates.map((b) => b.date));
|
||||||
const overridesByDate = new Map(overrides.map((o) => [o.date, o]));
|
const overridesByDate = new Map(overrides.map((o) => [o.date, o]));
|
||||||
@ -106,6 +128,9 @@ export function getAvailability(input: GetAvailabilityInput): Record<IsoDate, Av
|
|||||||
for (const date of enumerateDates(dateRange.startDate, dateRange.endDate)) {
|
for (const date of enumerateDates(dateRange.startDate, dateRange.endDate)) {
|
||||||
if (blackoutSet.has(date)) continue;
|
if (blackoutSet.has(date)) continue;
|
||||||
|
|
||||||
|
// Per-day cap: the whole date is closed once its own bookings reach the cap.
|
||||||
|
if (dailyCap != null && dailyCap > 0 && (consumedPerDate.get(date) ?? 0) >= dailyCap) continue;
|
||||||
|
|
||||||
const override = overridesByDate.get(date);
|
const override = overridesByDate.get(date);
|
||||||
if (override?.closed) continue;
|
if (override?.closed) continue;
|
||||||
|
|
||||||
@ -128,8 +153,14 @@ export function getAvailability(input: GetAvailabilityInput): Record<IsoDate, Av
|
|||||||
: (templatesByWeekday.get(weekdayOf(date, timezone)) ?? []);
|
: (templatesByWeekday.get(weekdayOf(date, timezone)) ?? []);
|
||||||
|
|
||||||
const daySlots: AvailableSlot[] = [];
|
const daySlots: AvailableSlot[] = [];
|
||||||
|
const dayRemainingBudget =
|
||||||
|
dailyCap != null && dailyCap > 0 ? Math.max(0, dailyCap - (consumedPerDate.get(date) ?? 0)) : Infinity;
|
||||||
|
|
||||||
for (const def of daySlotDefs) {
|
for (const def of daySlotDefs) {
|
||||||
|
// Cart-content slot block (study §3.5): this start time is off-limits for
|
||||||
|
// what's currently in the cart.
|
||||||
|
if (blockedStartMins.has(def.startMin)) continue;
|
||||||
|
|
||||||
const start = slotDateTime(date, def.startMin, timezone);
|
const start = slotDateTime(date, def.startMin, timezone);
|
||||||
const end = slotDateTime(date, def.endMin, timezone);
|
const end = slotDateTime(date, def.endMin, timezone);
|
||||||
|
|
||||||
@ -141,10 +172,12 @@ export function getAvailability(input: GetAvailabilityInput): Record<IsoDate, Av
|
|||||||
const minimumLeadMinutes = Math.max(def.cutoffMin ?? 0, def.leadTimeMin);
|
const minimumLeadMinutes = Math.max(def.cutoffMin ?? 0, def.leadTimeMin);
|
||||||
if (minutesUntil(now, start) < minimumLeadMinutes) continue;
|
if (minutesUntil(now, start) < minimumLeadMinutes) continue;
|
||||||
|
|
||||||
const remaining = remainingCapacity({
|
const slotRemaining = remainingCapacity({
|
||||||
capacity: def.capacity,
|
capacity: def.capacity,
|
||||||
consumed: consumed.get(slotKey(date, def.startMin)) ?? 0,
|
consumed: consumed.get(slotKey(date, def.startMin)) ?? 0,
|
||||||
});
|
});
|
||||||
|
// The slot can never sell more than the day has left, when a daily cap applies.
|
||||||
|
const remaining = Math.min(slotRemaining, dayRemainingBudget);
|
||||||
if (remaining <= 0) continue;
|
if (remaining <= 0) continue;
|
||||||
|
|
||||||
daySlots.push({
|
daySlots.push({
|
||||||
|
|||||||
2
extensions/checkout-datetime/.gitignore
vendored
Normal file
2
extensions/checkout-datetime/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
43
extensions/checkout-datetime/README.md
Normal file
43
extensions/checkout-datetime/README.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# checkout-datetime
|
||||||
|
|
||||||
|
Checkout UI Extension. Three targets in one extension:
|
||||||
|
|
||||||
|
| Module | Target | Plans |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/Checkout.jsx` | `purchase.checkout.block.render` | **Plus only** — native in-checkout date/time picker |
|
||||||
|
| `src/ThankYou.jsx` | `purchase.thank-you.block.render` | all plans |
|
||||||
|
| `src/OrderStatus.jsx` | `customer-account.order-status.block.render` | all plans |
|
||||||
|
|
||||||
|
The picker calls this app's own `checkout.scheduling.availability` /
|
||||||
|
`checkout.scheduling.hold` routes (session-token auth) and draws from the
|
||||||
|
same capacity pool as the storefront widget and POS. It only *collects* a
|
||||||
|
selection into checkout attributes — the `validation-slot` Function is still
|
||||||
|
what enforces it, so the extension is not a security boundary.
|
||||||
|
|
||||||
|
The all-plan confirmation is also covered by the theme block
|
||||||
|
`extensions/datetime-widget/blocks/order-confirmation.liquid`; the Thank you
|
||||||
|
/ Order status modules here are the Plus-native version of the same thing.
|
||||||
|
|
||||||
|
## Before deploy
|
||||||
|
|
||||||
|
1. `npm --prefix extensions/checkout-datetime install` (pulls `@shopify/ui-extensions` + `preact`).
|
||||||
|
2. In the extension's settings (Partners dashboard or `shopify.extension.toml`
|
||||||
|
defaults), set **App backend URL** to the same value as `application_url`
|
||||||
|
in `shopify.app.toml` — the extension runs on a different origin and needs
|
||||||
|
the absolute URL to reach the backend.
|
||||||
|
3. `shopify app deploy`.
|
||||||
|
|
||||||
|
> The previous build output under `dist/` is gitignored and regenerated on
|
||||||
|
> deploy — this `src/` tree is the source of record.
|
||||||
|
|
||||||
|
## Type checking
|
||||||
|
|
||||||
|
`tsconfig.json` ships with `checkJs: false` because this source hasn't yet
|
||||||
|
been iterated against the live `@shopify/ui-extensions` checkout component
|
||||||
|
types (that needs the extension's deps installed and, ideally, a real
|
||||||
|
checkout to verify runtime shapes — same caveat `pos-datetime` carries).
|
||||||
|
Before deploying: `npm install` in this folder, flip `checkJs` back to
|
||||||
|
`true`, run `npm run typecheck`, and reconcile the `<s-*>` component props
|
||||||
|
against the real typings. The decision logic it depends on
|
||||||
|
(`resolveAvailabilityRequest`, `resolveHoldRequest`) is already fully
|
||||||
|
tested on the server side.
|
||||||
20
extensions/checkout-datetime/locales/en.default.json
Normal file
20
extensions/checkout-datetime/locales/en.default.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "Delivery Date & Time",
|
||||||
|
"picker_heading": "Choose your delivery date & time",
|
||||||
|
"method_shipping": "Shipping",
|
||||||
|
"method_local_delivery": "Local delivery",
|
||||||
|
"method_pickup": "Pickup",
|
||||||
|
"choose_date": "Choose a date",
|
||||||
|
"choose_time": "Choose a time",
|
||||||
|
"no_dates": "No dates are available right now.",
|
||||||
|
"loading": "Loading available dates…",
|
||||||
|
"error": "Couldn't load available dates. Please try again.",
|
||||||
|
"confirmed": "Scheduled for",
|
||||||
|
"change": "Change",
|
||||||
|
"postal_code": "Enter your postal/ZIP code",
|
||||||
|
"postal_code_submit": "Check availability",
|
||||||
|
"out_of_area": "Sorry, we don't deliver to this address.",
|
||||||
|
"confirmation_pickup": "Pickup",
|
||||||
|
"confirmation_delivery": "Delivery",
|
||||||
|
"confirmation_shipping": "Shipping"
|
||||||
|
}
|
||||||
13
extensions/checkout-datetime/package.json
Normal file
13
extensions/checkout-datetime/package.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "checkout-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"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
extensions/checkout-datetime/shopify.d.ts
vendored
Normal file
19
extensions/checkout-datetime/shopify.d.ts
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
declare module './src/OrderStatus.jsx' {
|
||||||
|
const shopify: import('@shopify/ui-extensions/customer-account.order-status.block.render').Api;
|
||||||
|
const globalThis: { shopify: typeof shopify };
|
||||||
|
}
|
||||||
33
extensions/checkout-datetime/shopify.extension.toml
Normal file
33
extensions/checkout-datetime/shopify.extension.toml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
api_version = "2025-10"
|
||||||
|
|
||||||
|
[[extensions]]
|
||||||
|
type = "ui_extension"
|
||||||
|
name = "t:name"
|
||||||
|
handle = "checkout-datetime"
|
||||||
|
description = "Native delivery date & time picker in checkout (Plus), plus confirmation blocks on the Thank you and Order status pages (all plans)."
|
||||||
|
|
||||||
|
# The picker calls this app's own backend (checkout.scheduling.* routes,
|
||||||
|
# session-token auth). A checkout extension runs on a different origin than
|
||||||
|
# the app, so it needs the app's absolute URL — set this to the value of
|
||||||
|
# `application_url` in shopify.app.toml.
|
||||||
|
[extensions.settings]
|
||||||
|
[[extensions.settings.fields]]
|
||||||
|
key = "app_url"
|
||||||
|
type = "single_line_text_field"
|
||||||
|
name = "App backend URL"
|
||||||
|
description = "https://<your-app-domain> — same as application_url in shopify.app.toml."
|
||||||
|
|
||||||
|
# --- Plus: native picker inside checkout -------------------------------------
|
||||||
|
[[extensions.targeting]]
|
||||||
|
module = "./src/Checkout.jsx"
|
||||||
|
target = "purchase.checkout.block.render"
|
||||||
|
|
||||||
|
# --- All plans: confirmation on the Thank you page -------------------------
|
||||||
|
[[extensions.targeting]]
|
||||||
|
module = "./src/ThankYou.jsx"
|
||||||
|
target = "purchase.thank-you.block.render"
|
||||||
|
|
||||||
|
# --- All plans: confirmation on the Order status page ---------------------
|
||||||
|
[[extensions.targeting]]
|
||||||
|
module = "./src/OrderStatus.jsx"
|
||||||
|
target = "customer-account.order-status.block.render"
|
||||||
173
extensions/checkout-datetime/src/Checkout.jsx
Normal file
173
extensions/checkout-datetime/src/Checkout.jsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import "@shopify/ui-extensions/preact";
|
||||||
|
import { render } from "preact";
|
||||||
|
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||||
|
import { METHODS, authedFetch, slotTimeLabel, writeAttribute, readAttribute } from "./lib.js";
|
||||||
|
|
||||||
|
export default async () => {
|
||||||
|
render(<Extension />, document.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Native in-checkout picker (Plus). Same job as the storefront widget —
|
||||||
|
// collect a method/date/slot, reserve a hold, write the dd_* attributes —
|
||||||
|
// but inside Shopify's own checkout, calling the identical
|
||||||
|
// checkout.scheduling.* backend routes so it draws from the one capacity
|
||||||
|
// pool (CLAUDE.md: behavior must never diverge between channels).
|
||||||
|
function Extension() {
|
||||||
|
const initialMethod = readAttribute(shopify, "dd_method") || "PICKUP";
|
||||||
|
|
||||||
|
const [method, setMethod] = useState(initialMethod);
|
||||||
|
const [postalCode, setPostalCode] = useState("");
|
||||||
|
const [availability, setAvailability] = useState(null);
|
||||||
|
const [date, setDate] = useState("");
|
||||||
|
const [status, setStatus] = useState("idle"); // idle|loading|error
|
||||||
|
const [confirmed, setConfirmed] = useState(readAttribute(shopify, "dd_date") || null);
|
||||||
|
|
||||||
|
const needsPostal = method === "LOCAL_DELIVERY";
|
||||||
|
const cartToken = shopify.checkoutToken?.value || `checkout-${Date.now()}`;
|
||||||
|
|
||||||
|
async function loadAvailability() {
|
||||||
|
setStatus("loading");
|
||||||
|
setAvailability(null);
|
||||||
|
setDate("");
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ method, days: "14" });
|
||||||
|
if (needsPostal && postalCode) params.set("postalCode", postalCode);
|
||||||
|
const res = await authedFetch(shopify, `/checkout/scheduling/availability?${params.toString()}`);
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok || !body.locationId) {
|
||||||
|
setStatus("idle");
|
||||||
|
setAvailability({ error: body.error || shopify.i18n.translate("out_of_area"), dates: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAvailability(body);
|
||||||
|
setStatus("idle");
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!needsPostal) loadAvailability();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [method]);
|
||||||
|
|
||||||
|
const dates = useMemo(() => Object.keys(availability?.dates ?? {}).sort(), [availability]);
|
||||||
|
const slots = date ? availability?.dates?.[date] ?? [] : [];
|
||||||
|
|
||||||
|
async function selectSlot(slot) {
|
||||||
|
setStatus("loading");
|
||||||
|
try {
|
||||||
|
const holdRes = await authedFetch(shopify, "/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) {
|
||||||
|
setStatus("idle");
|
||||||
|
setAvailability({ ...availability, error: hold.error || "That slot was just taken" });
|
||||||
|
await loadAvailability();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodMeta = METHODS.find((m) => m.value === method);
|
||||||
|
const display = `${date}, ${slotTimeLabel(slot)}`;
|
||||||
|
await Promise.all([
|
||||||
|
writeAttribute(shopify, methodMeta.attrLabel, display),
|
||||||
|
writeAttribute(shopify, "dd_method", method),
|
||||||
|
writeAttribute(shopify, "dd_date", date),
|
||||||
|
writeAttribute(shopify, "dd_start_min", slot.startMin),
|
||||||
|
writeAttribute(shopify, "dd_end_min", slot.endMin),
|
||||||
|
writeAttribute(shopify, "dd_location_id", availability.locationId),
|
||||||
|
availability.zoneId ? writeAttribute(shopify, "dd_zone_id", availability.zoneId) : Promise.resolve(),
|
||||||
|
]);
|
||||||
|
setConfirmed(display);
|
||||||
|
setStatus("idle");
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
return (
|
||||||
|
<s-stack gap="base">
|
||||||
|
<s-text>
|
||||||
|
{shopify.i18n.translate("confirmed")} {confirmed}
|
||||||
|
</s-text>
|
||||||
|
<s-button onClick={() => setConfirmed(null)}>
|
||||||
|
{shopify.i18n.translate("change")}
|
||||||
|
</s-button>
|
||||||
|
</s-stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<s-stack gap="base">
|
||||||
|
<s-heading>{shopify.i18n.translate("picker_heading")}</s-heading>
|
||||||
|
|
||||||
|
<s-choice-list
|
||||||
|
name="dd-method"
|
||||||
|
values={[method]}
|
||||||
|
onChange={(e) => setMethod(e.currentTarget.values[0])}
|
||||||
|
>
|
||||||
|
{METHODS.map((m) => (
|
||||||
|
<s-choice key={m.value} value={m.value}>
|
||||||
|
{shopify.i18n.translate(m.labelKey)}
|
||||||
|
</s-choice>
|
||||||
|
))}
|
||||||
|
</s-choice-list>
|
||||||
|
|
||||||
|
{needsPostal && (
|
||||||
|
<s-stack gap="base">
|
||||||
|
<s-text-field
|
||||||
|
label={shopify.i18n.translate("postal_code")}
|
||||||
|
value={postalCode}
|
||||||
|
onChange={(e) => setPostalCode(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<s-button onClick={loadAvailability}>
|
||||||
|
{shopify.i18n.translate("postal_code_submit")}
|
||||||
|
</s-button>
|
||||||
|
</s-stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "loading" && <s-text>{shopify.i18n.translate("loading")}</s-text>}
|
||||||
|
{status === "error" && <s-banner tone="critical">{shopify.i18n.translate("error")}</s-banner>}
|
||||||
|
{availability?.error && <s-banner tone="warning">{availability.error}</s-banner>}
|
||||||
|
|
||||||
|
{dates.length > 0 && (
|
||||||
|
<s-choice-list name="dd-date" values={date ? [date] : []} onChange={(e) => setDate(e.currentTarget.values[0])}>
|
||||||
|
{dates.map((d) => (
|
||||||
|
<s-choice key={d} value={d}>
|
||||||
|
{d}
|
||||||
|
</s-choice>
|
||||||
|
))}
|
||||||
|
</s-choice-list>
|
||||||
|
)}
|
||||||
|
{availability && !availability.error && dates.length === 0 && status === "idle" && (
|
||||||
|
<s-text>{shopify.i18n.translate("no_dates")}</s-text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{date && slots.length > 0 && (
|
||||||
|
<s-choice-list
|
||||||
|
name="dd-slot"
|
||||||
|
onChange={(e) => {
|
||||||
|
const slot = slots.find((s) => String(s.startMin) === e.currentTarget.values[0]);
|
||||||
|
if (slot) selectSlot(slot);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{slots.map((slot) => (
|
||||||
|
<s-choice key={slot.startMin} value={String(slot.startMin)}>
|
||||||
|
{slotTimeLabel(slot)}
|
||||||
|
</s-choice>
|
||||||
|
))}
|
||||||
|
</s-choice-list>
|
||||||
|
)}
|
||||||
|
</s-stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
extensions/checkout-datetime/src/OrderStatus.jsx
Normal file
21
extensions/checkout-datetime/src/OrderStatus.jsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import "@shopify/ui-extensions/preact";
|
||||||
|
import { render } from "preact";
|
||||||
|
import { confirmationText } from "./lib.js";
|
||||||
|
|
||||||
|
export default async () => {
|
||||||
|
render(<Extension />, document.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Order status page equivalent of ThankYou.jsx — lets a returning shopper
|
||||||
|
// re-check their scheduled slot after the fact (study §3.7 "Order status
|
||||||
|
// page"). Same all-plan Liquid block is the non-Plus fallback.
|
||||||
|
function Extension() {
|
||||||
|
const text = confirmationText(shopify);
|
||||||
|
if (!text) return null;
|
||||||
|
return (
|
||||||
|
<s-stack gap="base">
|
||||||
|
<s-heading>{shopify.i18n.translate("name")}</s-heading>
|
||||||
|
<s-text>{text}</s-text>
|
||||||
|
</s-stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
extensions/checkout-datetime/src/ThankYou.jsx
Normal file
22
extensions/checkout-datetime/src/ThankYou.jsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import "@shopify/ui-extensions/preact";
|
||||||
|
import { render } from "preact";
|
||||||
|
import { confirmationText } from "./lib.js";
|
||||||
|
|
||||||
|
export default async () => {
|
||||||
|
render(<Extension />, document.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Plus-only nicety layered on top of the all-plan Liquid block
|
||||||
|
// (extensions/datetime-widget/blocks/order-confirmation.liquid). Reads the
|
||||||
|
// same dd_* attributes every surface writes and echoes the scheduled slot
|
||||||
|
// straight back on the Thank you page.
|
||||||
|
function Extension() {
|
||||||
|
const text = confirmationText(shopify);
|
||||||
|
if (!text) return null;
|
||||||
|
return (
|
||||||
|
<s-stack gap="base">
|
||||||
|
<s-heading>{shopify.i18n.translate("name")}</s-heading>
|
||||||
|
<s-text>{text}</s-text>
|
||||||
|
</s-stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
extensions/checkout-datetime/src/lib.js
Normal file
75
extensions/checkout-datetime/src/lib.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
// Shared helpers for the checkout-datetime extension modules.
|
||||||
|
//
|
||||||
|
// NOTE: like extensions/pos-datetime, this is written against
|
||||||
|
// @shopify/ui-extensions' typings but is unverified against a live checkout
|
||||||
|
// session (this environment can't run one). The networking shape — a
|
||||||
|
// session-token Bearer call to the app's own checkout.scheduling.* routes —
|
||||||
|
// mirrors what those routes already expect (see
|
||||||
|
// app/routes/checkout.scheduling.availability.tsx).
|
||||||
|
|
||||||
|
export const METHODS = [
|
||||||
|
{ value: "PICKUP", labelKey: "method_pickup", attrLabel: "Pickup date" },
|
||||||
|
{ value: "LOCAL_DELIVERY", labelKey: "method_local_delivery", attrLabel: "Delivery date" },
|
||||||
|
{ value: "SHIPPING", labelKey: "method_shipping", attrLabel: "Shipping date" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export 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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slotTimeLabel(slot) {
|
||||||
|
if (slot.arrivalRangeStart && slot.arrivalRangeEnd) {
|
||||||
|
return `Arrives ${slot.arrivalRangeStart.slice(0, 10)}–${slot.arrivalRangeEnd.slice(0, 10)}`;
|
||||||
|
}
|
||||||
|
return `${minutesToDisplayTime(slot.startMin)}–${minutesToDisplayTime(slot.endMin)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trims a trailing slash so `${base}/checkout/...` never doubles up. */
|
||||||
|
export function backendBase(shopify) {
|
||||||
|
const fromSettings = shopify.settings?.value?.app_url;
|
||||||
|
return typeof fromSettings === "string" && fromSettings ? fromSettings.replace(/\/+$/, "") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authedFetch(shopify, path, options = {}) {
|
||||||
|
const base = backendBase(shopify);
|
||||||
|
const token = await shopify.sessionToken.get();
|
||||||
|
return fetch(`${base}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: { ...(options.headers || {}), Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a dd_* value out of the checkout's current attributes signal. */
|
||||||
|
export function readAttribute(shopify, key) {
|
||||||
|
const attrs = shopify.attributes?.value ?? [];
|
||||||
|
return attrs.find((a) => a.key === key)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAttribute(shopify, key, value) {
|
||||||
|
const result = await shopify.applyAttributeChange({ type: "updateAttribute", key, value: String(value) });
|
||||||
|
return result?.type === "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIRMATION_LABEL = {
|
||||||
|
PICKUP: "confirmation_pickup",
|
||||||
|
LOCAL_DELIVERY: "confirmation_delivery",
|
||||||
|
SHIPPING: "confirmation_shipping",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Shared "Pickup — Fri, Aug 25, 9:00 AM–12:00 PM" line for the TY / order-status blocks. */
|
||||||
|
export function confirmationText(shopify) {
|
||||||
|
const method = readAttribute(shopify, "dd_method");
|
||||||
|
const date = readAttribute(shopify, "dd_date");
|
||||||
|
const startMin = readAttribute(shopify, "dd_start_min");
|
||||||
|
const endMin = readAttribute(shopify, "dd_end_min");
|
||||||
|
if (!method || !date) return null;
|
||||||
|
|
||||||
|
const label = shopify.i18n.translate(CONFIRMATION_LABEL[method] ?? "confirmation_shipping");
|
||||||
|
const time =
|
||||||
|
startMin && endMin ? `, ${minutesToDisplayTime(Number(startMin))}–${minutesToDisplayTime(Number(endMin))}` : "";
|
||||||
|
return `${label}: ${date}${time}`;
|
||||||
|
}
|
||||||
16
extensions/checkout-datetime/tsconfig.json
Normal file
16
extensions/checkout-datetime/tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"//": "checkJs is off until this source has been iterated against the live @shopify/ui-extensions checkout types with the extension's deps installed (npm install here, then flip checkJs on and fix). shopify.d.ts still compiles so the target imports are validated.",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"target": "ES2020",
|
||||||
|
"checkJs": false,
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "shopify.d.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@ -106,3 +106,16 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dd-widget__preview {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-widget__preview-item {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,70 @@
|
|||||||
|
{% comment %}
|
||||||
|
Product-page placement (study §3.7 "Delivery/availability information can
|
||||||
|
surface at the product level"). Renders the same widget in read-only
|
||||||
|
"preview" mode: it shows the earliest available date per method and
|
||||||
|
collects nothing — the interactive picker still lives on the cart page.
|
||||||
|
Merchants add this block to their product template from the theme editor.
|
||||||
|
{% endcomment %}
|
||||||
|
<div
|
||||||
|
class="dd-widget dd-widget--preview"
|
||||||
|
data-dd-widget
|
||||||
|
data-mode="preview"
|
||||||
|
data-heading="{{ block.settings.heading | escape }}"
|
||||||
|
data-show-shipping="{{ block.settings.show_shipping }}"
|
||||||
|
data-show-local-delivery="{{ block.settings.show_local_delivery }}"
|
||||||
|
data-show-pickup="{{ block.settings.show_pickup }}"
|
||||||
|
data-label-shipping="{{ 'widget.method_shipping' | t | escape }}"
|
||||||
|
data-label-local-delivery="{{ 'widget.method_local_delivery' | t | escape }}"
|
||||||
|
data-label-pickup="{{ 'widget.method_pickup' | t | escape }}"
|
||||||
|
data-attr-label-shipping="{{ 'widget.date_label_shipping' | t | escape }}"
|
||||||
|
data-attr-label-local-delivery="{{ 'widget.date_label_local_delivery' | t | escape }}"
|
||||||
|
data-attr-label-pickup="{{ 'widget.date_label_pickup' | t | escape }}"
|
||||||
|
data-label-no-dates="{{ 'widget.no_dates' | t | escape }}"
|
||||||
|
data-label-loading="{{ 'widget.loading' | t | escape }}"
|
||||||
|
data-label-error="{{ 'widget.error' | t | escape }}"
|
||||||
|
data-label-earliest-prefix="{{ 'widget.earliest_prefix' | t | escape }}"
|
||||||
|
data-label-delivery-at-checkout="{{ 'widget.delivery_at_checkout' | t | escape }}"
|
||||||
|
{% if block.settings.location_id != blank %}data-location-id="{{ block.settings.location_id | escape }}"{% endif %}
|
||||||
|
{{ block.shopify_attributes }}
|
||||||
|
>
|
||||||
|
<noscript>{{ 'widget.enable_js' | t | escape }}</noscript>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% schema %}
|
||||||
|
{
|
||||||
|
"name": "t:product_availability.name",
|
||||||
|
"target": "section",
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "heading",
|
||||||
|
"label": "t:product_availability.heading_label",
|
||||||
|
"default": "Delivery & pickup availability"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "checkbox",
|
||||||
|
"id": "show_shipping",
|
||||||
|
"label": "t:datetime_picker.show_shipping_label",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "checkbox",
|
||||||
|
"id": "show_local_delivery",
|
||||||
|
"label": "t:datetime_picker.show_local_delivery_label",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "checkbox",
|
||||||
|
"id": "show_pickup",
|
||||||
|
"label": "t:datetime_picker.show_pickup_label",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "location_id",
|
||||||
|
"label": "t:datetime_picker.location_id_label",
|
||||||
|
"info": "t:datetime_picker.location_id_info"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
{% endschema %}
|
||||||
@ -16,7 +16,9 @@
|
|||||||
"enable_js": "Please enable JavaScript to choose a delivery date and time.",
|
"enable_js": "Please enable JavaScript to choose a delivery date and time.",
|
||||||
"postal_code": "Enter your postal/ZIP code",
|
"postal_code": "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.",
|
||||||
|
"earliest_prefix": "Earliest",
|
||||||
|
"delivery_at_checkout": "Enter your address at checkout to see local delivery dates."
|
||||||
},
|
},
|
||||||
"order_confirmation": {
|
"order_confirmation": {
|
||||||
"pickup_label": "Pickup",
|
"pickup_label": "Pickup",
|
||||||
|
|||||||
@ -23,6 +23,10 @@
|
|||||||
"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."
|
||||||
},
|
},
|
||||||
|
"product_availability": {
|
||||||
|
"name": "Product Availability Preview",
|
||||||
|
"heading_label": "Heading"
|
||||||
|
},
|
||||||
"order_confirmation": {
|
"order_confirmation": {
|
||||||
"name": "Delivery Confirmation"
|
"name": "Delivery Confirmation"
|
||||||
}
|
}
|
||||||
|
|||||||
28
extensions/datetime-widget/locales/fr.json
Normal file
28
extensions/datetime-widget/locales/fr.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"widget": {
|
||||||
|
"method_shipping": "Livraison postale",
|
||||||
|
"method_local_delivery": "Livraison locale",
|
||||||
|
"method_pickup": "Retrait en magasin",
|
||||||
|
"date_label_shipping": "Date d'expédition",
|
||||||
|
"date_label_local_delivery": "Date de livraison",
|
||||||
|
"date_label_pickup": "Date de retrait",
|
||||||
|
"choose_date": "Choisissez une date",
|
||||||
|
"choose_time": "Choisissez une heure",
|
||||||
|
"no_dates": "Aucune date n'est disponible pour le moment.",
|
||||||
|
"confirmed": "Confirmé pour le",
|
||||||
|
"change": "Modifier",
|
||||||
|
"loading": "Chargement des dates disponibles…",
|
||||||
|
"error": "Impossible de charger les dates disponibles. Veuillez réessayer.",
|
||||||
|
"enable_js": "Veuillez activer JavaScript pour choisir une date et une heure de livraison.",
|
||||||
|
"postal_code": "Saisissez votre code postal",
|
||||||
|
"postal_code_submit": "Vérifier la disponibilité",
|
||||||
|
"out_of_area": "Désolé, nous ne livrons pas à cette adresse.",
|
||||||
|
"earliest_prefix": "Au plus tôt",
|
||||||
|
"delivery_at_checkout": "Saisissez votre adresse au paiement pour voir les dates de livraison locale."
|
||||||
|
},
|
||||||
|
"order_confirmation": {
|
||||||
|
"pickup_label": "Retrait",
|
||||||
|
"delivery_label": "Livraison",
|
||||||
|
"shipping_label": "Expédition"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
extensions/datetime-widget/locales/fr.schema.json
Normal file
33
extensions/datetime-widget/locales/fr.schema.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"app_embed": {
|
||||||
|
"name": "Date et heure de livraison",
|
||||||
|
"auto_place_cart_label": "Afficher automatiquement sur la page du panier",
|
||||||
|
"auto_place_cart_info": "Insère le sélecteur juste avant le bouton Paiement de votre thème, sans avoir à ajouter de bloc dans l'éditeur de thème. Désactivez cette option si vous avez déjà placé le bloc « Sélecteur de date et d'heure » manuellement.",
|
||||||
|
"heading_label": "Titre",
|
||||||
|
"show_shipping_label": "Afficher la livraison postale",
|
||||||
|
"show_local_delivery_label": "Afficher la livraison locale",
|
||||||
|
"show_pickup_label": "Afficher le retrait",
|
||||||
|
"location_id_label": "Identifiant de l'emplacement (avancé)",
|
||||||
|
"location_id_info": "Laissez vide pour utiliser l'emplacement par défaut de la boutique.",
|
||||||
|
"google_maps_api_key_label": "Clé API Google Maps",
|
||||||
|
"google_maps_api_key_info": "Facultatif — affiche une carte pour les points de retrait. Restreignez cette clé au domaine de votre boutique dans la console Google Cloud."
|
||||||
|
},
|
||||||
|
"datetime_picker": {
|
||||||
|
"name": "Sélecteur de date et d'heure",
|
||||||
|
"heading_label": "Titre",
|
||||||
|
"show_shipping_label": "Afficher la livraison postale",
|
||||||
|
"show_local_delivery_label": "Afficher la livraison locale",
|
||||||
|
"show_pickup_label": "Afficher le retrait",
|
||||||
|
"location_id_label": "Identifiant de l'emplacement (avancé)",
|
||||||
|
"location_id_info": "Laissez vide pour utiliser l'emplacement par défaut de la boutique.",
|
||||||
|
"google_maps_api_key_label": "Clé API Google Maps",
|
||||||
|
"google_maps_api_key_info": "Facultatif — affiche une carte pour les points de retrait. Restreignez cette clé au domaine de votre boutique dans la console Google Cloud."
|
||||||
|
},
|
||||||
|
"product_availability": {
|
||||||
|
"name": "Aperçu de la disponibilité produit",
|
||||||
|
"heading_label": "Titre"
|
||||||
|
},
|
||||||
|
"order_confirmation": {
|
||||||
|
"name": "Confirmation de livraison"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
extensions/payment-customization/.gitignore
vendored
Normal file
2
extensions/payment-customization/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
dist
|
||||||
|
generated
|
||||||
24
extensions/payment-customization/README.md
Normal file
24
extensions/payment-customization/README.md
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# payment-customization
|
||||||
|
|
||||||
|
Shopify Function (`cart.payment-methods.transform.run`) that hides
|
||||||
|
cash-on-delivery / pay-in-store payment methods when the shopper's chosen
|
||||||
|
fulfillment method is `SHIPPING` (study §5.2 parity).
|
||||||
|
|
||||||
|
Pure decision logic lives in `src/evaluate.js` and is unit-tested at the
|
||||||
|
repo root (`tests/unit/payment-customization.test.ts`) with plain Vitest —
|
||||||
|
no WASM build needed for that.
|
||||||
|
|
||||||
|
## Before first build / deploy
|
||||||
|
|
||||||
|
`schema.graphql` and `generated/` are **not** committed (they're produced
|
||||||
|
by codegen and gitignored, same as the other Function extensions). Run:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm --prefix extensions/payment-customization run typegen
|
||||||
|
```
|
||||||
|
|
||||||
|
to pull the API schema and regenerate `generated/api.ts`, then
|
||||||
|
`shopify app deploy` picks it up. The extension also needs the
|
||||||
|
`write_payment_customizations` scope, which `shopify.app.toml` already
|
||||||
|
requests, and the merchant must activate it under
|
||||||
|
**Settings → Payments → Payment method customizations** in the admin.
|
||||||
4
extensions/payment-customization/locales/en.default.json
Normal file
4
extensions/payment-customization/locales/en.default.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "Payment Methods by Fulfillment",
|
||||||
|
"description": "Hides cash-on-delivery / pay-in-store payment methods when the order is being shipped."
|
||||||
|
}
|
||||||
35
extensions/payment-customization/package.json
Normal file
35
extensions/payment-customization/package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "payment-customization",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"shopify": "npm exec -- shopify",
|
||||||
|
"typegen": "npm exec -- shopify app function typegen",
|
||||||
|
"build": "npm exec -- shopify app function build",
|
||||||
|
"preview": "npm exec -- shopify app function run",
|
||||||
|
"test": "vitest run --passWithNoTests"
|
||||||
|
},
|
||||||
|
"codegen": {
|
||||||
|
"schema": "schema.graphql",
|
||||||
|
"documents": "src/*.graphql",
|
||||||
|
"generates": {
|
||||||
|
"./generated/api.ts": {
|
||||||
|
"plugins": [
|
||||||
|
"typescript",
|
||||||
|
"typescript-operations"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"omitOperationSuffix": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@shopify/shopify_function": "^2.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@shopify/shopify-function-test-helpers": "^1.0.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
extensions/payment-customization/shopify.extension.toml
Normal file
20
extensions/payment-customization/shopify.extension.toml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
api_version = "2026-07"
|
||||||
|
|
||||||
|
[[extensions]]
|
||||||
|
name = "t:name"
|
||||||
|
handle = "payment-customization"
|
||||||
|
type = "function"
|
||||||
|
description = "t:description"
|
||||||
|
|
||||||
|
[[extensions.targeting]]
|
||||||
|
target = "cart.payment-methods.transform.run"
|
||||||
|
input_query = "src/cart_payment_methods_transform_run.graphql"
|
||||||
|
export = "cart-payment-methods-transform-run"
|
||||||
|
|
||||||
|
[extensions.build]
|
||||||
|
command = ""
|
||||||
|
path = "dist/function.wasm"
|
||||||
|
|
||||||
|
[extensions.ui.paths]
|
||||||
|
create = "/"
|
||||||
|
details = "/"
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
query CartPaymentMethodsTransformRunInput {
|
||||||
|
cart {
|
||||||
|
ddMethod: attribute(key: "dd_method") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paymentMethods {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { paymentMethodNamesToHide } from "./evaluate.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import("../generated/api").CartPaymentMethodsTransformRunInput} CartPaymentMethodsTransformRunInput
|
||||||
|
* @typedef {import("../generated/api").CartPaymentMethodsTransformRunResult} CartPaymentMethodsTransformRunResult
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {CartPaymentMethodsTransformRunResult} */
|
||||||
|
const NO_CHANGES = { operations: [] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment Methods Transform Function — hides pay-in-person gateways on
|
||||||
|
* shipped orders (see evaluate.js for the rationale).
|
||||||
|
* @param {CartPaymentMethodsTransformRunInput} input
|
||||||
|
* @returns {CartPaymentMethodsTransformRunResult}
|
||||||
|
*/
|
||||||
|
export function cartPaymentMethodsTransformRun(input) {
|
||||||
|
const attributes = { dd_method: input.cart.ddMethod?.value };
|
||||||
|
const methods = input.paymentMethods ?? [];
|
||||||
|
|
||||||
|
const hideNames = new Set(paymentMethodNamesToHide(attributes, methods.map((m) => m.name)));
|
||||||
|
if (hideNames.size === 0) return NO_CHANGES;
|
||||||
|
|
||||||
|
const operations = methods
|
||||||
|
.filter((method) => hideNames.has(method.name))
|
||||||
|
.map((method) => ({ paymentMethodHide: { paymentMethodId: method.id } }));
|
||||||
|
|
||||||
|
return { operations };
|
||||||
|
};
|
||||||
30
extensions/payment-customization/src/evaluate.js
Normal file
30
extensions/payment-customization/src/evaluate.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Pure decision logic, kept separate from the run.js adapter so it's
|
||||||
|
// unit-testable directly with plain Vitest (see
|
||||||
|
// tests/unit/payment-customization.test.ts at the repo root) with no WASM
|
||||||
|
// build.
|
||||||
|
//
|
||||||
|
// Study §5.2 / DS parity: "payment options can be adjusted based on the
|
||||||
|
// fulfillment method chosen — e.g. hiding cash-on-delivery for a shipped
|
||||||
|
// order." A shipped order leaves the store before it's paid in person, so
|
||||||
|
// cash-on-delivery / pay-in-store gateways make no sense for it; pickup and
|
||||||
|
// local delivery keep every method.
|
||||||
|
|
||||||
|
/** Matches the common names Shopify merchants give manual "pay later / in person" gateways. */
|
||||||
|
export const DEFAULT_HIDE_PATTERN = /cash on delivery|\bc\.?o\.?d\.?\b|pay on delivery|pay on pickup|pay in store|pay in person/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, string | null | undefined>} attributes The cart's dd_* attributes.
|
||||||
|
* @param {string[]} paymentMethodNames Names of every payment method offered at checkout.
|
||||||
|
* @param {{ pattern?: RegExp }} [options]
|
||||||
|
* @returns {string[]} the subset of `paymentMethodNames` that should be hidden.
|
||||||
|
*/
|
||||||
|
export function paymentMethodNamesToHide(attributes, paymentMethodNames, options = {}) {
|
||||||
|
const pattern = options.pattern ?? DEFAULT_HIDE_PATTERN;
|
||||||
|
|
||||||
|
// Only shipped orders lose the pay-in-person options. No selection yet, or
|
||||||
|
// pickup/local delivery → touch nothing.
|
||||||
|
if (attributes.dd_method !== "SHIPPING") return [];
|
||||||
|
|
||||||
|
return paymentMethodNames.filter((name) => typeof name === "string" && pattern.test(name));
|
||||||
|
}
|
||||||
1
extensions/payment-customization/src/index.js
Normal file
1
extensions/payment-customization/src/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './cart_payment_methods_transform_run';
|
||||||
@ -1,4 +1,14 @@
|
|||||||
query CartValidationsGenerateRunInput {
|
query CartValidationsGenerateRunInput {
|
||||||
|
# Denormalized capacity + enforcement snapshot written by
|
||||||
|
# app/services/checkout-snapshot.server.ts. Functions can't query the app
|
||||||
|
# DB, so anything needed to enforce "the slot has since become invalid"
|
||||||
|
# (study §5.2) or "only enforce for tagged products" (study §3.5) is
|
||||||
|
# pushed into this metafield.
|
||||||
|
shop {
|
||||||
|
checkoutSnapshot: metafield(namespace: "delivery_datetime", key: "checkout_snapshot") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
cart {
|
cart {
|
||||||
ddMethod: attribute(key: "dd_method") {
|
ddMethod: attribute(key: "dd_method") {
|
||||||
value
|
value
|
||||||
@ -15,5 +25,15 @@ query CartValidationsGenerateRunInput {
|
|||||||
ddLocationId: attribute(key: "dd_location_id") {
|
ddLocationId: attribute(key: "dd_location_id") {
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
lines {
|
||||||
|
merchandise {
|
||||||
|
__typename
|
||||||
|
... on ProductVariant {
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { evaluateCartAttributes } from "./evaluate.js";
|
import { evaluateCheckout } from "./evaluate.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import("../generated/api").CartValidationsGenerateRunInput} CartValidationsGenerateRunInput
|
* @typedef {import("../generated/api").CartValidationsGenerateRunInput} CartValidationsGenerateRunInput
|
||||||
@ -9,6 +9,8 @@ import { evaluateCartAttributes } from "./evaluate.js";
|
|||||||
const ERROR_MESSAGE = {
|
const ERROR_MESSAGE = {
|
||||||
no_slot_selected: "Please choose a delivery date and time before checking out.",
|
no_slot_selected: "Please choose a delivery date and time before checking out.",
|
||||||
incomplete_slot_selection: "Your delivery date/time selection is incomplete — please choose it again.",
|
incomplete_slot_selection: "Your delivery date/time selection is incomplete — please choose it again.",
|
||||||
|
slot_unavailable: "The delivery slot you picked is no longer available. Please choose another.",
|
||||||
|
slot_full: "The delivery slot you picked just filled up. Please choose another.",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -26,7 +28,21 @@ export function cartValidationsGenerateRun(input) {
|
|||||||
dd_location_id: input.cart.ddLocationId?.value,
|
dd_location_id: input.cart.ddLocationId?.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = evaluateCartAttributes(attributes);
|
let snapshot = null;
|
||||||
|
try {
|
||||||
|
const raw = input.shop?.checkoutSnapshot?.value;
|
||||||
|
if (raw) snapshot = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
snapshot = null; // malformed metafield → fall back to presence-only enforcement
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartProductIds = (input.cart.lines ?? [])
|
||||||
|
.map((line) =>
|
||||||
|
line.merchandise && "product" in line.merchandise ? line.merchandise.product?.id : undefined,
|
||||||
|
)
|
||||||
|
.filter((id) => Boolean(id));
|
||||||
|
|
||||||
|
const result = evaluateCheckout({ attributes, snapshot, cartProductIds });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
operations: [
|
operations: [
|
||||||
|
|||||||
@ -10,24 +10,30 @@
|
|||||||
// storefront widget still cannot complete checkout, because this runs
|
// storefront widget still cannot complete checkout, because this runs
|
||||||
// server-side inside Shopify's own checkout, not in the browser.
|
// server-side inside Shopify's own checkout, not in the browser.
|
||||||
//
|
//
|
||||||
// Scope note (Phase 4): every order on shops that activate this Function is
|
// Two things layer on top of the original presence check, both driven by
|
||||||
// currently treated as requiring a schedule selection — there's no
|
// the shop-metafield snapshot (app/services/checkout-snapshot.server.ts),
|
||||||
// ProductRule yet (that's Phase 5) to scope enforcement to specific
|
// since the Function has no way to read the app DB:
|
||||||
// products/collections. Merchants who haven't finished configuring
|
// 1. Enforcement scope (study §3.5) — `all` blocks every order, `tagged`
|
||||||
// locations/slots simply shouldn't activate this Function in
|
// blocks only orders containing a product the merchant flagged, `off`
|
||||||
// Settings > Checkout yet, the same way any other checkout validation
|
// never blocks.
|
||||||
// works. Also out of scope for now: re-validating against a *live*
|
// 2. Live slot re-validation (study §5.2 "has since become invalid") —
|
||||||
// capacity snapshot at the moment checkout completes ("has since been
|
// even a fully-formed selection is rejected if the snapshot shows the
|
||||||
// taken" in PRODUCT_STRATEGY.md §3.1) — Functions can't call our DB, and
|
// slot is now full, blacked out, past a closed override, or over the
|
||||||
// building a metafield-snapshot refresh pipeline for that is real,
|
// location's daily cap.
|
||||||
// unscoped work. The 10-minute hold TTL (app/services/holds.server.ts) is
|
// The 10-minute hold TTL (app/services/holds.server.ts) still covers the
|
||||||
// the mitigation for that specific race in the meantime.
|
// sub-refresh-interval race; this closes the rest.
|
||||||
|
|
||||||
const REQUIRED_ATTRIBUTES = ["dd_method", "dd_date", "dd_start_min", "dd_end_min", "dd_location_id"];
|
const REQUIRED_ATTRIBUTES = ["dd_method", "dd_date", "dd_start_min", "dd_end_min", "dd_location_id"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @typedef {"no_slot_selected" | "incomplete_slot_selection" | "slot_unavailable" | "slot_full"} InvalidReason
|
||||||
|
* @typedef {{ valid: true } | { valid: false, reason: InvalidReason, missing?: string[] }} EvalResult
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The original presence check: are all five scheduling attributes set?
|
||||||
* @param {Record<string, string | null | undefined>} attributes
|
* @param {Record<string, string | null | undefined>} attributes
|
||||||
* @returns {{ valid: true } | { valid: false, reason: "no_slot_selected" | "incomplete_slot_selection", missing: string[] }}
|
* @returns {EvalResult}
|
||||||
*/
|
*/
|
||||||
export function evaluateCartAttributes(attributes) {
|
export function evaluateCartAttributes(attributes) {
|
||||||
const missing = REQUIRED_ATTRIBUTES.filter((key) => !attributes[key]);
|
const missing = REQUIRED_ATTRIBUTES.filter((key) => !attributes[key]);
|
||||||
@ -40,3 +46,66 @@ export function evaluateCartAttributes(attributes) {
|
|||||||
}
|
}
|
||||||
return { valid: false, reason: "incomplete_slot_selection", missing };
|
return { valid: false, reason: "incomplete_slot_selection", missing };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} CheckoutSnapshot
|
||||||
|
* @property {{ mode: "all" | "tagged" | "off" }} [enforcement]
|
||||||
|
* @property {string[]} [schedulableProductIds]
|
||||||
|
* @property {string} [horizonDate]
|
||||||
|
* @property {Record<string, number>} [slots]
|
||||||
|
* @property {Record<string, true>} [closedDates]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full checkout evaluation: enforcement scope + presence + live capacity.
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {Record<string, string | null | undefined>} params.attributes
|
||||||
|
* @param {CheckoutSnapshot | null | undefined} params.snapshot
|
||||||
|
* @param {string[]} [params.cartProductIds] Product GIDs of everything in the cart.
|
||||||
|
* @returns {EvalResult}
|
||||||
|
*/
|
||||||
|
export function evaluateCheckout({ attributes, snapshot, cartProductIds = [] }) {
|
||||||
|
const mode = snapshot?.enforcement?.mode ?? "all";
|
||||||
|
|
||||||
|
// 1. Does this cart need a slot at all?
|
||||||
|
if (mode === "off") return { valid: true };
|
||||||
|
if (mode === "tagged") {
|
||||||
|
const schedulable = new Set(snapshot?.schedulableProductIds ?? []);
|
||||||
|
const anyTagged = cartProductIds.some((id) => schedulable.has(id));
|
||||||
|
if (!anyTagged) return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Presence check.
|
||||||
|
const presence = evaluateCartAttributes(attributes);
|
||||||
|
if (!presence.valid) return presence;
|
||||||
|
|
||||||
|
// 3. Live capacity / calendar re-check — only when the snapshot actually
|
||||||
|
// carries slot data (a store mid-setup with no templates shouldn't have
|
||||||
|
// every checkout hard-blocked here).
|
||||||
|
const slots = snapshot?.slots ?? {};
|
||||||
|
const closedDates = snapshot?.closedDates ?? {};
|
||||||
|
const hasSlotData = Object.keys(slots).length > 0 || Object.keys(closedDates).length > 0;
|
||||||
|
if (!hasSlotData) return { valid: true };
|
||||||
|
|
||||||
|
const date = String(attributes.dd_date);
|
||||||
|
// Past the snapshot's rolling horizon we can't judge — stay permissive.
|
||||||
|
if (snapshot?.horizonDate && date > snapshot.horizonDate) return { valid: true };
|
||||||
|
|
||||||
|
const loc = String(attributes.dd_location_id);
|
||||||
|
const method = String(attributes.dd_method);
|
||||||
|
const startMin = String(parseInt(String(attributes.dd_start_min), 10));
|
||||||
|
|
||||||
|
if (closedDates[`${loc}|${method}|${date}`]) {
|
||||||
|
return { valid: false, reason: "slot_unavailable" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = slots[`${loc}|${method}|${date}|${startMin}`];
|
||||||
|
if (remaining === undefined) {
|
||||||
|
// Snapshot has slot data, just not this slot — it's gone from the schedule.
|
||||||
|
return { valid: false, reason: "slot_unavailable" };
|
||||||
|
}
|
||||||
|
if (remaining <= 0) {
|
||||||
|
return { valid: false, reason: "slot_full" };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|||||||
30
extensions/validation-slot/tests/fixtures/enforcement-off-passes.json
vendored
Normal file
30
extensions/validation-slot/tests/fixtures/enforcement-off-passes.json
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"payload": {
|
||||||
|
"export": "cart-validations-generate-run",
|
||||||
|
"target": "cart.validations.generate.run",
|
||||||
|
"input": {
|
||||||
|
"shop": {
|
||||||
|
"checkoutSnapshot": {
|
||||||
|
"value": "{\"enforcement\":{\"mode\":\"off\"},\"schedulableProductIds\":[],\"horizonDate\":\"2026-12-31\",\"slots\":{},\"closedDates\":{}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cart": {
|
||||||
|
"ddMethod": null,
|
||||||
|
"ddDate": null,
|
||||||
|
"ddStartMin": null,
|
||||||
|
"ddEndMin": null,
|
||||||
|
"ddLocationId": null,
|
||||||
|
"lines": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"validationAdd": {
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
extensions/validation-slot/tests/fixtures/stale-slot-blocks-checkout.json
vendored
Normal file
35
extensions/validation-slot/tests/fixtures/stale-slot-blocks-checkout.json
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"payload": {
|
||||||
|
"export": "cart-validations-generate-run",
|
||||||
|
"target": "cart.validations.generate.run",
|
||||||
|
"input": {
|
||||||
|
"shop": {
|
||||||
|
"checkoutSnapshot": {
|
||||||
|
"value": "{\"enforcement\":{\"mode\":\"all\"},\"schedulableProductIds\":[],\"horizonDate\":\"2026-12-31\",\"slots\":{\"loc_123|PICKUP|2026-08-25|540\":0},\"closedDates\":{}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cart": {
|
||||||
|
"ddMethod": { "value": "PICKUP" },
|
||||||
|
"ddDate": { "value": "2026-08-25" },
|
||||||
|
"ddStartMin": { "value": "540" },
|
||||||
|
"ddEndMin": { "value": "600" },
|
||||||
|
"ddLocationId": { "value": "loc_123" },
|
||||||
|
"lines": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"validationAdd": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"message": "The delivery slot you picked just filled up. Please choose another.",
|
||||||
|
"target": "$.cart"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,7 +11,13 @@ const connection = new Redis(process.env.REDIS_URL || "redis://127.0.0.1:6379",
|
|||||||
// TODO (Phase 4): hold-expiry — release a SlotHold whose TTL has passed.
|
// TODO (Phase 4): hold-expiry — release a SlotHold whose TTL has passed.
|
||||||
// TODO (Phase 9): notifications — send queued reminder/ETA messages.
|
// TODO (Phase 9): notifications — send queued reminder/ETA messages.
|
||||||
// TODO (Phase 5+): capacity-recompute — recalculate denormalized capacity
|
// TODO (Phase 5+): capacity-recompute — recalculate denormalized capacity
|
||||||
// snapshots after config changes.
|
// snapshots after config changes. The checkout snapshot
|
||||||
|
// (app/services/checkout-snapshot.server.ts) is refreshed inline on every
|
||||||
|
// order webhook and slot/blackout/enforcement edit, which covers all the
|
||||||
|
// cases that matter. A periodic sweep here would only keep the 30-day
|
||||||
|
// rolling horizon advancing on a store that takes zero orders and makes
|
||||||
|
// zero edits for a month — worth adding once offline-session storage is
|
||||||
|
// wired so this process can get an Admin client per shop.
|
||||||
const holdExpiryWorker = new Worker(
|
const holdExpiryWorker = new Worker(
|
||||||
"hold-expiry",
|
"hold-expiry",
|
||||||
async (job) => {
|
async (job) => {
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
-- Per-day order cap at a location (study §3.3).
|
||||||
|
ALTER TABLE "Location" ADD COLUMN "dailyOrderCap" INTEGER;
|
||||||
|
|
||||||
|
-- Cart-content slot blocking on a product rule (study §3.5).
|
||||||
|
ALTER TABLE "ProductRule" ADD COLUMN "blockedStartMins" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[];
|
||||||
|
|
||||||
|
-- Scopable checkout enforcement for the Cart/Checkout Validation Function (study §3.5).
|
||||||
|
ALTER TABLE "Shop" ADD COLUMN "enforcementMode" TEXT NOT NULL DEFAULT 'all';
|
||||||
|
ALTER TABLE "Shop" ADD COLUMN "enforcementTag" TEXT;
|
||||||
@ -51,6 +51,15 @@ model Shop {
|
|||||||
tier String @default("free") // free|starter|growth|pro
|
tier String @default("free") // free|starter|growth|pro
|
||||||
timezone String @default("UTC")
|
timezone String @default("UTC")
|
||||||
settings Json @default("{}") // widget copy, i18n, feature flags
|
settings Json @default("{}") // widget copy, i18n, feature flags
|
||||||
|
// Checkout enforcement scope for the Cart/Checkout Validation Function
|
||||||
|
// (study §3.5 — enforcement should be scopable, not all-or-nothing).
|
||||||
|
// "all" → every order must carry a valid slot selection
|
||||||
|
// "tagged" → only orders containing a product tagged `enforcementTag`
|
||||||
|
// "off" → the Function never blocks (widget still collects)
|
||||||
|
// The Function can't read this DB, so it's mirrored into the shop
|
||||||
|
// metafield snapshot written by checkout-snapshot.server.ts.
|
||||||
|
enforcementMode String @default("all") // all|tagged|off
|
||||||
|
enforcementTag String? // product tag that triggers enforcement when mode = "tagged"
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +72,11 @@ model Location {
|
|||||||
lng Float?
|
lng Float?
|
||||||
timezone String
|
timezone String
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
|
// Per-day order cap across every slot/method at this location (study §3.3
|
||||||
|
// "a maximum number of orders … allowed in a given slot **or day**") —
|
||||||
|
// once a calendar date reaches this many confirmed bookings, all of that
|
||||||
|
// date's slots disappear from the picker. Null = no daily cap.
|
||||||
|
dailyOrderCap Int?
|
||||||
// Maps this row to Shopify's own Location resource (gid://shopify/Location/…)
|
// Maps this row to Shopify's own Location resource (gid://shopify/Location/…)
|
||||||
// so inventory-based exclusion (Phase 5) can query stock at the right
|
// so inventory-based exclusion (Phase 5) can query stock at the right
|
||||||
// Shopify location — optional since not every merchant needs it wired up.
|
// Shopify location — optional since not every merchant needs it wired up.
|
||||||
@ -212,6 +226,11 @@ model ProductRule {
|
|||||||
allowedMethods Method[] // empty = unrestricted (any method allowed)
|
allowedMethods Method[] // empty = unrestricted (any method allowed)
|
||||||
leadTimeMin Int? // extra prep-time buffer this rule imposes, on top of the slot's own leadTimeMin
|
leadTimeMin Int? // extra prep-time buffer this rule imposes, on top of the slot's own leadTimeMin
|
||||||
allowedLocationIds String[] // empty = unrestricted (any location allowed)
|
allowedLocationIds String[] // empty = unrestricted (any location allowed)
|
||||||
|
// Slot start-minutes (minutes from local midnight) this rule forbids when a
|
||||||
|
// matching product is in the cart (study §3.5 "block a specific date or time
|
||||||
|
// slot at checkout based on which products are in the cart" — e.g. a fragile
|
||||||
|
// item that can't go on the earliest run). Empty = nothing blocked.
|
||||||
|
blockedStartMins Int[]
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
|||||||
41
tests/unit/payment-customization.test.ts
Normal file
41
tests/unit/payment-customization.test.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { paymentMethodNamesToHide, DEFAULT_HIDE_PATTERN } from "../../extensions/payment-customization/src/evaluate.js";
|
||||||
|
|
||||||
|
const METHODS = ["Credit card", "Shop Pay", "Cash on Delivery (COD)", "Pay in store"];
|
||||||
|
|
||||||
|
describe("paymentMethodNamesToHide", () => {
|
||||||
|
it("hides pay-in-person methods on a shipped order", () => {
|
||||||
|
const hidden = paymentMethodNamesToHide({ dd_method: "SHIPPING" }, METHODS);
|
||||||
|
expect(hidden.sort()).toEqual(["Cash on Delivery (COD)", "Pay in store"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides nothing for a pickup order", () => {
|
||||||
|
expect(paymentMethodNamesToHide({ dd_method: "PICKUP" }, METHODS)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides nothing for a local delivery order", () => {
|
||||||
|
expect(paymentMethodNamesToHide({ dd_method: "LOCAL_DELIVERY" }, METHODS)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides nothing when no fulfillment method has been chosen yet", () => {
|
||||||
|
expect(paymentMethodNamesToHide({}, METHODS)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves ordinary gateways alone even when shipping", () => {
|
||||||
|
const hidden = paymentMethodNamesToHide({ dd_method: "SHIPPING" }, ["Credit card", "Shop Pay", "PayPal"]);
|
||||||
|
expect(hidden).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a custom pattern", () => {
|
||||||
|
const hidden = paymentMethodNamesToHide({ dd_method: "SHIPPING" }, ["Bank transfer", "Credit card"], {
|
||||||
|
pattern: /bank transfer/i,
|
||||||
|
});
|
||||||
|
expect(hidden).toEqual(["Bank transfer"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches common COD spellings", () => {
|
||||||
|
for (const name of ["Cash on delivery", "COD", "C.O.D.", "Pay on delivery", "Pay on pickup", "Pay in person"]) {
|
||||||
|
expect(DEFAULT_HIDE_PATTERN.test(name)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -67,7 +67,7 @@ describe("matchesRule", () => {
|
|||||||
describe("resolveProductRuleConstraints", () => {
|
describe("resolveProductRuleConstraints", () => {
|
||||||
it("returns unrestricted defaults when nothing matches", () => {
|
it("returns unrestricted defaults when nothing matches", () => {
|
||||||
const result = resolveProductRuleConstraints([rule({ scopeValue: "Other" })], [product()]);
|
const result = resolveProductRuleConstraints([rule({ scopeValue: "Other" })], [product()]);
|
||||||
expect(result).toEqual({ minLeadTimeMin: 0, allowedMethods: null, allowedLocationIds: null });
|
expect(result).toEqual({ minLeadTimeMin: 0, allowedMethods: null, allowedLocationIds: null, blockedStartMins: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("takes the max lead time across multiple matching rules", () => {
|
it("takes the max lead time across multiple matching rules", () => {
|
||||||
@ -121,7 +121,16 @@ describe("resolveProductRuleConstraints", () => {
|
|||||||
rule({ scopeType: "vendor", scopeValue: "Acme", leadTimeMin: 30 }),
|
rule({ scopeType: "vendor", scopeValue: "Acme", leadTimeMin: 30 }),
|
||||||
];
|
];
|
||||||
const result = resolveProductRuleConstraints(rules, [product()]);
|
const result = resolveProductRuleConstraints(rules, [product()]);
|
||||||
expect(result).toEqual({ minLeadTimeMin: 30, allowedMethods: null, allowedLocationIds: null });
|
expect(result).toEqual({ minLeadTimeMin: 30, allowedMethods: null, allowedLocationIds: null, blockedStartMins: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unions blockedStartMins across matching rules, sorted and de-duped", () => {
|
||||||
|
const rules = [
|
||||||
|
rule({ scopeType: "vendor", scopeValue: "Acme", blockedStartMins: [540, 600] }),
|
||||||
|
rule({ scopeType: "tag", scopeValue: "fragile", blockedStartMins: [600, 480] }),
|
||||||
|
];
|
||||||
|
const result = resolveProductRuleConstraints(rules, [product()]);
|
||||||
|
expect(result.blockedStartMins).toEqual([480, 540, 600]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("multiple cart products each contribute their own matching rules", () => {
|
it("multiple cart products each contribute their own matching rules", () => {
|
||||||
|
|||||||
@ -160,6 +160,73 @@ describe("getAvailability", () => {
|
|||||||
expect(result["2024-03-04"].map((s) => s.startMin)).toEqual([9 * 60, 14 * 60]);
|
expect(result["2024-03-04"].map((s) => s.startMin)).toEqual([9 * 60, 14 * 60]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("per-day order cap (dailyCap / consumedPerDate)", () => {
|
||||||
|
it("hides an entire date once its bookings reach the cap", () => {
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-05" },
|
||||||
|
slotTemplates: WEEKDAY_TEMPLATE,
|
||||||
|
now: now("2024-03-01", 0),
|
||||||
|
dailyCap: 3,
|
||||||
|
consumedPerDate: new Map([["2024-03-04", 3]]),
|
||||||
|
});
|
||||||
|
expect(result["2024-03-04"]).toBeUndefined();
|
||||||
|
expect(result["2024-03-05"]).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps a slot's remainingCapacity to the day's remaining budget", () => {
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
||||||
|
slotTemplates: WEEKDAY_TEMPLATE, // slot capacity 5
|
||||||
|
now: now("2024-03-01", 0),
|
||||||
|
dailyCap: 4,
|
||||||
|
consumedPerDate: new Map([["2024-03-04", 3]]), // only 1 left for the whole day
|
||||||
|
});
|
||||||
|
expect(result["2024-03-04"][0].remainingCapacity).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when dailyCap is null", () => {
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
||||||
|
slotTemplates: WEEKDAY_TEMPLATE,
|
||||||
|
now: now("2024-03-01", 0),
|
||||||
|
dailyCap: null,
|
||||||
|
consumedPerDate: new Map([["2024-03-04", 999]]),
|
||||||
|
});
|
||||||
|
expect(result["2024-03-04"][0].remainingCapacity).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cart-content slot blocking (blockedStartMins)", () => {
|
||||||
|
it("drops a slot whose start minute is blocked for the cart", () => {
|
||||||
|
const templates: SlotTemplateLike[] = [
|
||||||
|
{ weekday: 1, startMin: 9 * 60, endMin: 11 * 60, capacity: 3, cutoffMin: 0, leadTimeMin: 0 },
|
||||||
|
{ weekday: 1, startMin: 14 * 60, endMin: 16 * 60, capacity: 3, cutoffMin: 0, leadTimeMin: 0 },
|
||||||
|
];
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
||||||
|
slotTemplates: templates,
|
||||||
|
now: now("2024-03-01", 0),
|
||||||
|
blockedStartMins: [9 * 60],
|
||||||
|
});
|
||||||
|
expect(result["2024-03-04"].map((s) => s.startMin)).toEqual([14 * 60]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the date entirely if every slot on it is blocked", () => {
|
||||||
|
const result = getAvailability({
|
||||||
|
timezone: ZONE,
|
||||||
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
||||||
|
slotTemplates: WEEKDAY_TEMPLATE,
|
||||||
|
now: now("2024-03-01", 0),
|
||||||
|
blockedStartMins: [9 * 60],
|
||||||
|
});
|
||||||
|
expect(result["2024-03-04"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("SHIPPING arrival range (transitMinDays/transitMaxDays)", () => {
|
describe("SHIPPING arrival range (transitMinDays/transitMaxDays)", () => {
|
||||||
it("computes arrivalRangeStart/End from the slot's start date, not left unset", () => {
|
it("computes arrivalRangeStart/End from the slot's start date, not left unset", () => {
|
||||||
const templates: SlotTemplateLike[] = [
|
const templates: SlotTemplateLike[] = [
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { evaluateCartAttributes } from "../../extensions/validation-slot/src/evaluate.js";
|
import { evaluateCartAttributes, evaluateCheckout } from "../../extensions/validation-slot/src/evaluate.js";
|
||||||
|
|
||||||
const COMPLETE = {
|
const COMPLETE = {
|
||||||
dd_method: "PICKUP",
|
dd_method: "PICKUP",
|
||||||
@ -35,6 +35,75 @@ describe("evaluateCartAttributes", () => {
|
|||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
if (result.valid) throw new Error("unreachable");
|
if (result.valid) throw new Error("unreachable");
|
||||||
expect(result.reason).toBe("incomplete_slot_selection");
|
expect(result.reason).toBe("incomplete_slot_selection");
|
||||||
expect([...result.missing].sort()).toEqual(["dd_location_id", "dd_method"]);
|
expect([...(result.missing ?? [])].sort()).toEqual(["dd_location_id", "dd_method"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A snapshot that says loc_123 PICKUP on 2026-08-25 has a 09:00 slot with 2 left.
|
||||||
|
const SNAPSHOT = {
|
||||||
|
enforcement: { mode: "all" as const },
|
||||||
|
schedulableProductIds: [] as string[],
|
||||||
|
horizonDate: "2026-09-30",
|
||||||
|
slots: { "loc_123|PICKUP|2026-08-25|540": 2 },
|
||||||
|
closedDates: {} as Record<string, true>,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("evaluateCheckout — enforcement scope", () => {
|
||||||
|
it("never blocks when the shop set enforcement to off, even with nothing selected", () => {
|
||||||
|
const result = evaluateCheckout({ attributes: {}, snapshot: { ...SNAPSHOT, enforcement: { mode: "off" } } });
|
||||||
|
expect(result).toEqual({ valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a slotless order when mode is 'all'", () => {
|
||||||
|
const result = evaluateCheckout({ attributes: {}, snapshot: SNAPSHOT });
|
||||||
|
expect(result).toMatchObject({ valid: false, reason: "no_slot_selected" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips enforcement for a 'tagged' shop when no cart product is flagged", () => {
|
||||||
|
const snapshot = { ...SNAPSHOT, enforcement: { mode: "tagged" as const }, schedulableProductIds: ["gid://shopify/Product/1"] };
|
||||||
|
const result = evaluateCheckout({ attributes: {}, snapshot, cartProductIds: ["gid://shopify/Product/999"] });
|
||||||
|
expect(result).toEqual({ valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces for a 'tagged' shop when a cart product is flagged", () => {
|
||||||
|
const snapshot = { ...SNAPSHOT, enforcement: { mode: "tagged" as const }, schedulableProductIds: ["gid://shopify/Product/1"] };
|
||||||
|
const result = evaluateCheckout({ attributes: {}, snapshot, cartProductIds: ["gid://shopify/Product/1"] });
|
||||||
|
expect(result).toMatchObject({ valid: false, reason: "no_slot_selected" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("evaluateCheckout — live slot re-validation", () => {
|
||||||
|
it("passes a complete selection that still has capacity", () => {
|
||||||
|
expect(evaluateCheckout({ attributes: COMPLETE, snapshot: SNAPSHOT })).toEqual({ valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks when the picked slot has filled up since selection", () => {
|
||||||
|
const snapshot = { ...SNAPSHOT, slots: { "loc_123|PICKUP|2026-08-25|540": 0 } };
|
||||||
|
expect(evaluateCheckout({ attributes: COMPLETE, snapshot })).toMatchObject({ valid: false, reason: "slot_full" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks when the picked date has been blacked out since selection", () => {
|
||||||
|
const snapshot = { ...SNAPSHOT, closedDates: { "loc_123|PICKUP|2026-08-25": true as const } };
|
||||||
|
expect(evaluateCheckout({ attributes: COMPLETE, snapshot })).toMatchObject({ valid: false, reason: "slot_unavailable" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks when the picked slot no longer exists in the schedule", () => {
|
||||||
|
const snapshot = { ...SNAPSHOT, slots: { "loc_123|PICKUP|2026-08-25|600": 3 } };
|
||||||
|
expect(evaluateCheckout({ attributes: COMPLETE, snapshot })).toMatchObject({ valid: false, reason: "slot_unavailable" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays permissive past the snapshot horizon", () => {
|
||||||
|
const result = evaluateCheckout({ attributes: { ...COMPLETE, dd_date: "2027-01-01" }, snapshot: SNAPSHOT });
|
||||||
|
expect(result).toEqual({ valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to presence-only when there is no snapshot at all", () => {
|
||||||
|
expect(evaluateCheckout({ attributes: COMPLETE, snapshot: null })).toEqual({ valid: true });
|
||||||
|
expect(evaluateCheckout({ attributes: {}, snapshot: null })).toMatchObject({ valid: false, reason: "no_slot_selected" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to presence-only when the snapshot carries no slot data yet (store mid-setup)", () => {
|
||||||
|
const bare = { enforcement: { mode: "all" as const }, slots: {}, closedDates: {}, horizonDate: "2026-09-30" };
|
||||||
|
expect(evaluateCheckout({ attributes: COMPLETE, snapshot: bare })).toEqual({ valid: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
{
|
{
|
||||||
"include": ["env.d.ts", "**/*.ts", "**/*.tsx"],
|
"include": ["env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"build",
|
||||||
|
"extensions/*/node_modules",
|
||||||
|
"extensions/pos-datetime",
|
||||||
|
"extensions/checkout-datetime"
|
||||||
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
@ -53,6 +53,13 @@ interface WidgetConfig {
|
|||||||
heading: string;
|
heading: string;
|
||||||
locationId: string | null;
|
locationId: string | null;
|
||||||
googleMapsApiKey: string | null;
|
googleMapsApiKey: string | null;
|
||||||
|
/**
|
||||||
|
* "full" (default): the interactive picker that reserves a hold and writes
|
||||||
|
* cart attributes. "preview": a read-only "earliest available date" line
|
||||||
|
* for the product page (study §3.7 "Delivery/availability information can
|
||||||
|
* surface at the product level") — it collects nothing.
|
||||||
|
*/
|
||||||
|
mode: "full" | "preview";
|
||||||
methods: Array<{ value: Method; label: string; attrLabel: string }>;
|
methods: Array<{ value: Method; label: string; attrLabel: string }>;
|
||||||
labels: {
|
labels: {
|
||||||
chooseDate: string;
|
chooseDate: string;
|
||||||
@ -65,6 +72,8 @@ interface WidgetConfig {
|
|||||||
postalCodeLabel: string;
|
postalCodeLabel: string;
|
||||||
postalCodeSubmit: string;
|
postalCodeSubmit: string;
|
||||||
outOfArea: string;
|
outOfArea: string;
|
||||||
|
earliestPrefix: string;
|
||||||
|
deliveryAtCheckout: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,6 +143,7 @@ function readConfig(root: HTMLElement): WidgetConfig {
|
|||||||
heading: d.heading || "",
|
heading: d.heading || "",
|
||||||
locationId: d.locationId || null,
|
locationId: d.locationId || null,
|
||||||
googleMapsApiKey: d.googleMapsApiKey || null,
|
googleMapsApiKey: d.googleMapsApiKey || null,
|
||||||
|
mode: d.mode === "preview" ? "preview" : "full",
|
||||||
methods,
|
methods,
|
||||||
labels: {
|
labels: {
|
||||||
chooseDate: d.labelChooseDate || "Choose a date",
|
chooseDate: d.labelChooseDate || "Choose a date",
|
||||||
@ -146,6 +156,8 @@ function readConfig(root: HTMLElement): WidgetConfig {
|
|||||||
postalCodeLabel: d.labelPostalCode || "Enter your postal/ZIP code",
|
postalCodeLabel: d.labelPostalCode || "Enter your postal/ZIP code",
|
||||||
postalCodeSubmit: d.labelPostalCodeSubmit || "Check availability",
|
postalCodeSubmit: d.labelPostalCodeSubmit || "Check availability",
|
||||||
outOfArea: d.labelOutOfArea || "Sorry, we don't deliver to this address.",
|
outOfArea: d.labelOutOfArea || "Sorry, we don't deliver to this address.",
|
||||||
|
earliestPrefix: d.labelEarliestPrefix || "Earliest",
|
||||||
|
deliveryAtCheckout: d.labelDeliveryAtCheckout || "Enter your address at checkout to see local delivery dates.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -159,6 +171,7 @@ interface CartLineInfo {
|
|||||||
vendor: string;
|
vendor: string;
|
||||||
productType: string;
|
productType: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
|
variantId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CartInfo {
|
interface CartInfo {
|
||||||
@ -170,7 +183,7 @@ async function fetchCartInfo(): Promise<CartInfo> {
|
|||||||
const res = await fetch("/cart.js", { headers: { Accept: "application/json" } });
|
const res = await fetch("/cart.js", { headers: { Accept: "application/json" } });
|
||||||
const cart = (await res.json()) as {
|
const cart = (await res.json()) as {
|
||||||
token: string;
|
token: string;
|
||||||
items?: Array<{ vendor?: string; product_type?: string; product_id: number }>;
|
items?: Array<{ vendor?: string; product_type?: string; product_id: number; id: number; variant_id?: number }>;
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
token: cart.token,
|
token: cart.token,
|
||||||
@ -178,6 +191,9 @@ async function fetchCartInfo(): Promise<CartInfo> {
|
|||||||
vendor: item.vendor ?? "",
|
vendor: item.vendor ?? "",
|
||||||
productType: item.product_type ?? "",
|
productType: item.product_type ?? "",
|
||||||
productId: `gid://shopify/Product/${item.product_id}`,
|
productId: `gid://shopify/Product/${item.product_id}`,
|
||||||
|
// /cart.js line `id` is the variant id; `variant_id` is present on some
|
||||||
|
// theme payloads too — prefer whichever we get.
|
||||||
|
variantId: `gid://shopify/ProductVariant/${item.variant_id ?? item.id}`,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -194,6 +210,7 @@ async function fetchAvailability(
|
|||||||
if (cart.lines.length > 0) {
|
if (cart.lines.length > 0) {
|
||||||
params.set("cartLines", JSON.stringify(cart.lines.map((l) => ({ vendor: l.vendor, productType: l.productType }))));
|
params.set("cartLines", JSON.stringify(cart.lines.map((l) => ({ vendor: l.vendor, productType: l.productType }))));
|
||||||
params.set("productIds", cart.lines.map((l) => l.productId).join(","));
|
params.set("productIds", cart.lines.map((l) => l.productId).join(","));
|
||||||
|
params.set("variantIds", cart.lines.map((l) => l.variantId).join(","));
|
||||||
}
|
}
|
||||||
const res = await fetch(`${PROXY_BASE}/availability?${params.toString()}`, {
|
const res = await fetch(`${PROXY_BASE}/availability?${params.toString()}`, {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
@ -305,6 +322,11 @@ class DateTimeWidget {
|
|||||||
|
|
||||||
if (methods.length === 0) return; // merchant disabled every method — render nothing
|
if (methods.length === 0) return; // merchant disabled every method — render nothing
|
||||||
|
|
||||||
|
if (this.config.mode === "preview") {
|
||||||
|
void this.mountPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (heading) {
|
if (heading) {
|
||||||
this.el.heading.className = "dd-widget__heading";
|
this.el.heading.className = "dd-widget__heading";
|
||||||
this.el.heading.textContent = heading;
|
this.el.heading.textContent = heading;
|
||||||
@ -339,6 +361,53 @@ class DateTimeWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product-page preview (study §3.7): a read-only "earliest available
|
||||||
|
* <method> date" line per method. Collects nothing, reserves nothing —
|
||||||
|
* the real picker on the cart page does that.
|
||||||
|
*/
|
||||||
|
private async mountPreview() {
|
||||||
|
const { root, heading, methods } = this.config;
|
||||||
|
|
||||||
|
if (heading) {
|
||||||
|
this.el.heading.className = "dd-widget__heading";
|
||||||
|
this.el.heading.textContent = heading;
|
||||||
|
root.appendChild(this.el.heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
list.className = "dd-widget__preview";
|
||||||
|
root.appendChild(list);
|
||||||
|
|
||||||
|
const cart = await fetchCartInfo().catch(() => ({ token: "", lines: [] as CartLineInfo[] }));
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
methods.map(async (method) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "dd-widget__preview-item";
|
||||||
|
|
||||||
|
if (method.value === "LOCAL_DELIVERY") {
|
||||||
|
li.textContent = this.config.labels.deliveryAtCheckout;
|
||||||
|
list.appendChild(li);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.textContent = this.config.labels.loading;
|
||||||
|
list.appendChild(li);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const availability = await fetchAvailability(method.value, this.config.locationId, cart);
|
||||||
|
const earliest = Object.keys(availability.dates ?? {}).sort()[0];
|
||||||
|
li.textContent = earliest
|
||||||
|
? `${this.config.labels.earliestPrefix} ${method.label.toLowerCase()}: ${formatDateLabel(earliest)}`
|
||||||
|
: `${method.label}: ${this.config.labels.noDates}`;
|
||||||
|
} catch {
|
||||||
|
li.textContent = `${method.label}: ${this.config.labels.error}`;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private renderMethods() {
|
private renderMethods() {
|
||||||
this.el.methodRow.innerHTML = "";
|
this.el.methodRow.innerHTML = "";
|
||||||
for (const method of this.config.methods) {
|
for (const method of this.config.methods) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user