setup onboarding page

This commit is contained in:
2026-09-02 11:26:00 +05:30
parent b72bbd2f12
commit 7df8a49e5f
23 changed files with 2490 additions and 417 deletions

View File

@@ -1,302 +0,0 @@
/**
* The whole journey from "signed in" to "selling", on one screen.
*
* The walkthrough started as a strip along the top of the console, and a strip
* is the wrong shape for this. It can show one step, so a merchant sees a
* sentence and a button with no idea what they have agreed to, how much is
* left, or why the thing they just finished was followed by something they were
* never shown. Onboarding completes two of the seven steps before anybody signs
* in, and the strip leapt over both in silence.
*
* So the journey gets a page. Every step is visible with its real state, the
* current one is open with its guidance, and a step that was finished before
* the merchant arrived says so instead of vanishing. The strip stays, but only
* as a way back here while the work is being done on another screen.
*
* Nothing here is stored. Every tick is derived from live data, so the page
* cannot claim work that was not done, and re-reading it after a change is what
* moves it on.
*/
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@astryxdesign/core/Button';
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, Check, Info } from 'lucide-react';
import { PageBody } from '@/components/PageBody';
import { PageHeader } from '@/components/PageHeader';
import type { SetupStep } from '@/features/store-admin/setupSteps';
export interface SetupPageProps {
steps: readonly SetupStep[];
/** Steps the merchant has waved past. Never counted as done. */
skipped: readonly string[];
isLoading?: boolean;
home: string;
onStart: () => void;
onSkip: (id: string) => void;
onExit: () => void;
/** True while the walkthrough is running, which changes the primary action. */
isActive: boolean;
}
export function SetupPage({
steps,
skipped,
isLoading = false,
home,
onStart,
onSkip,
onExit,
isActive,
}: SetupPageProps) {
const navigate = useNavigate();
const focus = useMemo(
() => steps.find((step) => !step.done && !skipped.includes(step.id)) ?? null,
[steps, skipped],
);
const done = steps.filter((step) => step.done).length;
const pct = steps.length === 0 ? 0 : Math.round((done / steps.length) * 100);
if (isLoading) {
return (
<PageBody measure="reading">
<PageHeader title="Set up your shop" />
<Card padding={4} elevation="low">
<Text type="body" color="secondary">
Checking what is already done…
</Text>
</Card>
</PageBody>
);
}
return (
<PageBody measure="reading">
<PageHeader
title="Set up your shop"
count={`${done} of ${steps.length}`}
description={
focus
? 'Work through these and your products will be on sale in the app. You can skip any of them and come back.'
: 'Everything is done — your shop is set up.'
}
/>
{/* Progress as a bar, not just a fraction. "2 of 7" tells somebody how far
they are; a bar tells them at a glance whether this is nearly over. */}
<div
aria-hidden
style={{
height: 4,
borderRadius: 999,
background: 'var(--color-line)',
overflow: 'hidden',
}}
>
<div
style={{
width: `${pct}%`,
height: '100%',
background: 'var(--color-brand)',
transition: 'width .3s ease',
}}
/>
</div>
<VStack gap={0}>
{steps.map((step, index) => (
<StepRow
key={step.id}
step={step}
index={index}
isLast={index === steps.length - 1}
isFocus={step.id === focus?.id}
isSkipped={!step.done && skipped.includes(step.id)}
isActive={isActive}
onGo={() => {
if (!isActive) onStart();
navigate(step.href);
}}
onSkip={() => onSkip(step.id)}
/>
))}
</VStack>
<HStack gap={1.5} wrap="wrap" style={{ paddingTop: 8 }}>
{focus ? (
<Button
label={isActive ? `Continue — ${focus.cta}` : 'Start setup'}
variant="primary"
endContent={<ArrowRight size={14} />}
onClick={() => {
if (!isActive) onStart();
navigate(focus.href);
}}
/>
) : (
<Button label="Go to the console" variant="primary" onClick={() => navigate(home)} />
)}
{isActive ? <Button label="Finish later" variant="ghost" onClick={onExit} /> : null}
</HStack>
</PageBody>
);
}
/* ── One step in the journey ──────────────────────────────────────────────── */
function StepRow({
step,
index,
isLast,
isFocus,
isSkipped,
isActive,
onGo,
onSkip,
}: {
step: SetupStep;
index: number;
isLast: boolean;
isFocus: boolean;
isSkipped: boolean;
isActive: boolean;
onGo: () => void;
onSkip: () => void;
}) {
const marker = step.done
? { bg: 'var(--color-success, #10b981)', fg: '#fff', ring: 'transparent' }
: isFocus
? { bg: 'var(--color-brand)', fg: '#fff', ring: 'transparent' }
: { bg: 'transparent', fg: 'var(--color-ink-4)', ring: 'var(--color-line)' };
return (
<HStack gap={2} align="start">
{/* The rail: a numbered marker with a line joining it to the next, so the
seven read as one sequence rather than seven cards. */}
<VStack gap={0} align="center" style={{ flex: 'none', width: 28 }}>
<span
style={{
width: 28,
height: 28,
borderRadius: 999,
display: 'grid',
placeItems: 'center',
background: marker.bg,
color: marker.fg,
border: `1px solid ${marker.ring}`,
fontSize: 12,
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
}}
>
{step.done ? <Check size={14} /> : index + 1}
</span>
{!isLast ? (
<span
aria-hidden
style={{
width: 1,
flex: 1,
minHeight: isFocus ? 120 : 28,
background: 'var(--color-line)',
}}
/>
) : null}
</VStack>
<VStack gap={0.5} style={{ flex: 1, minWidth: 0, paddingBottom: isLast ? 0 : 18 }}>
<HStack gap={1} align="center" wrap="wrap">
<Text
type="label"
size={isFocus ? 'lg' : 'sm'}
weight="semibold"
{...(step.done || isSkipped ? { color: 'secondary' as const } : {})}
>
{step.title}
</Text>
{step.detail ? (
<Text type="body" size="xsm" color="secondary" hasTabularNumbers>
{step.detail}
</Text>
) : null}
{isSkipped ? (
<Text type="body" size="xsm" color="secondary">
· skipped
</Text>
) : null}
</HStack>
{/* A step finished before the merchant arrived says why. Two of the
seven are done by onboarding, and silently ticking them looks like
the walkthrough inventing progress. */}
{step.done && step.doneNote ? (
<Text type="body" size="xsm" color="secondary">
{step.doneNote}
</Text>
) : null}
{/* Only the current step opens. Seven expanded panels is a manual, not
a walkthrough. */}
{isFocus ? (
<VStack gap={1.5} style={{ paddingTop: 6 }}>
<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}
<HStack gap={1} align="center" wrap="wrap">
<Button
label={step.cta}
variant="primary"
size="sm"
endContent={<ArrowRight size={13} />}
onClick={onGo}
/>
{/* Skipping sits after the action, never before it. */}
{isActive ? (
<Button label="Skip this step" variant="ghost" size="sm" onClick={onSkip} />
) : null}
</HStack>
</VStack>
) : null}
</VStack>
</HStack>
);
}

View File

@@ -43,11 +43,9 @@ export interface SetupTourProps {
steps: readonly SetupStep[];
/** Where "finished" lands. The console, for both roles. */
home: string;
/** The journey page — where the walkthrough starts and can be reviewed. */
setupHref: string;
}
export function SetupTour({ userid, tenantid, steps, home, setupHref }: SetupTourProps) {
export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
const navigate = useNavigate();
const { pathname } = useLocation();
const [tour, setTour] = useState<TourState>(() => readTour(userid, tenantid));
@@ -107,7 +105,6 @@ export function SetupTour({ userid, tenantid, steps, home, setupHref }: SetupTou
// 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.
navigate(setupHref);
}}
onCancel={() => setTour(declineTour(userid, tenantid))}
/>
@@ -154,12 +151,6 @@ export function SetupTour({ userid, tenantid, steps, home, setupHref }: SetupTou
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="All steps"
variant="ghost"
size="sm"
onClick={() => navigate(setupHref)}
/>
<Button
label={isOpen ? 'Hide guide' : 'How do I do this?'}
variant="ghost"

View File

@@ -1,35 +0,0 @@
/**
* The merchant's setup journey, at /admin/setup.
*
* Reachable at any time, not only during the walkthrough — somebody who
* dismissed the offer, or finished half of it last week, needs a way back to
* the list without being asked again.
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { SetupPage } from './SetupPage';
import { useStoreAdminSteps } from './useSetupSteps';
import { endTour, readTour, skipInTour, startTour, type TourState } from './tourState';
export function StoreAdminSetupPage() {
const navigate = useNavigate();
const { steps, isLoading, tenantid, userid } = useStoreAdminSteps();
const [tour, setTour] = useState<TourState>(() => readTour(userid, tenantid));
return (
<SetupPage
steps={steps}
skipped={tour.skipped}
isLoading={isLoading}
isActive={tour.active}
home="/admin/console"
onStart={() => setTour(startTour(userid, tenantid))}
onSkip={(id) => setTour(skipInTour(userid, tenantid, id))}
onExit={() => {
setTour(endTour(userid, tenantid));
navigate('/admin/console');
}}
/>
);
}

View File

@@ -1,28 +0,0 @@
/**
* The merchant's walkthrough strip, mounted in the Store Admin shell.
*
* Its own component rather than props on the shell, because it has to sit
* INSIDE `BranchScopeProvider` to read the branches — and because the two roles
* feed the walkthrough from entirely different data, which a single shared
* mount would turn into a pile of conditionals.
*
* Every hook it uses already existed. The walkthrough needed no new backend.
*/
import { useStoreAdminSteps } from './useSetupSteps';
import { SetupTour } from './SetupTour';
export function StoreAdminTour() {
const { steps, isLoading, tenantid, userid } = useStoreAdminSteps();
if (isLoading || !userid) return null;
return (
<SetupTour
userid={userid}
tenantid={tenantid}
steps={steps}
home="/admin/console"
setupHref="/admin/setup"
/>
);
}

View File

@@ -1,35 +0,0 @@
/**
* The branch user’s setup journey, at /store/setup.
*
* Reachable at any time, not only during the walkthrough — somebody who
* dismissed the offer, or finished half of it last week, needs a way back to
* the list without being asked again.
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { SetupPage } from './SetupPage';
import { useStoreUserSteps } from './useSetupSteps';
import { endTour, readTour, skipInTour, startTour, type TourState } from './tourState';
export function StoreUserSetupPage() {
const navigate = useNavigate();
const { steps, isLoading, tenantid, userid } = useStoreUserSteps();
const [tour, setTour] = useState<TourState>(() => readTour(userid, tenantid));
return (
<SetupPage
steps={steps}
skipped={tour.skipped}
isLoading={isLoading}
isActive={tour.active}
home="/store/console"
onStart={() => setTour(startTour(userid, tenantid))}
onSkip={(id) => setTour(skipInTour(userid, tenantid, id))}
onExit={() => {
setTour(endTour(userid, tenantid));
navigate('/store/console');
}}
/>
);
}

View File

@@ -22,7 +22,6 @@ export function StoreUserTour() {
tenantid={tenantid}
steps={steps}
home="/store/console"
setupHref="/store/setup"
/>
);
}