skip button

This commit is contained in:
2026-09-01 12:54:40 +05:30
parent fda463f123
commit 22c9f45b44
13 changed files with 614 additions and 376 deletions

View File

@@ -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}
<div className="admin-body app-gutter">
<main style={{ minWidth: 0, flex: 1, padding: '24px 0 48px' }}>
{/* Scoped to the page, not the shell: a page that throws should leave

View File

@@ -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<TourState>(() => 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<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}
firstStep={steps.find((step) => !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 (
<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' }}
>
<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">
{/* 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}
<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>
</HStack>
</div>
</div>
);
}
/* ── The offer ───────────────────────────────────────────────────────────── */
function StartDialog({
stepCount,
firstStep,
onStart,
onCancel,
}: {
stepCount: number;
firstStep: string;
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>
{firstStep ? (
<HStack gap={1} align="center">
<Check size={14} style={{ color: 'var(--color-ink-4)', flex: 'none' }} />
<Text type="body" size="sm">
First: {firstStep}
</Text>
</HStack>
) : null}
<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,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 (
<SetupTour
userid={user.userid}
tenantid={tenantid}
steps={steps}
home="/admin/console"
/>
);
}

View File

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

View File

@@ -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',
},
];
}

View File

@@ -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> = {}): 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

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

View File

@@ -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 (
<Card padding={2} variant="transparent">
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="body" size="sm" color="secondary">
Setup paused — {done} of {steps.length} done.
</Text>
<Button
label="Resume"
variant="ghost"
size="sm"
onClick={() => setSkipped(resumeAll(tenantid))}
/>
</HStack>
</Card>
);
}
const position = steps.findIndex((step) => step.id === focus.id) + 1;
return (
<Card padding={3} elevation="low">
<VStack gap={2}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
Set up your shop
</Text>
<Text type="body" size="sm" color="secondary" hasTabularNumbers>
Step {position} of {steps.length}
</Text>
</HStack>
{/* The one thing to do now. Everything else on this card is context. */}
<VStack gap={1}>
<Text type="large" weight="semibold">
{focus.title}
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6, maxWidth: '60ch' }}>
{focus.todo}
</Text>
{focus.detail ? (
<Text type="body" size="xsm" color="secondary">
{focus.detail}
</Text>
) : null}
</VStack>
<HStack gap={1.5} align="center" wrap="wrap">
<Button
label={focus.cta}
variant="primary"
size="sm"
endContent={<ArrowRight size={14} />}
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. */}
<Button
label="Skip for now"
variant="ghost"
size="sm"
onClick={() => setSkipped(skipStep(tenantid, focus.id))}
/>
</HStack>
<ProgressRail steps={steps} focusId={focus.id} skipped={skipped} />
</VStack>
</Card>
);
}
/**
* 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 (
<VStack gap={0} style={{ borderTop: '1px solid var(--color-line)', paddingTop: 12 }}>
{steps.map((step) => {
const isFocus = step.id === focusId;
const isSkipped = !step.done && skipped.includes(step.id);
return (
<HStack key={step.id} gap={1} align="center" style={{ padding: '4px 0' }}>
{step.done ? (
<Check size={13} style={{ color: 'var(--color-success, #10b981)', flex: 'none' }} />
) : (
<span
aria-hidden
style={{
width: 13,
display: 'grid',
placeItems: 'center',
flex: 'none',
color: isFocus ? 'var(--color-brand)' : 'var(--color-ink-4)',
fontSize: 11,
}}
>
●
</span>
)}
<Text
type="body"
size="xsm"
{...(step.done || !isFocus ? { color: 'secondary' as const } : {})}
{...(isFocus ? { weight: 'semibold' as const } : {})}
style={isSkipped ? { opacity: 0.55 } : undefined}
>
{step.title}
</Text>
{isSkipped ? (
<Text type="body" size="xsm" color="secondary">
skipped
</Text>
) : null}
</HStack>
);
})}
</VStack>
);
}

View File

@@ -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={<BranchSelector />}
manageItems={MANAGE}
banner={<StoreAdminTour />}
/>
</BranchScopeProvider>
);

View File

@@ -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 ? (
<SetupChecklist
tenantid={tenantid}
shop={shop.data}
people={people.data ?? []}
branches={branches}
products={products.data ?? []}
pendingUploads={pendingUploads}
/>
) : null}
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
<VStack gap={1.5}>
<SectionHeader

View File

@@ -1,77 +0,0 @@
/**
* Which step a merchant is walked through, and what skipping does.
*
* The rule that matters: skipping moves the guidance on, it never marks work
* done. A shop whose catalogue is unpriced does not start selling because
* somebody pressed "Skip for now", and a card that implied otherwise would be
* worse than no card.
*/
import assert from 'node:assert/strict';
import { test, beforeEach } from 'node:test';
import { focusStep, isPaused } from './setupProgress';
import type { SetupStep, SetupStepId } from './setupSteps';
const step = (id: SetupStepId, done: boolean): SetupStep => ({
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), []);
});

View File

@@ -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<string, SetupStepId[]>;
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));
}

View File

@@ -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={<BranchPill />}
manageItems={MANAGE}
banner={<StoreUserTour />}
headerActions={
<IconButton label="Store QR code" onClick={() => setQrOpen(true)}>
<QrCode size={16} />