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
|
# 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).
|
||||||
|
|||||||
@ -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 });
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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": "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"
|
||||||
|
|||||||
@ -12,6 +12,6 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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"
|
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,
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
@ -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>
|
||||||
|
|||||||
@ -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 }}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
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;
|
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() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user