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>
196 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|