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 AdminUploadsPage = named('UploadsPage', () => import('@/features/store-admin/pages/UploadsPage'));
|
||||
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
|
||||
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="uploads" element={<AdminUploadsPage />} />
|
||||
<Route path="profile" element={<ShopProfilePage />} />
|
||||
<Route path="setup" element={<StoreAdminSetupPage />} />
|
||||
{/* Catches `/admin/dashboard` and anything else that does not resolve.
|
||||
Without this, an unknown sub-path escapes to the global `*`, which
|
||||
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="staff" element={<StoreStaffPage />} />
|
||||
<Route path="uploads" element={<StoreUploadsPage />} />
|
||||
<Route path="setup" element={<StoreUserSetupPage />} />
|
||||
<Route path="account" element={<StoreAccountPage />} />
|
||||
<Route path="*" element={<Navigate to="/store/console" replace />} />
|
||||
</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[];
|
||||
/** 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 }: SetupTourProps) {
|
||||
export function SetupTour({ userid, tenantid, steps, home, setupHref }: SetupTourProps) {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const [tour, setTour] = useState<TourState>(() => readTour(userid, tenantid));
|
||||
@@ -102,6 +104,10 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
|
||||
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.
|
||||
navigate(setupHref);
|
||||
}}
|
||||
onCancel={() => setTour(declineTour(userid, tenantid))}
|
||||
/>
|
||||
@@ -128,7 +134,7 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
|
||||
justify="between"
|
||||
gap={2}
|
||||
wrap="wrap"
|
||||
style={{ padding: '10px 0' }}
|
||||
style={{ padding: '10px 0 0' }}
|
||||
>
|
||||
<HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
|
||||
<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
|
||||
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"
|
||||
@@ -167,27 +179,34 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
|
||||
onClick={() => navigate(focus.href)}
|
||||
/>
|
||||
) : 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>
|
||||
|
||||
{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>
|
||||
);
|
||||
|
||||
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
|
||||
* INSIDE `BranchScopeProvider` to read the branches — and because the two roles
|
||||
* feed the tour from entirely different data, which a single shared mount would
|
||||
* turn into a pile of conditionals.
|
||||
* feed the walkthrough from entirely different data, which a single shared
|
||||
* mount would turn into a pile of conditionals.
|
||||
*
|
||||
* Every hook here already existed. The walkthrough needed no new backend at
|
||||
* all, which is most of the reason it was worth building.
|
||||
* Every hook it uses already existed. The walkthrough needed no new backend.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
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 { useStoreAdminSteps } from './useSetupSteps';
|
||||
import { SetupTour } from './SetupTour';
|
||||
|
||||
export function StoreAdminTour() {
|
||||
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 — 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;
|
||||
const { steps, isLoading, tenantid, userid } = useStoreAdminSteps();
|
||||
if (isLoading || !userid) return null;
|
||||
|
||||
return (
|
||||
<SetupTour
|
||||
userid={user.userid}
|
||||
userid={userid}
|
||||
tenantid={tenantid}
|
||||
steps={steps}
|
||||
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,
|
||||
* 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 the "No store assigned" screen, which is the whole of what they can act
|
||||
* on; a walkthrough on top of it would be a second thing to read and no second
|
||||
* thing to do.
|
||||
* meets "No store assigned", which is the whole of what they can act on.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||
import { useLocationProducts } from '@/queries/hooks';
|
||||
import { storeUserSteps } from './storeUserSteps';
|
||||
import { useStoreUserSteps } from './useSetupSteps';
|
||||
import { SetupTour } from './SetupTour';
|
||||
|
||||
export function StoreUserTour() {
|
||||
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],
|
||||
);
|
||||
|
||||
if (!user?.userid || !tenantid || !current?.locationid) return null;
|
||||
if (products.isLoading) return null;
|
||||
const { steps, isLoading, tenantid, userid } = useStoreUserSteps();
|
||||
if (isLoading || !userid) return null;
|
||||
|
||||
return (
|
||||
<SetupTour
|
||||
userid={user.userid}
|
||||
userid={userid}
|
||||
tenantid={tenantid}
|
||||
steps={steps}
|
||||
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;
|
||||
/** A real count, when there is one worth showing. */
|
||||
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.
|
||||
@@ -101,7 +111,7 @@ export function setupSteps(input: SetupInput): SetupStep[] {
|
||||
gotcha:
|
||||
"A field left blank is not changed. Saving will never erase something you did not fill in.",
|
||||
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',
|
||||
done: isProfileComplete(shop ?? {}),
|
||||
href: '/admin/profile',
|
||||
@@ -122,6 +132,9 @@ export function setupSteps(input: SetupInput): SetupStep[] {
|
||||
cta: 'Add a person',
|
||||
done: people.length > 0,
|
||||
href: '/admin/users',
|
||||
...(people.length > 0
|
||||
? { doneNote: 'Your own account was created when the shop was set up.' }
|
||||
: {}),
|
||||
...(people.some(isUnplaced)
|
||||
? { detail: `${people.filter(isUnplaced).length} not at a shop yet` }
|
||||
: {}),
|
||||
@@ -142,6 +155,9 @@ export function setupSteps(input: SetupInput): SetupStep[] {
|
||||
cta: 'Open a branch',
|
||||
done: branches.length > 0,
|
||||
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}` } : {}),
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user