skip button
This commit is contained in:
253
src/features/setup/SetupTour.tsx
Normal file
253
src/features/setup/SetupTour.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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, 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));
|
||||
|
||||
/* 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}
|
||||
firstStep={steps.find((step) => !step.done)?.title ?? ''}
|
||||
onStart={() => {
|
||||
walkedTo.current = null;
|
||||
setTour(startTour(userid, tenantid));
|
||||
}}
|
||||
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' }}
|
||||
>
|
||||
<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">
|
||||
{/* 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}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The offer ───────────────────────────────────────────────────────────── */
|
||||
|
||||
function StartDialog({
|
||||
stepCount,
|
||||
firstStep,
|
||||
onStart,
|
||||
onCancel,
|
||||
}: {
|
||||
stepCount: number;
|
||||
firstStep: string;
|
||||
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>
|
||||
|
||||
{firstStep ? (
|
||||
<HStack gap={1} align="center">
|
||||
<Check size={14} style={{ color: 'var(--color-ink-4)', flex: 'none' }} />
|
||||
<Text type="body" size="sm">
|
||||
First: {firstStep}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack gap={1} justify="end">
|
||||
<Button label="Not now" variant="ghost" onClick={onCancel} />
|
||||
<Button label="Start setup" variant="primary" onClick={onStart} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
src/features/setup/StoreAdminTour.tsx
Normal file
71
src/features/setup/StoreAdminTour.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* The merchant's walkthrough, 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.
|
||||
*
|
||||
* Every hook here already existed. The walkthrough needed no new backend at
|
||||
* all, which is most of the reason it was worth building.
|
||||
*/
|
||||
|
||||
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 { 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;
|
||||
|
||||
return (
|
||||
<SetupTour
|
||||
userid={user.userid}
|
||||
tenantid={tenantid}
|
||||
steps={steps}
|
||||
home="/admin/console"
|
||||
/>
|
||||
);
|
||||
}
|
||||
48
src/features/setup/StoreUserTour.tsx
Normal file
48
src/features/setup/StoreUserTour.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The branch user's walkthrough, mounted in the Store user shell.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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 { 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;
|
||||
|
||||
return (
|
||||
<SetupTour
|
||||
userid={user.userid}
|
||||
tenantid={tenantid}
|
||||
steps={steps}
|
||||
home="/store/console"
|
||||
/>
|
||||
);
|
||||
}
|
||||
68
src/features/setup/storeUserSteps.ts
Normal file
68
src/features/setup/storeUserSteps.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { SetupStep } from '@/features/store-admin/setupSteps';
|
||||
import type { SessionUser } from '@/auth/roles';
|
||||
|
||||
/**
|
||||
* What a Store user is walked through on their first sign-in.
|
||||
*
|
||||
* Much shorter than the merchant's, and honestly so. A branch user cannot open
|
||||
* outlets, hire anybody or edit the business — those belong to their store
|
||||
* administrator, and putting them in a tour would be walking somebody through
|
||||
* work they are not allowed to do.
|
||||
*
|
||||
* So there is exactly one real task: their own details. The other two are
|
||||
* orientation — where the shop's products live, and where the day's takings
|
||||
* are — because the thing a new counter user actually needs is to know which
|
||||
* screen answers which question.
|
||||
*
|
||||
* The branch is deliberately NOT a step. Which shop somebody works at is set by
|
||||
* their store admin on the people screen; a step they cannot complete would sit
|
||||
* open forever.
|
||||
*/
|
||||
export function storeUserSteps(input: {
|
||||
user: SessionUser | null;
|
||||
/** True once they have a branch — until then there is nothing else to see. */
|
||||
hasBranch: boolean;
|
||||
/** Products visible at their branch. */
|
||||
productCount: number;
|
||||
/** The branch's own name, to spot a login named after the shop. */
|
||||
branchName?: string;
|
||||
}): SetupStep[] {
|
||||
const { user, hasBranch, productCount, branchName } = input;
|
||||
|
||||
// The auto-spawned branch login is named after the OUTLET — "Suriya Store
|
||||
// NSN" — because CreateTenantLocation set `firstname = locationname`. So a
|
||||
// name matching the branch is not a person's name, and this step is not done.
|
||||
const name = (user?.name ?? '').trim();
|
||||
const hasOwnName = name !== '' && name.toLowerCase() !== (branchName ?? '').trim().toLowerCase();
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'profile',
|
||||
title: 'Tell us who you are',
|
||||
todo: 'Add your name and mobile so your shop knows whose account this is.',
|
||||
cta: 'Add my details',
|
||||
done: hasOwnName,
|
||||
href: '/store/account',
|
||||
},
|
||||
{
|
||||
id: 'products',
|
||||
title: 'See what your shop sells',
|
||||
todo: 'Your branch catalogue — what is priced, what is on the shelf, what is out of stock.',
|
||||
cta: 'Open products',
|
||||
done: hasBranch && productCount > 0,
|
||||
href: '/store/products',
|
||||
...(hasBranch && productCount > 0 ? { detail: `${productCount} products` } : {}),
|
||||
},
|
||||
{
|
||||
id: 'onsale',
|
||||
title: 'Know where the day’s takings are',
|
||||
todo: 'Sales shows counter and app orders together, for this branch.',
|
||||
cta: 'Open sales',
|
||||
// Orientation, not a task — it completes by being visited, which the tour
|
||||
// does by walking them to it. Marking it done any other way would be
|
||||
// inventing a fact.
|
||||
done: false,
|
||||
href: '/store/sales',
|
||||
},
|
||||
];
|
||||
}
|
||||
56
src/features/setup/tourState.test.ts
Normal file
56
src/features/setup/tourState.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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, []);
|
||||
});
|
||||
102
src/features/setup/tourState.ts
Normal file
102
src/features/setup/tourState.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user