metatrondelivery/app/routes/app.dashboard.tsx
metatroncubeswdev d150509978
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
feat(phase-8): Billing API with server-side feature gating
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>
2026-08-24 09:29:42 -04:00

374 lines
13 KiB
TypeScript

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,
Badge,
Box,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { DateTime } from "luxon";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import {
bucketByLocalDate,
revenueByMethod,
upcomingFulfillments,
utilizationByDateLocation,
type BookingSummary,
} from "../services/dashboard.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 STATUSES = ["confirmed", "fulfilled", "no_show", "cancelled"];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const tier = await getShopTier(session.shop);
if (!tierAtLeast(tier, "starter")) {
return { gated: true as const, tier };
}
const url = new URL(request.url);
const locationIdFilter = url.searchParams.get("locationId") || undefined;
const methodFilter = (url.searchParams.get("method") as Method | null) || undefined;
const statusFilter = url.searchParams.get("status") || undefined;
const startDate = url.searchParams.get("startDate") || DateTime.now().toISODate()!;
const endDate = url.searchParams.get("endDate") || DateTime.now().plus({ days: 7 }).toISODate()!;
const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate();
const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate();
const [locations, bookings, slotTemplates] = await Promise.all([
db.location.findMany({ where: { shopDomain: session.shop }, orderBy: { createdAt: "asc" } }),
db.booking.findMany({
where: {
shopDomain: session.shop,
locationId: locationIdFilter,
method: methodFilter,
status: statusFilter,
slotStart: { gte: rangeStart, lt: rangeEnd },
},
include: { location: true },
orderBy: { slotStart: "asc" },
}),
db.slotTemplate.findMany({
where: { shopDomain: session.shop, locationId: locationIdFilter, method: methodFilter },
}),
]);
const summaries: BookingSummary[] = bookings.map((b) => ({
id: b.id,
orderName: b.orderName,
locationId: b.locationId,
locationName: b.location.name,
method: b.method,
slotStart: b.slotStart,
slotEnd: b.slotEnd,
timezone: b.location.timezone,
status: b.status,
totalPriceCents: b.totalPriceCents,
customerEmail: b.customerEmail,
}));
const byDate = bucketByLocalDate(summaries);
const revenue = revenueByMethod(summaries);
const utilization = utilizationByDateLocation(
summaries,
slotTemplates.map((t) => ({ locationId: t.locationId, method: t.method, weekday: t.weekday, capacity: t.capacity })),
);
const upcoming = upcomingFulfillments(summaries);
return {
gated: false as const,
locations,
filters: { locationId: locationIdFilter ?? "", method: methodFilter ?? "", status: statusFilter ?? "", startDate, endDate },
byDate,
revenue,
utilization,
upcoming,
totalBookings: summaries.length,
};
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const tier = await getShopTier(session.shop);
if (!tierAtLeast(tier, "starter")) {
return data({ error: "The dispatch dashboard needs the Starter plan or higher." }, { status: 403 });
}
const formData = await request.formData();
const bookingId = String(formData.get("bookingId") || "");
const status = String(formData.get("status") || "");
if (!bookingId || !STATUSES.includes(status)) {
return data({ error: "Invalid request" }, { status: 400 });
}
await db.booking.updateMany({ where: { id: bookingId, shopDomain: session.shop }, data: { status } });
return data({ ok: true });
};
export default function Dashboard() {
const loaderData = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (loaderData.gated) {
return (
<Page>
<TitleBar title="Dispatch dashboard" />
<UpsellState
requiredTier="starter"
currentTier={loaderData.tier}
feature="The dispatch dashboard"
description="See revenue, capacity utilization, and upcoming fulfillments across all your locations."
/>
</Page>
);
}
const { locations, filters, byDate, revenue, utilization, upcoming, totalBookings } = loaderData;
const setFilter = (key: string, value: string) => {
const next = new URLSearchParams(searchParams);
if (value) next.set(key, value);
else next.delete(key);
setSearchParams(next);
};
const dates = Object.keys(byDate).sort();
const exportQuery = new URLSearchParams(searchParams).toString();
return (
<Page>
<TitleBar title="Dispatch dashboard" />
<BlockStack gap="400">
<Card>
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Filters
</Text>
<InlineStack gap="300" wrap>
<TextField
label="Start date"
labelHidden={false}
type="date"
autoComplete="off"
value={filters.startDate}
onChange={(v) => setFilter("startDate", v)}
/>
<TextField
label="End date"
type="date"
autoComplete="off"
value={filters.endDate}
onChange={(v) => setFilter("endDate", v)}
/>
<Select
label="Location"
options={[{ label: "All locations", value: "" }, ...locations.map((l) => ({ label: l.name, value: l.id }))]}
value={filters.locationId}
onChange={(v) => setFilter("locationId", v)}
/>
<Select
label="Method"
options={[{ label: "All methods", value: "" }, ...METHODS.map((m) => ({ label: m.replace("_", " "), value: m }))]}
value={filters.method}
onChange={(v) => setFilter("method", v)}
/>
<Select
label="Status"
options={[{ label: "All statuses", value: "" }, ...STATUSES.map((s) => ({ label: s, value: s }))]}
value={filters.status}
onChange={(v) => setFilter("status", v)}
/>
</InlineStack>
<InlineStack gap="200">
<Button url={`/app/dashboard/export?${exportQuery}`} target="_blank">
Export CSV
</Button>
<Text as="span" tone="subdued">
{totalBookings} booking{totalBookings === 1 ? "" : "s"} in range
</Text>
</InlineStack>
</BlockStack>
</Card>
<InlineStack gap="400" wrap>
{METHODS.map((m) => (
<Card key={m}>
<BlockStack gap="100">
<Text as="span" tone="subdued">
{m.replace("_", " ")} revenue
</Text>
<Text as="span" variant="headingLg">
{formatPriceLabel(revenue[m])}
</Text>
</BlockStack>
</Card>
))}
</InlineStack>
<Card>
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Capacity utilization
</Text>
{utilization.length === 0 ? (
<Text as="p" tone="subdued">
No bookings in this range yet.
</Text>
) : (
<BlockStack gap="150">
{utilization.map((row) => (
<InlineStack key={`${row.date}-${row.locationId}-${row.method}`} align="space-between" blockAlign="center">
<Text as="span">
{row.date} · {row.locationName} · {row.method.replace("_", " ")}
</Text>
<InlineStack gap="200" blockAlign="center">
<Box
minWidth="120px"
background={row.utilizationPct >= 90 ? "bg-fill-critical" : row.utilizationPct >= 60 ? "bg-fill-caution" : "bg-fill-success"}
padding="100"
borderRadius="100"
>
<Text as="span" alignment="center">
{row.booked}/{row.capacity} ({row.utilizationPct}%)
</Text>
</Box>
</InlineStack>
</InlineStack>
))}
</BlockStack>
)}
</BlockStack>
</Card>
<Card padding="0">
<Box padding="400">
<Text as="h3" variant="headingSm">
Upcoming fulfillments
</Text>
</Box>
{upcoming.length === 0 ? (
<Box padding="400">
<Text as="p" tone="subdued">
Nothing confirmed and upcoming in this range.
</Text>
</Box>
) : (
<IndexTable
itemCount={upcoming.length}
headings={[
{ title: "Order" },
{ title: "When" },
{ title: "Method" },
{ title: "Location" },
{ title: "Customer" },
{ title: "" },
]}
selectable={false}
>
{upcoming.map((b, index) => {
const local = DateTime.fromISO(b.slotStart, { zone: "utc" }).setZone(b.timezone);
return (
<IndexTable.Row id={b.id} key={b.id} position={index}>
<IndexTable.Cell>{b.orderName ?? "—"}</IndexTable.Cell>
<IndexTable.Cell>{local.toFormat("ccc LLL d, h:mm a")}</IndexTable.Cell>
<IndexTable.Cell>{b.method.replace("_", " ")}</IndexTable.Cell>
<IndexTable.Cell>{b.locationName}</IndexTable.Cell>
<IndexTable.Cell>{b.customerEmail ?? "—"}</IndexTable.Cell>
<IndexTable.Cell>
<InlineStack gap="150">
<Form method="post">
<input type="hidden" name="bookingId" value={b.id} />
<input type="hidden" name="status" value="fulfilled" />
<Button submit size="slim" loading={isSubmitting}>
Fulfilled
</Button>
</Form>
<Form method="post">
<input type="hidden" name="bookingId" value={b.id} />
<input type="hidden" name="status" value="no_show" />
<Button submit size="slim" tone="critical" loading={isSubmitting}>
No-show
</Button>
</Form>
</InlineStack>
</IndexTable.Cell>
</IndexTable.Row>
);
})}
</IndexTable>
)}
</Card>
<Card padding="0">
<Box padding="400">
<Text as="h3" variant="headingSm">
All bookings by day
</Text>
</Box>
{dates.length === 0 ? (
<Box padding="400">
<Text as="p" tone="subdued">
No bookings in this range.
</Text>
</Box>
) : (
dates.map((date) => (
<Box key={date} padding="400" borderBlockStartWidth="025" borderColor="border">
<BlockStack gap="200">
<Text as="h4" variant="headingXs">
{date}
</Text>
{byDate[date].map((b) => {
const local = DateTime.fromISO(b.slotStart, { zone: "utc" }).setZone(b.timezone);
return (
<InlineStack key={b.id} align="space-between">
<Text as="span">
{local.toFormat("h:mm a")} · {b.orderName ?? "—"} · {b.method.replace("_", " ")} · {b.locationName}
</Text>
<Badge
tone={
b.status === "confirmed"
? "info"
: b.status === "fulfilled"
? "success"
: b.status === "no_show"
? "critical"
: "read-only"
}
>
{b.status}
</Badge>
</InlineStack>
);
})}
</BlockStack>
</Box>
))
)}
</Card>
</BlockStack>
</Page>
);
}