diff --git a/src/App.tsx b/src/App.tsx index 7fe0214..70a68a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,6 +58,7 @@ const StoreProductsPage = named('StoreProductsPage', () => import('@/features/st const StoreCustomersPage = named('StoreCustomersPage', () => import('@/features/store-user/pages/StoreCustomersPage')); const StoreStaffPage = named('StoreStaffPage', () => import('@/features/store-user/pages/StoreStaffPage')); const StoreAccountPage = named('StoreAccountPage', () => import('@/features/store-user/pages/StoreAccountPage')); +const StoreSetupPage = named('StoreSetupPage', () => import('@/features/store-user/pages/StoreSetupPage')); const StoreUploadsPage = named('StoreUploadsPage', () => import('@/features/store-user/pages/StoreUploadsPage')); function RouteFallback() { @@ -168,6 +169,9 @@ export function App() { } /> } /> } /> + {/* The branch user's half of setup. Same route shape as the merchant's + `/admin/onboarding`, so the two logins are reached the same way. */} + } /> } /> diff --git a/src/features/onboarding/OnboardingPage.tsx b/src/features/onboarding/OnboardingPage.tsx index 3056dd2..9615bac 100644 --- a/src/features/onboarding/OnboardingPage.tsx +++ b/src/features/onboarding/OnboardingPage.tsx @@ -25,6 +25,7 @@ import { useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { Card } from '@astryxdesign/core/Card'; +import { Boxes, LayoutDashboard, PackageSearch } from 'lucide-react'; import { Text } from '@astryxdesign/core/Text'; import { errorMessage } from '@/api/client'; import { tenantsApi } from '@/api/tenants'; @@ -35,6 +36,8 @@ import { useLocationProducts, useOwnTenant } from '@/queries/hooks'; import { StepFrame } from './StepFrame'; import { Stepper } from './Stepper'; import { + PROGRESS_STEPS, + STEP_LABEL, completeStep, progressOf, readOnboarding, @@ -236,7 +239,15 @@ export function OnboardingPage() { return ( - {!isFirst && !isLast ? : null} + {!isFirst && !isLast ? ( + + ) : null} {step === 'welcome' ? ( navigate('/admin/inventory?tab=catalogue')} - onInventory={() => navigate('/admin/inventory?tab=stock')} - onStorefront={() => navigate('/admin/inventory?tab=products')} + blurb={ + productCount > 0 + ? 'Your store is set up and your products are ready for customers.' + : 'Your store is set up. Add some products and they will be ready for customers.' + } + actions={[ + { + icon: , + title: 'Manage your catalogue', + body: 'Add products, set prices, and release them to your shops.', + cta: 'Open catalogue', + onClick: () => navigate('/admin/inventory?tab=catalogue'), + }, + { + icon: , + title: 'Update your stock', + body: 'Upload your latest counts so customers see what is really there.', + cta: 'Update stock', + onClick: () => navigate('/admin/inventory?tab=stock'), + }, + { + icon: , + title: 'See your shop', + body: 'Check what a customer sees, and which products are on sale.', + cta: 'View products', + onClick: () => navigate('/admin/inventory?tab=products'), + }, + ]} onDashboard={() => navigate('/admin/console')} /> ) : null} diff --git a/src/features/onboarding/SetupReturnBar.tsx b/src/features/onboarding/SetupReturnBar.tsx index 8d0b99a..549ed25 100644 --- a/src/features/onboarding/SetupReturnBar.tsx +++ b/src/features/onboarding/SetupReturnBar.tsx @@ -4,7 +4,7 @@ import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; import { ArrowRight, Check } from 'lucide-react'; -import { PROGRESS_STEPS, STEP_LABEL, type StepId } from './onboardingState'; + /** * The way back out of a working screen and into setup. @@ -22,16 +22,29 @@ import { PROGRESS_STEPS, STEP_LABEL, type StepId } from './onboardingState'; * turns "am I done?" into an obvious yes. */ export interface SetupReturnBarProps { - /** The step the merchant left. Also what gets marked complete on return. */ - step: StepId; - /** Products the tenant has now. 0 means the errand is not done yet. */ - productCount: number; + /** Where the step sits in its own flow. Given by the caller — see `Stepper`. */ + position: number; + total: number; + /** The step's name, for the "Store setup — Products" line. */ + stepLabel: string; + /** True once the errand is done. The bar says so and offers to carry on. */ + isDone: boolean; + /** What to say when it is done — "12 products added", "Your details saved". */ + doneTitle: string; + /** Where "Back to setup" goes, including whatever it needs to advance. */ + href: string; } -export function SetupReturnBar({ step, productCount }: SetupReturnBarProps) { +export function SetupReturnBar({ + position, + total, + stepLabel, + isDone, + doneTitle, + href, +}: SetupReturnBarProps) { const navigate = useNavigate(); - const position = PROGRESS_STEPS.indexOf(step) + 1; - const hasDone = productCount > 0; + const hasDone = isDone; return (
@@ -42,14 +55,12 @@ export function SetupReturnBar({ step, productCount }: SetupReturnBarProps) { - {hasDone - ? `${productCount} product${productCount === 1 ? '' : 's'} added` - : `Store setup — ${STEP_LABEL[step]}`} + {hasDone ? doneTitle : `Store setup — ${stepLabel}`} {hasDone ? 'Nice work. Carry on with setup whenever you are ready.' - : `Step ${position} of ${PROGRESS_STEPS.length}. Add your products, then head back to setup.`} + : `Step ${position} of ${total}. Finish here, then head back to setup.`} @@ -63,7 +74,7 @@ export function SetupReturnBar({ step, productCount }: SetupReturnBarProps) { because products exist would also fire for a merchant who wandered here on their own, marking work done that they never chose to finish. Pressing this button is the choice. */ - onClick={() => navigate(`/admin/onboarding?advance=${step}`)} + onClick={() => navigate(href)} />
diff --git a/src/features/onboarding/StepFrame.tsx b/src/features/onboarding/StepFrame.tsx index 60c995c..a35a2c8 100644 --- a/src/features/onboarding/StepFrame.tsx +++ b/src/features/onboarding/StepFrame.tsx @@ -4,7 +4,6 @@ import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; import { ArrowLeft, ArrowRight, LogOut } from 'lucide-react'; -import { PROGRESS_STEPS, type StepId } from './onboardingState'; /** * The frame every collecting step is drawn in. @@ -29,7 +28,15 @@ import { PROGRESS_STEPS, type StepId } from './onboardingState'; * the flow having lost their work. */ export interface StepFrameProps { - step: StepId; + /** + * Where this step sits, given by the caller rather than looked up. + * + * The frame used to find its own position in the merchant's `PROGRESS_STEPS`, + * which locked it to one role. Position is the caller's fact — it is the one + * that knows which flow this is. + */ + position: number; + total: number; title: string; blurb?: string; /** Primary action label. The Review step ends the flow, so it says so. */ @@ -44,7 +51,8 @@ export interface StepFrameProps { } export function StepFrame({ - step, + position, + total, title, blurb, continueLabel, @@ -56,9 +64,7 @@ export function StepFrame({ onExit, children, }: StepFrameProps) { - const position = PROGRESS_STEPS.indexOf(step) + 1; - const total = PROGRESS_STEPS.length; - const pct = Math.round((position / total) * 100); + const pct = total > 0 ? Math.round((position / total) * 100) : 0; return (
diff --git a/src/features/onboarding/Stepper.tsx b/src/features/onboarding/Stepper.tsx index 1555b9e..5e4874d 100644 --- a/src/features/onboarding/Stepper.tsx +++ b/src/features/onboarding/Stepper.tsx @@ -13,19 +13,38 @@ */ import { Check } from 'lucide-react'; -import { PROGRESS_STEPS, STEP_LABEL, type StepId } from './onboardingState'; -export interface StepperProps { - current: StepId; - completed: readonly StepId[]; +/** + * The step list comes from the CALLER, not from a module constant. + * + * It used to read the merchant's own `PROGRESS_STEPS`, which is why the branch + * user's setup could not use this component and grew a second design instead. + * The two roles have different work — seven steps against three — but "where am + * I and how much is left" is the same question and deserves the same answer. + */ +export interface StepperProps { + steps: readonly Id[]; + labels: Readonly>; + current: Id; + completed: readonly Id[]; + /** Ids that sit outside the numbered run — a welcome screen, a done screen. */ + lastId?: Id; } -export function Stepper({ current, completed }: StepperProps) { +export function Stepper({ + steps, + labels, + current, + completed, + lastId, +}: StepperProps) { + const PROGRESS_STEPS = steps; + const STEP_LABEL = labels; const index = PROGRESS_STEPS.indexOf(current); // Welcome sits before the five and Done after them, so neither has a node. // Clamped rather than hidden: a bar that disappears on the first screen makes // the flow look like it started somewhere else. - const position = index < 0 ? (current === 'done' ? PROGRESS_STEPS.length : 0) : index + 1; + const position = index < 0 ? (current === lastId ? PROGRESS_STEPS.length : 0) : index + 1; const doneCount = PROGRESS_STEPS.filter((id) => completed.includes(id)).length; const pct = Math.round((doneCount / PROGRESS_STEPS.length) * 100); @@ -45,7 +64,7 @@ export function Stepper({ current, completed }: StepperProps) { {position > 0 ? `Step ${position} of ${PROGRESS_STEPS.length}` : 'Getting started'} - {STEP_LABEL[current]} + {STEP_LABEL[current] ?? ''}
void; +} + export interface DoneStepProps { - productCount: number; - onCatalogue: () => void; - onInventory: () => void; - onStorefront: () => void; + /** The line under the title. The two roles finish having done different work. */ + blurb: string; + actions: readonly NextAction[]; onDashboard: () => void; } -export function DoneStep({ - productCount, - onCatalogue, - onInventory, - onStorefront, - onDashboard, -}: DoneStepProps) { +export function DoneStep({ blurb, actions, onDashboard }: DoneStepProps) { return ( @@ -43,34 +45,21 @@ export function DoneStep({ You’re all set 🎉 - {productCount > 0 - ? 'Your store is set up and your products are ready for customers.' - : 'Your store is set up. Add some products and they will be ready for customers.'} + {blurb}
- } - title="Manage your catalogue" - body="Add products, set prices, and release them to your shops." - cta="Open catalogue" - onClick={onCatalogue} - /> - } - title="Update your stock" - body="Upload your latest counts so customers see what is really there." - cta="Update stock" - onClick={onInventory} - /> - } - title="See your shop" - body="Check what a customer sees, and which products are on sale." - cta="View products" - onClick={onStorefront} - /> + {actions.map((action) => ( + + ))}
diff --git a/src/features/onboarding/steps/WelcomeStep.tsx b/src/features/onboarding/steps/WelcomeStep.tsx index 2d33bb8..d4988fc 100644 --- a/src/features/onboarding/steps/WelcomeStep.tsx +++ b/src/features/onboarding/steps/WelcomeStep.tsx @@ -3,7 +3,7 @@ import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; -import { ArrowRight, Boxes, Rocket, Store } from 'lucide-react'; +import { ArrowRight, Boxes, Rocket, Store, type LucideIcon } from 'lucide-react'; /** * The first screen, and the only one whose job is not to collect anything. @@ -16,11 +16,30 @@ import { ArrowRight, Boxes, Rocket, Store } from 'lucide-react'; * The one promise made in words is the one the state layer actually keeps: * progress is saved, so leaving is safe. */ +/** One of the three cards under the greeting. */ +export interface WelcomeBenefit { + icon: LucideIcon; + title: string; + body: string; +} + export interface WelcomeStepProps { shopName?: string; done: number; total: number; isReturning: boolean; + /** + * What setup is worth, in three cards. + * + * Given by the caller because the two roles are promised different things: a + * merchant is told about the catalogue and going on sale, a branch user about + * their own account and their shelf. The SHAPE is shared — that is the point + * of this component — and only the words differ. + */ + benefits?: readonly WelcomeBenefit[]; + /** The greeting, when the default merchant wording is not the right one. */ + greeting?: string; + blurb?: string; onStart: () => void; } @@ -42,7 +61,16 @@ const BENEFITS = [ }, ] as const; -export function WelcomeStep({ shopName, done, total, isReturning, onStart }: WelcomeStepProps) { +export function WelcomeStep({ + shopName, + done, + total, + isReturning, + benefits = BENEFITS, + greeting, + blurb, + onStart, +}: WelcomeStepProps) { const pct = total === 0 ? 0 : Math.round((done / total) * 100); return ( @@ -70,7 +98,7 @@ export function WelcomeStep({ shopName, done, total, isReturning, onStart }: Wel weight="semibold" style={{ fontFamily: 'var(--font-display)', textWrap: 'balance' }} > - {isReturning ? 'Welcome back 👋' : `Welcome${shopName ? ` to ${shopName}` : ''}! 👋`} + {isReturning ? 'Welcome back 👋' : (greeting ?? `Welcome${shopName ? ` to ${shopName}` : ''}! 👋`)} {isReturning ? `You have completed ${done} of ${total} setup steps. Pick up where you left off.` - : "Let's get your store ready to start selling."} + : (blurb ?? "Let's get your store ready to start selling.")}
- {BENEFITS.map(({ icon: Icon, title, body }) => ( + {benefits.map(({ icon: Icon, title, body }) => ( diff --git a/src/features/setup/SetupTour.tsx b/src/features/setup/SetupTour.tsx deleted file mode 100644 index 4586040..0000000 --- a/src/features/setup/SetupTour.tsx +++ /dev/null @@ -1,370 +0,0 @@ -/** - * The first-run walkthrough: offer it once, then walk them through it. - * - * Two pieces, and they are deliberately not one: - * - * - **The dialog** appears once, on the first sign-in of somebody with work to - * do. Start, or Cancel. Asked once and never again — an offer that reappears - * every morning stops being an offer. - * - **The bar** replaces it for the whole walkthrough. It lives in the shell - * rather than on a page because the tour crosses several pages: a step lives - * on Profile, the next on Users, the next on Inventory. Anything page-local - * would vanish the moment somebody followed it. - * - * Advancing is automatic and derived. The bar watches the same live data the - * steps are computed from, so finishing the work IS finishing the step — no - * "mark as done" button to press, and nothing that can claim a step somebody - * never did. When the last one lands, it takes them back to the console and - * closes itself. - */ - -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { Button } from '@astryxdesign/core/Button'; -import { HStack } from '@astryxdesign/core/HStack'; -import { Text } from '@astryxdesign/core/Text'; -import { VStack } from '@astryxdesign/core/VStack'; -import { ArrowRight, Check, ChevronDown, ChevronUp, Info, Sparkles } from 'lucide-react'; -import type { SetupStep } from '@/features/store-admin/setupSteps'; -import { - declineTour, - endTour, - readTour, - shouldOfferTour, - skipInTour, - startTour, - type TourState, -} from './tourState'; - -export interface SetupTourProps { - userid: number; - tenantid: number; - /** The role's own steps — the merchant's seven, or the branch user's three. */ - steps: readonly SetupStep[]; - /** Where "finished" lands. The console, for both roles. */ - home: string; -} - -export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) { - const navigate = useNavigate(); - const { pathname } = useLocation(); - const [tour, setTour] = useState(() => readTour(userid, tenantid)); - /* Sticky across steps. Somebody who opened the guide once wants it for the - next step too; making them reopen it seven times teaches them not to. */ - const [isOpen, setIsOpen] = useState(false); - - /* The step being walked through: the first that is neither done nor skipped. - Derived on every render, which is what makes completing the work advance - the tour without anything being pressed. */ - const focus = useMemo( - () => steps.find((step) => !step.done && !tour.skipped.includes(step.id)) ?? null, - [steps, tour.skipped], - ); - - const hasWorkToDo = steps.some((step) => !step.done); - const offering = shouldOfferTour(tour, hasWorkToDo); - - /** - * Walk to the step's page when it changes. - * - * The ref is what stops this fighting the person. Without it, navigating - * anywhere during the tour would be undone on the next render — the effect - * would see a focus whose href is not the current path and send them back. - * It fires only when the focused STEP changes, which is exactly the moment - * "move to the next stage" means. - */ - const walkedTo = useRef(null); - useEffect(() => { - if (!tour.active || !focus) return; - if (walkedTo.current === focus.id) return; - walkedTo.current = focus.id; - if (pathname !== focus.href) navigate(focus.href); - }, [tour.active, focus, navigate, pathname]); - - /** - * Finished. Back to the console, and the bar closes. - * - * In an effect rather than inline, because ending the tour is a state write - * and a navigation — doing either during render would either loop or warn. - */ - useEffect(() => { - if (!tour.active || focus) return; - setTour(endTour(userid, tenantid)); - walkedTo.current = null; - navigate(home); - }, [tour.active, focus, userid, tenantid, home, navigate]); - - if (offering) { - return ( - { - walkedTo.current = null; - setTour(startTour(userid, tenantid)); - // To the journey first, not to step one. Somebody agreeing to a - // walkthrough should see what they agreed to — how many steps, - // which are already done, and why. - }} - onCancel={() => setTour(declineTour(userid, tenantid))} - /> - ); - } - - if (!tour.active || !focus) return null; - - const position = steps.findIndex((step) => step.id === focus.id) + 1; - const doneCount = steps.filter((step) => step.done).length; - - return ( -
-
- - - - - - {focus.title} - - - Step {position} of {steps.length} · {doneCount} done · {focus.todo} - - - - - - {/* Collapsed by default. The bar sits on every page for the whole - walkthrough, so a permanently open panel would be a paragraph - between the header and the work on every screen. Open, it stays - open across steps — somebody who wants the detail wants it for - all of them. */} -
-
- ); -} - -/** - * The detail for one step: why it matters, what to do, and the trap. - * - * Three parts rather than a paragraph, because they answer different questions - * and people arrive wanting different ones. Somebody who already knows what to - * do wants the gotcha; somebody who does not wants the numbered actions; the - * reason is what makes a merchant bother at all. - * - * Every "why" here is a failure that has actually happened on this platform, - * not a generality — an unpriced catalogue that never sold, a category nobody - * set, a sheet sitting unreviewed. That is what makes them worth reading. - */ -function StepGuide({ step }: { step: SetupStep }) { - return ( - - - {step.why} - - - - - What to do - -
    - {step.how.map((line) => ( -
  1. - - {line} - -
  2. - ))} -
-
- - {step.gotcha ? ( - - - - {step.gotcha} - - - ) : null} -
- ); -} - -/* ── The offer ───────────────────────────────────────────────────────────── */ - -function StartDialog({ - stepCount, - steps, - onStart, - onCancel, -}: { - stepCount: number; - steps: readonly SetupStep[]; - onStart: () => void; - onCancel: () => void; -}) { - useEffect(() => { - const onKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') onCancel(); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [onCancel]); - - return ( - <> -
-
- - - - - Set up your shop - - - - - {stepCount} short steps, and we will take you to each one. You can skip any of them, or - stop at any point — nothing is locked. - - - {/* What the steps actually are, before agreeing to be walked through - them. "7 short steps" alone asks somebody to commit to an unknown - amount of work; the list is what makes it an informed yes. Already - finished ones are shown ticked, so the two a new tenant gets free - from onboarding are visible rather than a surprise. */} - - {steps.map((step) => ( - - {step.done ? ( - - ) : ( - - ● - - )} - - {step.title} - - - ))} - - - -
- - ); -} diff --git a/src/features/setup/StoreSetupGate.tsx b/src/features/setup/StoreSetupGate.tsx new file mode 100644 index 0000000..3156212 --- /dev/null +++ b/src/features/setup/StoreSetupGate.tsx @@ -0,0 +1,52 @@ +import { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useStoreUserSteps } from './useSetupSteps'; +import { readStoreSetup, shouldOfferStoreSetup, writeStoreSetup } from './storeSetupState'; + +/** + * Sends a first-time branch user to setup instead of the console. + * + * The merchant's `OnboardingGate`, for the other login, and deliberately the + * same three rules — the reasoning is written out there and holds identically + * here: + * + * - **Only once.** The redirect records `started`, so nobody is sent twice. + * Somebody who leaves setup has left it. + * - **Only from the landing page.** A user who deep-links to Products, or is + * already reading Sales, is not hauled away from what they opened. + * - **Only when there is something to do.** A branch user with all three steps + * already satisfied is not a first-time user, however new the account. + * + * ── What replaced what ────────────────────────────────────────────────────── + * + * This takes over from `StoreUserTour`, which offered a one-time dialog and, if + * declined, was gone permanently — no menu entry, no route, nothing. All three + * branch accounts on this install had already declined it, so the login with + * the least familiar users had no setup guidance at all while the merchant's + * had a page they could return to any time. A gate plus a route means declining + * now only postpones it. + */ +export function StoreSetupGate() { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const { steps, isLoading, tenantid, userid } = useStoreUserSteps(); + + useEffect(() => { + if (!userid || !tenantid) return; + + const state = readStoreSetup(userid, tenantid); + const outstanding = steps.filter( + (step) => !step.done && !state.skipped.includes(step.id), + ).length; + + if (!shouldOfferStoreSetup({ pathname, isReady: !isLoading, outstanding, state })) return; + + // Recorded BEFORE navigating, so the decision cannot be taken twice. Without + // it the user is trapped: leaving setup lands back on the console, which + // redirects here again, forever. + writeStoreSetup(userid, tenantid, { ...state, started: true }); + navigate('/store/setup', { replace: true }); + }, [userid, tenantid, isLoading, steps, pathname, navigate]); + + return null; +} diff --git a/src/features/setup/StoreSetupReturn.tsx b/src/features/setup/StoreSetupReturn.tsx new file mode 100644 index 0000000..af05e91 --- /dev/null +++ b/src/features/setup/StoreSetupReturn.tsx @@ -0,0 +1,46 @@ +import { useSearchParams } from 'react-router-dom'; +import { SetupReturnBar } from '@/features/onboarding/SetupReturnBar'; +import { useStoreUserSteps } from './useSetupSteps'; + +/** + * The way back out of a working screen and into a branch user's setup. + * + * The merchant's half of this already existed — setup sends them to Inventory + * to import products, and `?setup=` on the link makes a bar appear there + * that gets them home. The branch user's setup does exactly the same kind of + * thing (it sends them to Profile, to Products, to Sales) and had no bar, so + * the only route back was the browser's Back button. That is not a control + * anybody should have to find to finish onboarding. + * + * Same component as the merchant's, so the two cannot drift: this only supplies + * the branch user's numbers and destination. `?setupstep=` rather than `?setup=` + * because the two flows have different step vocabularies and a bar reading the + * wrong one would show the wrong position. + * + * Renders nothing when the page was not reached from setup, which is almost + * always. + */ +export function StoreSetupReturn() { + const [params] = useSearchParams(); + const from = params.get('setupstep'); + const { steps, isLoading } = useStoreUserSteps(); + + if (!from || isLoading) return null; + + const at = steps.findIndex((step) => step.id === from); + const step = steps[at]; + if (!step) return null; + + return ( + + ); +} diff --git a/src/features/setup/StoreUserTour.tsx b/src/features/setup/StoreUserTour.tsx deleted file mode 100644 index fc0d13e..0000000 --- a/src/features/setup/StoreUserTour.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/** - * The branch user's walkthrough strip. - * - * Three steps, not seven, and honestly so — a counter user cannot open outlets, - * hire anybody or edit the business, so walking them through those would be - * showing somebody work they are not allowed to do. - * - * Nothing is offered until they have a branch. An unassigned account already - * meets "No store assigned", which is the whole of what they can act on. - */ - -import { useStoreUserSteps } from './useSetupSteps'; -import { SetupTour } from './SetupTour'; - -export function StoreUserTour() { - const { steps, isLoading, tenantid, userid } = useStoreUserSteps(); - if (isLoading || !userid) return null; - - return ( - - ); -} diff --git a/src/features/setup/storeSetupState.test.ts b/src/features/setup/storeSetupState.test.ts new file mode 100644 index 0000000..76d1219 --- /dev/null +++ b/src/features/setup/storeSetupState.test.ts @@ -0,0 +1,95 @@ +/** + * The branch user's setup state. + * + * `localStorage` does not exist under `node:test`, so it is stood up here — the + * module reads `window.localStorage` and must degrade rather than throw when it + * cannot. + */ +import assert from 'node:assert/strict'; +import { beforeEach, test } from 'node:test'; +import { + EMPTY_STORE_SETUP, + readStoreSetup, + shouldOfferStoreSetup, + skipStoreStep, + writeStoreSetup, +} from './storeSetupState'; + +function stubStorage(): void { + const store = new Map(); + (globalThis as unknown as { window: unknown }).window = { + localStorage: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + }, + }; +} + +beforeEach(stubStorage); + +test('an account nobody has touched starts empty', () => { + assert.deepEqual(readStoreSetup(3002, 1147), EMPTY_STORE_SETUP); +}); + +test('what is written comes back', () => { + writeStoreSetup(3002, 1147, { started: true, skipped: ['products'], finished: false }); + assert.deepEqual(readStoreSetup(3002, 1147), { + started: true, + skipped: ['products'], + finished: false, + }); +}); + +// A till shared by two people is one browser with two accounts. One person's +// progress must never be read as the other's. +test('two users on one browser do not share progress', () => { + writeStoreSetup(3002, 1147, { started: true, skipped: [], finished: true }); + assert.deepEqual(readStoreSetup(1445, 1147), EMPTY_STORE_SETUP); +}); + +// A user moved to another merchant starts again: their work at the new one has +// genuinely not been done. +test('the same user at another merchant starts again', () => { + writeStoreSetup(3002, 1147, { started: true, skipped: [], finished: true }); + assert.deepEqual(readStoreSetup(3002, 1150), EMPTY_STORE_SETUP); +}); + +test('skipping is recorded once, not repeatedly', () => { + const once = skipStoreStep(EMPTY_STORE_SETUP, 'products'); + assert.deepEqual(once.skipped, ['products']); + assert.equal(skipStoreStep(once, 'products'), once, 'the same object, so nothing re-renders'); +}); + +/* ── When to send somebody to setup ───────────────────────────────────────── */ + +const base = { pathname: '/store/console', isReady: true, outstanding: 2, state: EMPTY_STORE_SETUP }; + +test('a first-time user with work outstanding is offered setup', () => { + assert.equal(shouldOfferStoreSetup(base), true); +}); + +// Once only. Somebody who leaves setup has left it — an offer that reappears +// every morning stops being an offer. +test('nobody is sent twice', () => { + assert.equal( + shouldOfferStoreSetup({ ...base, state: { ...EMPTY_STORE_SETUP, started: true } }), + false, + ); +}); + +// Someone who deep-linked to Products is not hauled away from what they opened. +test('only from the landing page', () => { + assert.equal(shouldOfferStoreSetup({ ...base, pathname: '/store/products' }), false); + assert.equal(shouldOfferStoreSetup({ ...base, pathname: '/store' }), true); +}); + +// Judging "incomplete" while the data is in flight would redirect everybody on +// their first paint, including the users this is written to leave alone. +test('nothing is decided until the data has arrived', () => { + assert.equal(shouldOfferStoreSetup({ ...base, isReady: false }), false); +}); + +test('a user with nothing outstanding is left alone', () => { + assert.equal(shouldOfferStoreSetup({ ...base, outstanding: 0 }), false); +}); diff --git a/src/features/setup/storeSetupState.ts b/src/features/setup/storeSetupState.ts new file mode 100644 index 0000000..7d4e5fe --- /dev/null +++ b/src/features/setup/storeSetupState.ts @@ -0,0 +1,107 @@ +/** + * Where a branch user has got to in setup. + * + * ── Why this is not `onboardingState` ─────────────────────────────────────── + * + * The merchant's setup COLLECTS answers — a shop name, an address, delivery + * settings — so its state has to hold half-typed drafts and remember what was + * saved. A branch user's setup collects nothing: every one of their three steps + * is real work done on a real screen (their own profile, their branch's + * products, the day's takings), and whether it is finished is read back from + * live data, not from anything stored here. + * + * So this holds the two things that genuinely cannot be derived: whether they + * have been sent here once, and which steps they chose to skip. Everything else + * is a question for the API. + * + * ── Why it is per user AND per tenant ─────────────────────────────────────── + * + * The same discipline as `onboardingState`. A till shared by two people is one + * browser with two accounts, and one person's progress must not be read as the + * other's; a user moved between merchants starts again, because their work at + * the new one has genuinely not been done. + */ + +const KEY = 'nearle.setup.store'; + +export interface StoreSetupState { + /** True once they have been sent here. Nobody is redirected twice. */ + started: boolean; + /** Steps put aside on purpose. Skipping is a choice and it is remembered. */ + skipped: string[]; + /** True once they have reached the end screen. */ + finished: boolean; +} + +export const EMPTY_STORE_SETUP: StoreSetupState = { + started: false, + skipped: [], + finished: false, +}; + +function slot(userid: number, tenantid: number): string { + return `${userid}:${tenantid}`; +} + +/** Everything stored, or an empty map when it cannot be read. */ +function readAll(): Record { + try { + const raw = window.localStorage.getItem(KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + } catch { + // Private-mode Safari throws on localStorage, and a corrupt value should + // cost somebody a redirect they have already had, not the whole console. + return {}; + } +} + +export function readStoreSetup(userid: number, tenantid: number): StoreSetupState { + const stored = readAll()[slot(userid, tenantid)]; + if (!stored) return EMPTY_STORE_SETUP; + return { + started: Boolean(stored.started), + skipped: Array.isArray(stored.skipped) ? stored.skipped : [], + finished: Boolean(stored.finished), + }; +} + +export function writeStoreSetup(userid: number, tenantid: number, next: StoreSetupState): void { + try { + const all = readAll(); + all[slot(userid, tenantid)] = next; + window.localStorage.setItem(KEY, JSON.stringify(all)); + } catch { + /* Nothing to do and nothing worth saying: the flow still works, it just + forgets. Better a repeated offer than a console that will not load. */ + } +} + +/** Puts a step aside without claiming it was done. */ +export function skipStoreStep(state: StoreSetupState, id: string): StoreSetupState { + return state.skipped.includes(id) ? state : { ...state, skipped: [...state.skipped, id] }; +} + +/** + * Whether to send this user to setup rather than to the console. + * + * The same three rules the merchant's gate follows, for the same reasons: + * only once, only from the landing page, and only when there is something to + * do. A branch user with nothing outstanding is not a first-time user however + * new the account. + */ +export function shouldOfferStoreSetup(input: { + pathname: string; + /** False while the data the steps are derived from is still in flight. */ + isReady: boolean; + /** Steps that are neither done nor skipped. */ + outstanding: number; + state: StoreSetupState; +}): boolean { + const { pathname, isReady, outstanding, state } = input; + if (!isReady) return false; + if (state.started) return false; + if (pathname !== '/store' && pathname !== '/store/console') return false; + return outstanding > 0; +} diff --git a/src/features/setup/tourState.test.ts b/src/features/setup/tourState.test.ts deleted file mode 100644 index 7a1efd2..0000000 --- a/src/features/setup/tourState.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * The three facts a first-run walkthrough has to keep apart. - * - * Conflating them is what makes onboarding annoying: an offer that reappears - * every morning, a walkthrough that drops out on refresh, or a "skip" that - * quietly counts as done. - */ -import assert from 'node:assert/strict'; -import { test, beforeEach } from 'node:test'; -import { shouldOfferTour, type TourState } from './tourState'; - -const state = (over: Partial = {}): TourState => ({ - prompted: false, - active: false, - skipped: [], - ...over, -}); - -beforeEach(() => { - delete (globalThis as { localStorage?: unknown }).localStorage; -}); - -test('a new account with work to do is offered the walkthrough', () => { - assert.equal(shouldOfferTour(state(), true), true); -}); - -// Asked once. An offer that returns on every sign-in stops being an offer and -// becomes something to dismiss without reading. -test('somebody who already said no is not asked again', () => { - assert.equal(shouldOfferTour(state({ prompted: true }), true), false); -}); - -test('somebody already in the walkthrough is not offered it again', () => { - assert.equal(shouldOfferTour(state({ active: true, prompted: true }), true), false); -}); - -/* -The check that matters most. A shop already selling is not a first-run case -however new the account is — three of the four live merchants on 31 Aug were -trading and still missing a licence number, and offering them a setup tour would -read as the console not knowing what it is looking at. -*/ -test('a shop with nothing left to do is never offered a walkthrough', () => { - assert.equal(shouldOfferTour(state(), false), false); - assert.equal(shouldOfferTour(state({ prompted: false }), false), false); -}); - -// Storage the browser refuses must not throw, and must fail towards offering -// the guidance rather than silently swallowing it. -test('unavailable storage reads as nothing recorded', async () => { - const { readTour } = await import('./tourState'); - const read = readTour(1475, 1141); - assert.equal(read.prompted, false); - assert.equal(read.active, false); - assert.deepEqual(read.skipped, []); -}); diff --git a/src/features/setup/tourState.ts b/src/features/setup/tourState.ts deleted file mode 100644 index 0c613fe..0000000 --- a/src/features/setup/tourState.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Whether somebody is being walked through setup, and where they have got to. - * - * Three separate facts, and conflating them is what makes onboarding annoying: - * - * - **prompted** — have we ever offered to start? Asked once. Somebody who - * said no is not asked again on every sign-in. - * - **active** — are they in the middle of it right now? Survives navigation - * and reload, because the tour walks across several different pages and a - * refresh in the middle must not drop them out of it. - * - **skipped** — which steps they waved past. Never marks a step done; it - * only moves the guidance on. - * - * Stored per browser, per person, per shop. Deliberately not synced: "not now" - * is a statement about this afternoon, and a colleague signing in on another - * machine should still be offered the guidance. - */ - -const KEY = 'nearle.setup.tour'; - -export interface TourState { - prompted: boolean; - active: boolean; - skipped: string[]; -} - -const EMPTY: TourState = { prompted: false, active: false, skipped: [] }; - -/** One record per (person, shop) — the same browser may serve both. */ -function scopeKey(userid: number, tenantid: number): string { - return `${userid}:${tenantid}`; -} - -function readAll(): Record { - try { - const raw = localStorage.getItem(KEY); - const parsed: unknown = raw ? JSON.parse(raw) : {}; - return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; - } catch { - // A private window, cleared site data, or storage the browser refuses. - // Answering "nothing recorded" means the guidance is offered again rather - // than lost — the safe direction for a first-run flow. - return {}; - } -} - -export function readTour(userid: number, tenantid: number): TourState { - return readAll()[scopeKey(userid, tenantid)] ?? EMPTY; -} - -export function writeTour(userid: number, tenantid: number, next: TourState): TourState { - const all = readAll(); - try { - localStorage.setItem(KEY, JSON.stringify({ ...all, [scopeKey(userid, tenantid)]: next })); - } catch { - /* Storage refused. The change still applies to this session — losing it on - reload is a smaller failure than the write throwing mid-click. */ - } - return next; -} - -export function startTour(userid: number, tenantid: number): TourState { - return writeTour(userid, tenantid, { - ...readTour(userid, tenantid), - prompted: true, - active: true, - }); -} - -/** Said no to the offer. Asked once, then left alone. */ -export function declineTour(userid: number, tenantid: number): TourState { - return writeTour(userid, tenantid, { - ...readTour(userid, tenantid), - prompted: true, - active: false, - }); -} - -/** Finished, or walked out of. Keeps `skipped` so a resume picks up where it was. */ -export function endTour(userid: number, tenantid: number): TourState { - return writeTour(userid, tenantid, { ...readTour(userid, tenantid), active: false }); -} - -export function skipInTour(userid: number, tenantid: number, id: string): TourState { - const current = readTour(userid, tenantid); - return writeTour(userid, tenantid, { - ...current, - skipped: [...new Set([...current.skipped, id])], - }); -} - -/** - * Whether to put the "Start setup" dialog in front of somebody. - * - * Only when there is genuinely something to set up. A shop already selling is - * not a first-run case however new the account is, and offering a tour to - * somebody whose products are live reads as the console not knowing what it is - * looking at. - */ -export function shouldOfferTour(state: TourState, hasWorkToDo: boolean): boolean { - return hasWorkToDo && !state.prompted && !state.active; -} diff --git a/src/features/store-admin/pages/InventoryPage.tsx b/src/features/store-admin/pages/InventoryPage.tsx index 8329abf..901697a 100644 --- a/src/features/store-admin/pages/InventoryPage.tsx +++ b/src/features/store-admin/pages/InventoryPage.tsx @@ -35,7 +35,11 @@ import { useBranchScope } from '../BranchScope'; import { CataloguePanel } from '../CataloguePanel'; import { ProductsPanel } from '../ProductsPanel'; import { SetupReturnBar } from '@/features/onboarding/SetupReturnBar'; -import type { StepId } from '@/features/onboarding/onboardingState'; +import { + PROGRESS_STEPS, + STEP_LABEL, + type StepId, +} from '@/features/onboarding/onboardingState'; import { useLocationProducts } from '@/queries/hooks'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; @@ -108,7 +112,16 @@ export function InventoryPage() { {/* Above the header, because it is about the errand rather than the page: it is the only thing on screen that knows setup is unfinished. */} {fromSetup ? ( - + 0} + doneTitle={`${(setupProducts.data ?? []).length} product${ + (setupProducts.data ?? []).length === 1 ? '' : 's' + } added`} + href={`/admin/onboarding?advance=${fromSetup}`} + /> ) : null} + {/* Sends a first-time branch user to setup, exactly as the merchant's + shell does with `OnboardingGate`. Renders nothing. */} + } manageItems={MANAGE} - banner={} headerActions={ setQrOpen(true)}> diff --git a/src/features/store-user/pages/StoreProductsPage.tsx b/src/features/store-user/pages/StoreProductsPage.tsx index 4474bbb..50e0c49 100644 --- a/src/features/store-user/pages/StoreProductsPage.tsx +++ b/src/features/store-user/pages/StoreProductsPage.tsx @@ -18,6 +18,7 @@ import { useStockRequests, useStockStatement, } from '@/queries/hooks'; +import { StoreSetupReturn } from '@/features/setup/StoreSetupReturn'; import { useBranchScope } from '@/features/store-admin/BranchScope'; import { ProductDrawer } from '@/features/store-admin/ProductDrawer'; import { count, money } from '@/features/store-admin/format'; @@ -145,6 +146,10 @@ export function StoreProductsPage() { return ( + {/* Above the header, because it is about the errand rather than the page + — the same placement the merchant's Inventory uses. */} + + (() => + userid ? readStoreSetup(userid, tenantid) : { started: false, skipped: [], finished: false }, + ); + + function save(next: StoreSetupState) { + setState(next); + if (userid) writeStoreSetup(userid, tenantid, next); + } + + /** + * Which step is on screen. + * + * The URL wins when it names one — that is how the return bar brings somebody + * back to where they left. Otherwise it is the first step still outstanding, + * which means finishing the work on another screen and coming back lands on + * what is next rather than on what is already done. + */ + const asked = params.get('step'); + const outstanding = useMemo( + () => steps.filter((step) => !step.done && !state.skipped.includes(step.id)), + [steps, state.skipped], + ); + const [manual, setManual] = useState(null); + + /* The welcome screen, exactly as the merchant's flow opens. It is skipped for + somebody coming back to a named step, because they have already begun. */ + const [hasStarted, setHasStarted] = useState(() => Boolean(asked) || state.started); + + const currentId = manual ?? asked ?? outstanding[0]?.id ?? null; + const current = steps.find((step) => step.id === currentId) ?? outstanding[0] ?? null; + + const ids = useMemo(() => steps.map((step) => step.id as string), [steps]); + const labels = useMemo( + () => Object.fromEntries(steps.map((step) => [step.id, step.title])) as Record, + [steps], + ); + const completed = useMemo( + () => steps.filter((step) => step.done).map((step) => step.id as string), + [steps], + ); + + const doneCount = steps.filter((step) => step.done).length; + + if (isLoading || !userid) { + return ( + + + Reading your shop… + + + ); + } + + if (!hasStarted) { + return ( + + 0} + greeting="Welcome! 👋" + blurb="Three short things, and you will know your way around this console." + benefits={[ + { + icon: UserRound, + title: 'Your own account', + body: 'So your shop can see who took a payment and who raised a request.', + }, + { + icon: Boxes, + title: 'Your shelf', + body: 'What is priced, what is in stock, and what a customer can buy today.', + }, + { + icon: Receipt, + title: 'The day’s takings', + body: 'Counter sales and app orders side by side, for this branch.', + }, + ]} + onStart={() => { + save({ ...state, started: true }); + setHasStarted(true); + }} + /> + + ); + } + + /* Everything done, or everything put aside. The end screen, same as the + merchant's — a flow that just stops on its last step never tells anybody + they have finished. */ + if (!current) { + return ( + + 0 + ? `${doneCount} of ${steps.length} done, ${state.skipped.length} put aside. Come back whenever you like.` + : 'You know your way around. Everything you need is on the console from here.' + } + actions={[ + { + icon: , + title: 'See your shelf', + body: 'What is priced, what is in stock, and what is out.', + cta: 'Open products', + onClick: () => navigate('/store/products'), + }, + { + icon: , + title: "Today's takings", + body: 'Counter sales and app orders, side by side for this branch.', + cta: 'Open sales', + onClick: () => navigate('/store/sales'), + }, + { + icon: , + title: 'Your details', + body: 'Your name, mobile and sign-in, whenever they need changing.', + cta: 'Open my account', + onClick: () => navigate('/store/account'), + }, + ]} + onDashboard={() => { + save({ ...state, started: true, finished: true }); + navigate(HOME, { replace: true }); + }} + /> + + ); + } + + const position = ids.indexOf(current.id) + 1; + const isLastOutstanding = outstanding.length <= 1; + + return ( + + + + { + const at = ids.indexOf(current.id); + setManual(at > 0 ? (ids[at - 1] as string) : null); + if (at <= 0) navigate(HOME); + }} + onSkip={() => { + save(skipStoreStep({ ...state, started: true }, current.id)); + setManual(null); + }} + onContinue={() => { + save({ ...state, started: true }); + setManual(null); + // Nothing is marked done here — see the note at the top. Moving on + // means moving on; the step stays outstanding until the work is. + if (isLastOutstanding) navigate(HOME, { replace: true }); + }} + onExit={() => { + save({ ...state, started: true }); + navigate(HOME); + }} + > + + {/* Why it matters, how to do it, and what catches people out — the + three things the tour strip carried and the reason it was worth + keeping when the strip went. */} + } title="Why this matters" body={current.why} /> + + {current.how && current.how.length > 0 ? ( +
+ + + + + + What you will do + +
    + {current.how.map((line) => ( +
  • {line}
  • + ))} +
+
+
+ ) : null} + + {current.gotcha ? ( + } + title="Worth knowing" + body={current.gotcha} + tone="warn" + /> + ) : null} + + {/* The work itself happens elsewhere, so the step carries the trip and + its return leg: `?step=` is what brings them back to this one. */} +
+ + {current.done ? : } + + + + {current.done ? 'Done' : current.cta} + + + {current.done + ? current.detail + ? `Finished — ${current.detail}.` + : 'Finished. Carry on to the next step.' + : 'This opens the screen where the work happens. Come back here when you are done.'} + + {current.done ? null : ( +
+
+ )} +
+
+ + {user?.name ? ( + + Signed in as {user.name}. Your branch is set by your store administrator. + + ) : null} +
+
+
+ ); +} + +function Guidance({ + icon, + title, + body, + tone, +}: { + icon: React.ReactNode; + title: string; + body: string; + tone?: 'warn'; +}) { + return ( +
+ + {icon} + + + + {title} + + + {body} + + +
+ ); +} diff --git a/src/index.css b/src/index.css index b979b8a..411aad2 100644 --- a/src/index.css +++ b/src/index.css @@ -1719,3 +1719,70 @@ main { .qty-input:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; } + +/* ── Setup: the guidance blocks on a branch user's step ──────────────────── + A branch user's steps do not collect anything — the work happens on another + screen — so what fills the frame is the explanation: why it matters, what + they will do there, and what catches people out. Three of the same block, + distinguished by tone rather than by three different shapes. + + Built from the tokens already here: brand for the neutral case, the warning + ochre for the caveat, success green once a step is finished. No new palette + and no card of its own — the frame is already a card, and a card inside a + card is how a simple page starts looking busy. */ +.ob-guide { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: start; + padding: 14px 16px; + border: 1px solid var(--color-line); + border-radius: 12px; + background: var(--color-surface-subtle); +} + +.ob-guide[data-tone='warn'] { + border-color: var(--color-warning, #b7860b); + background: color-mix(in oklab, var(--color-warning, #b7860b) 7%, transparent); +} + +.ob-guide[data-tone='done'] { + border-color: var(--color-success, #1f9d55); + background: var(--color-success-tint, #eaf7ef); +} + +.ob-guide-icon { + width: 26px; + height: 26px; + flex: none; + border-radius: 999px; + display: grid; + place-items: center; + background: var(--color-brand); + color: #fff; +} + +.ob-guide[data-tone='warn'] .ob-guide-icon { background: var(--color-warning, #b7860b); } +.ob-guide[data-tone='done'] .ob-guide-icon { background: var(--color-success, #1f9d55); } + +.ob-guide-list { + margin: 2px 0 0; + padding-left: 18px; + display: flex; + flex-direction: column; + gap: 4px; + font: 400 13.5px/1.6 var(--font-sans); + color: var(--color-ink-3); +} + +/* The tick that closes the flow. Same mark the merchant's Done step uses, so + both roles finish on the same note. */ +.ob-done-mark { + width: 56px; + height: 56px; + border-radius: 999px; + display: grid; + place-items: center; + background: var(--color-success-tint, #eaf7ef); + color: var(--color-success, #1f9d55); +}