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

@@ -58,6 +58,7 @@ const StoreProductsPage = named('StoreProductsPage', () => import('@/features/st
const StoreCustomersPage = named('StoreCustomersPage', () => import('@/features/store-user/pages/StoreCustomersPage'));
const StoreStaffPage = named('StoreStaffPage', () => import('@/features/store-user/pages/StoreStaffPage'));
const StoreAccountPage = named('StoreAccountPage', () => import('@/features/store-user/pages/StoreAccountPage'));
const StoreSetupPage = named('StoreSetupPage', () => import('@/features/store-user/pages/StoreSetupPage'));
const StoreUploadsPage = named('StoreUploadsPage', () => import('@/features/store-user/pages/StoreUploadsPage'));
function RouteFallback() {
@@ -168,6 +169,9 @@ export function App() {
<Route path="staff" element={<StoreStaffPage />} />
<Route path="uploads" element={<StoreUploadsPage />} />
<Route path="account" element={<StoreAccountPage />} />
{/* The branch user's half of setup. Same route shape as the merchant's
`/admin/onboarding`, so the two logins are reached the same way. */}
<Route path="setup" element={<StoreSetupPage />} />
<Route path="*" element={<Navigate to="/store/console" replace />} />
</Route>

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>
);
}

View File

@@ -1719,3 +1719,70 @@ main {
.qty-input:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }
/* ── Setup: the guidance blocks on a branch user's step ────────────────────
A branch user's steps do not collect anything — the work happens on another
screen — so what fills the frame is the explanation: why it matters, what
they will do there, and what catches people out. Three of the same block,
distinguished by tone rather than by three different shapes.
Built from the tokens already here: brand for the neutral case, the warning
ochre for the caveat, success green once a step is finished. No new palette
and no card of its own — the frame is already a card, and a card inside a
card is how a simple page starts looking busy. */
.ob-guide {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
align-items: start;
padding: 14px 16px;
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--color-surface-subtle);
}
.ob-guide[data-tone='warn'] {
border-color: var(--color-warning, #b7860b);
background: color-mix(in oklab, var(--color-warning, #b7860b) 7%, transparent);
}
.ob-guide[data-tone='done'] {
border-color: var(--color-success, #1f9d55);
background: var(--color-success-tint, #eaf7ef);
}
.ob-guide-icon {
width: 26px;
height: 26px;
flex: none;
border-radius: 999px;
display: grid;
place-items: center;
background: var(--color-brand);
color: #fff;
}
.ob-guide[data-tone='warn'] .ob-guide-icon { background: var(--color-warning, #b7860b); }
.ob-guide[data-tone='done'] .ob-guide-icon { background: var(--color-success, #1f9d55); }
.ob-guide-list {
margin: 2px 0 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 4px;
font: 400 13.5px/1.6 var(--font-sans);
color: var(--color-ink-3);
}
/* The tick that closes the flow. Same mark the merchant's Done step uses, so
both roles finish on the same note. */
.ob-done-mark {
width: 56px;
height: 56px;
border-radius: 999px;
display: grid;
place-items: center;
background: var(--color-success-tint, #eaf7ef);
color: var(--color-success, #1f9d55);
}