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. */}
- : }
- onClick={() => setIsOpen((open) => !open)}
- />
- {/* Only when they have wandered off. Following the tour puts them
- on the right page already, and a button that does nothing is
- worse than no button. */}
- {pathname !== focus.href ? (
- }
- onClick={() => navigate(focus.href)}
- />
- ) : null}
-
-
-
- {isOpen ? : null}
-
- {/* Skip and Exit sit at the END, bottom right.
- They were beside the action at the top, where the eye lands first —
- so the two ways OUT of a step were more prominent than the one way
- through it. Leaving is offered, not advertised. */}
-
-
-
-
- );
-}
-
-/**
- * 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) => (
-
-
-
-
-
- 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);
+}