feat(phase-8): onboarding checklist and in-app help page
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled

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>
This commit is contained in:
metatroncubeswdev 2026-08-24 11:50:55 -04:00
parent d150509978
commit 8615364ab8
4 changed files with 274 additions and 9 deletions

View File

@ -90,6 +90,21 @@ built in Phases 0-7 — several Growth/Pro features listed in
`PRODUCT_STRATEGY.md` §6 (waitlists, reschedule portal, SMS, etc.) are `PRODUCT_STRATEGY.md` §6 (waitlists, reschedule portal, SMS, etc.) are
Phase 9/10 and not yet implemented, so aren't gated on anything yet. Phase 9/10 and not yet implemented, so aren't gated on anything yet.
**Onboarding & help (Phase 8):** `app/routes/app._index.tsx` shows a "Get
set up" checklist (add a location, configure weekly slots, enable the
storefront widget, set up delivery zones if Growth+) that auto-detects
completion from your actual data, except the storefront-widget step —
theme customization isn't visible to this app's database, so it's a
manual "Mark as done" acknowledgment stored in `Shop.settings.onboarding`.
`app/routes/app.help.tsx` (linked from the nav as "Help") is a short
in-app reference explaining locations/slots, server-side enforcement,
zones/rates, the dashboard, POS/checkout, and data handling.
**Not yet verified live:** the onboarding checklist and Help page pass
typecheck/lint/build but haven't been exercised in an actual embedded
admin session — worth a quick look once you're running `shopify app dev`
against a dev store.
**Every scheduling surface calls the same two service functions** **Every scheduling surface calls the same two service functions**
(`app/services/availability-request.server.ts`, (`app/services/availability-request.server.ts`,
`app/services/hold-request.server.ts`) — the storefront widget (app-proxy `app/services/hold-request.server.ts`) — the storefront widget (app-proxy

View File

@ -1,29 +1,165 @@
import type { LoaderFunctionArgs } from "@remix-run/node"; import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react"; import { Form, useLoaderData, useNavigation } from "@remix-run/react";
import { Page, Layout, Text, Card, BlockStack, EmptyState, InlineStack, Badge } from "@shopify/polaris"; import { Page, Layout, Text, Card, BlockStack, EmptyState, InlineStack, Badge, Button } from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react"; import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server"; import { authenticate } from "../shopify.server";
import db from "../db.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) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request); const { session } = await authenticate.admin(request);
const locations = await db.location.findMany({ const [locations, shop, tier] = await Promise.all([
where: { shopDomain: session.shop }, db.location.findMany({
include: { _count: { select: { slotTemplates: true } } }, where: { shopDomain: session.shop },
}); include: { _count: { select: { slotTemplates: true } } },
}),
db.shop.findUnique({ where: { shopDomain: session.shop }, select: { settings: true } }),
getShopTier(session.shop),
]);
return { locations }; 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() { export default function Index() {
const { locations } = useLoaderData<typeof loader>(); 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 ( return (
<Page> <Page>
<TitleBar title="Delivery Date & Time" /> <TitleBar title="Delivery Date & Time" />
<BlockStack gap="500"> <BlockStack gap="500">
<Layout> <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> <Layout.Section>
<Card> <Card>
{locations.length === 0 ? ( {locations.length === 0 ? (

113
app/routes/app.help.tsx Normal file
View File

@ -0,0 +1,113 @@
import type { ReactNode } from "react";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { Page, Layout, Card, BlockStack, Text } from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
return null;
};
function Section({ heading, children }: { heading: string; children: ReactNode }) {
return (
<Card>
<BlockStack gap="200">
<Text as="h2" variant="headingMd">
{heading}
</Text>
{children}
</BlockStack>
</Card>
);
}
export default function Help() {
return (
<Page>
<TitleBar title="Help" />
<BlockStack gap="400">
<Layout>
<Layout.Section>
<Section heading="Locations & weekly slots">
<Text as="p">
A <b>location</b> is a fulfillment point — a store, warehouse, or kitchen. Each
location has its own <b>weekly slot templates</b>: the days and time windows it's
open for Shipping, Local Delivery, or Pickup, plus how many orders it can take per
slot (capacity), how long before a slot it stops accepting orders (cutoff), and how
much lead time it needs to prepare (prep time).
</Text>
<Text as="p">
<b>Overrides</b> and <b>blackout dates</b> adjust or close specific days without
touching the weekly pattern — a holiday, an early closure, a one-off capacity bump.
</Text>
</Section>
</Layout.Section>
<Layout.Section>
<Section heading="How enforcement works">
<Text as="p">
The date/time picker in your storefront and checkout is a convenience — the real
enforcement happens server-side in a Shopify Function attached to checkout. That
means a shopper can't bypass the picker (browser dev tools, a scripted checkout,
etc.) and get an order for a slot that's full, closed, or past cutoff. This works on
every Shopify plan, not just Plus.
</Text>
</Section>
</Layout.Section>
<Layout.Section>
<Section heading="Delivery zones & rates (Growth plan and up)">
<Text as="p">
<b>Zones</b> define which addresses a location's Local Delivery covers — by postal/ZIP
code list or by straight-line radius. When a shopper enters an address, the app finds
the nearest eligible location and offers only that location's slots. A zone's{" "}
<b>minimum orders</b> setting can delay opening it until you've already routed enough
orders there (delivery-density gating), so you're not committing to sparse routes
too early.
</Text>
<Text as="p">
<b>Rates</b> price Local Delivery either by zone or by distance band from the
location. Without a rate configured, checkout falls back to your own Shopify shipping
rates.
</Text>
</Section>
</Layout.Section>
<Layout.Section>
<Section heading="Dispatch dashboard (Starter plan and up)">
<Text as="p">
Filter bookings by date range, location, method, and status; see revenue by method,
capacity utilization per day/location, and a list of upcoming fulfillments you can
mark Fulfilled or No-show directly. Export the filtered list as CSV.
</Text>
</Section>
</Layout.Section>
<Layout.Section>
<Section heading="POS & checkout">
<Text as="p">
Staff can book the same Shipping/Delivery/Pickup slots from the POS Smart Grid tile
as a shopper booking online — both draw from the same capacity, so an in-store
booking can't double-book a slot a customer just took online, or vice versa. On
Shopify Plus, checkout shows a native date/time picker; on every plan, the confirmed
slot appears on the order confirmation block once the order's placed.
</Text>
</Section>
</Layout.Section>
<Layout.Section>
<Section heading="Your data">
<Text as="p">
Scheduling data (locations, slots, zones, rates, bookings) lives in this app's own
database, scoped to your shop — nothing is shared across merchants. If you uninstall
the app, your data is erased automatically 48 hours later, per Shopify's data
protection requirements.
</Text>
</Section>
</Layout.Section>
</Layout>
</BlockStack>
</Page>
);
}

View File

@ -31,6 +31,7 @@ export default function App() {
<Link to="/app/rates">Delivery rates</Link> <Link to="/app/rates">Delivery rates</Link>
<Link to="/app/dashboard">Dispatch dashboard</Link> <Link to="/app/dashboard">Dispatch dashboard</Link>
<Link to="/app/billing">Billing</Link> <Link to="/app/billing">Billing</Link>
<Link to="/app/help">Help</Link>
</NavMenu> </NavMenu>
<Outlet /> <Outlet />
</AppProvider> </AppProvider>