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
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**
(`app/services/availability-request.server.ts`,
`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 { useLoaderData } from "@remix-run/react";
import { Page, Layout, Text, Card, BlockStack, EmptyState, InlineStack, Badge } from "@shopify/polaris";
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 = await db.location.findMany({
where: { shopDomain: session.shop },
include: { _count: { select: { slotTemplates: true } } },
});
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),
]);
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() {
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 (
<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 ? (

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/dashboard">Dispatch dashboard</Link>
<Link to="/app/billing">Billing</Link>
<Link to="/app/help">Help</Link>
</NavMenu>
<Outlet />
</AppProvider>