metatrondelivery/app/routes/app.rates._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

270 lines
8.9 KiB
TypeScript
Raw Permalink 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 } 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 { formatPriceLabel } from "../lib/currency";
import { getShopTier } from "../services/billing.server";
import { tierAtLeast } from "../lib/billing-plans";
import { UpsellState } from "../components/UpsellState";
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
const KEYED_BY_OPTIONS = [
{ label: "Zone", value: "zone" },
{ label: "Distance band", value: "distance" },
];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const tier = await getShopTier(session.shop);
if (!tierAtLeast(tier, "growth")) {
return { gated: true as const, tier };
}
const [rates, zones] = await Promise.all([
db.rate.findMany({ where: { shopDomain: session.shop }, include: { zone: true }, orderBy: { createdAt: "asc" } }),
db.zone.findMany({ where: { shopDomain: session.shop }, include: { location: true }, orderBy: { name: "asc" } }),
]);
return { gated: false as const, rates, zones };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const tier = await getShopTier(session.shop);
if (!tierAtLeast(tier, "growth")) {
return data({ errors: { name: "Delivery rates need the Growth plan or higher." } }, { status: 403 });
}
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "delete") {
const id = String(formData.get("id") || "");
await db.rate.deleteMany({ where: { id, shopDomain: session.shop } });
return data({ ok: true });
}
const method = formData.get("method") as Method;
const name = String(formData.get("name") || "").trim();
const keyedBy = String(formData.get("keyedBy") || "zone");
const zoneId = String(formData.get("zoneId") || "") || null;
const priceRaw = String(formData.get("price") || "");
const minDistanceRaw = String(formData.get("minDistanceKm") || "");
const maxDistanceRaw = String(formData.get("maxDistanceKm") || "");
const errors: Record<string, string> = {};
if (!name) errors.name = "Name is required";
if (!priceRaw || Number.isNaN(Number(priceRaw))) errors.price = "Price is required";
if (keyedBy === "zone" && !zoneId) errors.zoneId = "Choose a zone";
if (Object.keys(errors).length > 0) {
return data({ errors });
}
await db.rate.create({
data: {
shopDomain: session.shop,
method,
name,
keyedBy,
zoneId: keyedBy === "zone" ? zoneId : null,
priceCents: Math.round(Number(priceRaw) * 100),
minDistanceKm: keyedBy === "distance" && minDistanceRaw ? Number(minDistanceRaw) : null,
maxDistanceKm: keyedBy === "distance" && maxDistanceRaw ? Number(maxDistanceRaw) : null,
},
});
return data({ ok: true });
};
export default function RatesIndex() {
const loaderData = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (loaderData.gated) {
return (
<Page>
<TitleBar title="Delivery rates" />
<UpsellState
requiredTier="growth"
currentTier={loaderData.tier}
feature="Delivery rates"
description="Price Local Delivery by zone or distance band instead of relying on Shopify's own shipping rates."
/>
</Page>
);
}
const { rates, zones } = loaderData;
if (zones.length === 0) {
return (
<Page>
<TitleBar title="Delivery rates" />
<Card>
<EmptyState
heading="Add a delivery zone first"
action={{ content: "Add a zone", url: "/app/zones" }}
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<Text as="p">Zone-keyed rates need at least one zone to attach to.</Text>
</EmptyState>
</Card>
</Page>
);
}
return (
<Page>
<TitleBar title="Delivery rates" />
<BlockStack gap="400">
<Card padding="0">
{rates.length === 0 ? (
<div style={{ padding: 16 }}>
<Text as="p" tone="subdued">
No rates yet. Without a rate, checkout uses Shopify's own configured shipping rates.
</Text>
</div>
) : (
<IndexTable
itemCount={rates.length}
headings={[
{ title: "Name" },
{ title: "Method" },
{ title: "Keyed by" },
{ title: "Coverage" },
{ title: "Price" },
{ title: "" },
]}
selectable={false}
>
{rates.map((rate, index) => (
<IndexTable.Row id={rate.id} key={rate.id} position={index}>
<IndexTable.Cell>{rate.name}</IndexTable.Cell>
<IndexTable.Cell>{rate.method.replace("_", " ")}</IndexTable.Cell>
<IndexTable.Cell>{rate.keyedBy}</IndexTable.Cell>
<IndexTable.Cell>
{rate.keyedBy === "zone"
? (rate.zone?.name ?? "—")
: `${rate.minDistanceKm ?? 0}${rate.maxDistanceKm ?? "∞"} km`}
</IndexTable.Cell>
<IndexTable.Cell>{formatPriceLabel(rate.priceCents)}</IndexTable.Cell>
<IndexTable.Cell>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={rate.id} />
<Button submit variant="plain" tone="critical">
Remove
</Button>
</Form>
</IndexTable.Cell>
</IndexTable.Row>
))}
</IndexTable>
)}
</Card>
<AddRateForm zones={zones} isSubmitting={isSubmitting} />
</BlockStack>
</Page>
);
}
function AddRateForm({
zones,
isSubmitting,
}: {
zones: Array<{ id: string; name: string; location: { name: string } }>;
isSubmitting: boolean;
}) {
const [keyedBy, setKeyedBy] = useState("zone");
const [method, setMethod] = useState<Method>(METHODS[0]);
const [zoneId, setZoneId] = useState(zones[0]?.id ?? "");
const [name, setName] = useState("");
const [price, setPrice] = useState("");
const [minDistanceKm, setMinDistanceKm] = useState("");
const [maxDistanceKm, setMaxDistanceKm] = useState("");
return (
<Card>
<Form method="post">
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Add a rate
</Text>
<InlineStack gap="300" wrap>
<TextField label="Name" name="name" autoComplete="off" value={name} onChange={setName} />
<Select
label="Method"
name="method"
value={method}
onChange={(v) => setMethod(v as Method)}
options={METHODS.map((m) => ({ label: m.replace("_", " "), value: m }))}
/>
<Select label="Keyed by" name="keyedBy" options={KEYED_BY_OPTIONS} value={keyedBy} onChange={setKeyedBy} />
{keyedBy === "zone" ? (
<Select
label="Zone"
name="zoneId"
value={zoneId}
onChange={setZoneId}
options={zones.map((z) => ({ label: `${z.name} (${z.location.name})`, value: z.id }))}
/>
) : (
<>
<TextField
label="Min distance (km)"
name="minDistanceKm"
type="number"
autoComplete="off"
value={minDistanceKm}
onChange={setMinDistanceKm}
/>
<TextField
label="Max distance (km)"
name="maxDistanceKm"
type="number"
autoComplete="off"
value={maxDistanceKm}
onChange={setMaxDistanceKm}
/>
</>
)}
<TextField
label="Price"
name="price"
type="number"
autoComplete="off"
value={price}
onChange={setPrice}
prefix="$"
/>
</InlineStack>
<div>
<Button submit variant="primary" loading={isSubmitting}>
Add rate
</Button>
</div>
</BlockStack>
</Form>
</Card>
);
}