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>