Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
Add real Shopify Billing API integration: Free/Starter/Growth/Pro plans (app/lib/billing-plans.ts, priced per PRODUCT_STRATEGY.md §6) wired into shopify.server.ts's billing config, a merchant-facing plan page (app/routes/app.billing.tsx) using billing.request/billing.cancel, and webhooks.app_subscriptions.update.tsx as the durable sync path for Shop.tier (fires even when a merchant cancels from Shopify's own billing page, not just from this app). Gate the features actually built so far in both loader and action (never just hidden in the UI, so a direct POST can't bypass a tier limit): delivery zones/rates require Growth+, the dispatch dashboard requires Starter+, and location count is capped per tier (Free=1, Starter=3, Growth/Pro=unlimited). Split pure tier logic (app/lib/billing-plans.ts) from DB-backed reads/writes (app/services/billing.server.ts) so the client-rendered UpsellState component can import the Tier type without pulling server code into the client bundle — same split as currency.ts. Covered by tests/unit/billing-plans.test.ts (pure tier ranking/mapping) and tests/integration/billing.test.ts (tier persistence and location-limit enforcement against live Postgres). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
260 lines
8.6 KiB
TypeScript
260 lines
8.6 KiB
TypeScript
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 [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" 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"
|
||
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>
|
||
);
|
||
}
|