metatrondelivery/app/routes/app.slots._index.tsx
metatroncubeswdev a2c78d703f
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
feat: close DS study coverage gaps (inventory exclusion, live checkout re-validation, per-day cap, payment fn, checkout ext)
Audited the implementation against DS_Delivery_Date_Time_App_Study.docx and
closed the actionable gaps (see IMPLEMENTATION_REVIEW_2026-09-04.md).

Core (code + unit tests, 156 green):
- Wire excludeLocationsWithoutStock into resolveAvailabilityRequest; widget
  now sends variantIds so inventory-based location exclusion actually runs.
- Live slot re-validation at checkout: new checkout-snapshot.server.ts writes
  a shop-metafield capacity snapshot; validation-slot's evaluateCheckout
  rejects a complete selection that has since filled / blacked out / closed /
  hit the daily cap / left the schedule. Refreshed on order webhooks and
  slot/blackout/location/enforcement edits.
- Scopable checkout enforcement: Shop.enforcementMode (all|tagged|off) +
  enforcementTag, new app.settings.tsx admin page, honoured via the snapshot.
- Per-day order cap: Location.dailyOrderCap threaded through getAvailability
  (dailyCap + consumedPerDate); admin field on the location screen.
- Product-rule slot blocking: ProductRule.blockedStartMins, unioned in
  resolveProductRuleConstraints, enforced in the engine and resolveHoldRequest;
  admin field on the product rules screen.
- Product-page placement: product-availability.liquid block + widget
  data-mode="preview" (read-only earliest-date line).
- Second locale: datetime-widget fr.json / fr.schema.json.
- Migration 20260904120000_review_gaps (apply with prisma migrate deploy).

New Functions (source + unit tests; need `shopify app deploy` to ship):
- extensions/payment-customization: cart.payment-methods.transform.run — hides
  cash-on-delivery / pay-in-store gateways on SHIPPING orders.
- extensions/checkout-datetime/src: restored from a gitignored dist-only state
  — Plus native picker + Thank you / Order status confirmation blocks, all
  calling the existing checkout.scheduling.* routes (one capacity pool).
  tsconfig ships checkJs:false pending reconciliation with live checkout types.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 01:31:02 -04:00

307 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from "react";
import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData, useNavigation, useSearchParams } from "@remix-run/react";
import {
Page,
Card,
BlockStack,
InlineStack,
Text,
Button,
Select,
TextField,
IndexTable,
EmptyState,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { writeCheckoutSnapshot } from "../services/checkout-snapshot.server";
const WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
function minutesToTime(min: number): string {
const h = Math.floor(min / 60)
.toString()
.padStart(2, "0");
const m = (min % 60).toString().padStart(2, "0");
return `${h}:${m}`;
}
function timeToMinutes(time: string): number {
const [h, m] = time.split(":").map(Number);
return h * 60 + m;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const url = new URL(request.url);
const locationId = url.searchParams.get("locationId");
const locations = await db.location.findMany({
where: { shopDomain: session.shop },
orderBy: { createdAt: "asc" },
});
const activeLocationId = locationId || locations[0]?.id || null;
const slotTemplates = activeLocationId
? await db.slotTemplate.findMany({
where: { shopDomain: session.shop, locationId: activeLocationId },
orderBy: [{ weekday: "asc" }, { startMin: "asc" }],
})
: [];
return { locations, activeLocationId, slotTemplates };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session, admin } = await authenticate.admin(request);
const formData = await request.formData();
const intent = formData.get("intent");
const locationId = String(formData.get("locationId") || "");
if (intent === "delete") {
const id = String(formData.get("id") || "");
await db.slotTemplate.deleteMany({ where: { id, shopDomain: session.shop } });
await writeCheckoutSnapshot(admin, session.shop);
return data({ ok: true });
}
const method = formData.get("method") as Method;
const weekday = Number(formData.get("weekday"));
const startMin = timeToMinutes(String(formData.get("startTime")));
const endMin = timeToMinutes(String(formData.get("endTime")));
const capacity = Number(formData.get("capacity"));
const cutoffMin = Number(formData.get("cutoffMin") || 0);
const leadTimeMin = Number(formData.get("leadTimeMin") || 0);
const transitMinDaysRaw = String(formData.get("transitMinDays") || "");
const transitMaxDaysRaw = String(formData.get("transitMaxDays") || "");
const errors: Record<string, string> = {};
if (!locationId) errors.locationId = "Choose a location";
if (startMin >= endMin) errors.endTime = "End time must be after start time";
if (!capacity || capacity < 1) errors.capacity = "Capacity must be at least 1";
if (transitMinDaysRaw && transitMaxDaysRaw && Number(transitMinDaysRaw) > Number(transitMaxDaysRaw)) {
errors.transitMaxDays = "Max transit days must be at least the min";
}
if (Object.keys(errors).length > 0) {
return data({ errors });
}
await db.slotTemplate.create({
data: {
shopDomain: session.shop,
locationId,
method,
weekday,
startMin,
endMin,
capacity,
cutoffMin,
leadTimeMin,
// Only meaningful for SHIPPING (PRODUCT_STRATEGY.md §2 "date ranges") —
// stored regardless of method since a merchant might switch a
// template's method later, but only ever read for SHIPPING.
transitMinDays: transitMinDaysRaw ? Number(transitMinDaysRaw) : null,
transitMaxDays: transitMaxDaysRaw ? Number(transitMaxDaysRaw) : null,
},
});
await writeCheckoutSnapshot(admin, session.shop);
return data({ ok: true });
};
export default function SlotsIndex() {
const { locations, activeLocationId, slotTemplates } = useLoaderData<typeof loader>();
const [, setSearchParams] = useSearchParams();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (locations.length === 0) {
return (
<Page>
<TitleBar title="Weekly slots" />
<Card>
<EmptyState
heading="Add a location first"
action={{ content: "Add location", url: "/app/locations/new" }}
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<Text as="p">Weekly slot templates belong to a location.</Text>
</EmptyState>
</Card>
</Page>
);
}
return (
<Page>
<TitleBar title="Weekly slots" />
<BlockStack gap="400">
<Card>
<Select
label="Location"
options={locations.map((l) => ({ label: l.name, value: l.id }))}
value={activeLocationId ?? undefined}
onChange={(value) => setSearchParams({ locationId: value })}
/>
</Card>
<Card padding="0">
{slotTemplates.length === 0 ? (
<div style={{ padding: 16 }}>
<Text as="p" tone="subdued">
No slot templates yet for this location. Add one below.
</Text>
</div>
) : (
<IndexTable
itemCount={slotTemplates.length}
headings={[
{ title: "Weekday" },
{ title: "Method" },
{ title: "Window" },
{ title: "Capacity" },
{ title: "Cutoff (min)" },
{ title: "Lead time (min)" },
{ title: "Transit (days)" },
{ title: "" },
]}
selectable={false}
>
{slotTemplates.map((slot, index) => (
<IndexTable.Row id={slot.id} key={slot.id} position={index}>
<IndexTable.Cell>{WEEKDAY_NAMES[slot.weekday]}</IndexTable.Cell>
<IndexTable.Cell>{slot.method.replace("_", " ")}</IndexTable.Cell>
<IndexTable.Cell>
{minutesToTime(slot.startMin)}{minutesToTime(slot.endMin)}
</IndexTable.Cell>
<IndexTable.Cell>{slot.capacity}</IndexTable.Cell>
<IndexTable.Cell>{slot.cutoffMin}</IndexTable.Cell>
<IndexTable.Cell>{slot.leadTimeMin}</IndexTable.Cell>
<IndexTable.Cell>
{slot.transitMinDays != null || slot.transitMaxDays != null
? `${slot.transitMinDays ?? "—"}${slot.transitMaxDays ?? "—"}`
: "—"}
</IndexTable.Cell>
<IndexTable.Cell>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={slot.id} />
<Button submit variant="plain" tone="critical">
Remove
</Button>
</Form>
</IndexTable.Cell>
</IndexTable.Row>
))}
</IndexTable>
)}
</Card>
<AddSlotForm key={activeLocationId} locationId={activeLocationId ?? ""} isSubmitting={isSubmitting} />
</BlockStack>
</Page>
);
}
function AddSlotForm({ locationId, isSubmitting }: { locationId: string; isSubmitting: boolean }) {
const [startTime, setStartTime] = useState("09:00");
const [endTime, setEndTime] = useState("17:00");
const [capacity, setCapacity] = useState("10");
const [cutoffMin, setCutoffMin] = useState("60");
const [leadTimeMin, setLeadTimeMin] = useState("0");
const [transitMinDays, setTransitMinDays] = useState("");
const [transitMaxDays, setTransitMaxDays] = useState("");
return (
<Card>
<Form method="post">
<input type="hidden" name="locationId" value={locationId} />
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Add a slot template
</Text>
<InlineStack gap="300" wrap>
<Select
label="Weekday"
name="weekday"
options={WEEKDAY_NAMES.map((name, value) => ({ label: name, value: String(value) }))}
/>
<Select
label="Method"
name="method"
options={METHODS.map((m) => ({ label: m.replace("_", " "), value: m }))}
/>
<TextField
label="Start time"
name="startTime"
type="time"
value={startTime}
onChange={setStartTime}
autoComplete="off"
/>
<TextField
label="End time"
name="endTime"
type="time"
value={endTime}
onChange={setEndTime}
autoComplete="off"
/>
<TextField
label="Capacity"
name="capacity"
type="number"
value={capacity}
onChange={setCapacity}
autoComplete="off"
/>
<TextField
label="Cutoff (min)"
name="cutoffMin"
type="number"
value={cutoffMin}
onChange={setCutoffMin}
autoComplete="off"
/>
<TextField
label="Lead time (min)"
name="leadTimeMin"
type="number"
value={leadTimeMin}
onChange={setLeadTimeMin}
autoComplete="off"
/>
<TextField
label="Min transit days (Shipping only)"
name="transitMinDays"
type="number"
value={transitMinDays}
onChange={setTransitMinDays}
autoComplete="off"
helpText="Shown as an estimated arrival range instead of a delivery time slot."
/>
<TextField
label="Max transit days (Shipping only)"
name="transitMaxDays"
type="number"
value={transitMaxDays}
onChange={setTransitMaxDays}
autoComplete="off"
/>
</InlineStack>
<div>
<Button submit variant="primary" loading={isSubmitting}>
Add slot template
</Button>
</div>
</BlockStack>
</Form>
</Card>
);
}