This commit is contained in:
2026-09-01 13:05:37 +05:30
parent 22c9f45b44
commit 9b24d9ec09
4 changed files with 309 additions and 12 deletions

View File

@@ -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} />

View 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);
});

View File

@@ -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',

View File

@@ -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',