From a2c78d703f267343507a1d13a917309d93d2e10c Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 4 Sep 2026 01:31:02 -0400 Subject: [PATCH] feat: close DS study coverage gaps (inventory exclusion, live checkout re-validation, per-day cap, payment fn, checkout ext) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- IMPLEMENTATION_REVIEW_2026-09-04.md | 195 ++++++++++++++ app/lib/cart-rule-params.ts | 5 + app/routes/app.blackouts._index.tsx | 5 +- app/routes/app.locations.$id.tsx | 25 +- app/routes/app.rules._index.tsx | 38 +++ app/routes/app.settings.tsx | 120 +++++++++ app/routes/app.slots._index.tsx | 5 +- app/routes/app.tsx | 1 + app/routes/apps.scheduling.availability.tsx | 6 +- app/routes/webhooks.orders.cancelled.tsx | 9 +- app/routes/webhooks.orders.create.tsx | 8 + app/routes/webhooks.orders.updated.tsx | 33 ++- app/services/availability-request.server.ts | 58 ++++- app/services/checkout-snapshot.server.ts | 241 ++++++++++++++++++ app/services/hold-request.server.ts | 3 + app/services/product-rules.server.ts | 8 +- app/services/scheduling.server.ts | 35 ++- extensions/checkout-datetime/.gitignore | 2 + extensions/checkout-datetime/README.md | 43 ++++ .../checkout-datetime/locales/en.default.json | 20 ++ extensions/checkout-datetime/package.json | 13 + extensions/checkout-datetime/shopify.d.ts | 19 ++ .../checkout-datetime/shopify.extension.toml | 33 +++ extensions/checkout-datetime/src/Checkout.jsx | 173 +++++++++++++ .../checkout-datetime/src/OrderStatus.jsx | 21 ++ extensions/checkout-datetime/src/ThankYou.jsx | 22 ++ extensions/checkout-datetime/src/lib.js | 75 ++++++ extensions/checkout-datetime/tsconfig.json | 16 ++ .../assets/datetime-widget.css | 13 + .../datetime-widget/assets/datetime-widget.js | 2 +- .../blocks/product-availability.liquid | 70 +++++ .../datetime-widget/locales/en.default.json | 4 +- .../locales/en.default.schema.json | 4 + extensions/datetime-widget/locales/fr.json | 28 ++ .../datetime-widget/locales/fr.schema.json | 33 +++ extensions/payment-customization/.gitignore | 2 + extensions/payment-customization/README.md | 24 ++ .../locales/en.default.json | 4 + extensions/payment-customization/package.json | 35 +++ .../shopify.extension.toml | 20 ++ ...cart_payment_methods_transform_run.graphql | 11 + .../src/cart_payment_methods_transform_run.js | 30 +++ .../payment-customization/src/evaluate.js | 30 +++ extensions/payment-customization/src/index.js | 1 + .../src/cart_validations_generate_run.graphql | 20 ++ .../src/cart_validations_generate_run.js | 20 +- extensions/validation-slot/src/evaluate.js | 95 ++++++- .../fixtures/enforcement-off-passes.json | 30 +++ .../fixtures/stale-slot-blocks-checkout.json | 35 +++ jobs/worker.ts | 8 +- .../20260904120000_review_gaps/migration.sql | 9 + prisma/schema.prisma | 19 ++ tests/unit/payment-customization.test.ts | 41 +++ tests/unit/product-rules.test.ts | 13 +- tests/unit/scheduling.test.ts | 67 +++++ tests/unit/validation-slot.test.ts | 73 +++++- tsconfig.json | 7 + widget-src/datetime-widget/datetime-widget.ts | 71 +++++- 58 files changed, 2009 insertions(+), 42 deletions(-) create mode 100644 IMPLEMENTATION_REVIEW_2026-09-04.md create mode 100644 app/routes/app.settings.tsx create mode 100644 app/services/checkout-snapshot.server.ts create mode 100644 extensions/checkout-datetime/.gitignore create mode 100644 extensions/checkout-datetime/README.md create mode 100644 extensions/checkout-datetime/locales/en.default.json create mode 100644 extensions/checkout-datetime/package.json create mode 100644 extensions/checkout-datetime/shopify.d.ts create mode 100644 extensions/checkout-datetime/shopify.extension.toml create mode 100644 extensions/checkout-datetime/src/Checkout.jsx create mode 100644 extensions/checkout-datetime/src/OrderStatus.jsx create mode 100644 extensions/checkout-datetime/src/ThankYou.jsx create mode 100644 extensions/checkout-datetime/src/lib.js create mode 100644 extensions/checkout-datetime/tsconfig.json create mode 100644 extensions/datetime-widget/blocks/product-availability.liquid create mode 100644 extensions/datetime-widget/locales/fr.json create mode 100644 extensions/datetime-widget/locales/fr.schema.json create mode 100644 extensions/payment-customization/.gitignore create mode 100644 extensions/payment-customization/README.md create mode 100644 extensions/payment-customization/locales/en.default.json create mode 100644 extensions/payment-customization/package.json create mode 100644 extensions/payment-customization/shopify.extension.toml create mode 100644 extensions/payment-customization/src/cart_payment_methods_transform_run.graphql create mode 100644 extensions/payment-customization/src/cart_payment_methods_transform_run.js create mode 100644 extensions/payment-customization/src/evaluate.js create mode 100644 extensions/payment-customization/src/index.js create mode 100644 extensions/validation-slot/tests/fixtures/enforcement-off-passes.json create mode 100644 extensions/validation-slot/tests/fixtures/stale-slot-blocks-checkout.json create mode 100644 prisma/migrations/20260904120000_review_gaps/migration.sql create mode 100644 tests/unit/payment-customization.test.ts diff --git a/IMPLEMENTATION_REVIEW_2026-09-04.md b/IMPLEMENTATION_REVIEW_2026-09-04.md new file mode 100644 index 0000000..a8224b7 --- /dev/null +++ b/IMPLEMENTATION_REVIEW_2026-09-04.md @@ -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 `` 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). diff --git a/app/lib/cart-rule-params.ts b/app/lib/cart-rule-params.ts index 7d2d142..02dd6f9 100644 --- a/app/lib/cart-rule-params.ts +++ b/app/lib/cart-rule-params.ts @@ -26,6 +26,11 @@ export function parseCartLinesParam(raw: string | null | undefined): CartLinePar /** `productIds` is a comma-separated list of Shopify Product GIDs. */ 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 []; return raw .split(",") diff --git a/app/routes/app.blackouts._index.tsx b/app/routes/app.blackouts._index.tsx index 4c9ea9d..acb98ae 100644 --- a/app/routes/app.blackouts._index.tsx +++ b/app/routes/app.blackouts._index.tsx @@ -17,6 +17,7 @@ import { TitleBar } from "@shopify/app-bridge-react"; import type { Method } from "@prisma/client"; import { authenticate } from "../shopify.server"; import db from "../db.server"; +import { writeCheckoutSnapshot } from "../services/checkout-snapshot.server"; const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]; @@ -36,13 +37,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; 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 intent = formData.get("intent"); if (intent === "delete") { const id = String(formData.get("id") || ""); await db.blackoutDate.deleteMany({ where: { id, shopDomain: session.shop } }); + await writeCheckoutSnapshot(admin, session.shop); return data({ ok: true }); } @@ -65,6 +67,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { }, }); + await writeCheckoutSnapshot(admin, session.shop); return data({ ok: true }); }; diff --git a/app/routes/app.locations.$id.tsx b/app/routes/app.locations.$id.tsx index 37f80a2..ff00464 100644 --- a/app/routes/app.locations.$id.tsx +++ b/app/routes/app.locations.$id.tsx @@ -15,6 +15,7 @@ import { TitleBar } from "@shopify/app-bridge-react"; import { authenticate } from "../shopify.server"; import db from "../db.server"; import { geocodeAddress } from "../services/zones.server"; +import { writeCheckoutSnapshot } from "../services/checkout-snapshot.server"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { session } = await authenticate.admin(request); @@ -31,12 +32,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; 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 intent = formData.get("intent"); if (intent === "delete") { await db.location.deleteMany({ where: { id: params.id, shopDomain: session.shop } }); + await writeCheckoutSnapshot(admin, session.shop); return redirect("/app/locations"); } @@ -45,10 +47,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const timezone = String(formData.get("timezone") || "").trim(); const active = formData.get("active") === "true"; const shopifyLocationId = String(formData.get("shopifyLocationId") || "").trim() || null; + const dailyOrderCapRaw = String(formData.get("dailyOrderCap") || "").trim(); + const dailyOrderCap = dailyOrderCapRaw ? Number(dailyOrderCapRaw) : null; const errors: Record = {}; if (!name) errors.name = "Name 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) { return { errors }; } @@ -66,10 +73,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { timezone, active, shopifyLocationId, + dailyOrderCap, ...(coordinates ? { lat: coordinates.lat, lng: coordinates.lng } : {}), }, }); + await writeCheckoutSnapshot(admin, session.shop); return { errors }; }; @@ -99,6 +108,7 @@ function LocationForm({ timezone: string; active: boolean; shopifyLocationId: string | null; + dailyOrderCap: number | null; lat: number | null; lng: number | null; }; @@ -110,6 +120,9 @@ function LocationForm({ const [timezone, setTimezone] = useState(location.timezone); const [active, setActive] = useState(location.active); const [shopifyLocationId, setShopifyLocationId] = useState(location.shopifyLocationId ?? ""); + const [dailyOrderCap, setDailyOrderCap] = useState( + location.dailyOrderCap != null ? String(location.dailyOrderCap) : "", + ); return ( @@ -155,6 +168,16 @@ function LocationForm({ onChange={setShopifyLocationId} helpText="gid://shopify/Location/… — only needed for inventory-based location exclusion." /> + + + + + + + + + Live slot re-validation + + + 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. + + + + + + ); +} diff --git a/app/routes/app.slots._index.tsx b/app/routes/app.slots._index.tsx index 7b8939b..e8f5ccd 100644 --- a/app/routes/app.slots._index.tsx +++ b/app/routes/app.slots._index.tsx @@ -17,6 +17,7 @@ import { TitleBar } from "@shopify/app-bridge-react"; import type { Method } from "@prisma/client"; import { authenticate } from "../shopify.server"; import db from "../db.server"; +import { writeCheckoutSnapshot } from "../services/checkout-snapshot.server"; const WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]; @@ -57,7 +58,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; 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 intent = formData.get("intent"); const locationId = String(formData.get("locationId") || ""); @@ -65,6 +66,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { if (intent === "delete") { const id = String(formData.get("id") || ""); await db.slotTemplate.deleteMany({ where: { id, shopDomain: session.shop } }); + await writeCheckoutSnapshot(admin, session.shop); return data({ ok: true }); } @@ -108,6 +110,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { }, }); + await writeCheckoutSnapshot(admin, session.shop); return data({ ok: true }); }; diff --git a/app/routes/app.tsx b/app/routes/app.tsx index 9d13e76..1175044 100644 --- a/app/routes/app.tsx +++ b/app/routes/app.tsx @@ -31,6 +31,7 @@ export default function App() { Delivery rates Product rules Dispatch dashboard + Checkout enforcement Billing Help diff --git a/app/routes/apps.scheduling.availability.tsx b/app/routes/apps.scheduling.availability.tsx index ba2e531..66785a5 100644 --- a/app/routes/apps.scheduling.availability.tsx +++ b/app/routes/apps.scheduling.availability.tsx @@ -3,7 +3,7 @@ import type { Method } from "@prisma/client"; import { authenticate } from "../shopify.server"; import { resolveAvailabilityRequest } from "../services/availability-request.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 // 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, cartLines: parseCartLinesParam(url.searchParams.get("cartLines")), 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) { diff --git a/app/routes/webhooks.orders.cancelled.tsx b/app/routes/webhooks.orders.cancelled.tsx index c00b527..968593e 100644 --- a/app/routes/webhooks.orders.cancelled.tsx +++ b/app/routes/webhooks.orders.cancelled.tsx @@ -1,12 +1,13 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; import db from "../db.server"; +import { writeCheckoutSnapshot } from "../services/checkout-snapshot.server"; // Marks the linked Booking cancelled, which frees its capacity for // getAvailability()/the hold endpoint's confirmedCount check on the next // request — see IMPLEMENTATION_PLAN.md §5.2. 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}`); const order = payload as unknown as { admin_graphql_api_id: string }; @@ -15,5 +16,11 @@ export const action = async ({ request }: ActionFunctionArgs) => { 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(); }; diff --git a/app/routes/webhooks.orders.create.tsx b/app/routes/webhooks.orders.create.tsx index f9d3333..aa5dc64 100644 --- a/app/routes/webhooks.orders.create.tsx +++ b/app/routes/webhooks.orders.create.tsx @@ -1,6 +1,7 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.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 // 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; 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) { const bookingSummary = summarizeBookingAttributes(order); if (bookingSummary) { diff --git a/app/routes/webhooks.orders.updated.tsx b/app/routes/webhooks.orders.updated.tsx index b20cd88..2243021 100644 --- a/app/routes/webhooks.orders.updated.tsx +++ b/app/routes/webhooks.orders.updated.tsx @@ -1,12 +1,37 @@ import type { ActionFunctionArgs } from "@remix-run/node"; 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) -// back from order updates. See IMPLEMENTATION_PLAN.md §5.2. +// Keeps Booking.status roughly in step with the order's fulfillment/cancel +// state, and refreshes the checkout capacity snapshot the Validation +// Function reads. See IMPLEMENTATION_PLAN.md §5.2. 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(); }; diff --git a/app/services/availability-request.server.ts b/app/services/availability-request.server.ts index 78d6aea..dbea4d1 100644 --- a/app/services/availability-request.server.ts +++ b/app/services/availability-request.server.ts @@ -2,7 +2,12 @@ import { DateTime } from "luxon"; import type { Method } from "@prisma/client"; import db from "../db.server"; import { getAvailability, type AvailableSlot } from "./scheduling.server"; -import { findEligibleLocationsForDelivery, meetsDeliveryDensity } from "./zones.server"; +import { + findEligibleLocationsForDelivery, + meetsDeliveryDensity, + excludeLocationsWithoutStock, + type AdminGraphQLClient, +} from "./zones.server"; import { resolveRate } from "./rates.server"; import { formatPriceLabel } from "../lib/currency"; import { resolveProductRuleConstraints, productRefsFromCartLines, type ProductRef } from "./product-rules.server"; @@ -35,6 +40,15 @@ export interface AvailabilityRequestParams { */ cartLines?: Array<{ vendor?: string; productType?: string }>; 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 { @@ -91,6 +105,15 @@ export async function resolveAvailabilityRequest( let zoneId: string | 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> => + stockCheckEnabled + ? excludeLocationsWithoutStock(params.admin!, locationIds, params.productVariantGids!) + : new Set(locationIds); + // Delivery-zone auto-assignment (IMPLEMENTATION_PLAN.md Phase 5): route to // the nearest eligible, density-qualified zone for the shopper's address. // Falls through to the plain location lookup below for PICKUP/SHIPPING, @@ -102,10 +125,12 @@ export async function resolveAvailabilityRequest( ? await db.zone.findMany({ where: { id: { in: matches.map((m) => m.zoneId) } } }) : []; 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) { if (locationIdParam && match.locationId !== locationIdParam) 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); if (!zone) continue; // 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 { const locationWhere = ruleConstraints.allowedLocationIds != null ? { id: { in: ruleConstraints.allowedLocationIds } } : {}; - location = locationIdParam - ? await db.location.findFirst({ where: { id: locationIdParam, shopDomain, active: true, ...locationWhere } }) - : await db.location.findFirst({ where: { shopDomain, active: true, ...locationWhere }, orderBy: { createdAt: "asc" } }); + const candidates = await db.location.findMany({ + where: locationIdParam + ? { 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) { @@ -147,7 +177,9 @@ export async function resolveAvailabilityRequest( error: ruleConstraints.allowedLocationIds != null ? "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: { shopDomain, 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"] }, slotStart: { gte: bookingRangeStart, lte: bookingRangeEnd }, }, - select: { slotStart: true }, + select: { slotStart: true, method: true }, }), db.rate.findMany({ where: { shopDomain, method } }), ]); const consumed = new Map(); + const consumedPerDate = new Map(); for (const booking of bookings) { const local = DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(location.timezone); - const key = slotKey(local.toISODate()!, local.hour * 60 + local.minute); + 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); } @@ -221,6 +260,9 @@ export async function resolveAvailabilityRequest( blackoutDates: blackouts.map((b) => ({ date: toIsoDate(b.date) })), now, consumed, + dailyCap: location.dailyOrderCap, + consumedPerDate, + blockedStartMins: ruleConstraints.blockedStartMins, }); const matchedRate = resolveRate(rates, { method, zoneId: zoneId ?? undefined, distanceKm: distanceKm ?? undefined }); diff --git a/app/services/checkout-snapshot.server.ts b/app/services/checkout-snapshot.server.ts new file mode 100644 index 0000000..6833dec --- /dev/null +++ b/app/services/checkout-snapshot.server.ts @@ -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; + /** `${locationId}|${METHOD}|${YYYY-MM-DD}` -> true when that day has no bookable slot (blackout / closed / daily cap / no template). */ + closedDates: Record; +} + +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> { + 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 = {}; + const closedDates: Record = {}; + + 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(); + const consumedBySlot = new Map>(); // 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(); + 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 { + 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 { + 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 { + 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 ?? ""; +} diff --git a/app/services/hold-request.server.ts b/app/services/hold-request.server.ts index 390203f..f9af73e 100644 --- a/app/services/hold-request.server.ts +++ b/app/services/hold-request.server.ts @@ -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." } }; } } + 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({ where: { shopDomain, locationId: location.id, method, slotStart: slotStart.toJSDate(), status: "confirmed" }, diff --git a/app/services/product-rules.server.ts b/app/services/product-rules.server.ts index de52123..9055684 100644 --- a/app/services/product-rules.server.ts +++ b/app/services/product-rules.server.ts @@ -14,6 +14,7 @@ export interface ProductRuleLike { allowedMethods: Method[]; // empty = unrestricted leadTimeMin: number | null; allowedLocationIds: string[]; // empty = unrestricted + blockedStartMins?: number[]; // slot start-minutes this rule forbids for a matching cart active: boolean; } @@ -54,6 +55,8 @@ export interface ProductRuleConstraints { allowedMethods: Method[] | null; /** Same null/empty-array convention as allowedMethods, for locations. */ 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 allowedMethods: Method[] | null = null; let allowedLocationIds: string[] | null = null; + const blockedStartMins = new Set(); for (const rule of matched) { if (rule.leadTimeMin != null) { minLeadTimeMin = Math.max(minLeadTimeMin, rule.leadTimeMin); } + for (const min of rule.blockedStartMins ?? []) blockedStartMins.add(min); + if (rule.allowedMethods.length > 0) { 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) }; } /** diff --git a/app/services/scheduling.server.ts b/app/services/scheduling.server.ts index d461e86..55df732 100644 --- a/app/services/scheduling.server.ts +++ b/app/services/scheduling.server.ts @@ -70,6 +70,25 @@ export interface GetAvailabilityInput { * every slot is treated as unconsumed until then. */ consumed?: Map; + /** + * 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; + /** + * 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 { @@ -91,6 +110,9 @@ export function getAvailability(input: GetAvailabilityInput): Record(); + const consumedPerDate = input.consumedPerDate ?? new Map(); + const dailyCap = input.dailyCap ?? null; + const blockedStartMins = new Set(input.blockedStartMins ?? []); const blackoutSet = new Set(blackoutDates.map((b) => b.date)); const overridesByDate = new Map(overrides.map((o) => [o.date, o])); @@ -106,6 +128,9 @@ export function getAvailability(input: GetAvailabilityInput): Record 0 && (consumedPerDate.get(date) ?? 0) >= dailyCap) continue; + const override = overridesByDate.get(date); if (override?.closed) continue; @@ -128,8 +153,14 @@ export function getAvailability(input: GetAvailabilityInput): Record 0 ? Math.max(0, dailyCap - (consumedPerDate.get(date) ?? 0)) : Infinity; 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 end = slotDateTime(date, def.endMin, timezone); @@ -141,10 +172,12 @@ export function getAvailability(input: GetAvailabilityInput): Record 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 `` component props +against the real typings. The decision logic it depends on +(`resolveAvailabilityRequest`, `resolveHoldRequest`) is already fully +tested on the server side. diff --git a/extensions/checkout-datetime/locales/en.default.json b/extensions/checkout-datetime/locales/en.default.json new file mode 100644 index 0000000..b4cbb96 --- /dev/null +++ b/extensions/checkout-datetime/locales/en.default.json @@ -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" +} diff --git a/extensions/checkout-datetime/package.json b/extensions/checkout-datetime/package.json new file mode 100644 index 0000000..49d9a6c --- /dev/null +++ b/extensions/checkout-datetime/package.json @@ -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" + } +} diff --git a/extensions/checkout-datetime/shopify.d.ts b/extensions/checkout-datetime/shopify.d.ts new file mode 100644 index 0000000..c7be4e3 --- /dev/null +++ b/extensions/checkout-datetime/shopify.d.ts @@ -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 }; +} diff --git a/extensions/checkout-datetime/shopify.extension.toml b/extensions/checkout-datetime/shopify.extension.toml new file mode 100644 index 0000000..6ac8612 --- /dev/null +++ b/extensions/checkout-datetime/shopify.extension.toml @@ -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:// — 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" diff --git a/extensions/checkout-datetime/src/Checkout.jsx b/extensions/checkout-datetime/src/Checkout.jsx new file mode 100644 index 0000000..fb16c38 --- /dev/null +++ b/extensions/checkout-datetime/src/Checkout.jsx @@ -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(, 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 ( + + + {shopify.i18n.translate("confirmed")} {confirmed} + + setConfirmed(null)}> + {shopify.i18n.translate("change")} + + + ); + } + + return ( + + {shopify.i18n.translate("picker_heading")} + + setMethod(e.currentTarget.values[0])} + > + {METHODS.map((m) => ( + + {shopify.i18n.translate(m.labelKey)} + + ))} + + + {needsPostal && ( + + setPostalCode(e.currentTarget.value)} + /> + + {shopify.i18n.translate("postal_code_submit")} + + + )} + + {status === "loading" && {shopify.i18n.translate("loading")}} + {status === "error" && {shopify.i18n.translate("error")}} + {availability?.error && {availability.error}} + + {dates.length > 0 && ( + setDate(e.currentTarget.values[0])}> + {dates.map((d) => ( + + {d} + + ))} + + )} + {availability && !availability.error && dates.length === 0 && status === "idle" && ( + {shopify.i18n.translate("no_dates")} + )} + + {date && slots.length > 0 && ( + { + const slot = slots.find((s) => String(s.startMin) === e.currentTarget.values[0]); + if (slot) selectSlot(slot); + }} + > + {slots.map((slot) => ( + + {slotTimeLabel(slot)} + + ))} + + )} + + ); +} diff --git a/extensions/checkout-datetime/src/OrderStatus.jsx b/extensions/checkout-datetime/src/OrderStatus.jsx new file mode 100644 index 0000000..9e93c60 --- /dev/null +++ b/extensions/checkout-datetime/src/OrderStatus.jsx @@ -0,0 +1,21 @@ +import "@shopify/ui-extensions/preact"; +import { render } from "preact"; +import { confirmationText } from "./lib.js"; + +export default async () => { + render(, 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 ( + + {shopify.i18n.translate("name")} + {text} + + ); +} diff --git a/extensions/checkout-datetime/src/ThankYou.jsx b/extensions/checkout-datetime/src/ThankYou.jsx new file mode 100644 index 0000000..a106973 --- /dev/null +++ b/extensions/checkout-datetime/src/ThankYou.jsx @@ -0,0 +1,22 @@ +import "@shopify/ui-extensions/preact"; +import { render } from "preact"; +import { confirmationText } from "./lib.js"; + +export default async () => { + render(, 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 ( + + {shopify.i18n.translate("name")} + {text} + + ); +} diff --git a/extensions/checkout-datetime/src/lib.js b/extensions/checkout-datetime/src/lib.js new file mode 100644 index 0000000..10d8334 --- /dev/null +++ b/extensions/checkout-datetime/src/lib.js @@ -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}`; +} diff --git a/extensions/checkout-datetime/tsconfig.json b/extensions/checkout-datetime/tsconfig.json new file mode 100644 index 0000000..234fe65 --- /dev/null +++ b/extensions/checkout-datetime/tsconfig.json @@ -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"] +} diff --git a/extensions/datetime-widget/assets/datetime-widget.css b/extensions/datetime-widget/assets/datetime-widget.css index e690526..79e8996 100644 --- a/extensions/datetime-widget/assets/datetime-widget.css +++ b/extensions/datetime-widget/assets/datetime-widget.css @@ -106,3 +106,16 @@ font-weight: 600; 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; +} diff --git a/extensions/datetime-widget/assets/datetime-widget.js b/extensions/datetime-widget/assets/datetime-widget.js index ecc3c90..e8ecb5c 100644 --- a/extensions/datetime-widget/assets/datetime-widget.js +++ b/extensions/datetime-widget/assets/datetime-widget.js @@ -1 +1 @@ -"use strict";(()=>{var E=Object.defineProperty;var S=(i,t,e)=>t in i?E(i,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):i[t]=e;var l=(i,t,e)=>S(i,typeof t!="symbol"?t+"":t,e);var M="/apps/scheduling";function y(i){let t=Math.floor(i/60),e=i%60,n=t<12?"AM":"PM";return`${t%12===0?12:t%12}:${e.toString().padStart(2,"0")} ${n}`}function m(i){return h(i.slice(0,10))}function b(i){return i.arrivalRangeStart&&i.arrivalRangeEnd?`Arrives ${m(i.arrivalRangeStart)}\u2013${m(i.arrivalRangeEnd)}`:i.arrivalRangeStart?`Arrives from ${m(i.arrivalRangeStart)}`:i.arrivalRangeEnd?`Arrives by ${m(i.arrivalRangeEnd)}`:`${y(i.startMin)}\u2013${y(i.endMin)}`}function h(i){let[t,e,n]=i.split("-").map(Number);return new Date(Date.UTC(t,e-1,n)).toLocaleDateString(void 0,{weekday:"short",month:"short",day:"numeric",timeZone:"UTC"})}function _(i){let t=i.dataset,e=[];return t.showShipping==="true"&&e.push({value:"SHIPPING",label:t.labelShipping||"Shipping",attrLabel:t.attrLabelShipping||"Shipping date"}),t.showLocalDelivery==="true"&&e.push({value:"LOCAL_DELIVERY",label:t.labelLocalDelivery||"Local delivery",attrLabel:t.attrLabelLocalDelivery||"Delivery date"}),t.showPickup==="true"&&e.push({value:"PICKUP",label:t.labelPickup||"Pickup",attrLabel:t.attrLabelPickup||"Pickup date"}),{root:i,heading:t.heading||"",locationId:t.locationId||null,googleMapsApiKey:t.googleMapsApiKey||null,methods:e,labels:{chooseDate:t.labelChooseDate||"Choose a date",chooseTime:t.labelChooseTime||"Choose a time",noDates:t.labelNoDates||"No dates are available right now.",confirmed:t.labelConfirmed||"Confirmed for",change:t.labelChange||"Change",loading:t.labelLoading||"Loading available dates\u2026",error:t.labelError||"Couldn't load available dates. Please try again.",postalCodeLabel:t.labelPostalCode||"Enter your postal/ZIP code",postalCodeSubmit:t.labelPostalCodeSubmit||"Check availability",outOfArea:t.labelOutOfArea||"Sorry, we don't deliver to this address."}}}async function C(){var e;let t=await(await fetch("/cart.js",{headers:{Accept:"application/json"}})).json();return{token:t.token,lines:((e=t.items)!=null?e:[]).map(n=>{var a,o;return{vendor:(a=n.vendor)!=null?a:"",productType:(o=n.product_type)!=null?o:"",productId:`gid://shopify/Product/${n.product_id}`}})}}async function k(i,t,e,n){let a=new URLSearchParams({method:i,days:"14"});t&&a.set("locationId",t),n&&a.set("postalCode",n),e.lines.length>0&&(a.set("cartLines",JSON.stringify(e.lines.map(s=>({vendor:s.vendor,productType:s.productType})))),a.set("productIds",e.lines.map(s=>s.productId).join(",")));let o=await fetch(`${M}/availability?${a.toString()}`,{headers:{Accept:"application/json"}}),r=await o.json();if(!o.ok)throw new Error(r.error||`Request failed (${o.status})`);return r}async function L(i){let t=await fetch(`${M}/hold`,{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify(i)}),e=await t.json();return!t.ok&&i.intent==="create"?{success:!1,error:e.error||"Slot unavailable"}:e}async function A(i,t,e){await fetch("/cart/update.js",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({attributes:{[i]:e,...t}})})}var p=null;function I(i){return p||(p=new Promise((t,e)=>{let n="__ddMapsReady";window[n]=()=>t();let a=document.createElement("script");a.src=`https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(i)}&callback=${n}`,a.async=!0,a.onerror=()=>e(new Error("Failed to load Google Maps")),document.head.appendChild(a)}),p)}async function T(i,t,e,n,a){try{await I(t);let o=window.google,r=new o.maps.Map(i,{center:{lat:e,lng:n},zoom:13});new o.maps.Marker({position:{lat:e,lng:n},map:r,title:a})}catch{i.hidden=!0}}var f=class{constructor(t){l(this,"config");l(this,"el",{heading:document.createElement("h3"),methodRow:document.createElement("div"),postalRow:document.createElement("div"),mapContainer:document.createElement("div"),dateRow:document.createElement("div"),timeRow:document.createElement("div"),status:document.createElement("p"),confirmation:document.createElement("div")});l(this,"selectedMethod",null);l(this,"selectedDate",null);l(this,"availability",null);l(this,"heldSlot",null);this.config=t}mount(){let{root:t,heading:e,methods:n}=this.config;t.classList.add("dd-widget--ready"),t.innerHTML="",n.length!==0&&(e&&(this.el.heading.className="dd-widget__heading",this.el.heading.textContent=e,t.appendChild(this.el.heading)),this.el.methodRow.className="dd-widget__row dd-widget__methods",this.el.postalRow.className="dd-widget__row dd-widget__postal",this.el.postalRow.hidden=!0,this.el.mapContainer.className="dd-widget__map",this.el.mapContainer.hidden=!0,this.el.dateRow.className="dd-widget__row dd-widget__dates",this.el.timeRow.className="dd-widget__row dd-widget__times",this.el.status.className="dd-widget__status",this.el.confirmation.className="dd-widget__confirmation",this.el.confirmation.hidden=!0,t.append(this.el.confirmation,this.el.methodRow,this.el.postalRow,this.el.mapContainer,this.el.dateRow,this.el.timeRow,this.el.status),n.length===1?this.selectMethod(n[0]):this.renderMethods())}renderMethods(){var t;this.el.methodRow.innerHTML="";for(let e of this.config.methods){let n=document.createElement("button");n.type="button",n.className="dd-widget__pill",n.textContent=e.label,n.setAttribute("aria-pressed",String(((t=this.selectedMethod)==null?void 0:t.value)===e.value)),n.addEventListener("click",()=>this.selectMethod(e)),this.el.methodRow.appendChild(n)}}async selectMethod(t){if(this.selectedMethod=t,this.selectedDate=null,this.el.timeRow.innerHTML="",this.el.dateRow.innerHTML="",this.el.mapContainer.hidden=!0,this.el.confirmation.hidden=!1,this.el.confirmation.hidden=!0,this.config.methods.length>1&&this.renderMethods(),t.value==="LOCAL_DELIVERY"){this.renderPostalCodeInput(t);return}this.el.postalRow.hidden=!0,await this.loadAvailability(t)}renderPostalCodeInput(t){this.el.postalRow.hidden=!1,this.el.postalRow.innerHTML="",this.el.status.textContent="";let e=document.createElement("input");e.type="text",e.className="dd-widget__input",e.placeholder=this.config.labels.postalCodeLabel,e.setAttribute("aria-label",this.config.labels.postalCodeLabel);let n=document.createElement("button");n.type="button",n.className="dd-widget__pill",n.textContent=this.config.labels.postalCodeSubmit,n.addEventListener("click",async()=>{let a=e.value.trim();a&&await this.loadAvailability(t,a)}),e.addEventListener("keydown",a=>{a.key==="Enter"&&n.click()}),this.el.postalRow.append(e,n)}async loadAvailability(t,e){this.el.status.textContent=this.config.labels.loading,this.el.dateRow.innerHTML="";try{let n=await C();if(this.availability=await k(t.value,this.config.locationId,n,e),!this.availability.locationId){this.el.status.textContent=this.availability.error||this.config.labels.outOfArea;return}t.value==="PICKUP"&&this.config.googleMapsApiKey&&this.showPickupMap(),this.renderDates()}catch{this.el.status.textContent=this.config.labels.error}}showPickupMap(){var e;let t=this.availability;!(t!=null&&t.locationLat)||!(t!=null&&t.locationLng)||!this.config.googleMapsApiKey||(this.el.mapContainer.hidden=!1,T(this.el.mapContainer,this.config.googleMapsApiKey,t.locationLat,t.locationLng,(e=t.locationName)!=null?e:""))}renderDates(){var e,n;let t=Object.keys((n=(e=this.availability)==null?void 0:e.dates)!=null?n:{}).sort();if(this.el.dateRow.innerHTML="",t.length===0){this.el.status.textContent=this.config.labels.noDates;return}this.el.status.textContent=this.config.labels.chooseDate;for(let a of t){let o=document.createElement("button");o.type="button",o.className="dd-widget__pill",o.textContent=h(a),o.setAttribute("aria-pressed",String(this.selectedDate===a)),o.addEventListener("click",()=>this.selectDate(a)),this.el.dateRow.appendChild(o)}}selectDate(t){var n,a;this.selectedDate=t;for(let o of Array.from(this.el.dateRow.children))o.setAttribute("aria-pressed",String(o.textContent===h(t)));let e=(a=(n=this.availability)==null?void 0:n.dates[t])!=null?a:[];this.el.timeRow.innerHTML="",this.el.status.textContent=this.config.labels.chooseTime;for(let o of e){let r=document.createElement("button");r.type="button",r.className="dd-widget__pill",r.textContent=b(o),r.addEventListener("click",()=>this.selectSlot(t,o)),this.el.timeRow.appendChild(r)}}async selectSlot(t,e){var s;let n=this.selectedMethod,a=this.availability,o=a.rate?` (${a.rate.label})`:"",r=e.arrivalRangeStart||e.arrivalRangeEnd?`Ships ${h(t)}, ${b(e)}${o}`:`${h(t)}, ${b(e)}${o}`;this.el.status.textContent=this.config.labels.loading;try{let u=await C(),w=await L({intent:"create",locationId:a.locationId,method:n.value,date:t,startMin:e.startMin,cartToken:u.token,cartLines:u.lines.map(g=>({vendor:g.vendor,productType:g.productType})),productIds:u.lines.map(g=>g.productId)});if(!w.success){this.el.status.textContent=w.error||this.config.labels.error,await this.selectMethod(n);return}this.heldSlot={locationId:a.locationId,method:n.value,date:t,startMin:e.startMin,cartToken:u.token,zoneId:(s=a.zoneId)!=null?s:null};let d={dd_method:n.value,dd_date:t,dd_start_min:String(e.startMin),dd_end_min:String(e.endMin),dd_location_id:a.locationId};a.zoneId&&(d.dd_zone_id=a.zoneId),a.rate&&(d.dd_rate_label=a.rate.label),e.arrivalRangeStart&&(d.dd_arrival_range_start=e.arrivalRangeStart),e.arrivalRangeEnd&&(d.dd_arrival_range_end=e.arrivalRangeEnd),await A(n.attrLabel,d,r),this.el.status.textContent="",this.el.methodRow.hidden=!0,this.el.postalRow.hidden=!0,this.el.mapContainer.hidden=!0,this.el.dateRow.hidden=!0,this.el.timeRow.hidden=!0,this.el.confirmation.hidden=!1,this.el.confirmation.innerHTML="";let v=document.createElement("p");v.textContent=`${this.config.labels.confirmed} ${r}`;let c=document.createElement("button");c.type="button",c.className="dd-widget__link",c.textContent=this.config.labels.change,c.addEventListener("click",()=>{this.heldSlot&&(L({intent:"release",...this.heldSlot}),this.heldSlot=null),this.el.methodRow.hidden=!1,this.el.dateRow.hidden=!1,this.el.timeRow.hidden=!1,this.el.confirmation.hidden=!0}),this.el.confirmation.append(v,c)}catch{this.el.status.textContent=this.config.labels.error}}},P=['form[action*="/cart"] button[name="checkout"]','form[action*="/cart"] input[name="checkout"]','[name="checkout"]',"#checkout",'a[href="/checkout"]'];function D(){for(let i of P){let t=document.querySelector(i);if(t)return t}return null}function N(){return window.location.pathname.replace(/\/+$/,"").endsWith("/cart")}function $(){if(!N()||document.querySelector("[data-dd-widget]"))return;let i=document.getElementById("dd-widget-cart-template");if(!i)return;let t=D();if(!t)return;let e=document.createElement("div");e.setAttribute("data-dd-widget",""),e.classList.add("dd-widget--cart-injected");for(let n of Array.from(i.attributes))n.name!=="id"&&e.setAttribute(n.name,n.value);t.insertAdjacentElement("beforebegin",e),new f(_(e)).mount()}function R(){document.querySelectorAll("[data-dd-widget]").forEach(t=>{new f(_(t)).mount()}),$()}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",R):R();})(); +"use strict";(()=>{var k=Object.defineProperty;var S=(i,t,e)=>t in i?k(i,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):i[t]=e;var c=(i,t,e)=>S(i,typeof t!="symbol"?t+"":t,e);var M="/apps/scheduling";function C(i){let t=Math.floor(i/60),e=i%60,n=t<12?"AM":"PM";return`${t%12===0?12:t%12}:${e.toString().padStart(2,"0")} ${n}`}function p(i){return h(i.slice(0,10))}function v(i){return i.arrivalRangeStart&&i.arrivalRangeEnd?`Arrives ${p(i.arrivalRangeStart)}\u2013${p(i.arrivalRangeEnd)}`:i.arrivalRangeStart?`Arrives from ${p(i.arrivalRangeStart)}`:i.arrivalRangeEnd?`Arrives by ${p(i.arrivalRangeEnd)}`:`${C(i.startMin)}\u2013${C(i.endMin)}`}function h(i){let[t,e,n]=i.split("-").map(Number);return new Date(Date.UTC(t,e-1,n)).toLocaleDateString(void 0,{weekday:"short",month:"short",day:"numeric",timeZone:"UTC"})}function E(i){let t=i.dataset,e=[];return t.showShipping==="true"&&e.push({value:"SHIPPING",label:t.labelShipping||"Shipping",attrLabel:t.attrLabelShipping||"Shipping date"}),t.showLocalDelivery==="true"&&e.push({value:"LOCAL_DELIVERY",label:t.labelLocalDelivery||"Local delivery",attrLabel:t.attrLabelLocalDelivery||"Delivery date"}),t.showPickup==="true"&&e.push({value:"PICKUP",label:t.labelPickup||"Pickup",attrLabel:t.attrLabelPickup||"Pickup date"}),{root:i,heading:t.heading||"",locationId:t.locationId||null,googleMapsApiKey:t.googleMapsApiKey||null,mode:t.mode==="preview"?"preview":"full",methods:e,labels:{chooseDate:t.labelChooseDate||"Choose a date",chooseTime:t.labelChooseTime||"Choose a time",noDates:t.labelNoDates||"No dates are available right now.",confirmed:t.labelConfirmed||"Confirmed for",change:t.labelChange||"Change",loading:t.labelLoading||"Loading available dates\u2026",error:t.labelError||"Couldn't load available dates. Please try again.",postalCodeLabel:t.labelPostalCode||"Enter your postal/ZIP code",postalCodeSubmit:t.labelPostalCodeSubmit||"Check availability",outOfArea:t.labelOutOfArea||"Sorry, we don't deliver to this address.",earliestPrefix:t.labelEarliestPrefix||"Earliest",deliveryAtCheckout:t.labelDeliveryAtCheckout||"Enter your address at checkout to see local delivery dates."}}}async function w(){var e;let t=await(await fetch("/cart.js",{headers:{Accept:"application/json"}})).json();return{token:t.token,lines:((e=t.items)!=null?e:[]).map(n=>{var a,o,r;return{vendor:(a=n.vendor)!=null?a:"",productType:(o=n.product_type)!=null?o:"",productId:`gid://shopify/Product/${n.product_id}`,variantId:`gid://shopify/ProductVariant/${(r=n.variant_id)!=null?r:n.id}`}})}}async function L(i,t,e,n){let a=new URLSearchParams({method:i,days:"14"});t&&a.set("locationId",t),n&&a.set("postalCode",n),e.lines.length>0&&(a.set("cartLines",JSON.stringify(e.lines.map(s=>({vendor:s.vendor,productType:s.productType})))),a.set("productIds",e.lines.map(s=>s.productId).join(",")),a.set("variantIds",e.lines.map(s=>s.variantId).join(",")));let o=await fetch(`${M}/availability?${a.toString()}`,{headers:{Accept:"application/json"}}),r=await o.json();if(!o.ok)throw new Error(r.error||`Request failed (${o.status})`);return r}async function _(i){let t=await fetch(`${M}/hold`,{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify(i)}),e=await t.json();return!t.ok&&i.intent==="create"?{success:!1,error:e.error||"Slot unavailable"}:e}async function I(i,t,e){await fetch("/cart/update.js",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({attributes:{[i]:e,...t}})})}var f=null;function A(i){return f||(f=new Promise((t,e)=>{let n="__ddMapsReady";window[n]=()=>t();let a=document.createElement("script");a.src=`https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(i)}&callback=${n}`,a.async=!0,a.onerror=()=>e(new Error("Failed to load Google Maps")),document.head.appendChild(a)}),f)}async function P(i,t,e,n,a){try{await A(t);let o=window.google,r=new o.maps.Map(i,{center:{lat:e,lng:n},zoom:13});new o.maps.Marker({position:{lat:e,lng:n},map:r,title:a})}catch{i.hidden=!0}}var b=class{constructor(t){c(this,"config");c(this,"el",{heading:document.createElement("h3"),methodRow:document.createElement("div"),postalRow:document.createElement("div"),mapContainer:document.createElement("div"),dateRow:document.createElement("div"),timeRow:document.createElement("div"),status:document.createElement("p"),confirmation:document.createElement("div")});c(this,"selectedMethod",null);c(this,"selectedDate",null);c(this,"availability",null);c(this,"heldSlot",null);this.config=t}mount(){let{root:t,heading:e,methods:n}=this.config;if(t.classList.add("dd-widget--ready"),t.innerHTML="",n.length!==0){if(this.config.mode==="preview"){this.mountPreview();return}e&&(this.el.heading.className="dd-widget__heading",this.el.heading.textContent=e,t.appendChild(this.el.heading)),this.el.methodRow.className="dd-widget__row dd-widget__methods",this.el.postalRow.className="dd-widget__row dd-widget__postal",this.el.postalRow.hidden=!0,this.el.mapContainer.className="dd-widget__map",this.el.mapContainer.hidden=!0,this.el.dateRow.className="dd-widget__row dd-widget__dates",this.el.timeRow.className="dd-widget__row dd-widget__times",this.el.status.className="dd-widget__status",this.el.confirmation.className="dd-widget__confirmation",this.el.confirmation.hidden=!0,t.append(this.el.confirmation,this.el.methodRow,this.el.postalRow,this.el.mapContainer,this.el.dateRow,this.el.timeRow,this.el.status),n.length===1?this.selectMethod(n[0]):this.renderMethods()}}async mountPreview(){let{root:t,heading:e,methods:n}=this.config;e&&(this.el.heading.className="dd-widget__heading",this.el.heading.textContent=e,t.appendChild(this.el.heading));let a=document.createElement("ul");a.className="dd-widget__preview",t.appendChild(a);let o=await w().catch(()=>({token:"",lines:[]}));await Promise.all(n.map(async r=>{var d;let s=document.createElement("li");if(s.className="dd-widget__preview-item",r.value==="LOCAL_DELIVERY"){s.textContent=this.config.labels.deliveryAtCheckout,a.appendChild(s);return}s.textContent=this.config.labels.loading,a.appendChild(s);try{let g=await L(r.value,this.config.locationId,o),l=Object.keys((d=g.dates)!=null?d:{}).sort()[0];s.textContent=l?`${this.config.labels.earliestPrefix} ${r.label.toLowerCase()}: ${h(l)}`:`${r.label}: ${this.config.labels.noDates}`}catch{s.textContent=`${r.label}: ${this.config.labels.error}`}}))}renderMethods(){var t;this.el.methodRow.innerHTML="";for(let e of this.config.methods){let n=document.createElement("button");n.type="button",n.className="dd-widget__pill",n.textContent=e.label,n.setAttribute("aria-pressed",String(((t=this.selectedMethod)==null?void 0:t.value)===e.value)),n.addEventListener("click",()=>this.selectMethod(e)),this.el.methodRow.appendChild(n)}}async selectMethod(t){if(this.selectedMethod=t,this.selectedDate=null,this.el.timeRow.innerHTML="",this.el.dateRow.innerHTML="",this.el.mapContainer.hidden=!0,this.el.confirmation.hidden=!1,this.el.confirmation.hidden=!0,this.config.methods.length>1&&this.renderMethods(),t.value==="LOCAL_DELIVERY"){this.renderPostalCodeInput(t);return}this.el.postalRow.hidden=!0,await this.loadAvailability(t)}renderPostalCodeInput(t){this.el.postalRow.hidden=!1,this.el.postalRow.innerHTML="",this.el.status.textContent="";let e=document.createElement("input");e.type="text",e.className="dd-widget__input",e.placeholder=this.config.labels.postalCodeLabel,e.setAttribute("aria-label",this.config.labels.postalCodeLabel);let n=document.createElement("button");n.type="button",n.className="dd-widget__pill",n.textContent=this.config.labels.postalCodeSubmit,n.addEventListener("click",async()=>{let a=e.value.trim();a&&await this.loadAvailability(t,a)}),e.addEventListener("keydown",a=>{a.key==="Enter"&&n.click()}),this.el.postalRow.append(e,n)}async loadAvailability(t,e){this.el.status.textContent=this.config.labels.loading,this.el.dateRow.innerHTML="";try{let n=await w();if(this.availability=await L(t.value,this.config.locationId,n,e),!this.availability.locationId){this.el.status.textContent=this.availability.error||this.config.labels.outOfArea;return}t.value==="PICKUP"&&this.config.googleMapsApiKey&&this.showPickupMap(),this.renderDates()}catch{this.el.status.textContent=this.config.labels.error}}showPickupMap(){var e;let t=this.availability;!(t!=null&&t.locationLat)||!(t!=null&&t.locationLng)||!this.config.googleMapsApiKey||(this.el.mapContainer.hidden=!1,P(this.el.mapContainer,this.config.googleMapsApiKey,t.locationLat,t.locationLng,(e=t.locationName)!=null?e:""))}renderDates(){var e,n;let t=Object.keys((n=(e=this.availability)==null?void 0:e.dates)!=null?n:{}).sort();if(this.el.dateRow.innerHTML="",t.length===0){this.el.status.textContent=this.config.labels.noDates;return}this.el.status.textContent=this.config.labels.chooseDate;for(let a of t){let o=document.createElement("button");o.type="button",o.className="dd-widget__pill",o.textContent=h(a),o.setAttribute("aria-pressed",String(this.selectedDate===a)),o.addEventListener("click",()=>this.selectDate(a)),this.el.dateRow.appendChild(o)}}selectDate(t){var n,a;this.selectedDate=t;for(let o of Array.from(this.el.dateRow.children))o.setAttribute("aria-pressed",String(o.textContent===h(t)));let e=(a=(n=this.availability)==null?void 0:n.dates[t])!=null?a:[];this.el.timeRow.innerHTML="",this.el.status.textContent=this.config.labels.chooseTime;for(let o of e){let r=document.createElement("button");r.type="button",r.className="dd-widget__pill",r.textContent=v(o),r.addEventListener("click",()=>this.selectSlot(t,o)),this.el.timeRow.appendChild(r)}}async selectSlot(t,e){var s;let n=this.selectedMethod,a=this.availability,o=a.rate?` (${a.rate.label})`:"",r=e.arrivalRangeStart||e.arrivalRangeEnd?`Ships ${h(t)}, ${v(e)}${o}`:`${h(t)}, ${v(e)}${o}`;this.el.status.textContent=this.config.labels.loading;try{let d=await w(),g=await _({intent:"create",locationId:a.locationId,method:n.value,date:t,startMin:e.startMin,cartToken:d.token,cartLines:d.lines.map(m=>({vendor:m.vendor,productType:m.productType})),productIds:d.lines.map(m=>m.productId)});if(!g.success){this.el.status.textContent=g.error||this.config.labels.error,await this.selectMethod(n);return}this.heldSlot={locationId:a.locationId,method:n.value,date:t,startMin:e.startMin,cartToken:d.token,zoneId:(s=a.zoneId)!=null?s:null};let l={dd_method:n.value,dd_date:t,dd_start_min:String(e.startMin),dd_end_min:String(e.endMin),dd_location_id:a.locationId};a.zoneId&&(l.dd_zone_id=a.zoneId),a.rate&&(l.dd_rate_label=a.rate.label),e.arrivalRangeStart&&(l.dd_arrival_range_start=e.arrivalRangeStart),e.arrivalRangeEnd&&(l.dd_arrival_range_end=e.arrivalRangeEnd),await I(n.attrLabel,l,r),this.el.status.textContent="",this.el.methodRow.hidden=!0,this.el.postalRow.hidden=!0,this.el.mapContainer.hidden=!0,this.el.dateRow.hidden=!0,this.el.timeRow.hidden=!0,this.el.confirmation.hidden=!1,this.el.confirmation.innerHTML="";let y=document.createElement("p");y.textContent=`${this.config.labels.confirmed} ${r}`;let u=document.createElement("button");u.type="button",u.className="dd-widget__link",u.textContent=this.config.labels.change,u.addEventListener("click",()=>{this.heldSlot&&(_({intent:"release",...this.heldSlot}),this.heldSlot=null),this.el.methodRow.hidden=!1,this.el.dateRow.hidden=!1,this.el.timeRow.hidden=!1,this.el.confirmation.hidden=!0}),this.el.confirmation.append(y,u)}catch{this.el.status.textContent=this.config.labels.error}}},T=['form[action*="/cart"] button[name="checkout"]','form[action*="/cart"] input[name="checkout"]','[name="checkout"]',"#checkout",'a[href="/checkout"]'];function D(){for(let i of T){let t=document.querySelector(i);if(t)return t}return null}function $(){return window.location.pathname.replace(/\/+$/,"").endsWith("/cart")}function x(){if(!$()||document.querySelector("[data-dd-widget]"))return;let i=document.getElementById("dd-widget-cart-template");if(!i)return;let t=D();if(!t)return;let e=document.createElement("div");e.setAttribute("data-dd-widget",""),e.classList.add("dd-widget--cart-injected");for(let n of Array.from(i.attributes))n.name!=="id"&&e.setAttribute(n.name,n.value);t.insertAdjacentElement("beforebegin",e),new b(E(e)).mount()}function R(){document.querySelectorAll("[data-dd-widget]").forEach(t=>{new b(E(t)).mount()}),x()}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",R):R();})(); diff --git a/extensions/datetime-widget/blocks/product-availability.liquid b/extensions/datetime-widget/blocks/product-availability.liquid new file mode 100644 index 0000000..777e6cc --- /dev/null +++ b/extensions/datetime-widget/blocks/product-availability.liquid @@ -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 %} +
+ +
+ +{% 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 %} diff --git a/extensions/datetime-widget/locales/en.default.json b/extensions/datetime-widget/locales/en.default.json index 910c405..fc6e524 100644 --- a/extensions/datetime-widget/locales/en.default.json +++ b/extensions/datetime-widget/locales/en.default.json @@ -16,7 +16,9 @@ "enable_js": "Please enable JavaScript to choose a delivery date and time.", "postal_code": "Enter your postal/ZIP code", "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": { "pickup_label": "Pickup", diff --git a/extensions/datetime-widget/locales/en.default.schema.json b/extensions/datetime-widget/locales/en.default.schema.json index 9548e5d..6af0ce5 100644 --- a/extensions/datetime-widget/locales/en.default.schema.json +++ b/extensions/datetime-widget/locales/en.default.schema.json @@ -23,6 +23,10 @@ "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." }, + "product_availability": { + "name": "Product Availability Preview", + "heading_label": "Heading" + }, "order_confirmation": { "name": "Delivery Confirmation" } diff --git a/extensions/datetime-widget/locales/fr.json b/extensions/datetime-widget/locales/fr.json new file mode 100644 index 0000000..d3ddea7 --- /dev/null +++ b/extensions/datetime-widget/locales/fr.json @@ -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" + } +} diff --git a/extensions/datetime-widget/locales/fr.schema.json b/extensions/datetime-widget/locales/fr.schema.json new file mode 100644 index 0000000..23c00cd --- /dev/null +++ b/extensions/datetime-widget/locales/fr.schema.json @@ -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" + } +} diff --git a/extensions/payment-customization/.gitignore b/extensions/payment-customization/.gitignore new file mode 100644 index 0000000..562ebe0 --- /dev/null +++ b/extensions/payment-customization/.gitignore @@ -0,0 +1,2 @@ +dist +generated diff --git a/extensions/payment-customization/README.md b/extensions/payment-customization/README.md new file mode 100644 index 0000000..3b793b3 --- /dev/null +++ b/extensions/payment-customization/README.md @@ -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. diff --git a/extensions/payment-customization/locales/en.default.json b/extensions/payment-customization/locales/en.default.json new file mode 100644 index 0000000..344ec58 --- /dev/null +++ b/extensions/payment-customization/locales/en.default.json @@ -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." +} diff --git a/extensions/payment-customization/package.json b/extensions/payment-customization/package.json new file mode 100644 index 0000000..a72d430 --- /dev/null +++ b/extensions/payment-customization/package.json @@ -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" + } +} diff --git a/extensions/payment-customization/shopify.extension.toml b/extensions/payment-customization/shopify.extension.toml new file mode 100644 index 0000000..9d63c88 --- /dev/null +++ b/extensions/payment-customization/shopify.extension.toml @@ -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 = "/" diff --git a/extensions/payment-customization/src/cart_payment_methods_transform_run.graphql b/extensions/payment-customization/src/cart_payment_methods_transform_run.graphql new file mode 100644 index 0000000..9cd94cd --- /dev/null +++ b/extensions/payment-customization/src/cart_payment_methods_transform_run.graphql @@ -0,0 +1,11 @@ +query CartPaymentMethodsTransformRunInput { + cart { + ddMethod: attribute(key: "dd_method") { + value + } + } + paymentMethods { + id + name + } +} diff --git a/extensions/payment-customization/src/cart_payment_methods_transform_run.js b/extensions/payment-customization/src/cart_payment_methods_transform_run.js new file mode 100644 index 0000000..52637ce --- /dev/null +++ b/extensions/payment-customization/src/cart_payment_methods_transform_run.js @@ -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 }; +}; diff --git a/extensions/payment-customization/src/evaluate.js b/extensions/payment-customization/src/evaluate.js new file mode 100644 index 0000000..389c777 --- /dev/null +++ b/extensions/payment-customization/src/evaluate.js @@ -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} 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)); +} diff --git a/extensions/payment-customization/src/index.js b/extensions/payment-customization/src/index.js new file mode 100644 index 0000000..6da48ef --- /dev/null +++ b/extensions/payment-customization/src/index.js @@ -0,0 +1 @@ +export * from './cart_payment_methods_transform_run'; diff --git a/extensions/validation-slot/src/cart_validations_generate_run.graphql b/extensions/validation-slot/src/cart_validations_generate_run.graphql index c4d7f1d..893e733 100644 --- a/extensions/validation-slot/src/cart_validations_generate_run.graphql +++ b/extensions/validation-slot/src/cart_validations_generate_run.graphql @@ -1,4 +1,14 @@ 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 { ddMethod: attribute(key: "dd_method") { value @@ -15,5 +25,15 @@ query CartValidationsGenerateRunInput { ddLocationId: attribute(key: "dd_location_id") { value } + lines { + merchandise { + __typename + ... on ProductVariant { + product { + id + } + } + } + } } } diff --git a/extensions/validation-slot/src/cart_validations_generate_run.js b/extensions/validation-slot/src/cart_validations_generate_run.js index b7aef25..13651ff 100644 --- a/extensions/validation-slot/src/cart_validations_generate_run.js +++ b/extensions/validation-slot/src/cart_validations_generate_run.js @@ -1,5 +1,5 @@ // @ts-check -import { evaluateCartAttributes } from "./evaluate.js"; +import { evaluateCheckout } from "./evaluate.js"; /** * @typedef {import("../generated/api").CartValidationsGenerateRunInput} CartValidationsGenerateRunInput @@ -9,6 +9,8 @@ import { evaluateCartAttributes } from "./evaluate.js"; const ERROR_MESSAGE = { no_slot_selected: "Please choose a delivery date and time before checking out.", incomplete_slot_selection: "Your delivery date/time selection is incomplete — please choose it again.", + 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, }; - 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 { operations: [ diff --git a/extensions/validation-slot/src/evaluate.js b/extensions/validation-slot/src/evaluate.js index 6fe33f9..3bc9b17 100644 --- a/extensions/validation-slot/src/evaluate.js +++ b/extensions/validation-slot/src/evaluate.js @@ -10,24 +10,30 @@ // storefront widget still cannot complete checkout, because this runs // server-side inside Shopify's own checkout, not in the browser. // -// Scope note (Phase 4): every order on shops that activate this Function is -// currently treated as requiring a schedule selection — there's no -// ProductRule yet (that's Phase 5) to scope enforcement to specific -// products/collections. Merchants who haven't finished configuring -// locations/slots simply shouldn't activate this Function in -// Settings > Checkout yet, the same way any other checkout validation -// works. Also out of scope for now: re-validating against a *live* -// capacity snapshot at the moment checkout completes ("has since been -// taken" in PRODUCT_STRATEGY.md §3.1) — Functions can't call our DB, and -// building a metafield-snapshot refresh pipeline for that is real, -// unscoped work. The 10-minute hold TTL (app/services/holds.server.ts) is -// the mitigation for that specific race in the meantime. +// Two things layer on top of the original presence check, both driven by +// the shop-metafield snapshot (app/services/checkout-snapshot.server.ts), +// since the Function has no way to read the app DB: +// 1. Enforcement scope (study §3.5) — `all` blocks every order, `tagged` +// blocks only orders containing a product the merchant flagged, `off` +// never blocks. +// 2. Live slot re-validation (study §5.2 "has since become invalid") — +// even a fully-formed selection is rejected if the snapshot shows the +// slot is now full, blacked out, past a closed override, or over the +// location's daily cap. +// The 10-minute hold TTL (app/services/holds.server.ts) still covers the +// sub-refresh-interval race; this closes the rest. 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} attributes - * @returns {{ valid: true } | { valid: false, reason: "no_slot_selected" | "incomplete_slot_selection", missing: string[] }} + * @returns {EvalResult} */ export function evaluateCartAttributes(attributes) { const missing = REQUIRED_ATTRIBUTES.filter((key) => !attributes[key]); @@ -40,3 +46,66 @@ export function evaluateCartAttributes(attributes) { } 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} [slots] + * @property {Record} [closedDates] + */ + +/** + * Full checkout evaluation: enforcement scope + presence + live capacity. + * @param {Object} params + * @param {Record} 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 }; +} diff --git a/extensions/validation-slot/tests/fixtures/enforcement-off-passes.json b/extensions/validation-slot/tests/fixtures/enforcement-off-passes.json new file mode 100644 index 0000000..f2bbda3 --- /dev/null +++ b/extensions/validation-slot/tests/fixtures/enforcement-off-passes.json @@ -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": [] + } + } + ] + } + } +} diff --git a/extensions/validation-slot/tests/fixtures/stale-slot-blocks-checkout.json b/extensions/validation-slot/tests/fixtures/stale-slot-blocks-checkout.json new file mode 100644 index 0000000..563a1a2 --- /dev/null +++ b/extensions/validation-slot/tests/fixtures/stale-slot-blocks-checkout.json @@ -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" + } + ] + } + } + ] + } + } +} diff --git a/jobs/worker.ts b/jobs/worker.ts index e316fb7..03ebb06 100644 --- a/jobs/worker.ts +++ b/jobs/worker.ts @@ -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 9): notifications — send queued reminder/ETA messages. // 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( "hold-expiry", async (job) => { diff --git a/prisma/migrations/20260904120000_review_gaps/migration.sql b/prisma/migrations/20260904120000_review_gaps/migration.sql new file mode 100644 index 0000000..f6f26be --- /dev/null +++ b/prisma/migrations/20260904120000_review_gaps/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 13cd48e..d64effc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,6 +51,15 @@ model Shop { tier String @default("free") // free|starter|growth|pro timezone String @default("UTC") 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()) } @@ -63,6 +72,11 @@ model Location { lng Float? timezone String 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/…) // so inventory-based exclusion (Phase 5) can query stock at the right // Shopify location — optional since not every merchant needs it wired up. @@ -212,6 +226,11 @@ model ProductRule { allowedMethods Method[] // empty = unrestricted (any method allowed) leadTimeMin Int? // extra prep-time buffer this rule imposes, on top of the slot's own leadTimeMin 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) createdAt DateTime @default(now()) diff --git a/tests/unit/payment-customization.test.ts b/tests/unit/payment-customization.test.ts new file mode 100644 index 0000000..999964c --- /dev/null +++ b/tests/unit/payment-customization.test.ts @@ -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); + } + }); +}); diff --git a/tests/unit/product-rules.test.ts b/tests/unit/product-rules.test.ts index f592e91..f52a5bf 100644 --- a/tests/unit/product-rules.test.ts +++ b/tests/unit/product-rules.test.ts @@ -67,7 +67,7 @@ describe("matchesRule", () => { describe("resolveProductRuleConstraints", () => { it("returns unrestricted defaults when nothing matches", () => { 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", () => { @@ -121,7 +121,16 @@ describe("resolveProductRuleConstraints", () => { rule({ scopeType: "vendor", scopeValue: "Acme", leadTimeMin: 30 }), ]; 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", () => { diff --git a/tests/unit/scheduling.test.ts b/tests/unit/scheduling.test.ts index 7ab782e..29f946d 100644 --- a/tests/unit/scheduling.test.ts +++ b/tests/unit/scheduling.test.ts @@ -160,6 +160,73 @@ describe("getAvailability", () => { 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)", () => { it("computes arrivalRangeStart/End from the slot's start date, not left unset", () => { const templates: SlotTemplateLike[] = [ diff --git a/tests/unit/validation-slot.test.ts b/tests/unit/validation-slot.test.ts index d08a60f..dbd5ad1 100644 --- a/tests/unit/validation-slot.test.ts +++ b/tests/unit/validation-slot.test.ts @@ -1,5 +1,5 @@ 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 = { dd_method: "PICKUP", @@ -35,6 +35,75 @@ describe("evaluateCartAttributes", () => { expect(result.valid).toBe(false); if (result.valid) throw new Error("unreachable"); expect(result.reason).toBe("incomplete_slot_selection"); - expect([...result.missing].sort()).toEqual(["dd_location_id", "dd_method"]); + 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, +}; + +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 }); }); }); diff --git a/tsconfig.json b/tsconfig.json index 7c89723..64ec20f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,12 @@ { "include": ["env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": [ + "node_modules", + "build", + "extensions/*/node_modules", + "extensions/pos-datetime", + "extensions/checkout-datetime" + ], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], "strict": true, diff --git a/widget-src/datetime-widget/datetime-widget.ts b/widget-src/datetime-widget/datetime-widget.ts index 5362867..17c3202 100644 --- a/widget-src/datetime-widget/datetime-widget.ts +++ b/widget-src/datetime-widget/datetime-widget.ts @@ -53,6 +53,13 @@ interface WidgetConfig { heading: string; locationId: 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 }>; labels: { chooseDate: string; @@ -65,6 +72,8 @@ interface WidgetConfig { postalCodeLabel: string; postalCodeSubmit: string; outOfArea: string; + earliestPrefix: string; + deliveryAtCheckout: string; }; } @@ -134,6 +143,7 @@ function readConfig(root: HTMLElement): WidgetConfig { heading: d.heading || "", locationId: d.locationId || null, googleMapsApiKey: d.googleMapsApiKey || null, + mode: d.mode === "preview" ? "preview" : "full", methods, labels: { chooseDate: d.labelChooseDate || "Choose a date", @@ -146,6 +156,8 @@ function readConfig(root: HTMLElement): WidgetConfig { postalCodeLabel: d.labelPostalCode || "Enter your postal/ZIP code", postalCodeSubmit: d.labelPostalCodeSubmit || "Check availability", 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; productType: string; productId: string; + variantId: string; } interface CartInfo { @@ -170,7 +183,7 @@ async function fetchCartInfo(): Promise { const res = await fetch("/cart.js", { headers: { Accept: "application/json" } }); const cart = (await res.json()) as { 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 { token: cart.token, @@ -178,6 +191,9 @@ async function fetchCartInfo(): Promise { vendor: item.vendor ?? "", productType: item.product_type ?? "", 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) { 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("variantIds", cart.lines.map((l) => l.variantId).join(",")); } const res = await fetch(`${PROXY_BASE}/availability?${params.toString()}`, { headers: { Accept: "application/json" }, @@ -305,6 +322,11 @@ class DateTimeWidget { if (methods.length === 0) return; // merchant disabled every method — render nothing + if (this.config.mode === "preview") { + void this.mountPreview(); + return; + } + if (heading) { this.el.heading.className = "dd-widget__heading"; this.el.heading.textContent = heading; @@ -339,6 +361,53 @@ class DateTimeWidget { } } + /** + * Product-page preview (study §3.7): a read-only "earliest available + * 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() { this.el.methodRow.innerHTML = ""; for (const method of this.config.methods) {