This commit is contained in:
2026-09-02 10:48:47 +05:30
parent 98712493c4
commit b72bbd2f12
9 changed files with 533 additions and 99 deletions

View File

@@ -43,6 +43,8 @@ const UsersPage = named('UsersPage', () => import('@/features/store-admin/pages/
const TerminalsPage = named('TerminalsPage', () => import('@/features/store-admin/pages/TerminalsPage'));
const AdminUploadsPage = named('UploadsPage', () => import('@/features/store-admin/pages/UploadsPage'));
const ShopProfilePage = named('ShopProfilePage', () => import('@/features/store-admin/pages/ShopProfilePage'));
const StoreAdminSetupPage = named('StoreAdminSetupPage', () => import('@/features/setup/StoreAdminSetupPage'));
const StoreUserSetupPage = named('StoreUserSetupPage', () => import('@/features/setup/StoreUserSetupPage'));
/* The Store user workspace reuses the merchant's four pages, pinned to one
branch by `BranchScopeProvider pin=`. Only what a shop does differently is
@@ -126,6 +128,7 @@ export function App() {
<Route path="terminals" element={<TerminalsPage />} />
<Route path="uploads" element={<AdminUploadsPage />} />
<Route path="profile" element={<ShopProfilePage />} />
<Route path="setup" element={<StoreAdminSetupPage />} />
{/* Catches `/admin/dashboard` and anything else that does not resolve.
Without this, an unknown sub-path escapes to the global `*`, which
redirects to this role's HOME_ROUTE — and if that is itself an
@@ -157,6 +160,7 @@ export function App() {
<Route path="terminals" element={<TerminalsPage />} />
<Route path="staff" element={<StoreStaffPage />} />
<Route path="uploads" element={<StoreUploadsPage />} />
<Route path="setup" element={<StoreUserSetupPage />} />
<Route path="account" element={<StoreAccountPage />} />
<Route path="*" element={<Navigate to="/store/console" replace />} />
</Route>

View File

@@ -0,0 +1,302 @@
/**
* The whole journey from "signed in" to "selling", on one screen.
*
* The walkthrough started as a strip along the top of the console, and a strip
* is the wrong shape for this. It can show one step, so a merchant sees a
* sentence and a button with no idea what they have agreed to, how much is
* left, or why the thing they just finished was followed by something they were
* never shown. Onboarding completes two of the seven steps before anybody signs
* in, and the strip leapt over both in silence.
*
* So the journey gets a page. Every step is visible with its real state, the
* current one is open with its guidance, and a step that was finished before
* the merchant arrived says so instead of vanishing. The strip stays, but only
* as a way back here while the work is being done on another screen.
*
* Nothing here is stored. Every tick is derived from live data, so the page
* cannot claim work that was not done, and re-reading it after a change is what
* moves it on.
*/
import { useMemo } 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, Info } from 'lucide-react';
import { PageBody } from '@/components/PageBody';
import { PageHeader } from '@/components/PageHeader';
import type { SetupStep } from '@/features/store-admin/setupSteps';
export interface SetupPageProps {
steps: readonly SetupStep[];
/** Steps the merchant has waved past. Never counted as done. */
skipped: readonly string[];
isLoading?: boolean;
home: string;
onStart: () => void;
onSkip: (id: string) => void;
onExit: () => void;
/** True while the walkthrough is running, which changes the primary action. */
isActive: boolean;
}
export function SetupPage({
steps,
skipped,
isLoading = false,
home,
onStart,
onSkip,
onExit,
isActive,
}: SetupPageProps) {
const navigate = useNavigate();
const focus = useMemo(
() => steps.find((step) => !step.done && !skipped.includes(step.id)) ?? null,
[steps, skipped],
);
const done = steps.filter((step) => step.done).length;
const pct = steps.length === 0 ? 0 : Math.round((done / steps.length) * 100);
if (isLoading) {
return (
<PageBody measure="reading">
<PageHeader title="Set up your shop" />
<Card padding={4} elevation="low">
<Text type="body" color="secondary">
Checking what is already done…
</Text>
</Card>
</PageBody>
);
}
return (
<PageBody measure="reading">
<PageHeader
title="Set up your shop"
count={`${done} of ${steps.length}`}
description={
focus
? 'Work through these and your products will be on sale in the app. You can skip any of them and come back.'
: 'Everything is done — your shop is set up.'
}
/>
{/* Progress as a bar, not just a fraction. "2 of 7" tells somebody how far
they are; a bar tells them at a glance whether this is nearly over. */}
<div
aria-hidden
style={{
height: 4,
borderRadius: 999,
background: 'var(--color-line)',
overflow: 'hidden',
}}
>
<div
style={{
width: `${pct}%`,
height: '100%',
background: 'var(--color-brand)',
transition: 'width .3s ease',
}}
/>
</div>
<VStack gap={0}>
{steps.map((step, index) => (
<StepRow
key={step.id}
step={step}
index={index}
isLast={index === steps.length - 1}
isFocus={step.id === focus?.id}
isSkipped={!step.done && skipped.includes(step.id)}
isActive={isActive}
onGo={() => {
if (!isActive) onStart();
navigate(step.href);
}}
onSkip={() => onSkip(step.id)}
/>
))}
</VStack>
<HStack gap={1.5} wrap="wrap" style={{ paddingTop: 8 }}>
{focus ? (
<Button
label={isActive ? `Continue — ${focus.cta}` : 'Start setup'}
variant="primary"
endContent={<ArrowRight size={14} />}
onClick={() => {
if (!isActive) onStart();
navigate(focus.href);
}}
/>
) : (
<Button label="Go to the console" variant="primary" onClick={() => navigate(home)} />
)}
{isActive ? <Button label="Finish later" variant="ghost" onClick={onExit} /> : null}
</HStack>
</PageBody>
);
}
/* ── One step in the journey ──────────────────────────────────────────────── */
function StepRow({
step,
index,
isLast,
isFocus,
isSkipped,
isActive,
onGo,
onSkip,
}: {
step: SetupStep;
index: number;
isLast: boolean;
isFocus: boolean;
isSkipped: boolean;
isActive: boolean;
onGo: () => void;
onSkip: () => void;
}) {
const marker = step.done
? { bg: 'var(--color-success, #10b981)', fg: '#fff', ring: 'transparent' }
: isFocus
? { bg: 'var(--color-brand)', fg: '#fff', ring: 'transparent' }
: { bg: 'transparent', fg: 'var(--color-ink-4)', ring: 'var(--color-line)' };
return (
<HStack gap={2} align="start">
{/* The rail: a numbered marker with a line joining it to the next, so the
seven read as one sequence rather than seven cards. */}
<VStack gap={0} align="center" style={{ flex: 'none', width: 28 }}>
<span
style={{
width: 28,
height: 28,
borderRadius: 999,
display: 'grid',
placeItems: 'center',
background: marker.bg,
color: marker.fg,
border: `1px solid ${marker.ring}`,
fontSize: 12,
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
}}
>
{step.done ? <Check size={14} /> : index + 1}
</span>
{!isLast ? (
<span
aria-hidden
style={{
width: 1,
flex: 1,
minHeight: isFocus ? 120 : 28,
background: 'var(--color-line)',
}}
/>
) : null}
</VStack>
<VStack gap={0.5} style={{ flex: 1, minWidth: 0, paddingBottom: isLast ? 0 : 18 }}>
<HStack gap={1} align="center" wrap="wrap">
<Text
type="label"
size={isFocus ? 'lg' : 'sm'}
weight="semibold"
{...(step.done || isSkipped ? { color: 'secondary' as const } : {})}
>
{step.title}
</Text>
{step.detail ? (
<Text type="body" size="xsm" color="secondary" hasTabularNumbers>
{step.detail}
</Text>
) : null}
{isSkipped ? (
<Text type="body" size="xsm" color="secondary">
· skipped
</Text>
) : null}
</HStack>
{/* A step finished before the merchant arrived says why. Two of the
seven are done by onboarding, and silently ticking them looks like
the walkthrough inventing progress. */}
{step.done && step.doneNote ? (
<Text type="body" size="xsm" color="secondary">
{step.doneNote}
</Text>
) : null}
{/* Only the current step opens. Seven expanded panels is a manual, not
a walkthrough. */}
{isFocus ? (
<VStack gap={1.5} style={{ paddingTop: 6 }}>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
{step.why}
</Text>
<VStack gap={0.5}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
What to do
</Text>
<ol style={{ margin: 0, paddingLeft: 20, display: 'grid', gap: 4 }}>
{step.how.map((line) => (
<li key={line}>
<Text type="body" size="sm" style={{ lineHeight: 1.6 }}>
{line}
</Text>
</li>
))}
</ol>
</VStack>
{step.gotcha ? (
<HStack gap={1} align="start">
<Info
size={14}
style={{ color: 'var(--color-warning, #b7860b)', flex: 'none', marginTop: 3 }}
/>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{step.gotcha}
</Text>
</HStack>
) : null}
<HStack gap={1} align="center" wrap="wrap">
<Button
label={step.cta}
variant="primary"
size="sm"
endContent={<ArrowRight size={13} />}
onClick={onGo}
/>
{/* Skipping sits after the action, never before it. */}
{isActive ? (
<Button label="Skip this step" variant="ghost" size="sm" onClick={onSkip} />
) : null}
</HStack>
</VStack>
) : null}
</VStack>
</HStack>
);
}

View File

@@ -43,9 +43,11 @@ export interface SetupTourProps {
steps: readonly SetupStep[];
/** Where "finished" lands. The console, for both roles. */
home: string;
/** The journey page — where the walkthrough starts and can be reviewed. */
setupHref: string;
}
export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
export function SetupTour({ userid, tenantid, steps, home, setupHref }: SetupTourProps) {
const navigate = useNavigate();
const { pathname } = useLocation();
const [tour, setTour] = useState<TourState>(() => readTour(userid, tenantid));
@@ -102,6 +104,10 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
onStart={() => {
walkedTo.current = null;
setTour(startTour(userid, tenantid));
// To the journey first, not to step one. Somebody agreeing to a
// walkthrough should see what they agreed to — how many steps,
// which are already done, and why.
navigate(setupHref);
}}
onCancel={() => setTour(declineTour(userid, tenantid))}
/>
@@ -128,7 +134,7 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
justify="between"
gap={2}
wrap="wrap"
style={{ padding: '10px 0' }}
style={{ padding: '10px 0 0' }}
>
<HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
<Sparkles size={15} style={{ color: 'var(--color-brand)', flex: 'none' }} />
@@ -148,6 +154,12 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
between the header and the work on every screen. Open, it stays
open across steps — somebody who wants the detail wants it for
all of them. */}
<Button
label="All steps"
variant="ghost"
size="sm"
onClick={() => navigate(setupHref)}
/>
<Button
label={isOpen ? 'Hide guide' : 'How do I do this?'}
variant="ghost"
@@ -167,27 +179,34 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
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>
{isOpen ? <StepGuide step={focus} /> : null}
{/* Skip and Exit sit at the END, bottom right.
They were beside the action at the top, where the eye lands first —
so the two ways OUT of a step were more prominent than the one way
through it. Leaving is offered, not advertised. */}
<HStack justify="end" gap={1} style={{ paddingBottom: 10 }}>
<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>
</div>
</div>
);

View File

@@ -0,0 +1,35 @@
/**
* The merchant's setup journey, at /admin/setup.
*
* Reachable at any time, not only during the walkthrough — somebody who
* dismissed the offer, or finished half of it last week, needs a way back to
* the list without being asked again.
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { SetupPage } from './SetupPage';
import { useStoreAdminSteps } from './useSetupSteps';
import { endTour, readTour, skipInTour, startTour, type TourState } from './tourState';
export function StoreAdminSetupPage() {
const navigate = useNavigate();
const { steps, isLoading, tenantid, userid } = useStoreAdminSteps();
const [tour, setTour] = useState<TourState>(() => readTour(userid, tenantid));
return (
<SetupPage
steps={steps}
skipped={tour.skipped}
isLoading={isLoading}
isActive={tour.active}
home="/admin/console"
onStart={() => setTour(startTour(userid, tenantid))}
onSkip={(id) => setTour(skipInTour(userid, tenantid, id))}
onExit={() => {
setTour(endTour(userid, tenantid));
navigate('/admin/console');
}}
/>
);
}

View File

@@ -1,71 +1,28 @@
/**
* The merchant's walkthrough, mounted in the Store Admin shell.
* The merchant's walkthrough strip, 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.
* feed the walkthrough 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.
* Every hook it uses already existed. The walkthrough needed no new backend.
*/
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 { useStoreAdminSteps } from './useSetupSteps';
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;
const { steps, isLoading, tenantid, userid } = useStoreAdminSteps();
if (isLoading || !userid) return null;
return (
<SetupTour
userid={user.userid}
userid={userid}
tenantid={tenantid}
steps={steps}
home="/admin/console"
setupHref="/admin/setup"
/>
);
}

View File

@@ -0,0 +1,35 @@
/**
* The branch user’s setup journey, at /store/setup.
*
* Reachable at any time, not only during the walkthrough — somebody who
* dismissed the offer, or finished half of it last week, needs a way back to
* the list without being asked again.
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { SetupPage } from './SetupPage';
import { useStoreUserSteps } from './useSetupSteps';
import { endTour, readTour, skipInTour, startTour, type TourState } from './tourState';
export function StoreUserSetupPage() {
const navigate = useNavigate();
const { steps, isLoading, tenantid, userid } = useStoreUserSteps();
const [tour, setTour] = useState<TourState>(() => readTour(userid, tenantid));
return (
<SetupPage
steps={steps}
skipped={tour.skipped}
isLoading={isLoading}
isActive={tour.active}
home="/store/console"
onStart={() => setTour(startTour(userid, tenantid))}
onSkip={(id) => setTour(skipInTour(userid, tenantid, id))}
onExit={() => {
setTour(endTour(userid, tenantid));
navigate('/store/console');
}}
/>
);
}

View File

@@ -1,48 +1,28 @@
/**
* The branch user's walkthrough, mounted in the Store user shell.
* The branch user's walkthrough strip.
*
* 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.
* meets "No store assigned", which is the whole of what they can act on.
*/
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 { useStoreUserSteps } from './useSetupSteps';
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;
const { steps, isLoading, tenantid, userid } = useStoreUserSteps();
if (isLoading || !userid) return null;
return (
<SetupTour
userid={user.userid}
userid={userid}
tenantid={tenantid}
steps={steps}
home="/store/console"
setupHref="/store/setup"
/>
);
}

View File

@@ -0,0 +1,86 @@
/**
* The steps for each role, in one place.
*
* Both the journey page and the strip along the top need them, computed
* identically — two copies of this would drift, and the first symptom would be
* a page saying a step is finished while the strip still asks for it.
*/
import { useMemo } from 'react';
import { useAuth } from '@/auth/AuthContext';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { setupSteps, type SetupStep } from '@/features/store-admin/setupSteps';
import { useLocationProducts, useOwnTenant, useStaff, useUploads } from '@/queries/hooks';
import { storeUserSteps } from './storeUserSteps';
export interface RoleSteps {
steps: SetupStep[];
isLoading: boolean;
tenantid: number;
userid: number;
}
export function useStoreAdminSteps(): RoleSteps {
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. */
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],
);
return {
steps,
// Nothing is judged until the data has arrived: every step reads as undone
// while the queries are in flight, and acting on that would walk somebody
// through work they had already finished.
isLoading: !tenantid || shop.isLoading || people.isLoading || products.isLoading,
tenantid,
userid: user?.userid ?? 0,
};
}
export function useStoreUserSteps(): RoleSteps {
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],
);
return {
steps,
isLoading: !tenantid || !current?.locationid || products.isLoading,
tenantid,
userid: user?.userid ?? 0,
};
}

View File

@@ -49,6 +49,16 @@ export interface SetupStep {
href: string;
/** A real count, when there is one worth showing. */
detail?: string;
/**
* Why a step was already finished before anybody started.
*
* Onboarding creates a tenant, its first branch AND its administrator in
* one transaction, so two of the seven are done before a merchant ever
* signs in. The walkthrough used to leap straight past them, which reads
* as the tour skipping steps by itself. Saying what happened costs one
* line and removes the whole confusion.
*/
doneNote?: string;
/**
* Why this step is worth doing — the consequence of not doing it.
@@ -101,7 +111,7 @@ export function setupSteps(input: SetupInput): SetupStep[] {
gotcha:
"A field left blank is not changed. Saving will never erase something you did not fill in.",
title: 'Complete your shop profile',
todo: 'Add your shop photo and licence — this is what shoppers see.',
todo: 'Add your shop photo, licence and a line about what you sell.',
cta: 'Add your shop details',
done: isProfileComplete(shop ?? {}),
href: '/admin/profile',
@@ -122,6 +132,9 @@ export function setupSteps(input: SetupInput): SetupStep[] {
cta: 'Add a person',
done: people.length > 0,
href: '/admin/users',
...(people.length > 0
? { doneNote: 'Your own account was created when the shop was set up.' }
: {}),
...(people.some(isUnplaced)
? { detail: `${people.filter(isUnplaced).length} not at a shop yet` }
: {}),
@@ -142,6 +155,9 @@ export function setupSteps(input: SetupInput): SetupStep[] {
cta: 'Open a branch',
done: branches.length > 0,
href: '/admin/branches/new',
...(branches.length > 0
? { doneNote: 'Your first outlet was opened when the shop was set up.' }
: {}),
...(branches.length > 0 ? { detail: `${branches.length}` } : {}),
},
{