feat: finish multi-pin pickup end-to-end; fix checkout-datetime followups
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
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:
parent
808a3b7001
commit
b29962bf9c
@ -1,20 +1,28 @@
|
||||
# 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
|
||||
|
||||
### Known Bugs / Open Issues
|
||||
- **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`.
|
||||
- **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** (`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:**
|
||||
- **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
|
||||
- **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).
|
||||
- **P2.4 | Multi-pin pickup-location map:**
|
||||
- **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** (code). Manual QA on Plus still pending.
|
||||
- **P2.4 | Multi-pin pickup-location selection (study §3.4):**
|
||||
- **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.
|
||||
|
||||
### 2. Known Bugs / Open Issues
|
||||
- **B1 | Storefront widget "Couldn't load available dates":** Blocked by 3.1 and 3.3.
|
||||
- **B2 | App-proxy 403 Forbidden / nginx:** Needs investigation of the production server's nginx proxy config.
|
||||
- **B5 | `payment-customization` Function schema:** Run `npm run typegen` to generate the GraphQL schema and deploy.
|
||||
- **B6 | GDPR compliance webhooks:** Re-enable in `shopify.app.toml` once Protected Customer Data Access is granted.
|
||||
- **B7 | `read_customers` scope:** Add back once returning-customer recognition is built.
|
||||
- **B8 | Checkout snapshot stale horizon:** Build a periodic worker sweep.
|
||||
- **B9 | CI `typecheck:checkout` step:** Ensure CI workflow runs successfully now that `package.json` and `tsconfig.json` are fixed.
|
||||
- **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:** Production server's nginx proxy config. Not code.
|
||||
- **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. **Blocked (Shopify approval).**
|
||||
- **B7 | `read_customers` scope:** Add back with B6, when returning-customer recognition is built. **Blocked.**
|
||||
- **B8 | Checkout snapshot stale horizon:** Periodic worker sweep — needs offline-session storage first so the worker can get an Admin client per shop.
|
||||
|
||||
### 3. Next-Step Implementation (P1 - P4)
|
||||
- **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.
|
||||
- **P2.5 | Returning-customer recognition:** Depends on B6/B7.
|
||||
- **P2.6 | Cart Transform / deposits:** Implement v2.
|
||||
- **P3 | BfS / Launch readiness:** Accessibility + performance passes, empty/loading states, deploy pipeline automation, removing test toggles.
|
||||
- **P4 | v1.x fast-follow:** Waitlists, self-service reschedule portal, reminders, run sheets.
|
||||
- **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:** Blocked on B6/B7.
|
||||
- **P2.6 | Cart Transform / deposits:** v2 milestone — draft orders + `cart-transform` Function. Its own task.
|
||||
- **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 (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).
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { resolveProductRuleConstraints, resolveProductRefs } from "../services/product-rules.server";
|
||||
import { productRefsFromCartLines } from "../services/product-rules.server";
|
||||
import {
|
||||
resolveProductRuleConstraints,
|
||||
resolveProductRefs,
|
||||
productRefsFromCartLines,
|
||||
} from "../services/product-rules.server";
|
||||
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) => {
|
||||
const { session, admin } = await authenticate.public.appProxy(request);
|
||||
if (!session) {
|
||||
@ -13,53 +22,27 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const method = "PICKUP";
|
||||
|
||||
const productIds = parseProductIdsParam(url.searchParams.get("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 cartProductRefs = [...productRefs, ...productRefsFromCartLines(parseCartLinesParam(url.searchParams.get("cartLines")))];
|
||||
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: [] });
|
||||
}
|
||||
|
||||
const locationWhere = ruleConstraints.allowedLocationIds != null ? { id: { in: ruleConstraints.allowedLocationIds } } : {};
|
||||
|
||||
// Only locations that offer PICKUP (they have slot templates for it)
|
||||
// Wait, the location model doesn't store methods, methods are on SlotTemplate.
|
||||
// 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 locations = await resolvePickupLocations(session.shop, {
|
||||
allowedLocationIds: ruleConstraints.allowedLocationIds,
|
||||
admin: admin ?? undefined,
|
||||
productVariantGids: parseGidListParam(url.searchParams.get("variantIds")),
|
||||
});
|
||||
|
||||
const templates = await db.slotTemplate.findMany({
|
||||
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 });
|
||||
return Response.json({ locations });
|
||||
};
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
import { resolveRate } from "./rates.server";
|
||||
import { formatPriceLabel } from "../lib/currency";
|
||||
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
|
||||
// (via apps.scheduling.availability.tsx, app-proxy auth), POS (via
|
||||
@ -62,6 +63,13 @@ export interface AvailabilityResult {
|
||||
zoneId: string | null;
|
||||
distanceKm: number | 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;
|
||||
}
|
||||
|
||||
@ -104,6 +112,7 @@ export async function resolveAvailabilityRequest(
|
||||
let location: Awaited<ReturnType<typeof db.location.findFirst>> = null;
|
||||
let zoneId: string | null = null;
|
||||
let distanceKm: number | null = null;
|
||||
let pickupLocations: PickupLocationDto[] | undefined;
|
||||
|
||||
// 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
|
||||
@ -153,6 +162,20 @@ export async function resolveAvailabilityRequest(
|
||||
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 {
|
||||
const locationWhere =
|
||||
ruleConstraints.allowedLocationIds != null ? { id: { in: ruleConstraints.allowedLocationIds } } : {};
|
||||
@ -174,11 +197,14 @@ export async function resolveAvailabilityRequest(
|
||||
zoneId: null,
|
||||
distanceKm: null,
|
||||
rate: null,
|
||||
pickupLocations: method === "PICKUP" ? [] : undefined,
|
||||
error:
|
||||
ruleConstraints.allowedLocationIds != null
|
||||
? "One or more items in your cart aren't available at this location."
|
||||
: stockCheckEnabled
|
||||
? "No location currently stocks every item in your cart."
|
||||
: method === "PICKUP"
|
||||
? "No pickup location is available right now."
|
||||
: "No active location configured",
|
||||
};
|
||||
}
|
||||
@ -280,5 +306,6 @@ export async function resolveAvailabilityRequest(
|
||||
rate: matchedRate
|
||||
? { name: matchedRate.name, priceCents: matchedRate.priceCents, label: formatPriceLabel(matchedRate.priceCents) }
|
||||
: null,
|
||||
pickupLocations,
|
||||
};
|
||||
}
|
||||
|
||||
92
app/services/pickup-locations.server.ts
Normal file
92
app/services/pickup-locations.server.ts
Normal 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 }));
|
||||
}
|
||||
@ -14,6 +14,7 @@
|
||||
"postal_code": "Enter your postal/ZIP code",
|
||||
"postal_code_submit": "Check availability",
|
||||
"out_of_area": "Sorry, we don't deliver to this address.",
|
||||
"choose_pickup_location": "Choose a pickup location",
|
||||
"confirmation_pickup": "Pickup",
|
||||
"confirmation_delivery": "Delivery",
|
||||
"confirmation_shipping": "Shipping"
|
||||
|
||||
@ -12,6 +12,6 @@
|
||||
"react": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.2"
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
19
extensions/checkout-datetime/shopify.d.ts
vendored
19
extensions/checkout-datetime/shopify.d.ts
vendored
@ -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 };
|
||||
}
|
||||
@ -4,6 +4,8 @@ api_version = "2025-10"
|
||||
type = "ui_extension"
|
||||
name = "t:name"
|
||||
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)."
|
||||
|
||||
# The picker calls this app's own backend (checkout.scheduling.* routes,
|
||||
|
||||
@ -32,6 +32,7 @@ function Extension() {
|
||||
|
||||
const [method, setMethod] = useState(initialMethod);
|
||||
const [postalCode, setPostalCode] = useState("");
|
||||
const [pickupLocationId, setPickupLocationId] = useState(/** @type {string | null} */ (null));
|
||||
const [availability, setAvailability] = useState(/** @type {any} */ (null));
|
||||
const [date, setDate] = useState("");
|
||||
const [status, setStatus] = useState("idle"); // idle|loading|error
|
||||
@ -39,17 +40,26 @@ function Extension() {
|
||||
|
||||
const needsPostal = method === "LOCAL_DELIVERY";
|
||||
|
||||
async function loadAvailability() {
|
||||
/** @param {string | null} [locId] */
|
||||
async function loadAvailability(locId) {
|
||||
setStatus("loading");
|
||||
setAvailability(null);
|
||||
setDate("");
|
||||
try {
|
||||
const params = new URLSearchParams({ method, days: "14" });
|
||||
if (needsPostal && postalCode) params.set("postalCode", postalCode);
|
||||
if (locId) params.set("locationId", locId);
|
||||
const base = backendBase(settings);
|
||||
const token = await sessionToken.get();
|
||||
const res = await authedFetch(base, token, `/checkout/scheduling/availability?${params.toString()}`);
|
||||
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) {
|
||||
setStatus("idle");
|
||||
setAvailability({ error: body.error || translate("out_of_area"), dates: {} });
|
||||
@ -63,10 +73,14 @@ function Extension() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setPickupLocationId(null);
|
||||
if (!needsPostal) loadAvailability();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [method]);
|
||||
|
||||
const pickupLocations = availability?.pickupLocations ?? [];
|
||||
const needsPickupChoice = method === "PICKUP" && !pickupLocationId && pickupLocations.length > 1;
|
||||
|
||||
/** @type {string[]} */
|
||||
const dates = useMemo(() => Object.keys(availability?.dates ?? {}).sort(), [availability]);
|
||||
/** @type {any[]} */
|
||||
@ -172,7 +186,25 @@ function Extension() {
|
||||
{status === "error" && <Banner status="critical">{translate("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)}>
|
||||
{dates.map((d) => (
|
||||
<Choice key={d} id={d}>
|
||||
@ -181,7 +213,7 @@ function Extension() {
|
||||
))}
|
||||
</ChoiceList>
|
||||
)}
|
||||
{availability && !availability.error && dates.length === 0 && status === "idle" && (
|
||||
{!needsPickupChoice && availability && !availability.error && dates.length === 0 && status === "idle" && (
|
||||
<Text>{translate("no_dates")}</Text>
|
||||
)}
|
||||
|
||||
|
||||
@ -10,6 +10,6 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*", "shopify.d.ts"],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@ -119,3 +119,37 @@
|
||||
.dd-widget__preview-item {
|
||||
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
@ -41,6 +41,7 @@
|
||||
data-label-postal-code="{{ 'widget.postal_code' | 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-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.google_maps_api_key != blank %}data-google-maps-api-key="{{ block.settings.google_maps_api_key | escape }}"{% endif %}
|
||||
></template>
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
data-label-postal-code="{{ 'widget.postal_code' | 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-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.google_maps_api_key != blank %}data-google-maps-api-key="{{ block.settings.google_maps_api_key | escape }}"{% endif %}
|
||||
{{ block.shopify_attributes }}
|
||||
|
||||
@ -18,7 +18,8 @@
|
||||
"postal_code_submit": "Check availability",
|
||||
"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."
|
||||
"delivery_at_checkout": "Enter your address at checkout to see local delivery dates.",
|
||||
"choose_pickup_location": "Choose a pickup location"
|
||||
},
|
||||
"order_confirmation": {
|
||||
"pickup_label": "Pickup",
|
||||
|
||||
@ -18,7 +18,8 @@
|
||||
"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."
|
||||
"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": {
|
||||
"pickup_label": "Retrait",
|
||||
|
||||
@ -3,6 +3,8 @@ api_version = "2026-07"
|
||||
[[extensions]]
|
||||
name = "t:name"
|
||||
handle = "payment-customization"
|
||||
|
||||
uid = "4f8a281d-8599-ed4f-dd32-1297c2872b96939fbf00"
|
||||
type = "function"
|
||||
description = "t:description"
|
||||
|
||||
|
||||
52
tests/unit/pickup-locations.test.ts
Normal file
52
tests/unit/pickup-locations.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@ -34,11 +34,21 @@ interface RateDto {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface PickupLocationDto {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
}
|
||||
|
||||
interface AvailabilityResponse {
|
||||
locationId: string | null;
|
||||
locationName?: string;
|
||||
// TODO: Add pickupLocations array when backend supports returning multiple pickup locations for selection.
|
||||
// pickupLocations?: Array<{ id: string, name: string, lat: number | null, lng: number | null }>;
|
||||
// Every eligible pickup point (study §3.4 multi-pin pickup). Present only
|
||||
// for method === "PICKUP"; `dates` is for whichever one is currently
|
||||
// active — re-request with `locationId` to switch.
|
||||
pickupLocations?: PickupLocationDto[];
|
||||
locationLat?: number | null;
|
||||
locationLng?: number | null;
|
||||
timezone?: string;
|
||||
@ -76,6 +86,7 @@ interface WidgetConfig {
|
||||
outOfArea: string;
|
||||
earliestPrefix: 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."),
|
||||
earliestPrefix: get("labelEarliestPrefix", "Earliest"),
|
||||
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 {
|
||||
await loadGoogleMaps(apiKey);
|
||||
const google = (window as unknown as { google: GoogleMapsGlobal }).google;
|
||||
const map = new google.maps.Map(container, { center: { lat, lng }, zoom: 13 });
|
||||
new google.maps.Marker({ position: { lat, lng }, map, title });
|
||||
const map = new google.maps.Map(container, { center: { lat: points[0].lat, lng: points[0].lng }, zoom: 13 });
|
||||
for (const p of points) {
|
||||
new google.maps.Marker({ position: { lat: p.lat, lng: p.lng }, map, title: p.title });
|
||||
}
|
||||
} catch {
|
||||
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"),
|
||||
methodRow: document.createElement("div"),
|
||||
postalRow: document.createElement("div"),
|
||||
pickupRow: document.createElement("div"),
|
||||
mapContainer: document.createElement("div"),
|
||||
dateRow: document.createElement("div"),
|
||||
timeRow: document.createElement("div"),
|
||||
@ -325,6 +348,7 @@ class DateTimeWidget {
|
||||
};
|
||||
private selectedMethod: WidgetConfig["methods"][number] | null = null;
|
||||
private selectedDate: string | null = null;
|
||||
private selectedPickupLocationId: string | null = null;
|
||||
private availability: AvailabilityResponse | null = null;
|
||||
private heldSlot:
|
||||
| { 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.postalRow.className = "dd-widget__row dd-widget__postal";
|
||||
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.hidden = true;
|
||||
this.el.dateRow.className = "dd-widget__row dd-widget__dates";
|
||||
@ -367,6 +393,7 @@ class DateTimeWidget {
|
||||
this.el.confirmation,
|
||||
this.el.methodRow,
|
||||
this.el.postalRow,
|
||||
this.el.pickupRow,
|
||||
this.el.mapContainer,
|
||||
this.el.dateRow,
|
||||
this.el.timeRow,
|
||||
@ -443,8 +470,11 @@ class DateTimeWidget {
|
||||
private async selectMethod(method: WidgetConfig["methods"][number]) {
|
||||
this.selectedMethod = method;
|
||||
this.selectedDate = null;
|
||||
this.selectedPickupLocationId = null;
|
||||
this.el.timeRow.innerHTML = "";
|
||||
this.el.dateRow.innerHTML = "";
|
||||
this.el.pickupRow.hidden = true;
|
||||
this.el.pickupRow.innerHTML = "";
|
||||
this.el.mapContainer.hidden = true;
|
||||
this.el.confirmation.hidden = false;
|
||||
this.el.confirmation.hidden = true;
|
||||
@ -489,23 +519,40 @@ class DateTimeWidget {
|
||||
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.dateRow.innerHTML = "";
|
||||
|
||||
try {
|
||||
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) {
|
||||
this.el.status.textContent = this.availability.error || this.config.labels.outOfArea;
|
||||
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) {
|
||||
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() {
|
||||
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;
|
||||
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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user