diff --git a/README.md b/README.md index 635c727..c7360c8 100644 --- a/README.md +++ b/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 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 diff --git a/app/routes/app._index.tsx b/app/routes/app._index.tsx index b5d0ead..d3ec40a 100644 --- a/app/routes/app._index.tsx +++ b/app/routes/app._index.tsx @@ -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(); + const { locations, checklist } = useLoaderData(); + 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 ( + {completedCount < steps.length && ( + + + + + + Get set up + + + {`${completedCount}/${steps.length} done`} + + + + {steps.map((step) => ( + + + + {step.done ? "Done" : "To do"} + + {step.title} + + + + {step.description} + + + {!step.done && step.action && ( + "url" in step.action ? ( + + ) : ( +
+ + +
+ ) + )} +
+ ))} +
+
+
+
+ )} {locations.length === 0 ? ( diff --git a/app/routes/app.help.tsx b/app/routes/app.help.tsx new file mode 100644 index 0000000..48bc612 --- /dev/null +++ b/app/routes/app.help.tsx @@ -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 ( + + + + {heading} + + {children} + + + ); +} + +export default function Help() { + return ( + + + + + +
+ + A location is a fulfillment point — a store, warehouse, or kitchen. Each + location has its own weekly slot templates: 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). + + + Overrides and blackout dates adjust or close specific days without + touching the weekly pattern — a holiday, an early closure, a one-off capacity bump. + +
+
+ + +
+ + 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. + +
+
+ + +
+ + Zones 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{" "} + minimum orders 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. + + + Rates 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. + +
+
+ + +
+ + 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. + +
+
+ + +
+ + 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. + +
+
+ + +
+ + 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. + +
+
+
+
+
+ ); +} diff --git a/app/routes/app.tsx b/app/routes/app.tsx index 2564a6c..e081183 100644 --- a/app/routes/app.tsx +++ b/app/routes/app.tsx @@ -31,6 +31,7 @@ export default function App() { Delivery rates Dispatch dashboard Billing + Help