diff --git a/src/features/store-admin/SetupChecklist.tsx b/src/features/store-admin/SetupChecklist.tsx
index e0bd88b..387d208 100644
--- a/src/features/store-admin/SetupChecklist.tsx
+++ b/src/features/store-admin/SetupChecklist.tsx
@@ -1,110 +1,184 @@
/**
- * Getting a new shop from "signed in" to "on sale", on the page they land on.
+ * Walking a new merchant from "signed in" to "on sale", one step at a time.
*
- * There was no onboarding of any kind in this console — no checklist, no tour,
- * no first-run anything. A brand-new merchant landed on a dashboard showing
- * four KPI cards reading ₹0 and a line saying "No branches yet" with nothing to
- * click, which reads as *everything is fine* rather than *nothing is set up*.
+ * 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.
*
- * Kmart is the argument for this screen: one branch, two products, both
- * unpriced, so nothing had ever been sellable — stuck since July with no
- * screen anywhere saying which step they were on.
+ * 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, so the card cannot claim work that was
- * not done, and it disappears entirely once the shop is trading.
+ * 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 { Link } from 'react-router-dom';
-import { Check, Circle } from 'lucide-react';
-import { setupSteps, currentStep, isSetupComplete, type SetupInput } from './setupSteps';
+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) {
- const steps = setupSteps(input);
+export function SetupChecklist(input: SetupInput & { tenantid: number }) {
+ const { tenantid, ...data } = input;
+ const navigate = useNavigate();
+ const [skipped, setSkipped] = useState(() => skippedSteps(tenantid));
- // Gone once the shop is trading. A checklist that lingers after it is
- // finished becomes furniture, and stops being read the next time it matters.
+ 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;
- const now = currentStep(steps);
- const done = steps.filter((step) => step.done).length;
+ /* 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.
+
+
+
+ );
+ }
+
+ const position = steps.findIndex((step) => step.id === focus.id) + 1;
return (
-
-
-
- Set up your shop
-
-
- {done} of {steps.length}
-
-
- {/* The one sentence that was missing: which step you are on, and what
- to do about it. */}
- {now ? (
-
- {now.todo}
+
+
+ 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}
-
- {steps.map((step) => {
- const isNow = step.id === now?.id;
- return (
-
- {step.done ? (
-
- ) : (
-
- )}
-
- {step.title}
-
- {step.detail ? (
-
- {step.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. */}
+
+
+
);
}
+
+/**
+ * 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/pages/ConsolePage.tsx b/src/features/store-admin/pages/ConsolePage.tsx
index acfba79..8c2dd5b 100644
--- a/src/features/store-admin/pages/ConsolePage.tsx
+++ b/src/features/store-admin/pages/ConsolePage.tsx
@@ -173,6 +173,7 @@ export function ConsolePage() {
of things they must ask somebody else to do. */}
{!isPinned ? (
({
+ 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
new file mode 100644
index 0000000..7d0ea61
--- /dev/null
+++ b/src/features/store-admin/setupProgress.ts
@@ -0,0 +1,84 @@
+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-admin/setupSteps.ts b/src/features/store-admin/setupSteps.ts
index b8ecafc..6804d80 100644
--- a/src/features/store-admin/setupSteps.ts
+++ b/src/features/store-admin/setupSteps.ts
@@ -36,6 +36,14 @@ export interface SetupStep {
title: string;
/** What to do, when it is not done. Never shown once it is. */
todo: string;
+ /**
+ * The button that starts the work.
+ *
+ * Named for the action, not the destination — "Add your shop details" rather
+ * than "Go to profile". A step somebody is being walked through has to say
+ * what pressing it does, or it reads as navigation and gets ignored.
+ */
+ cta: string;
done: boolean;
/** Where the work happens. */
href: string;
@@ -66,6 +74,7 @@ export function setupSteps(input: SetupInput): SetupStep[] {
id: 'profile',
title: 'Complete your shop profile',
todo: 'Add your shop photo and licence — this is what shoppers see.',
+ cta: 'Add your shop details',
done: isProfileComplete(shop ?? {}),
href: '/admin/profile',
},
@@ -73,6 +82,7 @@ export function setupSteps(input: SetupInput): SetupStep[] {
id: 'people',
title: 'Add your people',
todo: 'Add whoever will run your shops. You can add them before a branch exists.',
+ cta: 'Add a person',
done: people.length > 0,
href: '/admin/users',
...(people.some(isUnplaced)
@@ -83,6 +93,7 @@ export function setupSteps(input: SetupInput): SetupStep[] {
id: 'branch',
title: 'Open your first branch',
todo: 'Commission an outlet and say who runs it.',
+ cta: 'Open a branch',
done: branches.length > 0,
href: '/admin/branches/new',
...(branches.length > 0 ? { detail: `${branches.length}` } : {}),
@@ -91,6 +102,7 @@ export function setupSteps(input: SetupInput): SetupStep[] {
id: 'products',
title: 'Get your products in',
todo: 'Import from the catalogue, or upload your own spreadsheet.',
+ cta: 'Add products',
done: products.length > 0,
href: '/admin/inventory',
/* The waiting state, said plainly. A spreadsheet sits in the catalogue
@@ -107,6 +119,7 @@ export function setupSteps(input: SetupInput): SetupStep[] {
id: 'priced',
title: 'Price and release them',
todo: 'A product with no price cannot be rung up, and one not released reaches no shop.',
+ cta: 'Price and release',
done: priced.length > 0,
href: '/admin/inventory',
...(products.length > 0 && priced.length < products.length
@@ -117,6 +130,7 @@ export function setupSteps(input: SetupInput): SetupStep[] {
id: 'stocked',
title: 'Put stock on the shelf',
todo: 'Record what you actually hold — nothing sells at a balance of zero.',
+ cta: 'Add stock',
done: stocked.length > 0,
href: '/admin/inventory',
...(priced.length > 0 && stocked.length < priced.length
@@ -127,6 +141,7 @@ export function setupSteps(input: SetupInput): SetupStep[] {
id: 'onsale',
title: 'See it in the app',
todo: 'Once a product is priced, released, stocked and in a category, shoppers can buy it.',
+ cta: 'Check the app view',
done: onSale.length > 0,
href: '/admin/inventory',
...(onSale.length > 0 ? { detail: `${onSale.length} on sale` } : {}),