From 22c9f45b44f9378bbf6679ea3d2361dd6af4dcf1 Mon Sep 17 00:00:00 2001 From: abhishek Date: Tue, 1 Sep 2026 12:54:40 +0530 Subject: [PATCH] skip button --- src/components/shell/AppShell.tsx | 12 + src/features/setup/SetupTour.tsx | 253 ++++++++++++++++++ src/features/setup/StoreAdminTour.tsx | 71 +++++ src/features/setup/StoreUserTour.tsx | 48 ++++ src/features/setup/storeUserSteps.ts | 68 +++++ src/features/setup/tourState.test.ts | 56 ++++ src/features/setup/tourState.ts | 102 +++++++ src/features/store-admin/SetupChecklist.tsx | 184 ------------- src/features/store-admin/StoreAdminShell.tsx | 2 + .../store-admin/pages/ConsolePage.tsx | 31 --- .../store-admin/setupProgress.test.ts | 77 ------ src/features/store-admin/setupProgress.ts | 84 ------ src/features/store-user/StoreUserShell.tsx | 2 + 13 files changed, 614 insertions(+), 376 deletions(-) create mode 100644 src/features/setup/SetupTour.tsx create mode 100644 src/features/setup/StoreAdminTour.tsx create mode 100644 src/features/setup/StoreUserTour.tsx create mode 100644 src/features/setup/storeUserSteps.ts create mode 100644 src/features/setup/tourState.test.ts create mode 100644 src/features/setup/tourState.ts delete mode 100644 src/features/store-admin/SetupChecklist.tsx delete mode 100644 src/features/store-admin/setupProgress.test.ts delete mode 100644 src/features/store-admin/setupProgress.ts diff --git a/src/components/shell/AppShell.tsx b/src/components/shell/AppShell.tsx index 3333f59..a8cddd8 100644 --- a/src/components/shell/AppShell.tsx +++ b/src/components/shell/AppShell.tsx @@ -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} +
{/* Scoped to the page, not the shell: a page that throws should leave diff --git a/src/features/setup/SetupTour.tsx b/src/features/setup/SetupTour.tsx new file mode 100644 index 0000000..515573c --- /dev/null +++ b/src/features/setup/SetupTour.tsx @@ -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(() => 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(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 ( + !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 ( +
+
+ + + + + + {focus.title} + + + Step {position} of {steps.length} · {doneCount} done · {focus.todo} + + + + + + {/* 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 ? ( +
+
+ ); +} + +/* ── 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 ( + <> +
+
+ + + + + Set up your shop + + + + + {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. + + + {firstStep ? ( + + + + First: {firstStep} + + + ) : null} + + +
+ + ); +} diff --git a/src/features/setup/StoreAdminTour.tsx b/src/features/setup/StoreAdminTour.tsx new file mode 100644 index 0000000..026012a --- /dev/null +++ b/src/features/setup/StoreAdminTour.tsx @@ -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 ( + + ); +} diff --git a/src/features/setup/StoreUserTour.tsx b/src/features/setup/StoreUserTour.tsx new file mode 100644 index 0000000..96b7905 --- /dev/null +++ b/src/features/setup/StoreUserTour.tsx @@ -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 ( + + ); +} diff --git a/src/features/setup/storeUserSteps.ts b/src/features/setup/storeUserSteps.ts new file mode 100644 index 0000000..e08d04f --- /dev/null +++ b/src/features/setup/storeUserSteps.ts @@ -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', + }, + ]; +} diff --git a/src/features/setup/tourState.test.ts b/src/features/setup/tourState.test.ts new file mode 100644 index 0000000..7a1efd2 --- /dev/null +++ b/src/features/setup/tourState.test.ts @@ -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 => ({ + 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, []); +}); diff --git a/src/features/setup/tourState.ts b/src/features/setup/tourState.ts new file mode 100644 index 0000000..0c613fe --- /dev/null +++ b/src/features/setup/tourState.ts @@ -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 { + try { + const raw = localStorage.getItem(KEY); + const parsed: unknown = raw ? JSON.parse(raw) : {}; + return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + } 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; +} diff --git a/src/features/store-admin/SetupChecklist.tsx b/src/features/store-admin/SetupChecklist.tsx deleted file mode 100644 index 387d208..0000000 --- a/src/features/store-admin/SetupChecklist.tsx +++ /dev/null @@ -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 ( - - - - Setup paused — {done} of {steps.length} done. - -