feat: finish multi-pin pickup end-to-end; fix checkout-datetime followups
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled

Multi-pin pickup selection (study §3.4) — previously a standalone unused
endpoint + TODO comments:
- app/services/pickup-locations.server.ts: pure filterPickupLocationIds +
  I/O resolvePickupLocations (active + has a PICKUP slot template +
  ProductRule allowedLocationIds + inventory exclusion).
- resolveAvailabilityRequest returns pickupLocations[] on method=PICKUP and
  defaults the active location to the first eligible pickup point.
- apps.scheduling.locations.tsx refactored onto the shared resolver
  (was a second copy of the logic).
- Storefront widget: pickup-location chooser (name + address, optional
  multi-pin Google map) when >1 eligible point and none block-configured;
  picking one re-requests availability for that location. New styles +
  widget.choose_pickup_location locale (en + fr).
- Checkout extension Checkout.jsx: same chooser before the date list.
- tests/unit/pickup-locations.test.ts (5 cases); suite 161 green.

checkout-datetime follow-up fixes from the 808a3b7 review:
- typescript devDep ^7.0.2 -> ^5.6.3 (there is no typescript@7 on npm).
- Deleted dead shopify.d.ts (Preact-global shim, unused after the React
  rewrite) and dropped it from tsconfig include.

Also stages the CLI-written `uid` lines in the checkout-datetime and
payment-customization extension tomls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-09-10 16:47:59 -04:00
parent 808a3b7001
commit b29962bf9c
19 changed files with 416 additions and 103 deletions

View File

@ -1,20 +1,28 @@
# Handover Status Summary # Handover Status Summary
Based on the original `HANDOVER.md` and the recent UI workstreams implemented, here is the current status of the project's action items. Based on the original `HANDOVER.md` and the recent workstreams, here is the current status of the project's action items.
## ✅ Completed Items ## ✅ Completed Items
### Known Bugs / Open Issues ### Known Bugs / Open Issues
- **B3 | Polaris uncontrolled inputs:** - **B3 | Polaris uncontrolled inputs:**
- **Status**: **Completed**. Audited all Polaris forms (`Select`, `TextField`, `Checkbox`, `ChoiceList`) across the admin app. Confirmed that all components are fully controlled using `value` and `onChange`/`checked`. - **Status**: **Completed** (`f8dd5b2`). Weekday/method/location/zone `<Select>`s in the Slots, Blackout dates and Rates forms were inert (no `value`/`onChange`) — now controlled. Full admin-form audit done.
- **B4 | `checkout-datetime` `checkJs` & Type Errors:** - **B4 | `checkout-datetime` `checkJs` & type errors:**
- **Status**: **Completed**. Reconciled the extension with the latest Shopify UI Extension APIs. Updated `tsconfig.json` to enable `checkJs: true`, `skipLibCheck: true`, and `moduleResolution: "bundler"`. Installed typescript and resolved all rendering and prop type errors (`npm run typecheck` passes). - **Status**: **Completed** (`808a3b7` + follow-up). Rewritten to the React pattern (`@shopify/ui-extensions-react/checkout`, `reactExtension`, hooks). `tsconfig` `checkJs:true`, `moduleResolution:"bundler"`.
- **Follow-up fixes applied:** `typescript` devDep corrected `^7.0.2``^5.6.3` (TS 7 doesn't exist on npm); dead `shopify.d.ts` (Preact-global shim, unused after the rewrite) deleted and dropped from `tsconfig` include.
- **Still pending:** manual QA on a Plus dev store; confirm `OrderStatus.jsx`'s `customer-account.order-status.block.render` target against current Shopify docs (currently `@ts-ignore`'d).
- **B9 | CI `typecheck:checkout`:**
- **Status**: **Should pass now** — extension has `package.json` + `tsconfig` + real `src/`. Confirm on the first CI run; it needs `npm ci` to install `@shopify/ui-extensions-react`, `react`, `typescript`.
### Next-Step Implementation ### Next-Step Implementation
- **P1.1 | Reconcile `checkout-datetime` with live types (B4):** - **P1.1 | Reconcile `checkout-datetime` with live types (B4):**
- **Status**: **Completed**. Codebase is typed and reconciled. (Note: Still requires manual QA on a Plus dev store). - **Status**: **Completed** (code). Manual QA on Plus still pending.
- **P2.4 | Multi-pin pickup-location map:** - **P2.4 | Multi-pin pickup-location selection (study §3.4):**
- **Status**: **Completed (Frontend)**. Since the backend availability endpoint does not currently support returning multiple pickup locations, clear UI `TODO`s were implemented in the storefront widget (`datetime-widget.ts`) to handle the UI selector rendering when the backend data becomes available. - **Status**: **Completed, end-to-end.**
- **Backend:** new pure `filterPickupLocationIds` + I/O `resolvePickupLocations` in `app/services/pickup-locations.server.ts` (active + has a PICKUP slot template + ProductRule `allowedLocationIds` + inventory exclusion). `resolveAvailabilityRequest` now returns `pickupLocations[]` on `method=PICKUP` responses and defaults the active location to the first eligible pickup point. `app/routes/apps.scheduling.locations.tsx` refactored to call the shared resolver (was a duplicate implementation).
- **Storefront widget:** renders a pickup-location chooser (name + address, optional multi-pin Google map) when >1 eligible point and none is block-configured; picking one re-requests availability for that location. New `dd-widget__pickup-locations` styles + `widget.choose_pickup_location` locale (en + fr).
- **Checkout extension:** same chooser in `Checkout.jsx` before the date list.
- **Tests:** `tests/unit/pickup-locations.test.ts` (5 cases). Full suite 161 green.
--- ---
@ -28,18 +36,17 @@ Based on the original `HANDOVER.md` and the recent UI workstreams implemented, h
- **3.4 | Test-mode toggles:** Remove `UNLOCK_ALL_FEATURES` from `.env` before public launch. - **3.4 | Test-mode toggles:** Remove `UNLOCK_ALL_FEATURES` from `.env` before public launch.
### 2. Known Bugs / Open Issues ### 2. Known Bugs / Open Issues
- **B1 | Storefront widget "Couldn't load available dates":** Blocked by 3.1 and 3.3. - **B1 | Storefront widget "Couldn't load available dates":** Blocked by 3.1 and 3.3 (server-side). Not code.
- **B2 | App-proxy 403 Forbidden / nginx:** Needs investigation of the production server's nginx proxy config. - **B2 | App-proxy 403 Forbidden / nginx:** Production server's nginx proxy config. Not code.
- **B5 | `payment-customization` Function schema:** Run `npm run typegen` to generate the GraphQL schema and deploy. - **B5 | `payment-customization` Function schema:** Run `npm --prefix extensions/payment-customization run typegen`, then `shopify app deploy`.
- **B6 | GDPR compliance webhooks:** Re-enable in `shopify.app.toml` once Protected Customer Data Access is granted. - **B6 | GDPR compliance webhooks:** Re-enable in `shopify.app.toml` once Protected Customer Data Access is granted. **Blocked (Shopify approval).**
- **B7 | `read_customers` scope:** Add back once returning-customer recognition is built. - **B7 | `read_customers` scope:** Add back with B6, when returning-customer recognition is built. **Blocked.**
- **B8 | Checkout snapshot stale horizon:** Build a periodic worker sweep. - **B8 | Checkout snapshot stale horizon:** Periodic worker sweep — needs offline-session storage first so the worker can get an Admin client per shop.
- **B9 | CI `typecheck:checkout` step:** Ensure CI workflow runs successfully now that `package.json` and `tsconfig.json` are fixed.
### 3. Next-Step Implementation (P1 - P4) ### 3. Next-Step Implementation (P1 - P4)
- **P1.2 | Deploy + smoke `payment-customization`:** Test on a dev store with a manual COD gateway. - **P1.2 | Deploy + smoke `payment-customization`:** Test on a dev store with a manual COD gateway.
- **P1.3 | Rates:** Decide between display-only rates (current) vs. real carrier pricing integration. - **P1.3 | Rates:** **Product decision required** — display-only (current: `delivery-customization` renames the option with a price label) vs. real `delivery-customization` pricing / carrier integration. Not startable without that call.
- **P2.5 | Returning-customer recognition:** Depends on B6/B7. - **P2.5 | Returning-customer recognition:** Blocked on B6/B7.
- **P2.6 | Cart Transform / deposits:** Implement v2. - **P2.6 | Cart Transform / deposits:** v2 milestone — draft orders + `cart-transform` Function. Its own task.
- **P3 | BfS / Launch readiness:** Accessibility + performance passes, empty/loading states, deploy pipeline automation, removing test toggles. - **P3 | BfS / Launch readiness:** a11y + perf passes (best against the live store — deferred per earlier direction), empty/loading states, deploy-pipeline automation (fold in `prisma migrate deploy`), remove `UNLOCK_ALL_FEATURES`.
- **P4 | v1.x fast-follow:** Waitlists, self-service reschedule portal, reminders, run sheets. - **P4 | v1.x fast-follow:** Waitlists, self-service reschedule portal (order-status + magic link), email/SMS reminders (BullMQ + a `Notification` model), printable run sheets, holiday auto-import. Each is its own milestone (`IMPLEMENTATION_PLAN.md` Phase 9).

View File

@ -1,11 +1,20 @@
import type { LoaderFunctionArgs } from "@remix-run/node"; import type { LoaderFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server"; import { authenticate } from "../shopify.server";
import db from "../db.server"; import db from "../db.server";
import { resolveProductRuleConstraints, resolveProductRefs } from "../services/product-rules.server"; import {
import { productRefsFromCartLines } from "../services/product-rules.server"; resolveProductRuleConstraints,
resolveProductRefs,
productRefsFromCartLines,
} from "../services/product-rules.server";
import { parseCartLinesParam, parseProductIdsParam, parseGidListParam } from "../lib/cart-rule-params"; import { parseCartLinesParam, parseProductIdsParam, parseGidListParam } from "../lib/cart-rule-params";
import { excludeLocationsWithoutStock } from "../services/zones.server"; import { resolvePickupLocations } from "../services/pickup-locations.server";
// Standalone list of eligible pickup points (study §3.4 multi-pin pickup).
// The same data is also returned inline on `method=PICKUP` availability
// responses (`pickupLocations`) — this endpoint stays for callers that just
// want the list without asking for a specific location's calendar. Shared
// resolution logic lives in pickup-locations.server.ts so the two paths
// can't drift.
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session, admin } = await authenticate.public.appProxy(request); const { session, admin } = await authenticate.public.appProxy(request);
if (!session) { if (!session) {
@ -13,53 +22,27 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
} }
const url = new URL(request.url); const url = new URL(request.url);
const method = "PICKUP";
const productIds = parseProductIdsParam(url.searchParams.get("productIds")); const productIds = parseProductIdsParam(url.searchParams.get("productIds"));
const productRefs = admin && productIds.length > 0 ? await resolveProductRefs(admin, productIds) : []; const productRefs = admin && productIds.length > 0 ? await resolveProductRefs(admin, productIds) : [];
const cartProductRefs = [
...productRefs,
...productRefsFromCartLines(parseCartLinesParam(url.searchParams.get("cartLines"))),
];
const productRules = await db.productRule.findMany({ where: { shopDomain: session.shop, active: true } }); const productRules = await db.productRule.findMany({ where: { shopDomain: session.shop, active: true } });
const cartProductRefs = [...productRefs, ...productRefsFromCartLines(parseCartLinesParam(url.searchParams.get("cartLines")))];
const ruleConstraints = resolveProductRuleConstraints(productRules, cartProductRefs); const ruleConstraints = resolveProductRuleConstraints(productRules, cartProductRefs);
if (ruleConstraints.allowedMethods != null && !ruleConstraints.allowedMethods.includes(method)) { // A rule that forbids PICKUP for this cart ⇒ no pickup points at all.
if (ruleConstraints.allowedMethods != null && !ruleConstraints.allowedMethods.includes("PICKUP")) {
return Response.json({ locations: [] }); return Response.json({ locations: [] });
} }
const locationWhere = ruleConstraints.allowedLocationIds != null ? { id: { in: ruleConstraints.allowedLocationIds } } : {}; const locations = await resolvePickupLocations(session.shop, {
allowedLocationIds: ruleConstraints.allowedLocationIds,
// Only locations that offer PICKUP (they have slot templates for it) admin: admin ?? undefined,
// Wait, the location model doesn't store methods, methods are on SlotTemplate. productVariantGids: parseGidListParam(url.searchParams.get("variantIds")),
// We can just fetch all active locations, and then filter by those that have slot templates for PICKUP.
const candidates = await db.location.findMany({
where: { shopDomain: session.shop, active: true, ...locationWhere },
orderBy: { createdAt: "asc" },
}); });
const templates = await db.slotTemplate.findMany({ return Response.json({ locations });
where: { shopDomain: session.shop, method },
select: { locationId: true }
});
const locationsWithPickup = new Set(templates.map(t => t.locationId));
const eligibleCandidates = candidates.filter(c => locationsWithPickup.has(c.id));
const productVariantGids = parseGidListParam(url.searchParams.get("variantIds"));
const stockCheckEnabled = Boolean(admin) && productVariantGids.length > 0;
const stocked = stockCheckEnabled
? await excludeLocationsWithoutStock(admin!, eligibleCandidates.map(c => c.id), productVariantGids)
: new Set(eligibleCandidates.map(c => c.id));
const finalLocations = eligibleCandidates
.filter(c => stocked.has(c.id))
.map(c => ({
id: c.id,
name: c.name,
lat: c.lat,
lng: c.lng,
address: c.address,
}));
return Response.json({ locations: finalLocations });
}; };

View File

@ -11,6 +11,7 @@ import {
import { resolveRate } from "./rates.server"; import { resolveRate } from "./rates.server";
import { formatPriceLabel } from "../lib/currency"; import { formatPriceLabel } from "../lib/currency";
import { resolveProductRuleConstraints, productRefsFromCartLines, type ProductRef } from "./product-rules.server"; import { resolveProductRuleConstraints, productRefsFromCartLines, type ProductRef } from "./product-rules.server";
import { resolvePickupLocations, type PickupLocationDto } from "./pickup-locations.server";
// The single availability resolver every surface calls — storefront widget // The single availability resolver every surface calls — storefront widget
// (via apps.scheduling.availability.tsx, app-proxy auth), POS (via // (via apps.scheduling.availability.tsx, app-proxy auth), POS (via
@ -62,6 +63,13 @@ export interface AvailabilityResult {
zoneId: string | null; zoneId: string | null;
distanceKm: number | null; distanceKm: number | null;
rate: { name: string; priceCents: number; label: string } | null; rate: { name: string; priceCents: number; label: string } | null;
/**
* All eligible pickup points, for the multi-pin selector (study §3.4).
* Present only for `method === "PICKUP"`. `dates` above is for whichever
* one is currently active (`locationId` / `locationName`); the caller
* re-requests with `locationId` when the shopper picks a different pin.
*/
pickupLocations?: PickupLocationDto[];
error?: string; error?: string;
} }
@ -104,6 +112,7 @@ export async function resolveAvailabilityRequest(
let location: Awaited<ReturnType<typeof db.location.findFirst>> = null; let location: Awaited<ReturnType<typeof db.location.findFirst>> = null;
let zoneId: string | null = null; let zoneId: string | null = null;
let distanceKm: number | null = null; let distanceKm: number | null = null;
let pickupLocations: PickupLocationDto[] | undefined;
// Inventory-based location exclusion (study §3.4): only runs on the // 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 // app-proxy surface, which is the only one that hands us an Admin client
@ -153,6 +162,20 @@ export async function resolveAvailabilityRequest(
error: "This address is outside our delivery area right now.", error: "This address is outside our delivery area right now.",
}; };
} }
} else if (method === "PICKUP") {
// Multi-pin pickup (study §3.4): resolve every eligible pickup point so
// the widget/checkout can offer a chooser, then narrow the date picker
// to the shopper's pick (or the first eligible one).
pickupLocations = await resolvePickupLocations(shopDomain, {
allowedLocationIds: ruleConstraints.allowedLocationIds,
admin: params.admin,
productVariantGids: params.productVariantGids,
});
const activeId =
locationIdParam && pickupLocations.some((l) => l.id === locationIdParam)
? locationIdParam
: pickupLocations[0]?.id;
location = activeId ? await db.location.findFirst({ where: { id: activeId, shopDomain, active: true } }) : null;
} else { } else {
const locationWhere = const locationWhere =
ruleConstraints.allowedLocationIds != null ? { id: { in: ruleConstraints.allowedLocationIds } } : {}; ruleConstraints.allowedLocationIds != null ? { id: { in: ruleConstraints.allowedLocationIds } } : {};
@ -174,12 +197,15 @@ export async function resolveAvailabilityRequest(
zoneId: null, zoneId: null,
distanceKm: null, distanceKm: null,
rate: null, rate: null,
pickupLocations: method === "PICKUP" ? [] : undefined,
error: error:
ruleConstraints.allowedLocationIds != null ruleConstraints.allowedLocationIds != null
? "One or more items in your cart aren't available at this location." ? "One or more items in your cart aren't available at this location."
: stockCheckEnabled : stockCheckEnabled
? "No location currently stocks every item in your cart." ? "No location currently stocks every item in your cart."
: "No active location configured", : method === "PICKUP"
? "No pickup location is available right now."
: "No active location configured",
}; };
} }
@ -280,5 +306,6 @@ export async function resolveAvailabilityRequest(
rate: matchedRate rate: matchedRate
? { name: matchedRate.name, priceCents: matchedRate.priceCents, label: formatPriceLabel(matchedRate.priceCents) } ? { name: matchedRate.name, priceCents: matchedRate.priceCents, label: formatPriceLabel(matchedRate.priceCents) }
: null, : null,
pickupLocations,
}; };
} }

View File

@ -0,0 +1,92 @@
import type { Method } from "@prisma/client";
import db from "../db.server";
import { excludeLocationsWithoutStock, type AdminGraphQLClient } from "./zones.server";
// Multi-pin pickup selection (study §3.4 "Store pickup locations are shown
// visually on Google Maps"). A shop can have several pickup points; the
// shopper should be able to choose one before the date picker narrows to
// that location's calendar. This resolves the *set* of eligible pickup
// locations; resolveAvailabilityRequest then still returns one location's
// worth of dates (the chosen one, or the first eligible).
export interface PickupLocationDto {
id: string;
name: string;
address: string;
lat: number | null;
lng: number | null;
}
export interface PickupLocationFilterInput {
/** All active locations for the shop (already narrowed by any ProductRule allowedLocationIds). */
candidates: Array<{ id: string }>;
/** Location ids that have at least one PICKUP SlotTemplate — a location with no pickup slots can't be picked up from. */
pickupTemplateLocationIds: Set<string>;
/** Location ids that stock the cart (identity set when the inventory check is off). */
stockedLocationIds: Set<string>;
}
/**
* Pure intersection: a pickup location must be an active candidate, offer
* PICKUP slots, and stock the cart. Order is preserved from `candidates`
* (which the caller sorts by createdAt).
*/
export function filterPickupLocationIds(input: PickupLocationFilterInput): string[] {
const { candidates, pickupTemplateLocationIds, stockedLocationIds } = input;
return candidates
.map((c) => c.id)
.filter((id) => pickupTemplateLocationIds.has(id) && stockedLocationIds.has(id));
}
/**
* The eligible pickup locations for a shop, oldest-first. Applies the same
* ProductRule location restriction and inventory exclusion as the single-
* location path in resolveAvailabilityRequest, so the map/list the shopper
* sees never offers a location the order can't actually use.
*/
export async function resolvePickupLocations(
shopDomain: string,
opts: {
allowedLocationIds?: string[] | null;
admin?: AdminGraphQLClient;
productVariantGids?: string[];
} = {},
): Promise<PickupLocationDto[]> {
const locationWhere =
opts.allowedLocationIds != null ? { id: { in: opts.allowedLocationIds } } : {};
const [candidates, pickupTemplates] = await Promise.all([
db.location.findMany({
where: { shopDomain, active: true, ...locationWhere },
orderBy: { createdAt: "asc" },
select: { id: true, name: true, address: true, lat: true, lng: true },
}),
db.slotTemplate.findMany({
where: { shopDomain, method: "PICKUP" as Method },
select: { locationId: true },
}),
]);
const pickupTemplateLocationIds = new Set(pickupTemplates.map((t) => t.locationId));
const stockCheckEnabled = Boolean(opts.admin) && (opts.productVariantGids?.length ?? 0) > 0;
const stockedLocationIds = stockCheckEnabled
? await excludeLocationsWithoutStock(
opts.admin!,
candidates.map((c) => c.id),
opts.productVariantGids!,
)
: new Set(candidates.map((c) => c.id));
const eligibleIds = new Set(
filterPickupLocationIds({
candidates,
pickupTemplateLocationIds,
stockedLocationIds,
}),
);
return candidates
.filter((c) => eligibleIds.has(c.id))
.map((c) => ({ id: c.id, name: c.name, address: c.address, lat: c.lat, lng: c.lng }));
}

View File

@ -14,6 +14,7 @@
"postal_code": "Enter your postal/ZIP code", "postal_code": "Enter your postal/ZIP code",
"postal_code_submit": "Check availability", "postal_code_submit": "Check availability",
"out_of_area": "Sorry, we don't deliver to this address.", "out_of_area": "Sorry, we don't deliver to this address.",
"choose_pickup_location": "Choose a pickup location",
"confirmation_pickup": "Pickup", "confirmation_pickup": "Pickup",
"confirmation_delivery": "Delivery", "confirmation_delivery": "Delivery",
"confirmation_shipping": "Shipping" "confirmation_shipping": "Shipping"

View File

@ -12,6 +12,6 @@
"react": "^18.3.1" "react": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^7.0.2" "typescript": "^5.6.3"
} }
} }

View File

@ -1,19 +0,0 @@
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 };
}

View File

@ -4,6 +4,8 @@ api_version = "2025-10"
type = "ui_extension" type = "ui_extension"
name = "t:name" name = "t:name"
handle = "checkout-datetime" handle = "checkout-datetime"
uid = "14165501-9e99-cb7d-e2bf-2e25f3028ca218bc5e8a"
description = "Native delivery date & time picker in checkout (Plus), plus confirmation blocks on the Thank you and Order status pages (all plans)." 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, # The picker calls this app's own backend (checkout.scheduling.* routes,

View File

@ -32,6 +32,7 @@ function Extension() {
const [method, setMethod] = useState(initialMethod); const [method, setMethod] = useState(initialMethod);
const [postalCode, setPostalCode] = useState(""); const [postalCode, setPostalCode] = useState("");
const [pickupLocationId, setPickupLocationId] = useState(/** @type {string | null} */ (null));
const [availability, setAvailability] = useState(/** @type {any} */ (null)); const [availability, setAvailability] = useState(/** @type {any} */ (null));
const [date, setDate] = useState(""); const [date, setDate] = useState("");
const [status, setStatus] = useState("idle"); // idle|loading|error const [status, setStatus] = useState("idle"); // idle|loading|error
@ -39,17 +40,26 @@ function Extension() {
const needsPostal = method === "LOCAL_DELIVERY"; const needsPostal = method === "LOCAL_DELIVERY";
async function loadAvailability() { /** @param {string | null} [locId] */
async function loadAvailability(locId) {
setStatus("loading"); setStatus("loading");
setAvailability(null); setAvailability(null);
setDate(""); setDate("");
try { try {
const params = new URLSearchParams({ method, days: "14" }); const params = new URLSearchParams({ method, days: "14" });
if (needsPostal && postalCode) params.set("postalCode", postalCode); if (needsPostal && postalCode) params.set("postalCode", postalCode);
if (locId) params.set("locationId", locId);
const base = backendBase(settings); const base = backendBase(settings);
const token = await sessionToken.get(); const token = await sessionToken.get();
const res = await authedFetch(base, token, `/checkout/scheduling/availability?${params.toString()}`); const res = await authedFetch(base, token, `/checkout/scheduling/availability?${params.toString()}`);
const body = await res.json(); const body = await res.json();
// Multi-pin pickup (study §3.4): more than one eligible pickup point and
// none picked yet let the shopper choose before we show a calendar.
if (method === "PICKUP" && !locId && (body.pickupLocations?.length ?? 0) > 1) {
setAvailability(body);
setStatus("idle");
return;
}
if (!res.ok || !body.locationId) { if (!res.ok || !body.locationId) {
setStatus("idle"); setStatus("idle");
setAvailability({ error: body.error || translate("out_of_area"), dates: {} }); setAvailability({ error: body.error || translate("out_of_area"), dates: {} });
@ -63,10 +73,14 @@ function Extension() {
} }
useEffect(() => { useEffect(() => {
setPickupLocationId(null);
if (!needsPostal) loadAvailability(); if (!needsPostal) loadAvailability();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [method]); }, [method]);
const pickupLocations = availability?.pickupLocations ?? [];
const needsPickupChoice = method === "PICKUP" && !pickupLocationId && pickupLocations.length > 1;
/** @type {string[]} */ /** @type {string[]} */
const dates = useMemo(() => Object.keys(availability?.dates ?? {}).sort(), [availability]); const dates = useMemo(() => Object.keys(availability?.dates ?? {}).sort(), [availability]);
/** @type {any[]} */ /** @type {any[]} */
@ -172,7 +186,25 @@ function Extension() {
{status === "error" && <Banner status="critical">{translate("error")}</Banner>} {status === "error" && <Banner status="critical">{translate("error")}</Banner>}
{availability?.error && <Banner status="warning">{availability.error}</Banner>} {availability?.error && <Banner status="warning">{availability.error}</Banner>}
{dates.length > 0 && ( {needsPickupChoice && <Text>{translate("choose_pickup_location")}</Text>}
{needsPickupChoice && (
<ChoiceList
name="dd-pickup-location"
value=""
onChange={(/** @type {string} */ val) => {
setPickupLocationId(val);
loadAvailability(val);
}}
>
{pickupLocations.map((/** @type {any} */ loc) => (
<Choice key={loc.id} id={loc.id}>
{loc.name}{loc.address ? `${loc.address}` : ""}
</Choice>
))}
</ChoiceList>
)}
{!needsPickupChoice && dates.length > 0 && (
<ChoiceList name="dd-date" value={date} onChange={(/** @type {string} */ val) => setDate(val)}> <ChoiceList name="dd-date" value={date} onChange={(/** @type {string} */ val) => setDate(val)}>
{dates.map((d) => ( {dates.map((d) => (
<Choice key={d} id={d}> <Choice key={d} id={d}>
@ -181,7 +213,7 @@ function Extension() {
))} ))}
</ChoiceList> </ChoiceList>
)} )}
{availability && !availability.error && dates.length === 0 && status === "idle" && ( {!needsPickupChoice && availability && !availability.error && dates.length === 0 && status === "idle" && (
<Text>{translate("no_dates")}</Text> <Text>{translate("no_dates")}</Text>
)} )}

View File

@ -10,6 +10,6 @@
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src/**/*", "shopify.d.ts"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@ -119,3 +119,37 @@
.dd-widget__preview-item { .dd-widget__preview-item {
font-size: 0.9rem; font-size: 0.9rem;
} }
.dd-widget__pickup-locations {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.dd-widget__pickup-location {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
text-align: left;
padding: 0.5rem 0.75rem;
border: 1px solid currentColor;
border-radius: 8px;
background: transparent;
cursor: pointer;
font: inherit;
color: inherit;
}
.dd-widget__pickup-location[aria-pressed="true"] {
outline: 2px solid currentColor;
}
.dd-widget__pickup-location-name {
font-weight: 600;
}
.dd-widget__pickup-location-address {
font-size: 0.8rem;
opacity: 0.75;
}

File diff suppressed because one or more lines are too long

View File

@ -41,6 +41,7 @@
data-label-postal-code="{{ 'widget.postal_code' | t | escape }}" data-label-postal-code="{{ 'widget.postal_code' | t | escape }}"
data-label-postal-code-submit="{{ 'widget.postal_code_submit' | t | escape }}" data-label-postal-code-submit="{{ 'widget.postal_code_submit' | t | escape }}"
data-label-out-of-area="{{ 'widget.out_of_area' | t | escape }}" data-label-out-of-area="{{ 'widget.out_of_area' | t | escape }}"
data-label-choose-pickup-location="{{ 'widget.choose_pickup_location' | t | escape }}"
{% if block.settings.location_id != blank %}data-location-id="{{ block.settings.location_id | escape }}"{% endif %} {% if block.settings.location_id != blank %}data-location-id="{{ block.settings.location_id | escape }}"{% endif %}
{% if block.settings.google_maps_api_key != blank %}data-google-maps-api-key="{{ block.settings.google_maps_api_key | escape }}"{% endif %} {% if block.settings.google_maps_api_key != blank %}data-google-maps-api-key="{{ block.settings.google_maps_api_key | escape }}"{% endif %}
></template> ></template>

View File

@ -21,6 +21,7 @@
data-label-postal-code="{{ 'widget.postal_code' | t | escape }}" data-label-postal-code="{{ 'widget.postal_code' | t | escape }}"
data-label-postal-code-submit="{{ 'widget.postal_code_submit' | t | escape }}" data-label-postal-code-submit="{{ 'widget.postal_code_submit' | t | escape }}"
data-label-out-of-area="{{ 'widget.out_of_area' | t | escape }}" data-label-out-of-area="{{ 'widget.out_of_area' | t | escape }}"
data-label-choose-pickup-location="{{ 'widget.choose_pickup_location' | t | escape }}"
{% if block.settings.location_id != blank %}data-location-id="{{ block.settings.location_id | escape }}"{% endif %} {% if block.settings.location_id != blank %}data-location-id="{{ block.settings.location_id | escape }}"{% endif %}
{% if block.settings.google_maps_api_key != blank %}data-google-maps-api-key="{{ block.settings.google_maps_api_key | escape }}"{% endif %} {% if block.settings.google_maps_api_key != blank %}data-google-maps-api-key="{{ block.settings.google_maps_api_key | escape }}"{% endif %}
{{ block.shopify_attributes }} {{ block.shopify_attributes }}

View File

@ -18,7 +18,8 @@
"postal_code_submit": "Check availability", "postal_code_submit": "Check availability",
"out_of_area": "Sorry, we don't deliver to this address.", "out_of_area": "Sorry, we don't deliver to this address.",
"earliest_prefix": "Earliest", "earliest_prefix": "Earliest",
"delivery_at_checkout": "Enter your address at checkout to see local delivery dates." "delivery_at_checkout": "Enter your address at checkout to see local delivery dates.",
"choose_pickup_location": "Choose a pickup location"
}, },
"order_confirmation": { "order_confirmation": {
"pickup_label": "Pickup", "pickup_label": "Pickup",

View File

@ -18,7 +18,8 @@
"postal_code_submit": "Vérifier la disponibilité", "postal_code_submit": "Vérifier la disponibilité",
"out_of_area": "Désolé, nous ne livrons pas à cette adresse.", "out_of_area": "Désolé, nous ne livrons pas à cette adresse.",
"earliest_prefix": "Au plus tôt", "earliest_prefix": "Au plus tôt",
"delivery_at_checkout": "Saisissez votre adresse au paiement pour voir les dates de livraison locale." "delivery_at_checkout": "Saisissez votre adresse au paiement pour voir les dates de livraison locale.",
"choose_pickup_location": "Choisissez un point de retrait"
}, },
"order_confirmation": { "order_confirmation": {
"pickup_label": "Retrait", "pickup_label": "Retrait",

View File

@ -3,6 +3,8 @@ api_version = "2026-07"
[[extensions]] [[extensions]]
name = "t:name" name = "t:name"
handle = "payment-customization" handle = "payment-customization"
uid = "4f8a281d-8599-ed4f-dd32-1297c2872b96939fbf00"
type = "function" type = "function"
description = "t:description" description = "t:description"

View File

@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { filterPickupLocationIds } from "../../app/services/pickup-locations.server";
const candidates = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }];
describe("filterPickupLocationIds", () => {
it("keeps only candidates that both offer PICKUP slots and stock the cart, in candidate order", () => {
const result = filterPickupLocationIds({
candidates,
pickupTemplateLocationIds: new Set(["a", "b", "c"]),
stockedLocationIds: new Set(["b", "c", "d"]),
});
expect(result).toEqual(["b", "c"]);
});
it("drops a location with no PICKUP slot template even if it's in stock", () => {
const result = filterPickupLocationIds({
candidates,
pickupTemplateLocationIds: new Set(["a"]),
stockedLocationIds: new Set(["a", "b", "c", "d"]),
});
expect(result).toEqual(["a"]);
});
it("drops a pickup-capable location that stocks none of the cart", () => {
const result = filterPickupLocationIds({
candidates,
pickupTemplateLocationIds: new Set(["a", "b"]),
stockedLocationIds: new Set(["b"]),
});
expect(result).toEqual(["b"]);
});
it("returns [] when nothing qualifies", () => {
expect(
filterPickupLocationIds({
candidates,
pickupTemplateLocationIds: new Set(["a"]),
stockedLocationIds: new Set(["b"]),
}),
).toEqual([]);
});
it("preserves the candidate ordering (oldest-first) rather than set iteration order", () => {
const result = filterPickupLocationIds({
candidates: [{ id: "d" }, { id: "c" }, { id: "b" }, { id: "a" }],
pickupTemplateLocationIds: new Set(["a", "b", "c", "d"]),
stockedLocationIds: new Set(["a", "b", "c", "d"]),
});
expect(result).toEqual(["d", "c", "b", "a"]);
});
});

View File

@ -34,11 +34,21 @@ interface RateDto {
label: string; label: string;
} }
interface PickupLocationDto {
id: string;
name: string;
address: string;
lat: number | null;
lng: number | null;
}
interface AvailabilityResponse { interface AvailabilityResponse {
locationId: string | null; locationId: string | null;
locationName?: string; locationName?: string;
// TODO: Add pickupLocations array when backend supports returning multiple pickup locations for selection. // Every eligible pickup point (study §3.4 multi-pin pickup). Present only
// pickupLocations?: Array<{ id: string, name: string, lat: number | null, lng: number | null }>; // for method === "PICKUP"; `dates` is for whichever one is currently
// active — re-request with `locationId` to switch.
pickupLocations?: PickupLocationDto[];
locationLat?: number | null; locationLat?: number | null;
locationLng?: number | null; locationLng?: number | null;
timezone?: string; timezone?: string;
@ -76,6 +86,7 @@ interface WidgetConfig {
outOfArea: string; outOfArea: string;
earliestPrefix: string; earliestPrefix: string;
deliveryAtCheckout: string; deliveryAtCheckout: string;
choosePickupLocation: string;
}; };
} }
@ -177,6 +188,7 @@ function readConfig(root: HTMLElement): WidgetConfig {
outOfArea: get("labelOutOfArea", "Sorry, we don't deliver to this address."), outOfArea: get("labelOutOfArea", "Sorry, we don't deliver to this address."),
earliestPrefix: get("labelEarliestPrefix", "Earliest"), earliestPrefix: get("labelEarliestPrefix", "Earliest"),
deliveryAtCheckout: get("labelDeliveryAtCheckout", "Enter your address at checkout to see local delivery dates."), deliveryAtCheckout: get("labelDeliveryAtCheckout", "Enter your address at checkout to see local delivery dates."),
choosePickupLocation: get("labelChoosePickupLocation", "Choose a pickup location"),
}, },
}; };
} }
@ -300,12 +312,22 @@ interface GoogleMapsGlobal {
}; };
} }
async function renderPickupMap(container: HTMLElement, apiKey: string, lat: number, lng: number, title: string) { async function renderPickupMap(
container: HTMLElement,
apiKey: string,
points: Array<{ lat: number; lng: number; title: string }>,
) {
if (points.length === 0) {
container.hidden = true;
return;
}
try { try {
await loadGoogleMaps(apiKey); await loadGoogleMaps(apiKey);
const google = (window as unknown as { google: GoogleMapsGlobal }).google; const google = (window as unknown as { google: GoogleMapsGlobal }).google;
const map = new google.maps.Map(container, { center: { lat, lng }, zoom: 13 }); const map = new google.maps.Map(container, { center: { lat: points[0].lat, lng: points[0].lng }, zoom: 13 });
new google.maps.Marker({ position: { lat, lng }, map, title }); for (const p of points) {
new google.maps.Marker({ position: { lat: p.lat, lng: p.lng }, map, title: p.title });
}
} catch { } catch {
container.hidden = true; // no map, no crash — the date/time picker still works fine without it container.hidden = true; // no map, no crash — the date/time picker still works fine without it
} }
@ -317,6 +339,7 @@ class DateTimeWidget {
heading: document.createElement("h3"), heading: document.createElement("h3"),
methodRow: document.createElement("div"), methodRow: document.createElement("div"),
postalRow: document.createElement("div"), postalRow: document.createElement("div"),
pickupRow: document.createElement("div"),
mapContainer: document.createElement("div"), mapContainer: document.createElement("div"),
dateRow: document.createElement("div"), dateRow: document.createElement("div"),
timeRow: document.createElement("div"), timeRow: document.createElement("div"),
@ -325,6 +348,7 @@ class DateTimeWidget {
}; };
private selectedMethod: WidgetConfig["methods"][number] | null = null; private selectedMethod: WidgetConfig["methods"][number] | null = null;
private selectedDate: string | null = null; private selectedDate: string | null = null;
private selectedPickupLocationId: string | null = null;
private availability: AvailabilityResponse | null = null; private availability: AvailabilityResponse | null = null;
private heldSlot: private heldSlot:
| { locationId: string; method: Method; date: string; startMin: number; cartToken: string; zoneId: string | null } | { locationId: string; method: Method; date: string; startMin: number; cartToken: string; zoneId: string | null }
@ -355,6 +379,8 @@ class DateTimeWidget {
this.el.methodRow.className = "dd-widget__row dd-widget__methods"; this.el.methodRow.className = "dd-widget__row dd-widget__methods";
this.el.postalRow.className = "dd-widget__row dd-widget__postal"; this.el.postalRow.className = "dd-widget__row dd-widget__postal";
this.el.postalRow.hidden = true; this.el.postalRow.hidden = true;
this.el.pickupRow.className = "dd-widget__row dd-widget__pickup-locations";
this.el.pickupRow.hidden = true;
this.el.mapContainer.className = "dd-widget__map"; this.el.mapContainer.className = "dd-widget__map";
this.el.mapContainer.hidden = true; this.el.mapContainer.hidden = true;
this.el.dateRow.className = "dd-widget__row dd-widget__dates"; this.el.dateRow.className = "dd-widget__row dd-widget__dates";
@ -367,6 +393,7 @@ class DateTimeWidget {
this.el.confirmation, this.el.confirmation,
this.el.methodRow, this.el.methodRow,
this.el.postalRow, this.el.postalRow,
this.el.pickupRow,
this.el.mapContainer, this.el.mapContainer,
this.el.dateRow, this.el.dateRow,
this.el.timeRow, this.el.timeRow,
@ -443,8 +470,11 @@ class DateTimeWidget {
private async selectMethod(method: WidgetConfig["methods"][number]) { private async selectMethod(method: WidgetConfig["methods"][number]) {
this.selectedMethod = method; this.selectedMethod = method;
this.selectedDate = null; this.selectedDate = null;
this.selectedPickupLocationId = null;
this.el.timeRow.innerHTML = ""; this.el.timeRow.innerHTML = "";
this.el.dateRow.innerHTML = ""; this.el.dateRow.innerHTML = "";
this.el.pickupRow.hidden = true;
this.el.pickupRow.innerHTML = "";
this.el.mapContainer.hidden = true; this.el.mapContainer.hidden = true;
this.el.confirmation.hidden = false; this.el.confirmation.hidden = false;
this.el.confirmation.hidden = true; this.el.confirmation.hidden = true;
@ -489,23 +519,40 @@ class DateTimeWidget {
this.el.postalRow.append(input, button); this.el.postalRow.append(input, button);
} }
private async loadAvailability(method: WidgetConfig["methods"][number], postalCode?: string) { private async loadAvailability(
method: WidgetConfig["methods"][number],
postalCode?: string,
pickupLocationId?: string,
) {
this.el.status.textContent = this.config.labels.loading; this.el.status.textContent = this.config.labels.loading;
this.el.dateRow.innerHTML = ""; this.el.dateRow.innerHTML = "";
try { try {
const cart = await fetchCartInfo(); const cart = await fetchCartInfo();
this.availability = await fetchAvailability(method.value, this.config.locationId, cart, postalCode); // A block-configured fixed locationId always wins; otherwise use the
// shopper's pin choice (multi-pin pickup), else let the backend default.
const locationId = this.config.locationId ?? pickupLocationId ?? null;
this.availability = await fetchAvailability(method.value, locationId, cart, postalCode);
// Multi-pin pickup (study §3.4): when the shop has more than one
// eligible pickup point and the shopper hasn't picked one yet (and the
// block didn't hard-code one), show the chooser before the calendar.
const pickupLocations = this.availability.pickupLocations ?? [];
if (
method.value === "PICKUP" &&
!this.config.locationId &&
!this.selectedPickupLocationId &&
pickupLocations.length > 1
) {
this.renderPickupLocations(method, pickupLocations);
return;
}
if (!this.availability.locationId) { if (!this.availability.locationId) {
this.el.status.textContent = this.availability.error || this.config.labels.outOfArea; this.el.status.textContent = this.availability.error || this.config.labels.outOfArea;
return; return;
} }
// TODO: When backend supports `pickupLocations`, implement a UI selector (list/map)
// to let the user choose a location before rendering dates. Currently, it just defaults
// to the single returned location.
if (method.value === "PICKUP" && this.config.googleMapsApiKey) { if (method.value === "PICKUP" && this.config.googleMapsApiKey) {
this.showPickupMap(); this.showPickupMap();
} }
@ -516,11 +563,60 @@ class DateTimeWidget {
} }
} }
private renderPickupLocations(
method: WidgetConfig["methods"][number],
locations: PickupLocationDto[],
) {
this.el.pickupRow.hidden = false;
this.el.pickupRow.innerHTML = "";
this.el.status.textContent = this.config.labels.choosePickupLocation;
if (this.config.googleMapsApiKey) {
const points = locations
.filter((l): l is PickupLocationDto & { lat: number; lng: number } => l.lat != null && l.lng != null)
.map((l) => ({ lat: l.lat, lng: l.lng, title: l.name }));
if (points.length > 0) {
this.el.mapContainer.hidden = false;
void renderPickupMap(this.el.mapContainer, this.config.googleMapsApiKey, points);
}
}
for (const loc of locations) {
const button = document.createElement("button");
button.type = "button";
button.className = "dd-widget__pickup-location";
button.setAttribute("aria-pressed", String(this.selectedPickupLocationId === loc.id));
const name = document.createElement("span");
name.className = "dd-widget__pickup-location-name";
name.textContent = loc.name;
const addr = document.createElement("span");
addr.className = "dd-widget__pickup-location-address";
addr.textContent = loc.address;
button.append(name, addr);
button.addEventListener("click", async () => {
this.selectedPickupLocationId = loc.id;
this.el.pickupRow.hidden = true;
await this.loadAvailability(method, undefined, loc.id);
});
this.el.pickupRow.appendChild(button);
}
}
private showPickupMap() { private showPickupMap() {
const a = this.availability; const a = this.availability;
if (!a?.locationLat || !a?.locationLng || !this.config.googleMapsApiKey) return; if (!this.config.googleMapsApiKey) return;
// Prefer plotting every pin; fall back to the single active location.
const points = (a?.pickupLocations ?? [])
.filter((l): l is PickupLocationDto & { lat: number; lng: number } => l.lat != null && l.lng != null)
.map((l) => ({ lat: l.lat, lng: l.lng, title: l.name }));
if (points.length === 0 && a?.locationLat != null && a?.locationLng != null) {
points.push({ lat: a.locationLat, lng: a.locationLng, title: a.locationName ?? "" });
}
if (points.length === 0) return;
this.el.mapContainer.hidden = false; this.el.mapContainer.hidden = false;
void renderPickupMap(this.el.mapContainer, this.config.googleMapsApiKey, a.locationLat, a.locationLng, a.locationName ?? ""); void renderPickupMap(this.el.mapContainer, this.config.googleMapsApiKey, points);
} }
private renderDates() { private renderDates() {