metatrondelivery/app/routes/app.slots._index.tsx
metatroncubeswdev f8dd5b2586
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
fix: make weekday/method/location/zone Selects controlled in admin forms
Polaris <Select> in this App Bridge + Polaris version is fully controlled —
without value+onChange it renders but ignores the user's choice (same class
of bug as commit cc20f20 for <TextField>). The "Add slot template",
"Add blackout date" and "Add rate" forms each had one or more inert
dropdowns, so nothing could be selected. Wired each to local state.

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

316 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 }) {
// Polaris Select is a controlled component — without value/onChange it
// renders but won't register a selection (same class of bug as commit
// cc20f20 for TextField). Keep weekday/method in local state.
const [weekday, setWeekday] = useState("1");
const [method, setMethod] = useState<Method>("SHIPPING");
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"
value={weekday}
onChange={setWeekday}
options={WEEKDAY_NAMES.map((name, value) => ({ label: name, value: String(value) }))}
/>
<Select
label="Method"
name="method"
value={method}
onChange={(value) => setMethod(value as 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>
);
}