guide
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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’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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user