metatrondelivery/app/routes/app.slots._index.tsx
MOHAN 03574a4914 feat: product rules, driving-distance zones, and shipping date ranges
Closes remaining DS-parity gaps from the feature audit:

- ProductRule model (product/collection/vendor/type/tag scoping) with
  real server-side enforcement in hold-request.server.ts, plus shaped
  availability in availability-request.server.ts. Covers per-product
  prep time, cart-content-based slot blocking, and product-restricted
  locations in one mechanism. New /app/rules admin page (Growth+).
- Driving-distance delivery zones via Google's Distance Matrix API,
  cached like existing geocoding results.
- SHIPPING-only estimated arrival range (transitMinDays/transitMaxDays
  on SlotTemplate) — widget shows "Arrives Thu-Sat" instead of a
  meaningless ship-out time slot; carried through to the order
  metafield write-back.

Storefront widget and POS extension now send cart contents (vendor/
type from cart.js, product ids for Admin-API-resolved collection/tag
rules) to both availability and hold endpoints.

checkout-datetime remains excluded from this deploy pending Shopify's
Network Access approval (unrelated to this work) — re-add from
../checkout-datetime-disabled and redeploy once granted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 00:28:45 +05:30

304 lines
10 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";
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 } = 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 } });
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,
},
});
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>
);
}