This commit is contained in:
2026-09-08 17:03:17 +05:30
parent 59828811f9
commit 33c4542ddf
20 changed files with 891 additions and 632 deletions

View File

@@ -25,6 +25,7 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Card } from '@astryxdesign/core/Card';
import { Boxes, LayoutDashboard, PackageSearch } from 'lucide-react';
import { Text } from '@astryxdesign/core/Text';
import { errorMessage } from '@/api/client';
import { tenantsApi } from '@/api/tenants';
@@ -35,6 +36,8 @@ import { useLocationProducts, useOwnTenant } from '@/queries/hooks';
import { StepFrame } from './StepFrame';
import { Stepper } from './Stepper';
import {
PROGRESS_STEPS,
STEP_LABEL,
completeStep,
progressOf,
readOnboarding,
@@ -236,7 +239,15 @@ export function OnboardingPage() {
return (
<PageBody measure="reading">
{!isFirst && !isLast ? <Stepper current={step} completed={state.completed} /> : null}
{!isFirst && !isLast ? (
<Stepper
steps={PROGRESS_STEPS}
labels={STEP_LABEL}
current={step}
completed={state.completed}
lastId="done"
/>
) : null}
{step === 'welcome' ? (
<WelcomeStep
@@ -250,7 +261,8 @@ export function OnboardingPage() {
{!isFirst && !isLast ? (
<StepFrame
step={step}
position={PROGRESS_STEPS.indexOf(step as (typeof PROGRESS_STEPS)[number]) + 1}
total={PROGRESS_STEPS.length}
title={heading.title}
{...(heading.blurb ? { blurb: heading.blurb } : {})}
continueLabel={step === 'review' ? 'Complete setup' : 'Save & continue'}
@@ -319,10 +331,34 @@ export function OnboardingPage() {
{step === 'done' ? (
<DoneStep
productCount={productCount}
onCatalogue={() => navigate('/admin/inventory?tab=catalogue')}
onInventory={() => navigate('/admin/inventory?tab=stock')}
onStorefront={() => navigate('/admin/inventory?tab=products')}
blurb={
productCount > 0
? 'Your store is set up and your products are ready for customers.'
: 'Your store is set up. Add some products and they will be ready for customers.'
}
actions={[
{
icon: <Boxes size={20} />,
title: 'Manage your catalogue',
body: 'Add products, set prices, and release them to your shops.',
cta: 'Open catalogue',
onClick: () => navigate('/admin/inventory?tab=catalogue'),
},
{
icon: <PackageSearch size={20} />,
title: 'Update your stock',
body: 'Upload your latest counts so customers see what is really there.',
cta: 'Update stock',
onClick: () => navigate('/admin/inventory?tab=stock'),
},
{
icon: <LayoutDashboard size={20} />,
title: 'See your shop',
body: 'Check what a customer sees, and which products are on sale.',
cta: 'View products',
onClick: () => navigate('/admin/inventory?tab=products'),
},
]}
onDashboard={() => navigate('/admin/console')}
/>
) : null}

View File

@@ -4,7 +4,7 @@ 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 { PROGRESS_STEPS, STEP_LABEL, type StepId } from './onboardingState';
/**
* The way back out of a working screen and into setup.
@@ -22,16 +22,29 @@ import { PROGRESS_STEPS, STEP_LABEL, type StepId } from './onboardingState';
* turns "am I done?" into an obvious yes.
*/
export interface SetupReturnBarProps {
/** The step the merchant left. Also what gets marked complete on return. */
step: StepId;
/** Products the tenant has now. 0 means the errand is not done yet. */
productCount: number;
/** Where the step sits in its own flow. Given by the caller — see `Stepper`. */
position: number;
total: number;
/** The step's name, for the "Store setup — Products" line. */
stepLabel: string;
/** True once the errand is done. The bar says so and offers to carry on. */
isDone: boolean;
/** What to say when it is done — "12 products added", "Your details saved". */
doneTitle: string;
/** Where "Back to setup" goes, including whatever it needs to advance. */
href: string;
}
export function SetupReturnBar({ step, productCount }: SetupReturnBarProps) {
export function SetupReturnBar({
position,
total,
stepLabel,
isDone,
doneTitle,
href,
}: SetupReturnBarProps) {
const navigate = useNavigate();
const position = PROGRESS_STEPS.indexOf(step) + 1;
const hasDone = productCount > 0;
const hasDone = isDone;
return (
<div className="ob-returnbar" data-done={hasDone ? 'yes' : 'no'}>
@@ -42,14 +55,12 @@ export function SetupReturnBar({ step, productCount }: SetupReturnBarProps) {
</span>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{hasDone
? `${productCount} product${productCount === 1 ? '' : 's'} added`
: `Store setup — ${STEP_LABEL[step]}`}
{hasDone ? doneTitle : `Store setup — ${stepLabel}`}
</Text>
<Text type="body" size="xsm" color="secondary">
{hasDone
? 'Nice work. Carry on with setup whenever you are ready.'
: `Step ${position} of ${PROGRESS_STEPS.length}. Add your products, then head back to setup.`}
: `Step ${position} of ${total}. Finish here, then head back to setup.`}
</Text>
</VStack>
</HStack>
@@ -63,7 +74,7 @@ export function SetupReturnBar({ step, productCount }: SetupReturnBarProps) {
because products exist would also fire for a merchant who wandered
here on their own, marking work done that they never chose to
finish. Pressing this button is the choice. */
onClick={() => navigate(`/admin/onboarding?advance=${step}`)}
onClick={() => navigate(href)}
/>
</HStack>
</div>

View File

@@ -4,7 +4,6 @@ import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { ArrowLeft, ArrowRight, LogOut } from 'lucide-react';
import { PROGRESS_STEPS, type StepId } from './onboardingState';
/**
* The frame every collecting step is drawn in.
@@ -29,7 +28,15 @@ import { PROGRESS_STEPS, type StepId } from './onboardingState';
* the flow having lost their work.
*/
export interface StepFrameProps {
step: StepId;
/**
* Where this step sits, given by the caller rather than looked up.
*
* The frame used to find its own position in the merchant's `PROGRESS_STEPS`,
* which locked it to one role. Position is the caller's fact — it is the one
* that knows which flow this is.
*/
position: number;
total: number;
title: string;
blurb?: string;
/** Primary action label. The Review step ends the flow, so it says so. */
@@ -44,7 +51,8 @@ export interface StepFrameProps {
}
export function StepFrame({
step,
position,
total,
title,
blurb,
continueLabel,
@@ -56,9 +64,7 @@ export function StepFrame({
onExit,
children,
}: StepFrameProps) {
const position = PROGRESS_STEPS.indexOf(step) + 1;
const total = PROGRESS_STEPS.length;
const pct = Math.round((position / total) * 100);
const pct = total > 0 ? Math.round((position / total) * 100) : 0;
return (
<section className="ob-frame">

View File

@@ -13,19 +13,38 @@
*/
import { Check } from 'lucide-react';
import { PROGRESS_STEPS, STEP_LABEL, type StepId } from './onboardingState';
export interface StepperProps {
current: StepId;
completed: readonly StepId[];
/**
* The step list comes from the CALLER, not from a module constant.
*
* It used to read the merchant's own `PROGRESS_STEPS`, which is why the branch
* user's setup could not use this component and grew a second design instead.
* The two roles have different work — seven steps against three — but "where am
* I and how much is left" is the same question and deserves the same answer.
*/
export interface StepperProps<Id extends string> {
steps: readonly Id[];
labels: Readonly<Record<Id, string>>;
current: Id;
completed: readonly Id[];
/** Ids that sit outside the numbered run — a welcome screen, a done screen. */
lastId?: Id;
}
export function Stepper({ current, completed }: StepperProps) {
export function Stepper<Id extends string>({
steps,
labels,
current,
completed,
lastId,
}: StepperProps<Id>) {
const PROGRESS_STEPS = steps;
const STEP_LABEL = labels;
const index = PROGRESS_STEPS.indexOf(current);
// Welcome sits before the five and Done after them, so neither has a node.
// Clamped rather than hidden: a bar that disappears on the first screen makes
// the flow look like it started somewhere else.
const position = index < 0 ? (current === 'done' ? PROGRESS_STEPS.length : 0) : index + 1;
const position = index < 0 ? (current === lastId ? PROGRESS_STEPS.length : 0) : index + 1;
const doneCount = PROGRESS_STEPS.filter((id) => completed.includes(id)).length;
const pct = Math.round((doneCount / PROGRESS_STEPS.length) * 100);
@@ -45,7 +64,7 @@ export function Stepper({ current, completed }: StepperProps) {
{position > 0 ? `Step ${position} of ${PROGRESS_STEPS.length}` : 'Getting started'}
</span>
<span style={{ font: '500 12px/1.3 var(--font-sans)', color: 'var(--color-ink-3)' }}>
{STEP_LABEL[current]}
{STEP_LABEL[current] ?? ''}
</span>
</div>
<div

View File

@@ -2,7 +2,7 @@ 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, Boxes, Check, LayoutDashboard, PackageSearch } from 'lucide-react';
import { ArrowRight, Check } from 'lucide-react';
/**
* The end.
@@ -14,21 +14,23 @@ import { ArrowRight, Boxes, Check, LayoutDashboard, PackageSearch } from 'lucide
* The three cards are the real next actions, not a tour: each is a screen that
* exists and does something.
*/
/** One of the three real next actions. Each is a screen that exists. */
export interface NextAction {
icon: React.ReactNode;
title: string;
body: string;
cta: string;
onClick: () => void;
}
export interface DoneStepProps {
productCount: number;
onCatalogue: () => void;
onInventory: () => void;
onStorefront: () => void;
/** The line under the title. The two roles finish having done different work. */
blurb: string;
actions: readonly NextAction[];
onDashboard: () => void;
}
export function DoneStep({
productCount,
onCatalogue,
onInventory,
onStorefront,
onDashboard,
}: DoneStepProps) {
export function DoneStep({ blurb, actions, onDashboard }: DoneStepProps) {
return (
<VStack gap={4} className="ob-panel">
<VStack gap={1.5} style={{ textAlign: 'center', alignItems: 'center' }}>
@@ -43,34 +45,21 @@ export function DoneStep({
You&rsquo;re all set 🎉
</Text>
<Text type="body" color="secondary" style={{ maxWidth: '48ch', lineHeight: 1.65 }}>
{productCount > 0
? 'Your store is set up and your products are ready for customers.'
: 'Your store is set up. Add some products and they will be ready for customers.'}
{blurb}
</Text>
</VStack>
<div className="ob-benefits">
<NextCard
icon={<Boxes size={20} />}
title="Manage your catalogue"
body="Add products, set prices, and release them to your shops."
cta="Open catalogue"
onClick={onCatalogue}
/>
<NextCard
icon={<PackageSearch size={20} />}
title="Update your stock"
body="Upload your latest counts so customers see what is really there."
cta="Update stock"
onClick={onInventory}
/>
<NextCard
icon={<LayoutDashboard size={20} />}
title="See your shop"
body="Check what a customer sees, and which products are on sale."
cta="View products"
onClick={onStorefront}
/>
{actions.map((action) => (
<NextCard
key={action.title}
icon={action.icon}
title={action.title}
body={action.body}
cta={action.cta}
onClick={action.onClick}
/>
))}
</div>
<HStack justify="center">

View File

@@ -3,7 +3,7 @@ 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, Boxes, Rocket, Store } from 'lucide-react';
import { ArrowRight, Boxes, Rocket, Store, type LucideIcon } from 'lucide-react';
/**
* The first screen, and the only one whose job is not to collect anything.
@@ -16,11 +16,30 @@ import { ArrowRight, Boxes, Rocket, Store } from 'lucide-react';
* The one promise made in words is the one the state layer actually keeps:
* progress is saved, so leaving is safe.
*/
/** One of the three cards under the greeting. */
export interface WelcomeBenefit {
icon: LucideIcon;
title: string;
body: string;
}
export interface WelcomeStepProps {
shopName?: string;
done: number;
total: number;
isReturning: boolean;
/**
* What setup is worth, in three cards.
*
* Given by the caller because the two roles are promised different things: a
* merchant is told about the catalogue and going on sale, a branch user about
* their own account and their shelf. The SHAPE is shared — that is the point
* of this component — and only the words differ.
*/
benefits?: readonly WelcomeBenefit[];
/** The greeting, when the default merchant wording is not the right one. */
greeting?: string;
blurb?: string;
onStart: () => void;
}
@@ -42,7 +61,16 @@ const BENEFITS = [
},
] as const;
export function WelcomeStep({ shopName, done, total, isReturning, onStart }: WelcomeStepProps) {
export function WelcomeStep({
shopName,
done,
total,
isReturning,
benefits = BENEFITS,
greeting,
blurb,
onStart,
}: WelcomeStepProps) {
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
return (
@@ -70,7 +98,7 @@ export function WelcomeStep({ shopName, done, total, isReturning, onStart }: Wel
weight="semibold"
style={{ fontFamily: 'var(--font-display)', textWrap: 'balance' }}
>
{isReturning ? 'Welcome back 👋' : `Welcome${shopName ? ` to ${shopName}` : ''}! 👋`}
{isReturning ? 'Welcome back 👋' : (greeting ?? `Welcome${shopName ? ` to ${shopName}` : ''}! 👋`)}
</Text>
<Text
type="body"
@@ -79,12 +107,12 @@ export function WelcomeStep({ shopName, done, total, isReturning, onStart }: Wel
>
{isReturning
? `You have completed ${done} of ${total} setup steps. Pick up where you left off.`
: "Let's get your store ready to start selling."}
: (blurb ?? "Let's get your store ready to start selling.")}
</Text>
</VStack>
<div className="ob-benefits">
{BENEFITS.map(({ icon: Icon, title, body }) => (
{benefits.map(({ icon: Icon, title, body }) => (
<Card key={title} padding={3} elevation="low">
<VStack gap={1}>
<span style={{ color: 'var(--color-brand)' }} aria-hidden>

View File

@@ -1,370 +0,0 @@
/**
* 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, ChevronDown, ChevronUp, Info, 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));
/* Sticky across steps. Somebody who opened the guide once wants it for the
next step too; making them reopen it seven times teaches them not to. */
const [isOpen, setIsOpen] = useState(false);
/* 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}
steps={steps}
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.
}}
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 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">
{/* Collapsed by default. The bar sits on every page for the whole
walkthrough, so a permanently open panel would be a paragraph
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={isOpen ? 'Hide guide' : 'How do I do this?'}
variant="ghost"
size="sm"
endContent={isOpen ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
onClick={() => setIsOpen((open) => !open)}
/>
{/* 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}
</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>
);
}
/**
* The detail for one step: why it matters, what to do, and the trap.
*
* Three parts rather than a paragraph, because they answer different questions
* and people arrive wanting different ones. Somebody who already knows what to
* do wants the gotcha; somebody who does not wants the numbered actions; the
* reason is what makes a merchant bother at all.
*
* Every "why" here is a failure that has actually happened on this platform,
* not a generality — an unpriced catalogue that never sold, a category nobody
* set, a sheet sitting unreviewed. That is what makes them worth reading.
*/
function StepGuide({ step }: { step: SetupStep }) {
return (
<VStack
gap={1.5}
style={{
borderTop: '1px solid var(--color-line)',
padding: '14px 0 16px',
maxWidth: '72ch',
}}
>
<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}
</VStack>
);
}
/* ── The offer ───────────────────────────────────────────────────────────── */
function StartDialog({
stepCount,
steps,
onStart,
onCancel,
}: {
stepCount: number;
steps: readonly SetupStep[];
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>
{/* What the steps actually are, before agreeing to be walked through
them. "7 short steps" alone asks somebody to commit to an unknown
amount of work; the list is what makes it an informed yes. Already
finished ones are shown ticked, so the two a new tenant gets free
from onboarding are visible rather than a surprise. */}
<VStack gap={0.5} style={{ maxHeight: 240, overflowY: 'auto' }}>
{steps.map((step) => (
<HStack key={step.id} gap={1} align="center">
{step.done ? (
<Check
size={14}
style={{ color: 'var(--color-success, #10b981)', flex: 'none' }}
/>
) : (
<span
aria-hidden
style={{
width: 14,
textAlign: 'center',
flex: 'none',
color: 'var(--color-ink-4)',
fontSize: 10,
}}
>
●
</span>
)}
<Text
type="body"
size="sm"
{...(step.done ? { color: 'secondary' as const } : {})}
>
{step.title}
</Text>
</HStack>
))}
</VStack>
<HStack gap={1} justify="end">
<Button label="Not now" variant="ghost" onClick={onCancel} />
<Button label="Start setup" variant="primary" onClick={onStart} />
</HStack>
</VStack>
</div>
</>
);
}

View File

@@ -0,0 +1,52 @@
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useStoreUserSteps } from './useSetupSteps';
import { readStoreSetup, shouldOfferStoreSetup, writeStoreSetup } from './storeSetupState';
/**
* Sends a first-time branch user to setup instead of the console.
*
* The merchant's `OnboardingGate`, for the other login, and deliberately the
* same three rules — the reasoning is written out there and holds identically
* here:
*
* - **Only once.** The redirect records `started`, so nobody is sent twice.
* Somebody who leaves setup has left it.
* - **Only from the landing page.** A user who deep-links to Products, or is
* already reading Sales, is not hauled away from what they opened.
* - **Only when there is something to do.** A branch user with all three steps
* already satisfied is not a first-time user, however new the account.
*
* ── What replaced what ──────────────────────────────────────────────────────
*
* This takes over from `StoreUserTour`, which offered a one-time dialog and, if
* declined, was gone permanently — no menu entry, no route, nothing. All three
* branch accounts on this install had already declined it, so the login with
* the least familiar users had no setup guidance at all while the merchant's
* had a page they could return to any time. A gate plus a route means declining
* now only postpones it.
*/
export function StoreSetupGate() {
const navigate = useNavigate();
const { pathname } = useLocation();
const { steps, isLoading, tenantid, userid } = useStoreUserSteps();
useEffect(() => {
if (!userid || !tenantid) return;
const state = readStoreSetup(userid, tenantid);
const outstanding = steps.filter(
(step) => !step.done && !state.skipped.includes(step.id),
).length;
if (!shouldOfferStoreSetup({ pathname, isReady: !isLoading, outstanding, state })) return;
// Recorded BEFORE navigating, so the decision cannot be taken twice. Without
// it the user is trapped: leaving setup lands back on the console, which
// redirects here again, forever.
writeStoreSetup(userid, tenantid, { ...state, started: true });
navigate('/store/setup', { replace: true });
}, [userid, tenantid, isLoading, steps, pathname, navigate]);
return null;
}

View File

@@ -0,0 +1,46 @@
import { useSearchParams } from 'react-router-dom';
import { SetupReturnBar } from '@/features/onboarding/SetupReturnBar';
import { useStoreUserSteps } from './useSetupSteps';
/**
* The way back out of a working screen and into a branch user's setup.
*
* The merchant's half of this already existed — setup sends them to Inventory
* to import products, and `?setup=<step>` on the link makes a bar appear there
* that gets them home. The branch user's setup does exactly the same kind of
* thing (it sends them to Profile, to Products, to Sales) and had no bar, so
* the only route back was the browser's Back button. That is not a control
* anybody should have to find to finish onboarding.
*
* Same component as the merchant's, so the two cannot drift: this only supplies
* the branch user's numbers and destination. `?setupstep=` rather than `?setup=`
* because the two flows have different step vocabularies and a bar reading the
* wrong one would show the wrong position.
*
* Renders nothing when the page was not reached from setup, which is almost
* always.
*/
export function StoreSetupReturn() {
const [params] = useSearchParams();
const from = params.get('setupstep');
const { steps, isLoading } = useStoreUserSteps();
if (!from || isLoading) return null;
const at = steps.findIndex((step) => step.id === from);
const step = steps[at];
if (!step) return null;
return (
<SetupReturnBar
position={at + 1}
total={steps.length}
stepLabel={step.title}
/* Read from the same live data the step itself is derived from, so the
bar turns green when the work is genuinely done and not before. */
isDone={step.done}
doneTitle={step.detail ? `Done — ${step.detail}` : 'Done'}
href={`/store/setup?step=${step.id}`}
/>
);
}

View File

@@ -1,27 +0,0 @@
/**
* 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 "No store assigned", which is the whole of what they can act on.
*/
import { useStoreUserSteps } from './useSetupSteps';
import { SetupTour } from './SetupTour';
export function StoreUserTour() {
const { steps, isLoading, tenantid, userid } = useStoreUserSteps();
if (isLoading || !userid) return null;
return (
<SetupTour
userid={userid}
tenantid={tenantid}
steps={steps}
home="/store/console"
/>
);
}

View File

@@ -0,0 +1,95 @@
/**
* The branch user's setup state.
*
* `localStorage` does not exist under `node:test`, so it is stood up here — the
* module reads `window.localStorage` and must degrade rather than throw when it
* cannot.
*/
import assert from 'node:assert/strict';
import { beforeEach, test } from 'node:test';
import {
EMPTY_STORE_SETUP,
readStoreSetup,
shouldOfferStoreSetup,
skipStoreStep,
writeStoreSetup,
} from './storeSetupState';
function stubStorage(): void {
const store = new Map<string, string>();
(globalThis as unknown as { window: unknown }).window = {
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
};
}
beforeEach(stubStorage);
test('an account nobody has touched starts empty', () => {
assert.deepEqual(readStoreSetup(3002, 1147), EMPTY_STORE_SETUP);
});
test('what is written comes back', () => {
writeStoreSetup(3002, 1147, { started: true, skipped: ['products'], finished: false });
assert.deepEqual(readStoreSetup(3002, 1147), {
started: true,
skipped: ['products'],
finished: false,
});
});
// A till shared by two people is one browser with two accounts. One person's
// progress must never be read as the other's.
test('two users on one browser do not share progress', () => {
writeStoreSetup(3002, 1147, { started: true, skipped: [], finished: true });
assert.deepEqual(readStoreSetup(1445, 1147), EMPTY_STORE_SETUP);
});
// A user moved to another merchant starts again: their work at the new one has
// genuinely not been done.
test('the same user at another merchant starts again', () => {
writeStoreSetup(3002, 1147, { started: true, skipped: [], finished: true });
assert.deepEqual(readStoreSetup(3002, 1150), EMPTY_STORE_SETUP);
});
test('skipping is recorded once, not repeatedly', () => {
const once = skipStoreStep(EMPTY_STORE_SETUP, 'products');
assert.deepEqual(once.skipped, ['products']);
assert.equal(skipStoreStep(once, 'products'), once, 'the same object, so nothing re-renders');
});
/* ── When to send somebody to setup ───────────────────────────────────────── */
const base = { pathname: '/store/console', isReady: true, outstanding: 2, state: EMPTY_STORE_SETUP };
test('a first-time user with work outstanding is offered setup', () => {
assert.equal(shouldOfferStoreSetup(base), true);
});
// Once only. Somebody who leaves setup has left it — an offer that reappears
// every morning stops being an offer.
test('nobody is sent twice', () => {
assert.equal(
shouldOfferStoreSetup({ ...base, state: { ...EMPTY_STORE_SETUP, started: true } }),
false,
);
});
// Someone who deep-linked to Products is not hauled away from what they opened.
test('only from the landing page', () => {
assert.equal(shouldOfferStoreSetup({ ...base, pathname: '/store/products' }), false);
assert.equal(shouldOfferStoreSetup({ ...base, pathname: '/store' }), true);
});
// Judging "incomplete" while the data is in flight would redirect everybody on
// their first paint, including the users this is written to leave alone.
test('nothing is decided until the data has arrived', () => {
assert.equal(shouldOfferStoreSetup({ ...base, isReady: false }), false);
});
test('a user with nothing outstanding is left alone', () => {
assert.equal(shouldOfferStoreSetup({ ...base, outstanding: 0 }), false);
});

View File

@@ -0,0 +1,107 @@
/**
* Where a branch user has got to in setup.
*
* ── Why this is not `onboardingState` ───────────────────────────────────────
*
* The merchant's setup COLLECTS answers — a shop name, an address, delivery
* settings — so its state has to hold half-typed drafts and remember what was
* saved. A branch user's setup collects nothing: every one of their three steps
* is real work done on a real screen (their own profile, their branch's
* products, the day's takings), and whether it is finished is read back from
* live data, not from anything stored here.
*
* So this holds the two things that genuinely cannot be derived: whether they
* have been sent here once, and which steps they chose to skip. Everything else
* is a question for the API.
*
* ── Why it is per user AND per tenant ───────────────────────────────────────
*
* The same discipline as `onboardingState`. A till shared by two people is one
* browser with two accounts, and one person's progress must not be read as the
* other's; a user moved between merchants starts again, because their work at
* the new one has genuinely not been done.
*/
const KEY = 'nearle.setup.store';
export interface StoreSetupState {
/** True once they have been sent here. Nobody is redirected twice. */
started: boolean;
/** Steps put aside on purpose. Skipping is a choice and it is remembered. */
skipped: string[];
/** True once they have reached the end screen. */
finished: boolean;
}
export const EMPTY_STORE_SETUP: StoreSetupState = {
started: false,
skipped: [],
finished: false,
};
function slot(userid: number, tenantid: number): string {
return `${userid}:${tenantid}`;
}
/** Everything stored, or an empty map when it cannot be read. */
function readAll(): Record<string, StoreSetupState> {
try {
const raw = window.localStorage.getItem(KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, StoreSetupState>) : {};
} catch {
// Private-mode Safari throws on localStorage, and a corrupt value should
// cost somebody a redirect they have already had, not the whole console.
return {};
}
}
export function readStoreSetup(userid: number, tenantid: number): StoreSetupState {
const stored = readAll()[slot(userid, tenantid)];
if (!stored) return EMPTY_STORE_SETUP;
return {
started: Boolean(stored.started),
skipped: Array.isArray(stored.skipped) ? stored.skipped : [],
finished: Boolean(stored.finished),
};
}
export function writeStoreSetup(userid: number, tenantid: number, next: StoreSetupState): void {
try {
const all = readAll();
all[slot(userid, tenantid)] = next;
window.localStorage.setItem(KEY, JSON.stringify(all));
} catch {
/* Nothing to do and nothing worth saying: the flow still works, it just
forgets. Better a repeated offer than a console that will not load. */
}
}
/** Puts a step aside without claiming it was done. */
export function skipStoreStep(state: StoreSetupState, id: string): StoreSetupState {
return state.skipped.includes(id) ? state : { ...state, skipped: [...state.skipped, id] };
}
/**
* Whether to send this user to setup rather than to the console.
*
* The same three rules the merchant's gate follows, for the same reasons:
* only once, only from the landing page, and only when there is something to
* do. A branch user with nothing outstanding is not a first-time user however
* new the account.
*/
export function shouldOfferStoreSetup(input: {
pathname: string;
/** False while the data the steps are derived from is still in flight. */
isReady: boolean;
/** Steps that are neither done nor skipped. */
outstanding: number;
state: StoreSetupState;
}): boolean {
const { pathname, isReady, outstanding, state } = input;
if (!isReady) return false;
if (state.started) return false;
if (pathname !== '/store' && pathname !== '/store/console') return false;
return outstanding > 0;
}

View File

@@ -1,56 +0,0 @@
/**
* The three facts a first-run walkthrough has to keep apart.
*
* Conflating them is what makes onboarding annoying: an offer that reappears
* every morning, a walkthrough that drops out on refresh, or a "skip" that
* quietly counts as done.
*/
import assert from 'node:assert/strict';
import { test, beforeEach } from 'node:test';
import { shouldOfferTour, type TourState } from './tourState';
const state = (over: Partial<TourState> = {}): TourState => ({
prompted: false,
active: false,
skipped: [],
...over,
});
beforeEach(() => {
delete (globalThis as { localStorage?: unknown }).localStorage;
});
test('a new account with work to do is offered the walkthrough', () => {
assert.equal(shouldOfferTour(state(), true), true);
});
// Asked once. An offer that returns on every sign-in stops being an offer and
// becomes something to dismiss without reading.
test('somebody who already said no is not asked again', () => {
assert.equal(shouldOfferTour(state({ prompted: true }), true), false);
});
test('somebody already in the walkthrough is not offered it again', () => {
assert.equal(shouldOfferTour(state({ active: true, prompted: true }), true), false);
});
/*
The check that matters most. A shop already selling is not a first-run case
however new the account is — three of the four live merchants on 31 Aug were
trading and still missing a licence number, and offering them a setup tour would
read as the console not knowing what it is looking at.
*/
test('a shop with nothing left to do is never offered a walkthrough', () => {
assert.equal(shouldOfferTour(state(), false), false);
assert.equal(shouldOfferTour(state({ prompted: false }), false), false);
});
// Storage the browser refuses must not throw, and must fail towards offering
// the guidance rather than silently swallowing it.
test('unavailable storage reads as nothing recorded', async () => {
const { readTour } = await import('./tourState');
const read = readTour(1475, 1141);
assert.equal(read.prompted, false);
assert.equal(read.active, false);
assert.deepEqual(read.skipped, []);
});

View File

@@ -1,102 +0,0 @@
/**
* 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;
}

View File

@@ -35,7 +35,11 @@ import { useBranchScope } from '../BranchScope';
import { CataloguePanel } from '../CataloguePanel';
import { ProductsPanel } from '../ProductsPanel';
import { SetupReturnBar } from '@/features/onboarding/SetupReturnBar';
import type { StepId } from '@/features/onboarding/onboardingState';
import {
PROGRESS_STEPS,
STEP_LABEL,
type StepId,
} from '@/features/onboarding/onboardingState';
import { useLocationProducts } from '@/queries/hooks';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
@@ -108,7 +112,16 @@ export function InventoryPage() {
{/* Above the header, because it is about the errand rather than the page:
it is the only thing on screen that knows setup is unfinished. */}
{fromSetup ? (
<SetupReturnBar step={fromSetup} productCount={(setupProducts.data ?? []).length} />
<SetupReturnBar
position={PROGRESS_STEPS.indexOf(fromSetup) + 1}
total={PROGRESS_STEPS.length}
stepLabel={STEP_LABEL[fromSetup]}
isDone={(setupProducts.data ?? []).length > 0}
doneTitle={`${(setupProducts.data ?? []).length} product${
(setupProducts.data ?? []).length === 1 ? '' : 's'
} added`}
href={`/admin/onboarding?advance=${fromSetup}`}
/>
) : null}
<PageHeader

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { FileSpreadsheet, Monitor, QrCode, Store, UserCog, UserRound, Users } from 'lucide-react';
import { StoreQrDrawer } from './StoreQrDrawer';
import { AppShell, IconButton, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
import { StoreUserTour } from '@/features/setup/StoreUserTour';
import { StoreSetupGate } from '@/features/setup/StoreSetupGate';
import { useAuth } from '@/auth/AuthContext';
import { useTenantLocations } from '@/queries/hooks';
import { AssistantScope } from '@/features/console/AssistantScope';
@@ -116,6 +116,9 @@ export function StoreUserShell() {
<DateScopeProvider>
<BranchScopeProvider pin={locationid}>
<LiveWatch />
{/* Sends a first-time branch user to setup, exactly as the merchant's
shell does with `OnboardingGate`. Renders nothing. */}
<StoreSetupGate />
<AssistantScope>
<AppShell
nav={NAV}
@@ -123,7 +126,6 @@ export function StoreUserShell() {
navLabel="Store"
scopeControl={<BranchPill />}
manageItems={MANAGE}
banner={<StoreUserTour />}
headerActions={
<IconButton label="Store QR code" onClick={() => setQrOpen(true)}>
<QrCode size={16} />

View File

@@ -18,6 +18,7 @@ import {
useStockRequests,
useStockStatement,
} from '@/queries/hooks';
import { StoreSetupReturn } from '@/features/setup/StoreSetupReturn';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { ProductDrawer } from '@/features/store-admin/ProductDrawer';
import { count, money } from '@/features/store-admin/format';
@@ -145,6 +146,10 @@ export function StoreProductsPage() {
return (
<VStack gap={3}>
{/* Above the header, because it is about the errand rather than the page
— the same placement the merchant's Inventory uses. */}
<StoreSetupReturn />
<PageHeader
isTabsInline
title="Products"

View File

@@ -0,0 +1,334 @@
/**
* Setup for a branch user, in the shape the merchant's already has.
*
* ── Why this exists ─────────────────────────────────────────────────────────
*
* The two logins had two different answers to the same question. A merchant got
* a page inside the console — a numbered stepper, one step at a time, a way
* back in from the account menu. A branch user got a strip in the shell that
* offered itself once and, if declined, was gone for good: measured on this
* install, all three branch accounts read `{prompted: true, active: false}`, so
* every one of them had setup guidance available exactly once and none of them
* has it now. Same product, two designs, and the weaker one on the login least
* likely to know its way around.
*
* This is the merchant's flow, with the branch user's own work in it. The
* chrome is literally the same components — `Stepper`, `StepFrame`, and the
* return bar — so the two cannot drift apart again.
*
* ── What is different, and why it has to be ─────────────────────────────────
*
* The merchant's steps COLLECT: a shop name, an address, delivery settings, all
* typed into the flow and written to Fiesta as each step is saved. None of a
* branch user's three are like that. Their work happens on real screens — their
* own profile, their branch's products, the day's takings — so each step here
* explains the job, says why it matters and what to watch for, and sends them
* to the screen that does it.
*
* Which means completion is DERIVED, never claimed. `useStoreUserSteps` reads
* the same live data the rest of the console reads, so a step is done when the
* work is done. There is no "mark as complete" button, because there is nothing
* it could truthfully write.
*/
import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { ArrowRight, Boxes, Check, Info, Receipt, Sparkles, TriangleAlert, UserRound } from 'lucide-react';
import { Button } from '@astryxdesign/core/Button';
import { useAuth } from '@/auth/AuthContext';
import { PageBody } from '@/components/PageBody';
import { StepFrame } from '@/features/onboarding/StepFrame';
import { Stepper } from '@/features/onboarding/Stepper';
import { DoneStep } from '@/features/onboarding/steps/DoneStep';
import { WelcomeStep } from '@/features/onboarding/steps/WelcomeStep';
import { useStoreUserSteps } from '@/features/setup/useSetupSteps';
import {
readStoreSetup,
skipStoreStep,
writeStoreSetup,
type StoreSetupState,
} from '@/features/setup/storeSetupState';
const HOME = '/store/console';
export function StoreSetupPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const { user } = useAuth();
const { steps, isLoading, tenantid, userid } = useStoreUserSteps();
const [state, setState] = useState<StoreSetupState>(() =>
userid ? readStoreSetup(userid, tenantid) : { started: false, skipped: [], finished: false },
);
function save(next: StoreSetupState) {
setState(next);
if (userid) writeStoreSetup(userid, tenantid, next);
}
/**
* Which step is on screen.
*
* The URL wins when it names one — that is how the return bar brings somebody
* back to where they left. Otherwise it is the first step still outstanding,
* which means finishing the work on another screen and coming back lands on
* what is next rather than on what is already done.
*/
const asked = params.get('step');
const outstanding = useMemo(
() => steps.filter((step) => !step.done && !state.skipped.includes(step.id)),
[steps, state.skipped],
);
const [manual, setManual] = useState<string | null>(null);
/* The welcome screen, exactly as the merchant's flow opens. It is skipped for
somebody coming back to a named step, because they have already begun. */
const [hasStarted, setHasStarted] = useState(() => Boolean(asked) || state.started);
const currentId = manual ?? asked ?? outstanding[0]?.id ?? null;
const current = steps.find((step) => step.id === currentId) ?? outstanding[0] ?? null;
const ids = useMemo(() => steps.map((step) => step.id as string), [steps]);
const labels = useMemo(
() => Object.fromEntries(steps.map((step) => [step.id, step.title])) as Record<string, string>,
[steps],
);
const completed = useMemo(
() => steps.filter((step) => step.done).map((step) => step.id as string),
[steps],
);
const doneCount = steps.filter((step) => step.done).length;
if (isLoading || !userid) {
return (
<PageBody measure="reading">
<Text type="body" color="secondary">
Reading your shop…
</Text>
</PageBody>
);
}
if (!hasStarted) {
return (
<PageBody measure="reading">
<WelcomeStep
done={doneCount}
total={steps.length}
isReturning={state.started && doneCount > 0}
greeting="Welcome! 👋"
blurb="Three short things, and you will know your way around this console."
benefits={[
{
icon: UserRound,
title: 'Your own account',
body: 'So your shop can see who took a payment and who raised a request.',
},
{
icon: Boxes,
title: 'Your shelf',
body: 'What is priced, what is in stock, and what a customer can buy today.',
},
{
icon: Receipt,
title: 'The day’s takings',
body: 'Counter sales and app orders side by side, for this branch.',
},
]}
onStart={() => {
save({ ...state, started: true });
setHasStarted(true);
}}
/>
</PageBody>
);
}
/* Everything done, or everything put aside. The end screen, same as the
merchant's — a flow that just stops on its last step never tells anybody
they have finished. */
if (!current) {
return (
<PageBody measure="reading">
<DoneStep
blurb={
state.skipped.length > 0
? `${doneCount} of ${steps.length} done, ${state.skipped.length} put aside. Come back whenever you like.`
: 'You know your way around. Everything you need is on the console from here.'
}
actions={[
{
icon: <Boxes size={20} />,
title: 'See your shelf',
body: 'What is priced, what is in stock, and what is out.',
cta: 'Open products',
onClick: () => navigate('/store/products'),
},
{
icon: <Receipt size={20} />,
title: "Today's takings",
body: 'Counter sales and app orders, side by side for this branch.',
cta: 'Open sales',
onClick: () => navigate('/store/sales'),
},
{
icon: <UserRound size={20} />,
title: 'Your details',
body: 'Your name, mobile and sign-in, whenever they need changing.',
cta: 'Open my account',
onClick: () => navigate('/store/account'),
},
]}
onDashboard={() => {
save({ ...state, started: true, finished: true });
navigate(HOME, { replace: true });
}}
/>
</PageBody>
);
}
const position = ids.indexOf(current.id) + 1;
const isLastOutstanding = outstanding.length <= 1;
return (
<PageBody measure="reading">
<Stepper steps={ids} labels={labels} current={current.id as string} completed={completed} />
<StepFrame
position={position}
total={ids.length}
title={current.title}
blurb={current.todo}
continueLabel={isLastOutstanding ? 'Finish setup' : 'Next step'}
canSkip={!current.done}
onBack={() => {
const at = ids.indexOf(current.id);
setManual(at > 0 ? (ids[at - 1] as string) : null);
if (at <= 0) navigate(HOME);
}}
onSkip={() => {
save(skipStoreStep({ ...state, started: true }, current.id));
setManual(null);
}}
onContinue={() => {
save({ ...state, started: true });
setManual(null);
// Nothing is marked done here — see the note at the top. Moving on
// means moving on; the step stays outstanding until the work is.
if (isLastOutstanding) navigate(HOME, { replace: true });
}}
onExit={() => {
save({ ...state, started: true });
navigate(HOME);
}}
>
<VStack gap={2}>
{/* Why it matters, how to do it, and what catches people out — the
three things the tour strip carried and the reason it was worth
keeping when the strip went. */}
<Guidance icon={<Sparkles size={15} />} title="Why this matters" body={current.why} />
{current.how && current.how.length > 0 ? (
<div className="ob-guide">
<span className="ob-guide-icon" aria-hidden>
<Info size={15} />
</span>
<VStack gap={0.5} style={{ minWidth: 0 }}>
<Text type="label" size="sm" weight="semibold">
What you will do
</Text>
<ul className="ob-guide-list">
{current.how.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</VStack>
</div>
) : null}
{current.gotcha ? (
<Guidance
icon={<TriangleAlert size={15} />}
title="Worth knowing"
body={current.gotcha}
tone="warn"
/>
) : null}
{/* The work itself happens elsewhere, so the step carries the trip and
its return leg: `?step=` is what brings them back to this one. */}
<div className="ob-guide" data-tone={current.done ? 'done' : undefined}>
<span className="ob-guide-icon" aria-hidden>
{current.done ? <Check size={15} strokeWidth={3} /> : <ArrowRight size={15} />}
</span>
<VStack gap={1} style={{ minWidth: 0 }}>
<Text type="label" size="sm" weight="semibold">
{current.done ? 'Done' : current.cta}
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{current.done
? current.detail
? `Finished — ${current.detail}.`
: 'Finished. Carry on to the next step.'
: 'This opens the screen where the work happens. Come back here when you are done.'}
</Text>
{current.done ? null : (
<div>
<Button
label={current.cta}
variant="primary"
size="sm"
endContent={<ArrowRight size={14} />}
onClick={() => {
save({ ...state, started: true });
navigate(`${current.href}?setupstep=${current.id}`);
}}
/>
</div>
)}
</VStack>
</div>
{user?.name ? (
<Text type="body" size="xsm" color="secondary">
Signed in as {user.name}. Your branch is set by your store administrator.
</Text>
) : null}
</VStack>
</StepFrame>
</PageBody>
);
}
function Guidance({
icon,
title,
body,
tone,
}: {
icon: React.ReactNode;
title: string;
body: string;
tone?: 'warn';
}) {
return (
<div className="ob-guide" {...(tone ? { 'data-tone': tone } : {})}>
<span className="ob-guide-icon" aria-hidden>
{icon}
</span>
<VStack gap={0.5} style={{ minWidth: 0 }}>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{body}
</Text>
</VStack>
</div>
);
}