This commit is contained in:
2026-09-08 17:03:17 +05:30
parent 59828811f9
commit 33c4542ddf
20 changed files with 891 additions and 632 deletions

View File

@@ -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<TourState>(() => 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<string | null>(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 (
<StartDialog
stepCount={steps.length}
steps={steps}
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.
}}
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 (
<div
role="region"
aria-label="Setup walkthrough"
style={{
borderBottom: '1px solid var(--color-line)',
background: 'var(--color-surface-subtle)',
}}
>
<div className="app-gutter">
<HStack
align="center"
justify="between"
gap={2}
wrap="wrap"
style={{ padding: '10px 0 0' }}
>
<HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
<Sparkles size={15} style={{ color: 'var(--color-brand)', flex: 'none' }} />
<VStack gap={0} style={{ minWidth: 0 }}>
<Text type="label" size="sm" weight="semibold" maxLines={1}>
{focus.title}
</Text>
<Text type="body" size="xsm" color="secondary" maxLines={1}>
Step {position} of {steps.length} · {doneCount} done · {focus.todo}
</Text>
</VStack>
</HStack>
<HStack gap={1} align="center" wrap="wrap">
{/* 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. */}
<Button
label={isOpen ? 'Hide guide' : 'How do I do this?'}
variant="ghost"
size="sm"
endContent={isOpen ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
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 ? (
<Button
label={focus.cta}
variant="primary"
size="sm"
endContent={<ArrowRight size={13} />}
onClick={() => navigate(focus.href)}
/>
) : null}
</HStack>
</HStack>
{isOpen ? <StepGuide step={focus} /> : 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. */}
<HStack justify="end" gap={1} style={{ paddingBottom: 10 }}>
<Button
label="Skip this step"
variant="ghost"
size="sm"
onClick={() => 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. */}
<Button
label="Exit setup"
variant="ghost"
size="sm"
onClick={() => {
setTour(endTour(userid, tenantid));
navigate(home);
}}
/>
</HStack>
</div>
</div>
);
}
/**
* 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 (
<VStack
gap={1.5}
style={{
borderTop: '1px solid var(--color-line)',
padding: '14px 0 16px',
maxWidth: '72ch',
}}
>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
{step.why}
</Text>
<VStack gap={0.5}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
What to do
</Text>
<ol style={{ margin: 0, paddingLeft: 20, display: 'grid', gap: 4 }}>
{step.how.map((line) => (
<li key={line}>
<Text type="body" size="sm" style={{ lineHeight: 1.6 }}>
{line}
</Text>
</li>
))}
</ol>
</VStack>
{step.gotcha ? (
<HStack gap={1} align="start">
<Info
size={14}
style={{ color: 'var(--color-warning, #b7860b)', flex: 'none', marginTop: 3 }}
/>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{step.gotcha}
</Text>
</HStack>
) : null}
</VStack>
);
}
/* ── The offer ───────────────────────────────────────────────────────────── */
function StartDialog({
stepCount,
steps,
onStart,
onCancel,
}: {
stepCount: number;
steps: readonly SetupStep[];
onStart: () => void;
onCancel: () => void;
}) {
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onCancel();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onCancel]);
return (
<>
<div
onClick={onCancel}
style={{ position: 'fixed', inset: 0, zIndex: 70, background: 'rgb(16 24 40 / .4)' }}
/>
<div
role="dialog"
aria-modal
aria-label="Set up your shop"
style={{
position: 'fixed',
zIndex: 71,
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
width: 'min(440px, calc(100vw - 32px))',
borderRadius: 16,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
boxShadow: '0 24px 48px -12px rgb(16 24 40 / .3)',
}}
>
<VStack gap={2} padding={3}>
<HStack gap={1.5} align="center">
<Sparkles size={20} style={{ color: 'var(--color-brand)' }} />
<Text type="label" size="lg" weight="semibold">
Set up your shop
</Text>
</HStack>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
{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.
</Text>
{/* 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. */}
<VStack gap={0.5} style={{ maxHeight: 240, overflowY: 'auto' }}>
{steps.map((step) => (
<HStack key={step.id} gap={1} align="center">
{step.done ? (
<Check
size={14}
style={{ color: 'var(--color-success, #10b981)', flex: 'none' }}
/>
) : (
<span
aria-hidden
style={{
width: 14,
textAlign: 'center',
flex: 'none',
color: 'var(--color-ink-4)',
fontSize: 10,
}}
>
●
</span>
)}
<Text
type="body"
size="sm"
{...(step.done ? { color: 'secondary' as const } : {})}
>
{step.title}
</Text>
</HStack>
))}
</VStack>
<HStack gap={1} justify="end">
<Button label="Not now" variant="ghost" onClick={onCancel} />
<Button label="Start setup" variant="primary" onClick={onStart} />
</HStack>
</VStack>
</div>
</>
);
}

View File

@@ -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;
}

View File

@@ -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=<step>` 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 (
<SetupReturnBar
position={at + 1}
total={steps.length}
stepLabel={step.title}
/* Read from the same live data the step itself is derived from, so the
bar turns green when the work is genuinely done and not before. */
isDone={step.done}
doneTitle={step.detail ? `Done — ${step.detail}` : 'Done'}
href={`/store/setup?step=${step.id}`}
/>
);
}

View File

@@ -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 (
<SetupTour
userid={userid}
tenantid={tenantid}
steps={steps}
home="/store/console"
/>
);
}

View File

@@ -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<string, string>();
(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);
});

View File

@@ -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<string, StoreSetupState> {
try {
const raw = window.localStorage.getItem(KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, StoreSetupState>) : {};
} 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;
}

View File

@@ -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> = {}): 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, []);
});

View File

@@ -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<string, TourState> {
try {
const raw = localStorage.getItem(KEY);
const parsed: unknown = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === 'object' ? (parsed as Record<string, TourState>) : {};
} 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;
}