feat(phase-8): onboarding checklist and in-app help page
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
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:
parent
d150509978
commit
8615364ab8
15
README.md
15
README.md
@ -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
|
||||||
|
|||||||
@ -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
113
app/routes/app.help.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user