diff --git a/src/components/shell/AppShell.tsx b/src/components/shell/AppShell.tsx
index 3333f59..a8cddd8 100644
--- a/src/components/shell/AppShell.tsx
+++ b/src/components/shell/AppShell.tsx
@@ -59,6 +59,15 @@ export interface AppShellProps {
* closed.
*/
headerActions?: ReactNode;
+ /**
+ * A full-width strip between the header and the page.
+ *
+ * The setup walkthrough lives here. It has to sit in the shell rather than
+ * on a page because it crosses several: one step is on Profile, the next on
+ * Users, the next on Inventory — anything page-local would vanish the moment
+ * somebody followed it.
+ */
+ banner?: ReactNode;
}
/**
@@ -84,6 +93,7 @@ export function AppShell({
scopeControl,
manageItems,
headerActions,
+ banner,
}: AppShellProps) {
const { user, signOut } = useAuth();
const { pathname } = useLocation();
@@ -402,6 +412,8 @@ export function AppShell({
{/* Body: a column on a phone so the assistant stacks under the page, a
row from md where it becomes a side column. */}
+ {banner}
+
{/* Scoped to the page, not the shell: a page that throws should leave
diff --git a/src/features/setup/SetupTour.tsx b/src/features/setup/SetupTour.tsx
new file mode 100644
index 0000000..515573c
--- /dev/null
+++ b/src/features/setup/SetupTour.tsx
@@ -0,0 +1,253 @@
+/**
+ * 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, 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));
+
+ /* 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 (
+ !step.done)?.title ?? ''}
+ onStart={() => {
+ walkedTo.current = null;
+ setTour(startTour(userid, tenantid));
+ }}
+ 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}
+
+
+
+
+
+ {/* 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}
+
+
+
+
+
+
+
+ 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.
+
+
+ {firstStep ? (
+
+
+
+ First: {firstStep}
+
+
+ ) : null}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/features/setup/StoreAdminTour.tsx b/src/features/setup/StoreAdminTour.tsx
new file mode 100644
index 0000000..026012a
--- /dev/null
+++ b/src/features/setup/StoreAdminTour.tsx
@@ -0,0 +1,71 @@
+/**
+ * The merchant's walkthrough, 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.
+ *
+ * Every hook here already existed. The walkthrough needed no new backend at
+ * all, which is most of the reason it was worth building.
+ */
+
+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 { 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;
+
+ return (
+
+ );
+}
diff --git a/src/features/setup/StoreUserTour.tsx b/src/features/setup/StoreUserTour.tsx
new file mode 100644
index 0000000..96b7905
--- /dev/null
+++ b/src/features/setup/StoreUserTour.tsx
@@ -0,0 +1,48 @@
+/**
+ * The branch user's walkthrough, mounted in the Store user shell.
+ *
+ * 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.
+ */
+
+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 { 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;
+
+ return (
+
+ );
+}
diff --git a/src/features/setup/storeUserSteps.ts b/src/features/setup/storeUserSteps.ts
new file mode 100644
index 0000000..e08d04f
--- /dev/null
+++ b/src/features/setup/storeUserSteps.ts
@@ -0,0 +1,68 @@
+import type { SetupStep } from '@/features/store-admin/setupSteps';
+import type { SessionUser } from '@/auth/roles';
+
+/**
+ * What a Store user is walked through on their first sign-in.
+ *
+ * Much shorter than the merchant's, and honestly so. A branch user cannot open
+ * outlets, hire anybody or edit the business — those belong to their store
+ * administrator, and putting them in a tour would be walking somebody through
+ * work they are not allowed to do.
+ *
+ * So there is exactly one real task: their own details. The other two are
+ * orientation — where the shop's products live, and where the day's takings
+ * are — because the thing a new counter user actually needs is to know which
+ * screen answers which question.
+ *
+ * The branch is deliberately NOT a step. Which shop somebody works at is set by
+ * their store admin on the people screen; a step they cannot complete would sit
+ * open forever.
+ */
+export function storeUserSteps(input: {
+ user: SessionUser | null;
+ /** True once they have a branch — until then there is nothing else to see. */
+ hasBranch: boolean;
+ /** Products visible at their branch. */
+ productCount: number;
+ /** The branch's own name, to spot a login named after the shop. */
+ branchName?: string;
+}): SetupStep[] {
+ const { user, hasBranch, productCount, branchName } = input;
+
+ // The auto-spawned branch login is named after the OUTLET — "Suriya Store
+ // NSN" — because CreateTenantLocation set `firstname = locationname`. So a
+ // name matching the branch is not a person's name, and this step is not done.
+ const name = (user?.name ?? '').trim();
+ const hasOwnName = name !== '' && name.toLowerCase() !== (branchName ?? '').trim().toLowerCase();
+
+ return [
+ {
+ id: 'profile',
+ title: 'Tell us who you are',
+ todo: 'Add your name and mobile so your shop knows whose account this is.',
+ cta: 'Add my details',
+ done: hasOwnName,
+ href: '/store/account',
+ },
+ {
+ id: 'products',
+ title: 'See what your shop sells',
+ todo: 'Your branch catalogue — what is priced, what is on the shelf, what is out of stock.',
+ cta: 'Open products',
+ done: hasBranch && productCount > 0,
+ href: '/store/products',
+ ...(hasBranch && productCount > 0 ? { detail: `${productCount} products` } : {}),
+ },
+ {
+ id: 'onsale',
+ title: 'Know where the day’s takings are',
+ todo: 'Sales shows counter and app orders together, for this branch.',
+ cta: 'Open sales',
+ // Orientation, not a task — it completes by being visited, which the tour
+ // does by walking them to it. Marking it done any other way would be
+ // inventing a fact.
+ done: false,
+ href: '/store/sales',
+ },
+ ];
+}
diff --git a/src/features/setup/tourState.test.ts b/src/features/setup/tourState.test.ts
new file mode 100644
index 0000000..7a1efd2
--- /dev/null
+++ b/src/features/setup/tourState.test.ts
@@ -0,0 +1,56 @@
+/**
+ * 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
new file mode 100644
index 0000000..0c613fe
--- /dev/null
+++ b/src/features/setup/tourState.ts
@@ -0,0 +1,102 @@
+/**
+ * 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/SetupChecklist.tsx b/src/features/store-admin/SetupChecklist.tsx
deleted file mode 100644
index 387d208..0000000
--- a/src/features/store-admin/SetupChecklist.tsx
+++ /dev/null
@@ -1,184 +0,0 @@
-/**
- * Walking a new merchant from "signed in" to "on sale", one step at a time.
- *
- * It began as a plain checklist and that was not enough: seven lines all
- * demanding attention equally is a list of homework, not guidance. Somebody who
- * has never used the console does not need to be told there are seven things —
- * they need to be told the ONE thing to do now, with a button that does it.
- *
- * So one step is in front of you at a time, with a real action and a way past
- * it. The rest are a progress rail: visible, so nobody feels tricked about how
- * far there is to go, but quiet.
- *
- * Every tick is derived from live data (see `setupSteps.ts`), so the card can
- * never claim work that was not done. The only stored thing is what somebody
- * chose to skip, and skipping never marks a step done — an unpriced catalogue
- * does not start selling because a card was dismissed.
- */
-
-import { useMemo, useState } 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 } from 'lucide-react';
-import { setupSteps, isSetupComplete, type SetupInput, type SetupStep } from './setupSteps';
-import { focusStep, isPaused, resumeAll, skipStep, skippedSteps } from './setupProgress';
-
-export function SetupChecklist(input: SetupInput & { tenantid: number }) {
- const { tenantid, ...data } = input;
- const navigate = useNavigate();
- const [skipped, setSkipped] = useState(() => skippedSteps(tenantid));
-
- const steps = useMemo(() => setupSteps(data), [data]);
- const done = steps.filter((step) => step.done).length;
- const focus = focusStep(steps, skipped);
- const paused = isPaused(steps, skipped);
-
- // Gone once the shop is selling. A card that outlives its purpose becomes
- // furniture and stops being read the next time it matters.
- if (isSetupComplete(steps)) return null;
-
- /* Everything outstanding was waved past. One quiet line rather than nothing:
- the work is still undone, and a card that vanished entirely would leave no
- way back to it. */
- if (paused || !focus) {
- return (
-
-
-
- Setup paused — {done} of {steps.length} done.
-
- setSkipped(resumeAll(tenantid))}
- />
-
-
- );
- }
-
- const position = steps.findIndex((step) => step.id === focus.id) + 1;
-
- return (
-
-
-
-
- Set up your shop
-
-
- Step {position} of {steps.length}
-
-
-
- {/* The one thing to do now. Everything else on this card is context. */}
-
-
- {focus.title}
-
-
- {focus.todo}
-
- {focus.detail ? (
-
- {focus.detail}
-
- ) : null}
-
-
-
- }
- onClick={() => navigate(focus.href)}
- />
- {/* Skipping is offered, not hidden. Somebody who cannot do this step
- today — no licence to hand, no staff hired yet — should be able to
- get on with the rest rather than abandon the guidance entirely.
- The step stays open in the rail. */}
- setSkipped(skipStep(tenantid, focus.id))}
- />
-
-
-
-
-
- );
-}
-
-/**
- * How far there is to go, without making it the point of the card.
- *
- * Names rather than bare pips: "step 4 of 7" tells somebody how much is left
- * but not what is coming, and a merchant deciding whether to start now wants
- * both. Muted enough that the focused step above still reads first.
- */
-function ProgressRail({
- steps,
- focusId,
- skipped,
-}: {
- steps: readonly SetupStep[];
- focusId: string;
- skipped: readonly string[];
-}) {
- return (
-
- {steps.map((step) => {
- const isFocus = step.id === focusId;
- const isSkipped = !step.done && skipped.includes(step.id);
- return (
-
- {step.done ? (
-
- ) : (
-
- ●
-
- )}
-
- {step.title}
-
- {isSkipped ? (
-
- skipped
-
- ) : null}
-
- );
- })}
-
- );
-}
diff --git a/src/features/store-admin/StoreAdminShell.tsx b/src/features/store-admin/StoreAdminShell.tsx
index 1906ce5..ed4ebc3 100644
--- a/src/features/store-admin/StoreAdminShell.tsx
+++ b/src/features/store-admin/StoreAdminShell.tsx
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { Check, ChevronDown, FileSpreadsheet, Monitor, Store, Users } from 'lucide-react';
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
+import { StoreAdminTour } from '@/features/setup/StoreAdminTour';
import { BranchScopeProvider, useBranchScope } from './BranchScope';
import { useLiveEvents } from '@/queries/useLiveEvents';
@@ -85,6 +86,7 @@ export function StoreAdminShell() {
navLabel="Store Admin"
scopeControl={}
manageItems={MANAGE}
+ banner={}
/>
);
diff --git a/src/features/store-admin/pages/ConsolePage.tsx b/src/features/store-admin/pages/ConsolePage.tsx
index 8c2dd5b..97a0559 100644
--- a/src/features/store-admin/pages/ConsolePage.tsx
+++ b/src/features/store-admin/pages/ConsolePage.tsx
@@ -21,16 +21,11 @@ import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import {
- useLocationProducts,
useLocationSummary,
- useOwnTenant,
usePosHealthByBranch,
usePosSalesByBranch,
- useStaff,
useStockRequests,
- useUploads,
} from '@/queries/hooks';
-import { SetupChecklist } from '../SetupChecklist';
import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, percent, share } from '../format';
@@ -73,17 +68,6 @@ export function ConsolePage() {
const orders = useLocationSummary(tenantid || undefined);
const posSales = usePosSalesByBranch(branchIds, range);
const posHealth = usePosHealthByBranch(branchIds);
- /* The checklist's data. All existing hooks — nothing new was needed on the
- backend for it, which is most of why it is worth having. */
- 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 — the wait that makes
- step 4 look stuck when it is simply not our turn. */
- const pendingUploads = (uploads.data ?? []).filter(
- (receipt) => receipt.laststatus === 'pending' && !receipt.runid,
- ).length;
const requests = useStockRequests(
tenantid ? { tenantid, locationid: selected ?? undefined, status: 'Pending' } : undefined,
@@ -167,21 +151,6 @@ export function ConsolePage() {
}
/>
- {/* Setup, before revenue — but only for the merchant, and only until they
- are trading. A Store user cannot open a branch, hire anybody or edit
- the shop profile, so the same card in their workspace would be a list
- of things they must ask somebody else to do. */}
- {!isPinned ? (
-
- ) : null}
-
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
({
- id,
- title: id,
- todo: '',
- cta: '',
- done,
- href: '/',
-});
-
-const steps = [
- step('profile', false),
- step('people', false),
- step('branch', true),
- step('products', false),
-];
-
-beforeEach(() => {
- // `localStorage` does not exist under the test runner, and the module must
- // survive that — a private window and a browser refusing site data reach the
- // same code path.
- delete (globalThis as { localStorage?: unknown }).localStorage;
-});
-
-test('the focus is the first step that is neither done nor skipped', () => {
- assert.equal(focusStep(steps, [])?.id, 'profile');
- assert.equal(focusStep(steps, ['profile'])?.id, 'people');
- assert.equal(focusStep(steps, ['profile', 'people'])?.id, 'products');
-});
-
-// Done steps are stepped over whether or not they were skipped — 'branch' is
-// already complete and never becomes the focus.
-test('a completed step is never focused', () => {
- assert.notEqual(focusStep(steps, ['profile', 'people'])?.id, 'branch');
-});
-
-test('skipping everything outstanding leaves nothing to focus', () => {
- assert.equal(focusStep(steps, ['profile', 'people', 'products']), null);
- assert.equal(isPaused(steps, ['profile', 'people', 'products']), true);
-});
-
-/*
-Paused is not finished. Every outstanding step being skipped means the merchant
-waved the guidance past — the work is still undone, which is why the card shows
-a resume line rather than disappearing.
-*/
-test('paused is false while any outstanding step is unskipped', () => {
- assert.equal(isPaused(steps, ['profile']), false);
- assert.equal(isPaused(steps, []), false);
-});
-
-// A shop with nothing left to do is not "paused" either — there is simply
-// nothing outstanding to have skipped.
-test('a finished shop is not reported as paused', () => {
- const finished = [step('profile', true), step('people', true)];
- assert.equal(isPaused(finished, []), false);
- assert.equal(isPaused(finished, ['profile']), false);
-});
-
-// Storage the browser refuses must not take the page down, and must fail
-// towards showing the guidance rather than silently hiding it.
-test('unavailable storage reads as nothing skipped', async () => {
- const { skippedSteps } = await import('./setupProgress');
- assert.deepEqual(skippedSteps(1141), []);
-});
diff --git a/src/features/store-admin/setupProgress.ts b/src/features/store-admin/setupProgress.ts
deleted file mode 100644
index 7d0ea61..0000000
--- a/src/features/store-admin/setupProgress.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import type { SetupStep, SetupStepId } from './setupSteps';
-
-/**
- * Which step a merchant is being walked through, and which they have waved
- * past.
- *
- * Skipping is a preference, not a fact, so it is the one thing here that IS
- * stored rather than derived — in `localStorage`, per browser, per shop. It
- * deliberately does not travel: "not now" is a statement about this afternoon,
- * not a decision about the business, and a colleague opening the console should
- * still be shown what is outstanding.
- *
- * A skipped step is never marked done. It drops out of the guided sequence and
- * stays visibly incomplete in the list, because the work still has to happen —
- * an unpriced catalogue does not start selling because somebody dismissed a
- * card.
- */
-const KEY = 'nearle.setup.skipped';
-
-type SkipMap = Record;
-
-function read(): SkipMap {
- try {
- const raw = localStorage.getItem(KEY);
- const parsed: unknown = raw ? JSON.parse(raw) : {};
- return parsed && typeof parsed === 'object' ? (parsed as SkipMap) : {};
- } catch {
- // A private window, cleared site data, or storage the browser refuses.
- // Nothing skipped is the safe answer: the merchant sees the guidance again
- // rather than losing it silently.
- return {};
- }
-}
-
-export function skippedSteps(tenantid: number): SetupStepId[] {
- return read()[String(tenantid)] ?? [];
-}
-
-export function skipStep(tenantid: number, id: SetupStepId): SetupStepId[] {
- const all = read();
- const key = String(tenantid);
- const next = [...new Set([...(all[key] ?? []), id])];
- try {
- localStorage.setItem(KEY, JSON.stringify({ ...all, [key]: next }));
- } catch {
- /* Storage refused. The skip applies to this render either way — losing it
- on reload is a smaller failure than the write throwing mid-click. */
- }
- return next;
-}
-
-export function resumeAll(tenantid: number): SetupStepId[] {
- const all = read();
- try {
- localStorage.setItem(KEY, JSON.stringify({ ...all, [String(tenantid)]: [] }));
- } catch {
- /* As above. */
- }
- return [];
-}
-
-/**
- * The one step to put in front of somebody: the first that is neither done nor
- * skipped.
- *
- * Null when there is nothing left to guide — either everything is done, or
- * everything outstanding has been waved past. The two are different states and
- * the card renders them differently; this only says there is no step to focus.
- */
-export function focusStep(
- steps: readonly SetupStep[],
- skipped: readonly SetupStepId[],
-): SetupStep | null {
- return steps.find((step) => !step.done && !skipped.includes(step.id)) ?? null;
-}
-
-/** True when work remains but every outstanding step has been waved past. */
-export function isPaused(
- steps: readonly SetupStep[],
- skipped: readonly SetupStepId[],
-): boolean {
- const outstanding = steps.filter((step) => !step.done);
- return outstanding.length > 0 && outstanding.every((step) => skipped.includes(step.id));
-}
diff --git a/src/features/store-user/StoreUserShell.tsx b/src/features/store-user/StoreUserShell.tsx
index ab8cf3c..c1cd6b3 100644
--- a/src/features/store-user/StoreUserShell.tsx
+++ b/src/features/store-user/StoreUserShell.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { FileSpreadsheet, Monitor, QrCode, Store, UserCog, UserRound, Users } from 'lucide-react';
import { StoreQrDrawer } from './StoreQrDrawer';
import { AppShell, IconButton, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
+import { StoreUserTour } from '@/features/setup/StoreUserTour';
import { useAuth } from '@/auth/AuthContext';
import { useTenantLocations } from '@/queries/hooks';
import { BranchScopeProvider, useBranchScope } from '@/features/store-admin/BranchScope';
@@ -117,6 +118,7 @@ export function StoreUserShell() {
navLabel="Store"
scopeControl={}
manageItems={MANAGE}
+ banner={}
headerActions={
setQrOpen(true)}>