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

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