skip button
This commit is contained in:
@@ -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
|
||||
|
||||
253
src/features/setup/SetupTour.tsx
Normal file
253
src/features/setup/SetupTour.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
src/features/setup/StoreAdminTour.tsx
Normal file
71
src/features/setup/StoreAdminTour.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
48
src/features/setup/StoreUserTour.tsx
Normal file
48
src/features/setup/StoreUserTour.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
68
src/features/setup/storeUserSteps.ts
Normal file
68
src/features/setup/storeUserSteps.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
}
|
||||
56
src/features/setup/tourState.test.ts
Normal file
56
src/features/setup/tourState.test.ts
Normal 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, []);
|
||||
});
|
||||
102
src/features/setup/tourState.ts
Normal file
102
src/features/setup/tourState.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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), []);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user