skip button
This commit is contained in:
@@ -59,6 +59,15 @@ export interface AppShellProps {
|
|||||||
* closed.
|
* closed.
|
||||||
*/
|
*/
|
||||||
headerActions?: ReactNode;
|
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,
|
scopeControl,
|
||||||
manageItems,
|
manageItems,
|
||||||
headerActions,
|
headerActions,
|
||||||
|
banner,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
const { user, signOut } = useAuth();
|
const { user, signOut } = useAuth();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -402,6 +412,8 @@ export function AppShell({
|
|||||||
|
|
||||||
{/* Body: a column on a phone so the assistant stacks under the page, a
|
{/* Body: a column on a phone so the assistant stacks under the page, a
|
||||||
row from md where it becomes a side column. */}
|
row from md where it becomes a side column. */}
|
||||||
|
{banner}
|
||||||
|
|
||||||
<div className="admin-body app-gutter">
|
<div className="admin-body app-gutter">
|
||||||
<main style={{ minWidth: 0, flex: 1, padding: '24px 0 48px' }}>
|
<main style={{ minWidth: 0, flex: 1, padding: '24px 0 48px' }}>
|
||||||
{/* Scoped to the page, not the shell: a page that throws should leave
|
{/* 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 { useState, useRef, useEffect } from 'react';
|
||||||
import { Check, ChevronDown, FileSpreadsheet, Monitor, Store, Users } from 'lucide-react';
|
import { Check, ChevronDown, FileSpreadsheet, Monitor, Store, Users } from 'lucide-react';
|
||||||
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
|
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
|
||||||
|
import { StoreAdminTour } from '@/features/setup/StoreAdminTour';
|
||||||
import { BranchScopeProvider, useBranchScope } from './BranchScope';
|
import { BranchScopeProvider, useBranchScope } from './BranchScope';
|
||||||
import { useLiveEvents } from '@/queries/useLiveEvents';
|
import { useLiveEvents } from '@/queries/useLiveEvents';
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ export function StoreAdminShell() {
|
|||||||
navLabel="Store Admin"
|
navLabel="Store Admin"
|
||||||
scopeControl={<BranchSelector />}
|
scopeControl={<BranchSelector />}
|
||||||
manageItems={MANAGE}
|
manageItems={MANAGE}
|
||||||
|
banner={<StoreAdminTour />}
|
||||||
/>
|
/>
|
||||||
</BranchScopeProvider>
|
</BranchScopeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,16 +21,11 @@ import { KpiCard } from '@/components/KpiCard';
|
|||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { SectionHeader } from '@/components/SectionHeader';
|
||||||
import {
|
import {
|
||||||
useLocationProducts,
|
|
||||||
useLocationSummary,
|
useLocationSummary,
|
||||||
useOwnTenant,
|
|
||||||
usePosHealthByBranch,
|
usePosHealthByBranch,
|
||||||
usePosSalesByBranch,
|
usePosSalesByBranch,
|
||||||
useStaff,
|
|
||||||
useStockRequests,
|
useStockRequests,
|
||||||
useUploads,
|
|
||||||
} from '@/queries/hooks';
|
} from '@/queries/hooks';
|
||||||
import { SetupChecklist } from '../SetupChecklist';
|
|
||||||
import { useBranchScope } from '../BranchScope';
|
import { useBranchScope } from '../BranchScope';
|
||||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||||
import { branchLabel, count, money, percent, share } from '../format';
|
import { branchLabel, count, money, percent, share } from '../format';
|
||||||
@@ -73,17 +68,6 @@ export function ConsolePage() {
|
|||||||
const orders = useLocationSummary(tenantid || undefined);
|
const orders = useLocationSummary(tenantid || undefined);
|
||||||
const posSales = usePosSalesByBranch(branchIds, range);
|
const posSales = usePosSalesByBranch(branchIds, range);
|
||||||
const posHealth = usePosHealthByBranch(branchIds);
|
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(
|
const requests = useStockRequests(
|
||||||
tenantid ? { tenantid, locationid: selected ?? undefined, status: 'Pending' } : undefined,
|
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 ──────────────────────────── */}
|
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<SectionHeader
|
<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 { FileSpreadsheet, Monitor, QrCode, Store, UserCog, UserRound, Users } from 'lucide-react';
|
||||||
import { StoreQrDrawer } from './StoreQrDrawer';
|
import { StoreQrDrawer } from './StoreQrDrawer';
|
||||||
import { AppShell, IconButton, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
|
import { AppShell, IconButton, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
|
||||||
|
import { StoreUserTour } from '@/features/setup/StoreUserTour';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { useTenantLocations } from '@/queries/hooks';
|
import { useTenantLocations } from '@/queries/hooks';
|
||||||
import { BranchScopeProvider, useBranchScope } from '@/features/store-admin/BranchScope';
|
import { BranchScopeProvider, useBranchScope } from '@/features/store-admin/BranchScope';
|
||||||
@@ -117,6 +118,7 @@ export function StoreUserShell() {
|
|||||||
navLabel="Store"
|
navLabel="Store"
|
||||||
scopeControl={<BranchPill />}
|
scopeControl={<BranchPill />}
|
||||||
manageItems={MANAGE}
|
manageItems={MANAGE}
|
||||||
|
banner={<StoreUserTour />}
|
||||||
headerActions={
|
headerActions={
|
||||||
<IconButton label="Store QR code" onClick={() => setQrOpen(true)}>
|
<IconButton label="Store QR code" onClick={() => setQrOpen(true)}>
|
||||||
<QrCode size={16} />
|
<QrCode size={16} />
|
||||||
|
|||||||
Reference in New Issue
Block a user