metatrondelivery/app/routes/app._index.tsx
metatroncubeswdev 8615364ab8
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
feat(phase-8): onboarding checklist and in-app help page
Add a "Get set up" checklist to the app home page that auto-detects
progress from real data (has a location, has weekly slots, has zones if
on Growth+) — the one step that can't be data-detected (enabling the
storefront widget in the theme editor) is a manual acknowledgment stored
in Shop.settings.onboarding, reusing the JSON settings field
templates.server.ts already established for widgetCopy.

Add app/routes/app.help.tsx (linked from nav as "Help"): a short in-app
reference on locations/slots, why enforcement is server-side, zones/rates,
the dashboard, POS/checkout, and data handling — documenting only features
that actually exist in this codebase.

Both pass typecheck/lint/build but haven't been exercised in a live
embedded admin session (documented in README); the remaining Phase 8 items
(accessibility pass, performance budget, empty/loading/error-state review)
are deferred to that live pass rather than guessed at blind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:50:55 -04:00

196 lines
7.8 KiB
TypeScript

import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData, useNavigation } from "@remix-run/react";
import { Page, Layout, Text, Card, BlockStack, EmptyState, InlineStack, Badge, Button } 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";
interface OnboardingSettings {
onboarding?: { widgetDone?: boolean };
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const [locations, shop, tier] = await Promise.all([
db.location.findMany({
where: { shopDomain: session.shop },
include: { _count: { select: { slotTemplates: true } } },
}),
db.shop.findUnique({ where: { shopDomain: session.shop }, select: { settings: true } }),
getShopTier(session.shop),
]);
const zonesEligible = tierAtLeast(tier, "growth");
const zonesCount = zonesEligible ? await db.zone.count({ where: { shopDomain: session.shop } }) : 0;
const settings = (shop?.settings as OnboardingSettings | null) ?? {};
return {
locations,
tier,
checklist: {
hasLocation: locations.length > 0,
hasSlots: locations.some((l) => l._count.slotTemplates > 0),
widgetDone: settings.onboarding?.widgetDone ?? false,
zonesEligible,
hasZones: zonesCount > 0,
},
};
};
// Only the widget step needs an action — every other checklist item is
// "done" once its own underlying data exists (a Location, a SlotTemplate,
// a Zone), which those pages already create. Theme customization isn't
// something this app's data can detect, so it's a manual acknowledgment
// stored in Shop.settings.onboarding, the same JSON field templates.server.ts
// already uses for widgetCopy.
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
if (formData.get("intent") === "ack-widget") {
const shop = await db.shop.findUnique({ where: { shopDomain: session.shop }, select: { settings: true } });
const settings = (shop?.settings as OnboardingSettings | null) ?? {};
const nextSettings = { ...settings, onboarding: { ...settings.onboarding, widgetDone: true } };
await db.shop.upsert({
where: { shopDomain: session.shop },
create: { shopDomain: session.shop, settings: nextSettings },
update: { settings: nextSettings },
});
return data({ ok: true });
}
return data({ ok: false }, { status: 400 });
};
interface ChecklistStep {
title: string;
description: string;
done: boolean;
action: { label: string; url: string } | { label: string; formIntent: string } | null;
}
export default function Index() {
const { locations, checklist } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const steps: ChecklistStep[] = [
{
title: "Add a location",
description: "A fulfillment point — a store, warehouse, or kitchen — that ships, delivers, or hands off orders.",
done: checklist.hasLocation,
action: { label: "Add location", url: "/app/locations/new" },
},
{
title: "Configure weekly slots",
description: "Set the days and time windows each location is open for Shipping, Local Delivery, or Pickup.",
done: checklist.hasSlots,
action: { label: "Set up slots", url: "/app/slots" },
},
{
title: "Enable the widget on your storefront",
description:
'Add the "Date & time picker" app block to your product or cart page from Online Store > Themes > Customize.',
done: checklist.widgetDone,
action: { label: "Mark as done", formIntent: "ack-widget" },
},
...(checklist.zonesEligible
? [
{
title: "Set up delivery zones (optional)",
description: "Route Local Delivery orders to the nearest location by postal code or radius.",
done: checklist.hasZones,
action: { label: "Set up zones", url: "/app/zones" },
},
]
: []),
];
const completedCount = steps.filter((s) => s.done).length;
return (
<Page>
<TitleBar title="Delivery Date & Time" />
<BlockStack gap="500">
<Layout>
{completedCount < steps.length && (
<Layout.Section>
<Card>
<BlockStack gap="300">
<InlineStack align="space-between" blockAlign="center">
<Text as="h2" variant="headingMd">
Get set up
</Text>
<Badge tone={completedCount === steps.length ? "success" : "info"}>
{`${completedCount}/${steps.length} done`}
</Badge>
</InlineStack>
<BlockStack gap="200">
{steps.map((step) => (
<InlineStack key={step.title} align="space-between" blockAlign="center" wrap={false}>
<BlockStack gap="050">
<InlineStack gap="150" blockAlign="center">
<Badge tone={step.done ? "success" : undefined}>{step.done ? "Done" : "To do"}</Badge>
<Text as="span" fontWeight="semibold">
{step.title}
</Text>
</InlineStack>
<Text as="span" tone="subdued" variant="bodySm">
{step.description}
</Text>
</BlockStack>
{!step.done && step.action && (
"url" in step.action ? (
<Button url={step.action.url}>{step.action.label}</Button>
) : (
<Form method="post">
<input type="hidden" name="intent" value={step.action.formIntent} />
<Button submit loading={isSubmitting}>
{step.action.label}
</Button>
</Form>
)
)}
</InlineStack>
))}
</BlockStack>
</BlockStack>
</Card>
</Layout.Section>
)}
<Layout.Section>
<Card>
{locations.length === 0 ? (
<EmptyState
heading="No locations configured yet"
action={{ content: "Add a location", url: "/app/locations" }}
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<Text as="p" variant="bodyMd">
Set up a fulfillment location and its weekly slots to start
taking scheduled Shipping, Local Delivery, or Pickup orders.
</Text>
</EmptyState>
) : (
<BlockStack gap="300">
<Text as="h2" variant="headingMd">
{locations.length} location{locations.length === 1 ? "" : "s"} configured
</Text>
{locations.map((location) => (
<InlineStack key={location.id} align="space-between">
<Text as="span">{location.name}</Text>
<Badge>{`${location._count.slotTemplates} slot templates`}</Badge>
</InlineStack>
))}
</BlockStack>
)}
</Card>
</Layout.Section>
</Layout>
</BlockStack>
</Page>
);
}