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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
src/features/setup/StoreSetupGate.tsx
Normal file
52
src/features/setup/StoreSetupGate.tsx
Normal 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;
|
||||
}
|
||||
46
src/features/setup/StoreSetupReturn.tsx
Normal file
46
src/features/setup/StoreSetupReturn.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
95
src/features/setup/storeSetupState.test.ts
Normal file
95
src/features/setup/storeSetupState.test.ts
Normal 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);
|
||||
});
|
||||
107
src/features/setup/storeSetupState.ts
Normal file
107
src/features/setup/storeSetupState.ts
Normal 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;
|
||||
}
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
334
src/features/store-user/pages/StoreSetupPage.tsx
Normal file
334
src/features/store-user/pages/StoreSetupPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user