changes
This commit is contained in:
@@ -43,6 +43,8 @@ const UsersPage = named('UsersPage', () => import('@/features/store-admin/pages/
|
|||||||
const TerminalsPage = named('TerminalsPage', () => import('@/features/store-admin/pages/TerminalsPage'));
|
const TerminalsPage = named('TerminalsPage', () => import('@/features/store-admin/pages/TerminalsPage'));
|
||||||
const AdminUploadsPage = named('UploadsPage', () => import('@/features/store-admin/pages/UploadsPage'));
|
const AdminUploadsPage = named('UploadsPage', () => import('@/features/store-admin/pages/UploadsPage'));
|
||||||
const ShopProfilePage = named('ShopProfilePage', () => import('@/features/store-admin/pages/ShopProfilePage'));
|
const ShopProfilePage = named('ShopProfilePage', () => import('@/features/store-admin/pages/ShopProfilePage'));
|
||||||
|
const StoreAdminSetupPage = named('StoreAdminSetupPage', () => import('@/features/setup/StoreAdminSetupPage'));
|
||||||
|
const StoreUserSetupPage = named('StoreUserSetupPage', () => import('@/features/setup/StoreUserSetupPage'));
|
||||||
|
|
||||||
/* The Store user workspace reuses the merchant's four pages, pinned to one
|
/* The Store user workspace reuses the merchant's four pages, pinned to one
|
||||||
branch by `BranchScopeProvider pin=`. Only what a shop does differently is
|
branch by `BranchScopeProvider pin=`. Only what a shop does differently is
|
||||||
@@ -126,6 +128,7 @@ export function App() {
|
|||||||
<Route path="terminals" element={<TerminalsPage />} />
|
<Route path="terminals" element={<TerminalsPage />} />
|
||||||
<Route path="uploads" element={<AdminUploadsPage />} />
|
<Route path="uploads" element={<AdminUploadsPage />} />
|
||||||
<Route path="profile" element={<ShopProfilePage />} />
|
<Route path="profile" element={<ShopProfilePage />} />
|
||||||
|
<Route path="setup" element={<StoreAdminSetupPage />} />
|
||||||
{/* Catches `/admin/dashboard` and anything else that does not resolve.
|
{/* Catches `/admin/dashboard` and anything else that does not resolve.
|
||||||
Without this, an unknown sub-path escapes to the global `*`, which
|
Without this, an unknown sub-path escapes to the global `*`, which
|
||||||
redirects to this role's HOME_ROUTE — and if that is itself an
|
redirects to this role's HOME_ROUTE — and if that is itself an
|
||||||
@@ -157,6 +160,7 @@ export function App() {
|
|||||||
<Route path="terminals" element={<TerminalsPage />} />
|
<Route path="terminals" element={<TerminalsPage />} />
|
||||||
<Route path="staff" element={<StoreStaffPage />} />
|
<Route path="staff" element={<StoreStaffPage />} />
|
||||||
<Route path="uploads" element={<StoreUploadsPage />} />
|
<Route path="uploads" element={<StoreUploadsPage />} />
|
||||||
|
<Route path="setup" element={<StoreUserSetupPage />} />
|
||||||
<Route path="account" element={<StoreAccountPage />} />
|
<Route path="account" element={<StoreAccountPage />} />
|
||||||
<Route path="*" element={<Navigate to="/store/console" replace />} />
|
<Route path="*" element={<Navigate to="/store/console" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
302
src/features/setup/SetupPage.tsx
Normal file
302
src/features/setup/SetupPage.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,9 +43,11 @@ export interface SetupTourProps {
|
|||||||
steps: readonly SetupStep[];
|
steps: readonly SetupStep[];
|
||||||
/** Where "finished" lands. The console, for both roles. */
|
/** Where "finished" lands. The console, for both roles. */
|
||||||
home: string;
|
home: string;
|
||||||
|
/** The journey page — where the walkthrough starts and can be reviewed. */
|
||||||
|
setupHref: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
|
export function SetupTour({ userid, tenantid, steps, home, setupHref }: SetupTourProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [tour, setTour] = useState<TourState>(() => readTour(userid, tenantid));
|
const [tour, setTour] = useState<TourState>(() => readTour(userid, tenantid));
|
||||||
@@ -102,6 +104,10 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
|
|||||||
onStart={() => {
|
onStart={() => {
|
||||||
walkedTo.current = null;
|
walkedTo.current = null;
|
||||||
setTour(startTour(userid, tenantid));
|
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.
|
||||||
|
navigate(setupHref);
|
||||||
}}
|
}}
|
||||||
onCancel={() => setTour(declineTour(userid, tenantid))}
|
onCancel={() => setTour(declineTour(userid, tenantid))}
|
||||||
/>
|
/>
|
||||||
@@ -128,7 +134,7 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
|
|||||||
justify="between"
|
justify="between"
|
||||||
gap={2}
|
gap={2}
|
||||||
wrap="wrap"
|
wrap="wrap"
|
||||||
style={{ padding: '10px 0' }}
|
style={{ padding: '10px 0 0' }}
|
||||||
>
|
>
|
||||||
<HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
|
<HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
|
||||||
<Sparkles size={15} style={{ color: 'var(--color-brand)', flex: 'none' }} />
|
<Sparkles size={15} style={{ color: 'var(--color-brand)', flex: 'none' }} />
|
||||||
@@ -148,6 +154,12 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
|
|||||||
between the header and the work on every screen. Open, it stays
|
between the header and the work on every screen. Open, it stays
|
||||||
open across steps — somebody who wants the detail wants it for
|
open across steps — somebody who wants the detail wants it for
|
||||||
all of them. */}
|
all of them. */}
|
||||||
|
<Button
|
||||||
|
label="All steps"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(setupHref)}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
label={isOpen ? 'Hide guide' : 'How do I do this?'}
|
label={isOpen ? 'Hide guide' : 'How do I do this?'}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -167,27 +179,34 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
|
|||||||
onClick={() => navigate(focus.href)}
|
onClick={() => navigate(focus.href)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<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>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{isOpen ? <StepGuide step={focus} /> : null}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
35
src/features/setup/StoreAdminSetupPage.tsx
Normal file
35
src/features/setup/StoreAdminSetupPage.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,71 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* The merchant's walkthrough, mounted in the Store Admin shell.
|
* 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
|
* 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
|
* INSIDE `BranchScopeProvider` to read the branches — and because the two roles
|
||||||
* feed the tour from entirely different data, which a single shared mount would
|
* feed the walkthrough from entirely different data, which a single shared
|
||||||
* turn into a pile of conditionals.
|
* mount would turn into a pile of conditionals.
|
||||||
*
|
*
|
||||||
* Every hook here already existed. The walkthrough needed no new backend at
|
* Every hook it uses already existed. The walkthrough needed no new backend.
|
||||||
* all, which is most of the reason it was worth building.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useStoreAdminSteps } from './useSetupSteps';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
|
||||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
|
||||||
import { setupSteps } from '@/features/store-admin/setupSteps';
|
|
||||||
import {
|
|
||||||
useLocationProducts,
|
|
||||||
useOwnTenant,
|
|
||||||
useStaff,
|
|
||||||
useUploads,
|
|
||||||
} from '@/queries/hooks';
|
|
||||||
import { SetupTour } from './SetupTour';
|
import { SetupTour } from './SetupTour';
|
||||||
|
|
||||||
export function StoreAdminTour() {
|
export function StoreAdminTour() {
|
||||||
const { user } = useAuth();
|
const { steps, isLoading, tenantid, userid } = useStoreAdminSteps();
|
||||||
const { branches, tenantid } = useBranchScope();
|
if (isLoading || !userid) return null;
|
||||||
|
|
||||||
const shop = useOwnTenant(tenantid || undefined);
|
|
||||||
const people = useStaff(tenantid || undefined);
|
|
||||||
const products = useLocationProducts(tenantid || undefined, undefined, 0, { allBranches: true });
|
|
||||||
const uploads = useUploads(tenantid || undefined);
|
|
||||||
|
|
||||||
/* Drops the catalogue service has not released yet. Without this the
|
|
||||||
products step reads as neglected when it is simply not our turn — the
|
|
||||||
sheet is sitting in their review queue. */
|
|
||||||
const pendingUploads = useMemo(
|
|
||||||
() =>
|
|
||||||
(uploads.data ?? []).filter((receipt) => receipt.laststatus === 'pending' && !receipt.runid)
|
|
||||||
.length,
|
|
||||||
[uploads.data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const steps = useMemo(
|
|
||||||
() =>
|
|
||||||
setupSteps({
|
|
||||||
shop: shop.data,
|
|
||||||
people: people.data ?? [],
|
|
||||||
branches,
|
|
||||||
products: products.data ?? [],
|
|
||||||
pendingUploads,
|
|
||||||
}),
|
|
||||||
[shop.data, people.data, branches, products.data, pendingUploads],
|
|
||||||
);
|
|
||||||
|
|
||||||
/* Nothing is offered until the data has actually arrived. Every step reads
|
|
||||||
as undone while the queries are in flight, so a tour started then would
|
|
||||||
walk somebody through work they had already finished. */
|
|
||||||
const isReady =
|
|
||||||
Boolean(tenantid) && !shop.isLoading && !people.isLoading && !products.isLoading;
|
|
||||||
if (!isReady || !user?.userid) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SetupTour
|
<SetupTour
|
||||||
userid={user.userid}
|
userid={userid}
|
||||||
tenantid={tenantid}
|
tenantid={tenantid}
|
||||||
steps={steps}
|
steps={steps}
|
||||||
home="/admin/console"
|
home="/admin/console"
|
||||||
|
setupHref="/admin/setup"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/features/setup/StoreUserSetupPage.tsx
Normal file
35
src/features/setup/StoreUserSetupPage.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,48 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* The branch user's walkthrough, mounted in the Store user shell.
|
* The branch user's walkthrough strip.
|
||||||
*
|
*
|
||||||
* Three steps, not seven, and honestly so — a counter user cannot open outlets,
|
* 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
|
* hire anybody or edit the business, so walking them through those would be
|
||||||
* showing somebody work they are not allowed to do.
|
* showing somebody work they are not allowed to do.
|
||||||
*
|
*
|
||||||
* Nothing is offered until they have a branch. An unassigned account already
|
* Nothing is offered until they have a branch. An unassigned account already
|
||||||
* meets the "No store assigned" screen, which is the whole of what they can act
|
* meets "No store assigned", which is the whole of what they can act on.
|
||||||
* on; a walkthrough on top of it would be a second thing to read and no second
|
|
||||||
* thing to do.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useStoreUserSteps } from './useSetupSteps';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
|
||||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
|
||||||
import { useLocationProducts } from '@/queries/hooks';
|
|
||||||
import { storeUserSteps } from './storeUserSteps';
|
|
||||||
import { SetupTour } from './SetupTour';
|
import { SetupTour } from './SetupTour';
|
||||||
|
|
||||||
export function StoreUserTour() {
|
export function StoreUserTour() {
|
||||||
const { user } = useAuth();
|
const { steps, isLoading, tenantid, userid } = useStoreUserSteps();
|
||||||
const { current, tenantid } = useBranchScope();
|
if (isLoading || !userid) return null;
|
||||||
const products = useLocationProducts(tenantid || undefined, current?.locationid, 0);
|
|
||||||
|
|
||||||
const steps = useMemo(
|
|
||||||
() =>
|
|
||||||
storeUserSteps({
|
|
||||||
user,
|
|
||||||
hasBranch: Boolean(current?.locationid),
|
|
||||||
productCount: (products.data ?? []).length,
|
|
||||||
...(current?.locationname ? { branchName: current.locationname } : {}),
|
|
||||||
}),
|
|
||||||
[user, current?.locationid, current?.locationname, products.data],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!user?.userid || !tenantid || !current?.locationid) return null;
|
|
||||||
if (products.isLoading) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SetupTour
|
<SetupTour
|
||||||
userid={user.userid}
|
userid={userid}
|
||||||
tenantid={tenantid}
|
tenantid={tenantid}
|
||||||
steps={steps}
|
steps={steps}
|
||||||
home="/store/console"
|
home="/store/console"
|
||||||
|
setupHref="/store/setup"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/features/setup/useSetupSteps.ts
Normal file
86
src/features/setup/useSetupSteps.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* The steps for each role, in one place.
|
||||||
|
*
|
||||||
|
* Both the journey page and the strip along the top need them, computed
|
||||||
|
* identically — two copies of this would drift, and the first symptom would be
|
||||||
|
* a page saying a step is finished while the strip still asks for it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
|
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||||
|
import { setupSteps, type SetupStep } from '@/features/store-admin/setupSteps';
|
||||||
|
import { useLocationProducts, useOwnTenant, useStaff, useUploads } from '@/queries/hooks';
|
||||||
|
import { storeUserSteps } from './storeUserSteps';
|
||||||
|
|
||||||
|
export interface RoleSteps {
|
||||||
|
steps: SetupStep[];
|
||||||
|
isLoading: boolean;
|
||||||
|
tenantid: number;
|
||||||
|
userid: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStoreAdminSteps(): RoleSteps {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { branches, tenantid } = useBranchScope();
|
||||||
|
|
||||||
|
const shop = useOwnTenant(tenantid || undefined);
|
||||||
|
const people = useStaff(tenantid || undefined);
|
||||||
|
const products = useLocationProducts(tenantid || undefined, undefined, 0, { allBranches: true });
|
||||||
|
const uploads = useUploads(tenantid || undefined);
|
||||||
|
|
||||||
|
/* Drops the catalogue service has not released yet. Without this the products
|
||||||
|
step reads as neglected when it is simply not our turn. */
|
||||||
|
const pendingUploads = useMemo(
|
||||||
|
() =>
|
||||||
|
(uploads.data ?? []).filter((receipt) => receipt.laststatus === 'pending' && !receipt.runid)
|
||||||
|
.length,
|
||||||
|
[uploads.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const steps = useMemo(
|
||||||
|
() =>
|
||||||
|
setupSteps({
|
||||||
|
shop: shop.data,
|
||||||
|
people: people.data ?? [],
|
||||||
|
branches,
|
||||||
|
products: products.data ?? [],
|
||||||
|
pendingUploads,
|
||||||
|
}),
|
||||||
|
[shop.data, people.data, branches, products.data, pendingUploads],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
// Nothing is judged until the data has arrived: every step reads as undone
|
||||||
|
// while the queries are in flight, and acting on that would walk somebody
|
||||||
|
// through work they had already finished.
|
||||||
|
isLoading: !tenantid || shop.isLoading || people.isLoading || products.isLoading,
|
||||||
|
tenantid,
|
||||||
|
userid: user?.userid ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStoreUserSteps(): RoleSteps {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { current, tenantid } = useBranchScope();
|
||||||
|
const products = useLocationProducts(tenantid || undefined, current?.locationid, 0);
|
||||||
|
|
||||||
|
const steps = useMemo(
|
||||||
|
() =>
|
||||||
|
storeUserSteps({
|
||||||
|
user,
|
||||||
|
hasBranch: Boolean(current?.locationid),
|
||||||
|
productCount: (products.data ?? []).length,
|
||||||
|
...(current?.locationname ? { branchName: current.locationname } : {}),
|
||||||
|
}),
|
||||||
|
[user, current?.locationid, current?.locationname, products.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
isLoading: !tenantid || !current?.locationid || products.isLoading,
|
||||||
|
tenantid,
|
||||||
|
userid: user?.userid ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -49,6 +49,16 @@ export interface SetupStep {
|
|||||||
href: string;
|
href: string;
|
||||||
/** A real count, when there is one worth showing. */
|
/** A real count, when there is one worth showing. */
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
/**
|
||||||
|
* Why a step was already finished before anybody started.
|
||||||
|
*
|
||||||
|
* Onboarding creates a tenant, its first branch AND its administrator in
|
||||||
|
* one transaction, so two of the seven are done before a merchant ever
|
||||||
|
* signs in. The walkthrough used to leap straight past them, which reads
|
||||||
|
* as the tour skipping steps by itself. Saying what happened costs one
|
||||||
|
* line and removes the whole confusion.
|
||||||
|
*/
|
||||||
|
doneNote?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Why this step is worth doing — the consequence of not doing it.
|
* Why this step is worth doing — the consequence of not doing it.
|
||||||
@@ -101,7 +111,7 @@ export function setupSteps(input: SetupInput): SetupStep[] {
|
|||||||
gotcha:
|
gotcha:
|
||||||
"A field left blank is not changed. Saving will never erase something you did not fill in.",
|
"A field left blank is not changed. Saving will never erase something you did not fill in.",
|
||||||
title: 'Complete your shop profile',
|
title: 'Complete your shop profile',
|
||||||
todo: 'Add your shop photo and licence — this is what shoppers see.',
|
todo: 'Add your shop photo, licence and a line about what you sell.',
|
||||||
cta: 'Add your shop details',
|
cta: 'Add your shop details',
|
||||||
done: isProfileComplete(shop ?? {}),
|
done: isProfileComplete(shop ?? {}),
|
||||||
href: '/admin/profile',
|
href: '/admin/profile',
|
||||||
@@ -122,6 +132,9 @@ export function setupSteps(input: SetupInput): SetupStep[] {
|
|||||||
cta: 'Add a person',
|
cta: 'Add a person',
|
||||||
done: people.length > 0,
|
done: people.length > 0,
|
||||||
href: '/admin/users',
|
href: '/admin/users',
|
||||||
|
...(people.length > 0
|
||||||
|
? { doneNote: 'Your own account was created when the shop was set up.' }
|
||||||
|
: {}),
|
||||||
...(people.some(isUnplaced)
|
...(people.some(isUnplaced)
|
||||||
? { detail: `${people.filter(isUnplaced).length} not at a shop yet` }
|
? { detail: `${people.filter(isUnplaced).length} not at a shop yet` }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -142,6 +155,9 @@ export function setupSteps(input: SetupInput): SetupStep[] {
|
|||||||
cta: 'Open a branch',
|
cta: 'Open a branch',
|
||||||
done: branches.length > 0,
|
done: branches.length > 0,
|
||||||
href: '/admin/branches/new',
|
href: '/admin/branches/new',
|
||||||
|
...(branches.length > 0
|
||||||
|
? { doneNote: 'Your first outlet was opened when the shop was set up.' }
|
||||||
|
: {}),
|
||||||
...(branches.length > 0 ? { detail: `${branches.length}` } : {}),
|
...(branches.length > 0 ? { detail: `${branches.length}` } : {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user