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>
265 lines
8.7 KiB
TypeScript
265 lines
8.7 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 { authenticate } from "../shopify.server";
|
|
import db from "../db.server";
|
|
import { getShopTier } from "../services/billing.server";
|
|
import { tierAtLeast } from "../lib/billing-plans";
|
|
import { UpsellState } from "../components/UpsellState";
|
|
|
|
const ZONE_TYPES = [
|
|
{ label: "Postal / ZIP codes", value: "postal" },
|
|
{ label: "Radius (straight-line distance)", value: "radius" },
|
|
];
|
|
|
|
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 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 zones = activeLocationId
|
|
? await db.zone.findMany({
|
|
where: { shopDomain: session.shop, locationId: activeLocationId },
|
|
orderBy: { createdAt: "asc" },
|
|
})
|
|
: [];
|
|
|
|
return { gated: false as const, locations, activeLocationId, 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 zones 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.zone.deleteMany({ where: { id, shopDomain: session.shop } });
|
|
return data({ ok: true });
|
|
}
|
|
|
|
const locationId = String(formData.get("locationId") || "");
|
|
const name = String(formData.get("name") || "").trim();
|
|
const type = String(formData.get("type") || "postal");
|
|
const postalCodesRaw = String(formData.get("postalCodes") || "");
|
|
const radiusKmRaw = String(formData.get("radiusKm") || "");
|
|
const minOrdersRaw = String(formData.get("minOrders") || "");
|
|
|
|
const errors: Record<string, string> = {};
|
|
if (!locationId) errors.locationId = "Choose a location";
|
|
if (!name) errors.name = "Name is required";
|
|
if (type === "radius" && !radiusKmRaw) errors.radiusKm = "Radius is required for a radius zone";
|
|
if (Object.keys(errors).length > 0) {
|
|
return data({ errors });
|
|
}
|
|
|
|
const postalCodes = postalCodesRaw
|
|
.split(",")
|
|
.map((code) => code.trim())
|
|
.filter(Boolean);
|
|
|
|
await db.zone.create({
|
|
data: {
|
|
shopDomain: session.shop,
|
|
locationId,
|
|
name,
|
|
type,
|
|
postalCodes: type === "postal" ? postalCodes : [],
|
|
radiusKm: type === "radius" && radiusKmRaw ? Number(radiusKmRaw) : null,
|
|
minOrders: minOrdersRaw ? Number(minOrdersRaw) : null,
|
|
},
|
|
});
|
|
|
|
return data({ ok: true });
|
|
};
|
|
|
|
export default function ZonesIndex() {
|
|
const loaderData = useLoaderData<typeof loader>();
|
|
const [, setSearchParams] = useSearchParams();
|
|
const navigation = useNavigation();
|
|
const isSubmitting = navigation.state === "submitting";
|
|
|
|
if (loaderData.gated) {
|
|
return (
|
|
<Page>
|
|
<TitleBar title="Delivery zones" />
|
|
<UpsellState
|
|
requiredTier="growth"
|
|
currentTier={loaderData.tier}
|
|
feature="Delivery zones"
|
|
description="Route Local Delivery orders by postal code or radius, with automatic nearest-location assignment."
|
|
/>
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
const { locations, activeLocationId, zones } = loaderData;
|
|
|
|
if (locations.length === 0) {
|
|
return (
|
|
<Page>
|
|
<TitleBar title="Delivery zones" />
|
|
<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">Delivery zones determine which addresses a location's Local Delivery covers.</Text>
|
|
</EmptyState>
|
|
</Card>
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Page>
|
|
<TitleBar title="Delivery zones" />
|
|
<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">
|
|
{zones.length === 0 ? (
|
|
<div style={{ padding: 16 }}>
|
|
<Text as="p" tone="subdued">
|
|
No delivery zones yet for this location. Without a zone, Local Delivery is offered everywhere.
|
|
</Text>
|
|
</div>
|
|
) : (
|
|
<IndexTable
|
|
itemCount={zones.length}
|
|
headings={[
|
|
{ title: "Name" },
|
|
{ title: "Type" },
|
|
{ title: "Coverage" },
|
|
{ title: "Min. orders" },
|
|
{ title: "" },
|
|
]}
|
|
selectable={false}
|
|
>
|
|
{zones.map((zone, index) => (
|
|
<IndexTable.Row id={zone.id} key={zone.id} position={index}>
|
|
<IndexTable.Cell>{zone.name}</IndexTable.Cell>
|
|
<IndexTable.Cell>{zone.type === "postal" ? "Postal/ZIP" : "Radius"}</IndexTable.Cell>
|
|
<IndexTable.Cell>
|
|
{zone.type === "postal" ? zone.postalCodes.join(", ") || "—" : `${zone.radiusKm ?? "—"} km`}
|
|
</IndexTable.Cell>
|
|
<IndexTable.Cell>{zone.minOrders ?? "—"}</IndexTable.Cell>
|
|
<IndexTable.Cell>
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="delete" />
|
|
<input type="hidden" name="id" value={zone.id} />
|
|
<Button submit variant="plain" tone="critical">
|
|
Remove
|
|
</Button>
|
|
</Form>
|
|
</IndexTable.Cell>
|
|
</IndexTable.Row>
|
|
))}
|
|
</IndexTable>
|
|
)}
|
|
</Card>
|
|
|
|
<AddZoneForm key={activeLocationId} locationId={activeLocationId ?? ""} isSubmitting={isSubmitting} />
|
|
</BlockStack>
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
function AddZoneForm({ locationId, isSubmitting }: { locationId: string; isSubmitting: boolean }) {
|
|
const [type, setType] = useState("postal");
|
|
const [name, setName] = useState("");
|
|
const [postalCodes, setPostalCodes] = useState("");
|
|
const [radiusKm, setRadiusKm] = useState("");
|
|
const [minOrders, setMinOrders] = useState("");
|
|
|
|
return (
|
|
<Card>
|
|
<Form method="post">
|
|
<input type="hidden" name="locationId" value={locationId} />
|
|
<BlockStack gap="300">
|
|
<Text as="h3" variant="headingSm">
|
|
Add a delivery zone
|
|
</Text>
|
|
<InlineStack gap="300" wrap>
|
|
<TextField label="Name" name="name" autoComplete="off" value={name} onChange={setName} />
|
|
<Select label="Type" name="type" options={ZONE_TYPES} value={type} onChange={setType} />
|
|
{type === "postal" ? (
|
|
<TextField
|
|
label="Postal/ZIP codes (comma-separated)"
|
|
name="postalCodes"
|
|
autoComplete="off"
|
|
value={postalCodes}
|
|
onChange={setPostalCodes}
|
|
helpText="e.g. M5V 3A8, M4B 1B3"
|
|
/>
|
|
) : (
|
|
<TextField
|
|
label="Radius (km)"
|
|
name="radiusKm"
|
|
type="number"
|
|
autoComplete="off"
|
|
value={radiusKm}
|
|
onChange={setRadiusKm}
|
|
/>
|
|
)}
|
|
<TextField
|
|
label="Min. orders before this zone unlocks (optional)"
|
|
name="minOrders"
|
|
type="number"
|
|
autoComplete="off"
|
|
value={minOrders}
|
|
onChange={setMinOrders}
|
|
helpText="Delivery-density threshold — leave blank for no minimum."
|
|
/>
|
|
</InlineStack>
|
|
<div>
|
|
<Button submit variant="primary" loading={isSubmitting}>
|
|
Add zone
|
|
</Button>
|
|
</div>
|
|
</BlockStack>
|
|
</Form>
|
|
</Card>
|
|
);
|
|
}
|