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>
161 lines
6.0 KiB
TypeScript
161 lines
6.0 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,
|
|
Layout,
|
|
Card,
|
|
BlockStack,
|
|
InlineStack,
|
|
Text,
|
|
Button,
|
|
ButtonGroup,
|
|
ResourceList,
|
|
ResourceItem,
|
|
Badge,
|
|
EmptyState,
|
|
Select,
|
|
} from "@shopify/polaris";
|
|
import { TitleBar } from "@shopify/app-bridge-react";
|
|
import { authenticate } from "../shopify.server";
|
|
import db from "../db.server";
|
|
import { listVerticalTemplates, seedVerticalTemplate, type VerticalKey } from "../services/templates.server";
|
|
import { canAddLocation, getShopTier, locationLimitFor } from "../services/billing.server";
|
|
|
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|
const { session } = await authenticate.admin(request);
|
|
|
|
const locations = await db.location.findMany({
|
|
where: { shopDomain: session.shop },
|
|
include: { _count: { select: { slotTemplates: true, blackouts: true } } },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
|
|
return { locations, verticals: listVerticalTemplates() };
|
|
};
|
|
|
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
|
const { session } = await authenticate.admin(request);
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
|
|
if (intent === "seed-template") {
|
|
if (!(await canAddLocation(session.shop))) {
|
|
const tier = await getShopTier(session.shop);
|
|
return data(
|
|
{ error: `Your ${tier} plan allows up to ${locationLimitFor(tier)} location(s). Upgrade to add more.` },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
const vertical = formData.get("vertical") as VerticalKey;
|
|
await seedVerticalTemplate(session.shop, vertical);
|
|
return data({ ok: true });
|
|
}
|
|
|
|
if (intent === "delete-location") {
|
|
const id = formData.get("id") as string;
|
|
await db.location.deleteMany({ where: { id, shopDomain: session.shop } });
|
|
return data({ ok: true });
|
|
}
|
|
|
|
return data({ ok: false }, { status: 400 });
|
|
};
|
|
|
|
export default function LocationsIndex() {
|
|
const { locations, verticals } = useLoaderData<typeof loader>();
|
|
const navigation = useNavigation();
|
|
const isSubmitting = navigation.state === "submitting";
|
|
const [vertical, setVertical] = useState<string>(verticals[0]);
|
|
|
|
return (
|
|
<Page>
|
|
<TitleBar title="Locations" />
|
|
<BlockStack gap="500">
|
|
<Layout>
|
|
<Layout.Section>
|
|
{locations.length === 0 ? (
|
|
<Card>
|
|
<EmptyState
|
|
heading="Set up your first location"
|
|
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
|
|
>
|
|
<BlockStack gap="400">
|
|
<Text as="p" variant="bodyMd">
|
|
Start from a vertical template — it seeds a location with a
|
|
realistic weekly Pickup/Delivery schedule you can then edit,
|
|
or add a location from scratch.
|
|
</Text>
|
|
<InlineStack gap="300" blockAlign="center">
|
|
<div style={{ minWidth: 180 }}>
|
|
<Select
|
|
label="Vertical"
|
|
labelHidden
|
|
options={verticals.map((v) => ({ label: capitalize(v), value: v }))}
|
|
value={vertical}
|
|
onChange={setVertical}
|
|
/>
|
|
</div>
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="seed-template" />
|
|
<input type="hidden" name="vertical" value={vertical} />
|
|
<Button submit variant="primary" loading={isSubmitting}>
|
|
Seed {capitalize(vertical)} template
|
|
</Button>
|
|
</Form>
|
|
<Button url="/app/locations/new">Add location manually</Button>
|
|
</InlineStack>
|
|
</BlockStack>
|
|
</EmptyState>
|
|
</Card>
|
|
) : (
|
|
<Card padding="0">
|
|
<ResourceList
|
|
items={locations}
|
|
resourceName={{ singular: "location", plural: "locations" }}
|
|
renderItem={(location) => (
|
|
<ResourceItem
|
|
id={location.id}
|
|
url={`/app/locations/${location.id}`}
|
|
accessibilityLabel={`View ${location.name}`}
|
|
>
|
|
<InlineStack align="space-between" blockAlign="center">
|
|
<BlockStack gap="100">
|
|
<Text as="h3" variant="bodyMd" fontWeight="bold">
|
|
{location.name}
|
|
</Text>
|
|
<Text as="span" variant="bodySm" tone="subdued">
|
|
{location.address || "No address set"} · {location.timezone}
|
|
</Text>
|
|
</BlockStack>
|
|
<InlineStack gap="200" blockAlign="center">
|
|
<Badge>{`${location._count.slotTemplates} slot templates`}</Badge>
|
|
<Badge tone={location.active ? "success" : "critical"}>
|
|
{location.active ? "Active" : "Inactive"}
|
|
</Badge>
|
|
</InlineStack>
|
|
</InlineStack>
|
|
</ResourceItem>
|
|
)}
|
|
/>
|
|
</Card>
|
|
)}
|
|
</Layout.Section>
|
|
{locations.length > 0 && (
|
|
<Layout.Section>
|
|
<ButtonGroup>
|
|
<Button url="/app/locations/new" variant="primary">
|
|
Add location
|
|
</Button>
|
|
</ButtonGroup>
|
|
</Layout.Section>
|
|
)}
|
|
</Layout>
|
|
</BlockStack>
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
function capitalize(s: string) {
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
}
|