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>
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
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>
|
||
);
|
||
}
|