changes
This commit is contained in:
@@ -24,7 +24,7 @@ import { Button } from '@astryxdesign/core/Button';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { ArrowRight, Check, Sparkles } from 'lucide-react';
|
||||
import { ArrowRight, Check, ChevronDown, ChevronUp, Info, Sparkles } from 'lucide-react';
|
||||
import type { SetupStep } from '@/features/store-admin/setupSteps';
|
||||
import {
|
||||
declineTour,
|
||||
@@ -49,6 +49,9 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const [tour, setTour] = useState<TourState>(() => readTour(userid, tenantid));
|
||||
/* Sticky across steps. Somebody who opened the guide once wants it for the
|
||||
next step too; making them reopen it seven times teaches them not to. */
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
/* The step being walked through: the first that is neither done nor skipped.
|
||||
Derived on every render, which is what makes completing the work advance
|
||||
@@ -95,7 +98,7 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
|
||||
return (
|
||||
<StartDialog
|
||||
stepCount={steps.length}
|
||||
firstStep={steps.find((step) => !step.done)?.title ?? ''}
|
||||
steps={steps}
|
||||
onStart={() => {
|
||||
walkedTo.current = null;
|
||||
setTour(startTour(userid, tenantid));
|
||||
@@ -140,6 +143,18 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
|
||||
</HStack>
|
||||
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
{/* Collapsed by default. The bar sits on every page for the whole
|
||||
walkthrough, so a permanently open panel would be a paragraph
|
||||
between the header and the work on every screen. Open, it stays
|
||||
open across steps — somebody who wants the detail wants it for
|
||||
all of them. */}
|
||||
<Button
|
||||
label={isOpen ? 'Hide guide' : 'How do I do this?'}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
endContent={isOpen ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
/>
|
||||
{/* Only when they have wandered off. Following the tour puts them
|
||||
on the right page already, and a button that does nothing is
|
||||
worse than no button. */}
|
||||
@@ -171,21 +186,84 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{isOpen ? <StepGuide step={focus} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail for one step: why it matters, what to do, and the trap.
|
||||
*
|
||||
* Three parts rather than a paragraph, because they answer different questions
|
||||
* and people arrive wanting different ones. Somebody who already knows what to
|
||||
* do wants the gotcha; somebody who does not wants the numbered actions; the
|
||||
* reason is what makes a merchant bother at all.
|
||||
*
|
||||
* Every "why" here is a failure that has actually happened on this platform,
|
||||
* not a generality — an unpriced catalogue that never sold, a category nobody
|
||||
* set, a sheet sitting unreviewed. That is what makes them worth reading.
|
||||
*/
|
||||
function StepGuide({ step }: { step: SetupStep }) {
|
||||
return (
|
||||
<VStack
|
||||
gap={1.5}
|
||||
style={{
|
||||
borderTop: '1px solid var(--color-line)',
|
||||
padding: '14px 0 16px',
|
||||
maxWidth: '72ch',
|
||||
}}
|
||||
>
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
|
||||
{step.why}
|
||||
</Text>
|
||||
|
||||
<VStack gap={0.5}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
What to do
|
||||
</Text>
|
||||
<ol style={{ margin: 0, paddingLeft: 20, display: 'grid', gap: 4 }}>
|
||||
{step.how.map((line) => (
|
||||
<li key={line}>
|
||||
<Text type="body" size="sm" style={{ lineHeight: 1.6 }}>
|
||||
{line}
|
||||
</Text>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</VStack>
|
||||
|
||||
{step.gotcha ? (
|
||||
<HStack gap={1} align="start">
|
||||
<Info
|
||||
size={14}
|
||||
style={{ color: 'var(--color-warning, #b7860b)', flex: 'none', marginTop: 3 }}
|
||||
/>
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
{step.gotcha}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The offer ───────────────────────────────────────────────────────────── */
|
||||
|
||||
function StartDialog({
|
||||
stepCount,
|
||||
firstStep,
|
||||
steps,
|
||||
onStart,
|
||||
onCancel,
|
||||
}: {
|
||||
stepCount: number;
|
||||
firstStep: string;
|
||||
steps: readonly SetupStep[];
|
||||
onStart: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
@@ -233,14 +311,43 @@ function StartDialog({
|
||||
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}
|
||||
{/* What the steps actually are, before agreeing to be walked through
|
||||
them. "7 short steps" alone asks somebody to commit to an unknown
|
||||
amount of work; the list is what makes it an informed yes. Already
|
||||
finished ones are shown ticked, so the two a new tenant gets free
|
||||
from onboarding are visible rather than a surprise. */}
|
||||
<VStack gap={0.5} style={{ maxHeight: 240, overflowY: 'auto' }}>
|
||||
{steps.map((step) => (
|
||||
<HStack key={step.id} gap={1} align="center">
|
||||
{step.done ? (
|
||||
<Check
|
||||
size={14}
|
||||
style={{ color: 'var(--color-success, #10b981)', flex: 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 14,
|
||||
textAlign: 'center',
|
||||
flex: 'none',
|
||||
color: 'var(--color-ink-4)',
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
●
|
||||
</span>
|
||||
)}
|
||||
<Text
|
||||
type="body"
|
||||
size="sm"
|
||||
{...(step.done ? { color: 'secondary' as const } : {})}
|
||||
>
|
||||
{step.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
<HStack gap={1} justify="end">
|
||||
<Button label="Not now" variant="ghost" onClick={onCancel} />
|
||||
|
||||
83
src/features/setup/stepGuidance.test.ts
Normal file
83
src/features/setup/stepGuidance.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Every step has to be able to explain itself.
|
||||
*
|
||||
* The walkthrough is for somebody who has never used the console, and a step
|
||||
* saying "Price and release them" with no more than that is a label, not
|
||||
* guidance. These check the content exists and is worth reading — a step that
|
||||
* ships with an empty `why` or a single vague action is the failure mode, and
|
||||
* it is invisible until a real merchant is stuck in front of it.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { setupSteps } from '@/features/store-admin/setupSteps';
|
||||
import { storeUserSteps } from './storeUserSteps';
|
||||
|
||||
const merchant = setupSteps({ shop: {}, people: [], branches: [], products: [] });
|
||||
const branchUser = storeUserSteps({ user: null, hasBranch: false, productCount: 0 });
|
||||
|
||||
test('the merchant has seven steps and the branch user three', () => {
|
||||
assert.equal(merchant.length, 7);
|
||||
assert.equal(branchUser.length, 3);
|
||||
});
|
||||
|
||||
for (const [role, steps] of [
|
||||
['merchant', merchant],
|
||||
['branch user', branchUser],
|
||||
] as const) {
|
||||
test(`every ${role} step explains why it matters`, () => {
|
||||
for (const step of steps) {
|
||||
assert.ok(step.why.length > 40, `${step.id}: "why" is too thin to be worth reading`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`every ${role} step lists concrete actions`, () => {
|
||||
for (const step of steps) {
|
||||
assert.ok(step.how.length >= 2, `${step.id}: needs more than one action`);
|
||||
for (const line of step.how) {
|
||||
assert.ok(line.trim().length > 8, `${step.id}: an action line is empty or a stub`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`every ${role} step has a button naming the action`, () => {
|
||||
for (const step of steps) {
|
||||
assert.ok(step.cta.trim().length > 0, `${step.id}: no call to action`);
|
||||
// Named for the work, not the destination. "Go to profile" tells somebody
|
||||
// where a link points; "Add your shop details" tells them what it does.
|
||||
assert.ok(!/^go to /i.test(step.cta), `${step.id}: the button names a place, not an action`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`every ${role} step points somewhere real`, () => {
|
||||
for (const step of steps) {
|
||||
assert.match(step.href, /^\/(admin|store)\//, `${step.id}: href is not a console route`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
The traps are the most valuable part and the easiest to let rot. Each of these
|
||||
is a failure that has actually happened here — a sheet waiting unreviewed, a
|
||||
category nobody set, a login shared by a whole counter — so a step that quietly
|
||||
loses its gotcha loses the thing a merchant most needed to be told.
|
||||
*/
|
||||
test('the merchant steps that have a known trap still carry it', () => {
|
||||
for (const id of ['profile', 'people', 'branch', 'products', 'priced', 'stocked', 'onsale']) {
|
||||
const step = merchant.find((entry) => entry.id === id);
|
||||
assert.ok(step?.gotcha && step.gotcha.length > 30, `${id}: lost its gotcha`);
|
||||
}
|
||||
});
|
||||
|
||||
// The one every shop has been caught by: priced, released, stocked — and
|
||||
// invisible, because no category was set.
|
||||
test('the final step names the category gate', () => {
|
||||
const onsale = merchant.find((step) => step.id === 'onsale');
|
||||
assert.match(String(onsale?.gotcha), /categor/i);
|
||||
});
|
||||
|
||||
// And the wait nobody can control: a sheet sits in the catalogue service's
|
||||
// review queue for hours, which reads as neglect unless it is said.
|
||||
test('the products step names the review wait', () => {
|
||||
const products = merchant.find((step) => step.id === 'products');
|
||||
assert.match(String(products?.gotcha), /review|queued/i);
|
||||
});
|
||||
@@ -38,6 +38,15 @@ export function storeUserSteps(input: {
|
||||
return [
|
||||
{
|
||||
id: 'profile',
|
||||
why:
|
||||
"Your shop sees who did what — took a payment, raised a stock request, served a customer. An account still named after the branch attributes all of it to nobody.",
|
||||
how: [
|
||||
"Your name, as your colleagues would say it",
|
||||
"Your mobile — how the shop reaches you",
|
||||
"Your email is your sign-in and can be changed here too",
|
||||
],
|
||||
gotcha:
|
||||
"Your branch is not on this page. Which shop you work at is set by your store administrator — ask them if it needs to change.",
|
||||
title: 'Tell us who you are',
|
||||
todo: 'Add your name and mobile so your shop knows whose account this is.',
|
||||
cta: 'Add my details',
|
||||
@@ -46,6 +55,15 @@ export function storeUserSteps(input: {
|
||||
},
|
||||
{
|
||||
id: 'products',
|
||||
why:
|
||||
"This is your shop’s shelf: what is priced, what is in stock, and what a customer can actually buy right now.",
|
||||
how: [
|
||||
"Every row shows its price and live stock balance",
|
||||
"“No stock” means the shelf balance is zero — it will not sell",
|
||||
"Ask head office for more through a stock request",
|
||||
],
|
||||
gotcha:
|
||||
"The list is your branch only. Another shop’s stock is not yours to sell.",
|
||||
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',
|
||||
@@ -55,6 +73,13 @@ export function storeUserSteps(input: {
|
||||
},
|
||||
{
|
||||
id: 'onsale',
|
||||
why:
|
||||
"Sales puts counter takings and app orders side by side for your branch, so you can answer “how did today go” without adding two numbers together.",
|
||||
how: [
|
||||
"Counter and app are shown separately, never summed",
|
||||
"Pick a date range at the top",
|
||||
"A bill that has not synced from the till shows as pending",
|
||||
],
|
||||
title: 'Know where the day’s takings are',
|
||||
todo: 'Sales shows counter and app orders together, for this branch.',
|
||||
cta: 'Open sales',
|
||||
|
||||
@@ -49,6 +49,24 @@ export interface SetupStep {
|
||||
href: string;
|
||||
/** A real count, when there is one worth showing. */
|
||||
detail?: string;
|
||||
|
||||
/**
|
||||
* Why this step is worth doing — the consequence of not doing it.
|
||||
*
|
||||
* Every one of these is a failure somebody has actually had on this platform,
|
||||
* not a generalisation. A merchant who understands that an unpriced product
|
||||
* cannot be rung up does the work; one told to "complete step 5" does not.
|
||||
*/
|
||||
why: string;
|
||||
/** The actual actions, in order. Short enough to follow without re-reading. */
|
||||
how: readonly string[];
|
||||
/**
|
||||
* The trap.
|
||||
*
|
||||
* Optional, and only present where there IS one — a gotcha invented to fill a
|
||||
* field teaches people to stop reading them.
|
||||
*/
|
||||
gotcha?: string;
|
||||
}
|
||||
|
||||
export interface SetupInput {
|
||||
@@ -72,6 +90,16 @@ export function setupSteps(input: SetupInput): SetupStep[] {
|
||||
return [
|
||||
{
|
||||
id: 'profile',
|
||||
why:
|
||||
"Shoppers see your photo, name and licence before they see a single product. An FSSAI or trade licence is a display requirement for a food business — and across every shop on the platform not one had entered it, because until now nothing could save it.",
|
||||
how: [
|
||||
"Add a shop photo — the picture shoppers see beside your shop",
|
||||
"Enter your FSSAI or trade licence number",
|
||||
"Write a line about what you sell",
|
||||
"Check the phone and address below are still right",
|
||||
],
|
||||
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.',
|
||||
cta: 'Add your shop details',
|
||||
@@ -80,6 +108,15 @@ export function setupSteps(input: SetupInput): SetupStep[] {
|
||||
},
|
||||
{
|
||||
id: 'people',
|
||||
why:
|
||||
"Every branch needs somebody who can sign in and run it. Adding people first is what lets you choose who runs a shop, rather than accepting a login named after the building.",
|
||||
how: [
|
||||
"Users & access, then Add person",
|
||||
"Their email is how they sign in",
|
||||
"Leave the shop as “Not at a shop yet” if their branch does not exist",
|
||||
],
|
||||
gotcha:
|
||||
"You never issue a password. They set their own the first time they sign in.",
|
||||
title: 'Add your people',
|
||||
todo: 'Add whoever will run your shops. You can add them before a branch exists.',
|
||||
cta: 'Add a person',
|
||||
@@ -91,6 +128,15 @@ export function setupSteps(input: SetupInput): SetupStep[] {
|
||||
},
|
||||
{
|
||||
id: 'branch',
|
||||
why:
|
||||
"Stock, prices and the sales ledger all hang off an outlet. Nothing can be sold until there is one to sell it from.",
|
||||
how: [
|
||||
"Choose who runs it from the people you have added",
|
||||
"Set the address and opening hours",
|
||||
"Set the delivery radius — it decides which addresses the app will serve",
|
||||
],
|
||||
gotcha:
|
||||
"Pick a person. Leave it blank and a login is created named after the shop, on the shop email — and everyone at that counter shares it.",
|
||||
title: 'Open your first branch',
|
||||
todo: 'Commission an outlet and say who runs it.',
|
||||
cta: 'Open a branch',
|
||||
@@ -100,6 +146,15 @@ export function setupSteps(input: SetupInput): SetupStep[] {
|
||||
},
|
||||
{
|
||||
id: 'products',
|
||||
why:
|
||||
"An empty catalogue is an empty shop. Nothing else in this list can be done until there are products to price and stock.",
|
||||
how: [
|
||||
"Inventory ▸ Catalogue to pick from the shared catalogue",
|
||||
"Or Inventory ▸ Upload sheet for your own list",
|
||||
"A sheet needs a product name; price and opening stock are read if present",
|
||||
],
|
||||
gotcha:
|
||||
"An uploaded sheet goes to the catalogue service for review first — usually a few hours. It is queued, not stuck. When it finishes you still press “Put on the shelf” to price and stock it.",
|
||||
title: 'Get your products in',
|
||||
todo: 'Import from the catalogue, or upload your own spreadsheet.',
|
||||
cta: 'Add products',
|
||||
@@ -117,6 +172,15 @@ export function setupSteps(input: SetupInput): SetupStep[] {
|
||||
},
|
||||
{
|
||||
id: 'priced',
|
||||
why:
|
||||
"A product with no price cannot be rung up at a till, and one that has not been released reaches no shop at all. Both look identical on the shelf.",
|
||||
how: [
|
||||
"Inventory ▸ Products, starting with the rows marked “Not ready”",
|
||||
"Each row says what is blocking it",
|
||||
"Set a price, then release",
|
||||
],
|
||||
gotcha:
|
||||
"Releasing sends a product to EVERY branch you run. Each keeps its own price — releasing several together will not overwrite them.",
|
||||
title: 'Price and release them',
|
||||
todo: 'A product with no price cannot be rung up, and one not released reaches no shop.',
|
||||
cta: 'Price and release',
|
||||
@@ -128,6 +192,15 @@ export function setupSteps(input: SetupInput): SetupStep[] {
|
||||
},
|
||||
{
|
||||
id: 'stocked',
|
||||
why:
|
||||
"The app shows only what you actually hold. A priced, released product with a balance of zero is invisible to shoppers.",
|
||||
how: [
|
||||
"Inventory ▸ Stock",
|
||||
"Record what is on the shelf now",
|
||||
"A branch asks head office for more through a stock request",
|
||||
],
|
||||
gotcha:
|
||||
"Stock is counted per outlet. Ten cases at one branch does not make the product sellable at another.",
|
||||
title: 'Put stock on the shelf',
|
||||
todo: 'Record what you actually hold — nothing sells at a balance of zero.',
|
||||
cta: 'Add stock',
|
||||
@@ -139,6 +212,15 @@ export function setupSteps(input: SetupInput): SetupStep[] {
|
||||
},
|
||||
{
|
||||
id: 'onsale',
|
||||
why:
|
||||
"This is the finish line: a shopper can find the product and buy it. Three things have to be true at once, and a product can look perfect while failing one.",
|
||||
how: [
|
||||
"It has a category — without one the app cannot list it at all",
|
||||
"It is on this branch’s shelf",
|
||||
"Its stock balance is above zero",
|
||||
],
|
||||
gotcha:
|
||||
"Category is the one that catches people. A product priced, released and stocked but filed under no category is invisible to every shopper.",
|
||||
title: 'See it in the app',
|
||||
todo: 'Once a product is priced, released, stocked and in a category, shoppers can buy it.',
|
||||
cta: 'Check the app view',
|
||||
|
||||
Reference in New Issue
Block a user