diff --git a/src/App.tsx b/src/App.tsx
index c41cc2e..3740703 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -43,6 +43,8 @@ const UsersPage = named('UsersPage', () => import('@/features/store-admin/pages/
const TerminalsPage = named('TerminalsPage', () => import('@/features/store-admin/pages/TerminalsPage'));
const AdminUploadsPage = named('UploadsPage', () => import('@/features/store-admin/pages/UploadsPage'));
const ShopProfilePage = named('ShopProfilePage', () => import('@/features/store-admin/pages/ShopProfilePage'));
+const StoreAdminSetupPage = named('StoreAdminSetupPage', () => import('@/features/setup/StoreAdminSetupPage'));
+const StoreUserSetupPage = named('StoreUserSetupPage', () => import('@/features/setup/StoreUserSetupPage'));
/* The Store user workspace reuses the merchant's four pages, pinned to one
branch by `BranchScopeProvider pin=`. Only what a shop does differently is
@@ -126,6 +128,7 @@ export function App() {
} />
} />
} />
+ } />
{/* Catches `/admin/dashboard` and anything else that does not resolve.
Without this, an unknown sub-path escapes to the global `*`, which
redirects to this role's HOME_ROUTE — and if that is itself an
@@ -157,6 +160,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/src/features/setup/SetupPage.tsx b/src/features/setup/SetupPage.tsx
new file mode 100644
index 0000000..e911ea8
--- /dev/null
+++ b/src/features/setup/SetupPage.tsx
@@ -0,0 +1,302 @@
+/**
+ * The whole journey from "signed in" to "selling", on one screen.
+ *
+ * The walkthrough started as a strip along the top of the console, and a strip
+ * is the wrong shape for this. It can show one step, so a merchant sees a
+ * sentence and a button with no idea what they have agreed to, how much is
+ * left, or why the thing they just finished was followed by something they were
+ * never shown. Onboarding completes two of the seven steps before anybody signs
+ * in, and the strip leapt over both in silence.
+ *
+ * So the journey gets a page. Every step is visible with its real state, the
+ * current one is open with its guidance, and a step that was finished before
+ * the merchant arrived says so instead of vanishing. The strip stays, but only
+ * as a way back here while the work is being done on another screen.
+ *
+ * Nothing here is stored. Every tick is derived from live data, so the page
+ * cannot claim work that was not done, and re-reading it after a change is what
+ * moves it on.
+ */
+
+import { useMemo } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Button } from '@astryxdesign/core/Button';
+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, Check, Info } from 'lucide-react';
+import { PageBody } from '@/components/PageBody';
+import { PageHeader } from '@/components/PageHeader';
+import type { SetupStep } from '@/features/store-admin/setupSteps';
+
+export interface SetupPageProps {
+ steps: readonly SetupStep[];
+ /** Steps the merchant has waved past. Never counted as done. */
+ skipped: readonly string[];
+ isLoading?: boolean;
+ home: string;
+ onStart: () => void;
+ onSkip: (id: string) => void;
+ onExit: () => void;
+ /** True while the walkthrough is running, which changes the primary action. */
+ isActive: boolean;
+}
+
+export function SetupPage({
+ steps,
+ skipped,
+ isLoading = false,
+ home,
+ onStart,
+ onSkip,
+ onExit,
+ isActive,
+}: SetupPageProps) {
+ const navigate = useNavigate();
+
+ const focus = useMemo(
+ () => steps.find((step) => !step.done && !skipped.includes(step.id)) ?? null,
+ [steps, skipped],
+ );
+
+ const done = steps.filter((step) => step.done).length;
+ const pct = steps.length === 0 ? 0 : Math.round((done / steps.length) * 100);
+
+ if (isLoading) {
+ return (
+
+
+
+
+ Checking what is already done…
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {/* Progress as a bar, not just a fraction. "2 of 7" tells somebody how far
+ they are; a bar tells them at a glance whether this is nearly over. */}
+
+
+
+
+
+ {steps.map((step, index) => (
+ {
+ if (!isActive) onStart();
+ navigate(step.href);
+ }}
+ onSkip={() => onSkip(step.id)}
+ />
+ ))}
+
+
+
+ {focus ? (
+ }
+ onClick={() => {
+ if (!isActive) onStart();
+ navigate(focus.href);
+ }}
+ />
+ ) : (
+
+
+ );
+}
+
+/* ── One step in the journey ──────────────────────────────────────────────── */
+
+function StepRow({
+ step,
+ index,
+ isLast,
+ isFocus,
+ isSkipped,
+ isActive,
+ onGo,
+ onSkip,
+}: {
+ step: SetupStep;
+ index: number;
+ isLast: boolean;
+ isFocus: boolean;
+ isSkipped: boolean;
+ isActive: boolean;
+ onGo: () => void;
+ onSkip: () => void;
+}) {
+ const marker = step.done
+ ? { bg: 'var(--color-success, #10b981)', fg: '#fff', ring: 'transparent' }
+ : isFocus
+ ? { bg: 'var(--color-brand)', fg: '#fff', ring: 'transparent' }
+ : { bg: 'transparent', fg: 'var(--color-ink-4)', ring: 'var(--color-line)' };
+
+ return (
+
+ {/* The rail: a numbered marker with a line joining it to the next, so the
+ seven read as one sequence rather than seven cards. */}
+
+
+ {step.done ? : index + 1}
+
+ {!isLast ? (
+
+ ) : null}
+
+
+
+
+
+ {step.title}
+
+ {step.detail ? (
+
+ {step.detail}
+
+ ) : null}
+ {isSkipped ? (
+
+ · skipped
+
+ ) : null}
+
+
+ {/* A step finished before the merchant arrived says why. Two of the
+ seven are done by onboarding, and silently ticking them looks like
+ the walkthrough inventing progress. */}
+ {step.done && step.doneNote ? (
+
+ {step.doneNote}
+
+ ) : null}
+
+ {/* Only the current step opens. Seven expanded panels is a manual, not
+ a walkthrough. */}
+ {isFocus ? (
+
+
+ {step.why}
+
+
+
+
+ What to do
+
+
+ {step.how.map((line) => (
+
+
+ {line}
+
+
+ ))}
+
+
+
+ {step.gotcha ? (
+
+
+
+ {step.gotcha}
+
+
+ ) : null}
+
+
+ }
+ onClick={onGo}
+ />
+ {/* Skipping sits after the action, never before it. */}
+ {isActive ? (
+
+ ) : null}
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/features/setup/SetupTour.tsx b/src/features/setup/SetupTour.tsx
index 021a15e..235a793 100644
--- a/src/features/setup/SetupTour.tsx
+++ b/src/features/setup/SetupTour.tsx
@@ -43,9 +43,11 @@ export interface SetupTourProps {
steps: readonly SetupStep[];
/** Where "finished" lands. The console, for both roles. */
home: string;
+ /** The journey page — where the walkthrough starts and can be reviewed. */
+ setupHref: string;
}
-export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
+export function SetupTour({ userid, tenantid, steps, home, setupHref }: SetupTourProps) {
const navigate = useNavigate();
const { pathname } = useLocation();
const [tour, setTour] = useState(() => readTour(userid, tenantid));
@@ -102,6 +104,10 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
onStart={() => {
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.
+ navigate(setupHref);
}}
onCancel={() => setTour(declineTour(userid, tenantid))}
/>
@@ -128,7 +134,7 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
justify="between"
gap={2}
wrap="wrap"
- style={{ padding: '10px 0' }}
+ style={{ padding: '10px 0 0' }}
>
@@ -148,6 +154,12 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
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. */}
+ navigate(setupHref)}
+ />
navigate(focus.href)}
/>
) : null}
- setTour(skipInTour(userid, tenantid, focus.id))}
- />
- {/* Leaving is always available. A walkthrough somebody cannot get
- out of is a trap, and the offer is not made again anyway. */}
- {
- setTour(endTour(userid, tenantid));
- navigate(home);
- }}
- />
{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. */}
+
+ setTour(skipInTour(userid, tenantid, focus.id))}
+ />
+ {/* Leaving is always available. A walkthrough somebody cannot get
+ out of is a trap, and the offer is not made again anyway. */}
+ {
+ setTour(endTour(userid, tenantid));
+ navigate(home);
+ }}
+ />
+
);
diff --git a/src/features/setup/StoreAdminSetupPage.tsx b/src/features/setup/StoreAdminSetupPage.tsx
new file mode 100644
index 0000000..62fbba9
--- /dev/null
+++ b/src/features/setup/StoreAdminSetupPage.tsx
@@ -0,0 +1,35 @@
+/**
+ * The merchant's setup journey, at /admin/setup.
+ *
+ * Reachable at any time, not only during the walkthrough — somebody who
+ * dismissed the offer, or finished half of it last week, needs a way back to
+ * the list without being asked again.
+ */
+
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { SetupPage } from './SetupPage';
+import { useStoreAdminSteps } from './useSetupSteps';
+import { endTour, readTour, skipInTour, startTour, type TourState } from './tourState';
+
+export function StoreAdminSetupPage() {
+ const navigate = useNavigate();
+ const { steps, isLoading, tenantid, userid } = useStoreAdminSteps();
+ const [tour, setTour] = useState(() => readTour(userid, tenantid));
+
+ return (
+ setTour(startTour(userid, tenantid))}
+ onSkip={(id) => setTour(skipInTour(userid, tenantid, id))}
+ onExit={() => {
+ setTour(endTour(userid, tenantid));
+ navigate('/admin/console');
+ }}
+ />
+ );
+}
diff --git a/src/features/setup/StoreAdminTour.tsx b/src/features/setup/StoreAdminTour.tsx
index 026012a..7693913 100644
--- a/src/features/setup/StoreAdminTour.tsx
+++ b/src/features/setup/StoreAdminTour.tsx
@@ -1,71 +1,28 @@
/**
- * The merchant's walkthrough, mounted in the Store Admin shell.
+ * The merchant's walkthrough strip, mounted in the Store Admin shell.
*
* Its own component rather than props on the shell, because it has to sit
* INSIDE `BranchScopeProvider` to read the branches — and because the two roles
- * feed the tour from entirely different data, which a single shared mount would
- * turn into a pile of conditionals.
+ * feed the walkthrough from entirely different data, which a single shared
+ * mount would turn into a pile of conditionals.
*
- * Every hook here already existed. The walkthrough needed no new backend at
- * all, which is most of the reason it was worth building.
+ * Every hook it uses already existed. The walkthrough needed no new backend.
*/
-import { useMemo } from 'react';
-import { useAuth } from '@/auth/AuthContext';
-import { useBranchScope } from '@/features/store-admin/BranchScope';
-import { setupSteps } from '@/features/store-admin/setupSteps';
-import {
- useLocationProducts,
- useOwnTenant,
- useStaff,
- useUploads,
-} from '@/queries/hooks';
+import { useStoreAdminSteps } from './useSetupSteps';
import { SetupTour } from './SetupTour';
export function StoreAdminTour() {
- const { user } = useAuth();
- const { branches, tenantid } = useBranchScope();
-
- const shop = useOwnTenant(tenantid || undefined);
- const people = useStaff(tenantid || undefined);
- const products = useLocationProducts(tenantid || undefined, undefined, 0, { allBranches: true });
- const uploads = useUploads(tenantid || undefined);
-
- /* Drops the catalogue service has not released yet. Without this the
- products step reads as neglected when it is simply not our turn — the
- sheet is sitting in their review queue. */
- const pendingUploads = useMemo(
- () =>
- (uploads.data ?? []).filter((receipt) => receipt.laststatus === 'pending' && !receipt.runid)
- .length,
- [uploads.data],
- );
-
- const steps = useMemo(
- () =>
- setupSteps({
- shop: shop.data,
- people: people.data ?? [],
- branches,
- products: products.data ?? [],
- pendingUploads,
- }),
- [shop.data, people.data, branches, products.data, pendingUploads],
- );
-
- /* Nothing is offered until the data has actually arrived. Every step reads
- as undone while the queries are in flight, so a tour started then would
- walk somebody through work they had already finished. */
- const isReady =
- Boolean(tenantid) && !shop.isLoading && !people.isLoading && !products.isLoading;
- if (!isReady || !user?.userid) return null;
+ const { steps, isLoading, tenantid, userid } = useStoreAdminSteps();
+ if (isLoading || !userid) return null;
return (
);
}
diff --git a/src/features/setup/StoreUserSetupPage.tsx b/src/features/setup/StoreUserSetupPage.tsx
new file mode 100644
index 0000000..96cc109
--- /dev/null
+++ b/src/features/setup/StoreUserSetupPage.tsx
@@ -0,0 +1,35 @@
+/**
+ * The branch user’s setup journey, at /store/setup.
+ *
+ * Reachable at any time, not only during the walkthrough — somebody who
+ * dismissed the offer, or finished half of it last week, needs a way back to
+ * the list without being asked again.
+ */
+
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { SetupPage } from './SetupPage';
+import { useStoreUserSteps } from './useSetupSteps';
+import { endTour, readTour, skipInTour, startTour, type TourState } from './tourState';
+
+export function StoreUserSetupPage() {
+ const navigate = useNavigate();
+ const { steps, isLoading, tenantid, userid } = useStoreUserSteps();
+ const [tour, setTour] = useState(() => readTour(userid, tenantid));
+
+ return (
+ setTour(startTour(userid, tenantid))}
+ onSkip={(id) => setTour(skipInTour(userid, tenantid, id))}
+ onExit={() => {
+ setTour(endTour(userid, tenantid));
+ navigate('/store/console');
+ }}
+ />
+ );
+}
diff --git a/src/features/setup/StoreUserTour.tsx b/src/features/setup/StoreUserTour.tsx
index 96b7905..39bc870 100644
--- a/src/features/setup/StoreUserTour.tsx
+++ b/src/features/setup/StoreUserTour.tsx
@@ -1,48 +1,28 @@
/**
- * The branch user's walkthrough, mounted in the Store user shell.
+ * 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 the "No store assigned" screen, which is the whole of what they can act
- * on; a walkthrough on top of it would be a second thing to read and no second
- * thing to do.
+ * meets "No store assigned", which is the whole of what they can act on.
*/
-import { useMemo } from 'react';
-import { useAuth } from '@/auth/AuthContext';
-import { useBranchScope } from '@/features/store-admin/BranchScope';
-import { useLocationProducts } from '@/queries/hooks';
-import { storeUserSteps } from './storeUserSteps';
+import { useStoreUserSteps } from './useSetupSteps';
import { SetupTour } from './SetupTour';
export function StoreUserTour() {
- const { user } = useAuth();
- const { current, tenantid } = useBranchScope();
- const products = useLocationProducts(tenantid || undefined, current?.locationid, 0);
-
- const steps = useMemo(
- () =>
- storeUserSteps({
- user,
- hasBranch: Boolean(current?.locationid),
- productCount: (products.data ?? []).length,
- ...(current?.locationname ? { branchName: current.locationname } : {}),
- }),
- [user, current?.locationid, current?.locationname, products.data],
- );
-
- if (!user?.userid || !tenantid || !current?.locationid) return null;
- if (products.isLoading) return null;
+ const { steps, isLoading, tenantid, userid } = useStoreUserSteps();
+ if (isLoading || !userid) return null;
return (
);
}
diff --git a/src/features/setup/useSetupSteps.ts b/src/features/setup/useSetupSteps.ts
new file mode 100644
index 0000000..e3f951b
--- /dev/null
+++ b/src/features/setup/useSetupSteps.ts
@@ -0,0 +1,86 @@
+/**
+ * The steps for each role, in one place.
+ *
+ * Both the journey page and the strip along the top need them, computed
+ * identically — two copies of this would drift, and the first symptom would be
+ * a page saying a step is finished while the strip still asks for it.
+ */
+
+import { useMemo } from 'react';
+import { useAuth } from '@/auth/AuthContext';
+import { useBranchScope } from '@/features/store-admin/BranchScope';
+import { setupSteps, type SetupStep } from '@/features/store-admin/setupSteps';
+import { useLocationProducts, useOwnTenant, useStaff, useUploads } from '@/queries/hooks';
+import { storeUserSteps } from './storeUserSteps';
+
+export interface RoleSteps {
+ steps: SetupStep[];
+ isLoading: boolean;
+ tenantid: number;
+ userid: number;
+}
+
+export function useStoreAdminSteps(): RoleSteps {
+ const { user } = useAuth();
+ const { branches, tenantid } = useBranchScope();
+
+ const shop = useOwnTenant(tenantid || undefined);
+ const people = useStaff(tenantid || undefined);
+ const products = useLocationProducts(tenantid || undefined, undefined, 0, { allBranches: true });
+ const uploads = useUploads(tenantid || undefined);
+
+ /* Drops the catalogue service has not released yet. Without this the products
+ step reads as neglected when it is simply not our turn. */
+ const pendingUploads = useMemo(
+ () =>
+ (uploads.data ?? []).filter((receipt) => receipt.laststatus === 'pending' && !receipt.runid)
+ .length,
+ [uploads.data],
+ );
+
+ const steps = useMemo(
+ () =>
+ setupSteps({
+ shop: shop.data,
+ people: people.data ?? [],
+ branches,
+ products: products.data ?? [],
+ pendingUploads,
+ }),
+ [shop.data, people.data, branches, products.data, pendingUploads],
+ );
+
+ return {
+ steps,
+ // Nothing is judged until the data has arrived: every step reads as undone
+ // while the queries are in flight, and acting on that would walk somebody
+ // through work they had already finished.
+ isLoading: !tenantid || shop.isLoading || people.isLoading || products.isLoading,
+ tenantid,
+ userid: user?.userid ?? 0,
+ };
+}
+
+export function useStoreUserSteps(): RoleSteps {
+ const { user } = useAuth();
+ const { current, tenantid } = useBranchScope();
+ const products = useLocationProducts(tenantid || undefined, current?.locationid, 0);
+
+ const steps = useMemo(
+ () =>
+ storeUserSteps({
+ user,
+ hasBranch: Boolean(current?.locationid),
+ productCount: (products.data ?? []).length,
+ ...(current?.locationname ? { branchName: current.locationname } : {}),
+ }),
+ [user, current?.locationid, current?.locationname, products.data],
+ );
+
+ return {
+ steps,
+ isLoading: !tenantid || !current?.locationid || products.isLoading,
+ tenantid,
+ userid: user?.userid ?? 0,
+ };
+}
diff --git a/src/features/store-admin/setupSteps.ts b/src/features/store-admin/setupSteps.ts
index 7f4baa8..ca69a2e 100644
--- a/src/features/store-admin/setupSteps.ts
+++ b/src/features/store-admin/setupSteps.ts
@@ -49,6 +49,16 @@ export interface SetupStep {
href: string;
/** A real count, when there is one worth showing. */
detail?: string;
+ /**
+ * Why a step was already finished before anybody started.
+ *
+ * Onboarding creates a tenant, its first branch AND its administrator in
+ * one transaction, so two of the seven are done before a merchant ever
+ * signs in. The walkthrough used to leap straight past them, which reads
+ * as the tour skipping steps by itself. Saying what happened costs one
+ * line and removes the whole confusion.
+ */
+ doneNote?: string;
/**
* Why this step is worth doing — the consequence of not doing it.
@@ -101,7 +111,7 @@ export function setupSteps(input: SetupInput): SetupStep[] {
gotcha:
"A field left blank is not changed. Saving will never erase something you did not fill in.",
title: 'Complete your shop profile',
- todo: 'Add your shop photo and licence — this is what shoppers see.',
+ todo: 'Add your shop photo, licence and a line about what you sell.',
cta: 'Add your shop details',
done: isProfileComplete(shop ?? {}),
href: '/admin/profile',
@@ -122,6 +132,9 @@ export function setupSteps(input: SetupInput): SetupStep[] {
cta: 'Add a person',
done: people.length > 0,
href: '/admin/users',
+ ...(people.length > 0
+ ? { doneNote: 'Your own account was created when the shop was set up.' }
+ : {}),
...(people.some(isUnplaced)
? { detail: `${people.filter(isUnplaced).length} not at a shop yet` }
: {}),
@@ -142,6 +155,9 @@ export function setupSteps(input: SetupInput): SetupStep[] {
cta: 'Open a branch',
done: branches.length > 0,
href: '/admin/branches/new',
+ ...(branches.length > 0
+ ? { doneNote: 'Your first outlet was opened when the shop was set up.' }
+ : {}),
...(branches.length > 0 ? { detail: `${branches.length}` } : {}),
},
{