106 lines
6.1 KiB
TypeScript
106 lines
6.1 KiB
TypeScript
import { useState } from "react";
|
|
import type { LoaderFunctionArgs } from "react-router";
|
|
import { Form, useLoaderData } from "react-router";
|
|
import { computeAvailability } from "../engine/availability";
|
|
import { evaluateRules } from "../engine/rules";
|
|
import { addDays, localParts } from "../engine/time";
|
|
import type { Method } from "../engine/types";
|
|
import { adminContext } from "../lib/admin.server";
|
|
import { niceDate, slotLabel } from "../lib/format";
|
|
import { enrichCart } from "../services/catalog.server";
|
|
import { reservations } from "../services/storefront.server";
|
|
|
|
/**
|
|
* "Why is this slot unavailable?" — runs the exact checkout engine for a sample cart,
|
|
* location, method and date and shows every reason with the rule that caused it.
|
|
*/
|
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|
const { cfg, admin } = await adminContext(request);
|
|
const sp = new URL(request.url).searchParams;
|
|
const today = localParts(Date.now(), cfg.shop.timezone).date;
|
|
const method = (sp.get("method") as Method) || "delivery";
|
|
const loc = cfg.locations.find((l) => l.id === sp.get("location")) ?? cfg.locations[0];
|
|
const date = sp.get("date") || addDays(today, 1);
|
|
const variants = (sp.get("variants") ?? "").split(",").filter(Boolean);
|
|
const base = { locations: cfg.locations.map((l) => ({ id: l.id, name: l.name })), method, locationId: loc?.id ?? "", date, variants };
|
|
if (!loc) return { ...base, result: null };
|
|
const s2a = Object.fromEntries(cfg.locations.filter((l) => l.shopifyLocationId).map((l) => [l.shopifyLocationId!, l.id]));
|
|
const { cart } = await enrichCart(admin, cfg.shop.domain, variants.map((v) => ({ variantId: v, quantity: 1 })), s2a);
|
|
const usage = await reservations.usage(cfg.shop.id, addDays(today, -1), addDays(today, 370), new Date());
|
|
const cons = evaluateRules(cart, cfg.rules, cfg.settings.mixedPrepPolicy);
|
|
const av = computeAvailability({ location: loc, method, cart, rules: cfg.rules, peakDays: cfg.peakDays, usage, settings: cfg.settings, now: Date.now(), constraints: cons });
|
|
const d = av.dates.find((x) => x.date === date);
|
|
return {
|
|
...base,
|
|
result: {
|
|
items: cart.items.map((i) => i.title),
|
|
matched: cons.matched,
|
|
prep: av.prepMinutes,
|
|
prepDriver: cons.prepDriver ?? null,
|
|
blockers: av.blockers,
|
|
horizon: loc.methods[method]?.horizonDays ?? null,
|
|
day: d ? { reasons: d.reasons, peak: d.peak, slots: d.slots.map((s) => ({ id: s.id, start: s.start, end: s.end, available: s.available, remaining: s.remaining, reasons: s.reasons })) } : null,
|
|
dateOnly: av.dateOnly,
|
|
},
|
|
};
|
|
};
|
|
|
|
export default function Diagnostics() {
|
|
const d = useLoaderData<typeof loader>();
|
|
const [variants, setVariants] = useState<{ id: string; title: string }[]>(d.variants.map((id) => ({ id, title: id.split("/").pop()! })));
|
|
const pick = async () => {
|
|
const sel = (await shopify.resourcePicker({ type: "variant", multiple: true, action: "select" })) as unknown as { id: string; displayName?: string; title?: string }[] | undefined;
|
|
if (sel) setVariants(sel.map((v) => ({ id: v.id, title: v.displayName ?? v.title ?? v.id })));
|
|
};
|
|
const r = d.result;
|
|
return (
|
|
<s-page heading="Why is a time unavailable?">
|
|
<s-section>
|
|
<Form method="get">
|
|
<input type="hidden" name="variants" value={variants.map((v) => v.id).join(",")} />
|
|
<s-stack gap="base">
|
|
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(180px, 1fr))" gap="base" alignItems="end">
|
|
<s-select name="method" label="Method" value={d.method}>
|
|
<s-option value="delivery">Delivery</s-option>
|
|
<s-option value="pickup">Pickup</s-option>
|
|
<s-option value="shipping">Shipping</s-option>
|
|
</s-select>
|
|
<s-select name="location" label="Location" value={d.locationId}>
|
|
{d.locations.map((l) => <s-option key={l.id} value={l.id}>{l.name}</s-option>)}
|
|
</s-select>
|
|
<s-date-field name="date" label="Date" value={d.date} />
|
|
</s-grid>
|
|
<s-stack direction="inline" gap="base" alignItems="center">
|
|
<s-button onClick={pick}>Choose products for the test cart</s-button>
|
|
<s-text color="subdued">{variants.length ? variants.map((v) => v.title).join(", ") : "Empty cart (only hours, closures and capacity apply)"}</s-text>
|
|
</s-stack>
|
|
<s-button type="submit" variant="primary">Check</s-button>
|
|
</s-stack>
|
|
</Form>
|
|
</s-section>
|
|
{r && (
|
|
<s-section heading={niceDate(d.date, true)}>
|
|
<s-stack gap="base">
|
|
<s-paragraph>
|
|
Rules matched: {r.matched.length ? r.matched.map((m) => `${m.ruleName} (${m.itemTitle})`).join("; ") : "none"}. Preparation used: {Math.round(r.prep / 6) / 10} h
|
|
{r.prepDriver ? `, set by ${r.prepDriver.itemTitle}` : ""}.
|
|
</s-paragraph>
|
|
{r.blockers.map((b, i) => <s-banner key={i} tone="critical" heading={b.code}>{b.message}{b.ruleId ? ` (rule ${b.ruleId})` : ""}</s-banner>)}
|
|
{!r.blockers.length && !r.day && <s-banner tone="warning" heading="BEYOND_HORIZON">This date is outside the {r.horizon}-day booking window, or in the past.</s-banner>}
|
|
{r.day?.reasons.map((x, i) => <s-banner key={i} tone="warning" heading={x.code}>{x.message}{x.ruleId ? ` (rule ${x.ruleId})` : ""}</s-banner>)}
|
|
{r.day?.slots.map((s) => (
|
|
<s-box key={s.id} padding="small" border="base" borderRadius="base" background={s.available ? "base" : "subdued"}>
|
|
<s-stack direction="inline" gap="base" alignItems="center">
|
|
<s-badge tone={s.available ? "success" : "critical"}>{s.available ? "Available" : "Blocked"}</s-badge>
|
|
<s-text type="strong">{r.dateOnly ? "Dispatch" : slotLabel(s.start, s.end)}</s-text>
|
|
<s-text color="subdued">{s.available ? (s.remaining == null ? "no limit" : `${s.remaining} left`) : s.reasons.map((x) => `${x.code}: ${x.message}`).join("; ")}</s-text>
|
|
</s-stack>
|
|
</s-box>
|
|
))}
|
|
</s-stack>
|
|
</s-section>
|
|
)}
|
|
</s-page>
|
|
);
|
|
}
|