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 { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack'; 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 type { SetupStep } from '@/features/store-admin/setupSteps';
import { import {
declineTour, declineTour,
@@ -49,6 +49,9 @@ export function SetupTour({ userid, tenantid, steps, home }: 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));
/* 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. /* 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 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 ( return (
<StartDialog <StartDialog
stepCount={steps.length} stepCount={steps.length}
firstStep={steps.find((step) => !step.done)?.title ?? ''} steps={steps}
onStart={() => { onStart={() => {
walkedTo.current = null; walkedTo.current = null;
setTour(startTour(userid, tenantid)); setTour(startTour(userid, tenantid));
@@ -140,6 +143,18 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
</HStack> </HStack>
<HStack gap={1} align="center" wrap="wrap"> <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 {/* Only when they have wandered off. Following the tour puts them
on the right page already, and a button that does nothing is on the right page already, and a button that does nothing is
worse than no button. */} worse than no button. */}
@@ -171,21 +186,84 @@ export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
/> />
</HStack> </HStack>
</HStack> </HStack>
{isOpen ? <StepGuide step={focus} /> : null}
</div> </div>
</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 ───────────────────────────────────────────────────────────── */ /* ── The offer ───────────────────────────────────────────────────────────── */
function StartDialog({ function StartDialog({
stepCount, stepCount,
firstStep, steps,
onStart, onStart,
onCancel, onCancel,
}: { }: {
stepCount: number; stepCount: number;
firstStep: string; steps: readonly SetupStep[];
onStart: () => void; onStart: () => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
@@ -233,14 +311,43 @@ function StartDialog({
stop at any point — nothing is locked. stop at any point — nothing is locked.
</Text> </Text>
{firstStep ? ( {/* What the steps actually are, before agreeing to be walked through
<HStack gap={1} align="center"> them. "7 short steps" alone asks somebody to commit to an unknown
<Check size={14} style={{ color: 'var(--color-ink-4)', flex: 'none' }} /> amount of work; the list is what makes it an informed yes. Already
<Text type="body" size="sm"> finished ones are shown ticked, so the two a new tenant gets free
First: {firstStep} 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> </Text>
</HStack> </HStack>
) : null} ))}
</VStack>
<HStack gap={1} justify="end"> <HStack gap={1} justify="end">
<Button label="Not now" variant="ghost" onClick={onCancel} /> <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 [ return [
{ {
id: 'profile', 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', title: 'Tell us who you are',
todo: 'Add your name and mobile so your shop knows whose account this is.', todo: 'Add your name and mobile so your shop knows whose account this is.',
cta: 'Add my details', cta: 'Add my details',
@@ -46,6 +55,15 @@ export function storeUserSteps(input: {
}, },
{ {
id: 'products', 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', title: 'See what your shop sells',
todo: 'Your branch catalogue — what is priced, what is on the shelf, what is out of stock.', todo: 'Your branch catalogue — what is priced, what is on the shelf, what is out of stock.',
cta: 'Open products', cta: 'Open products',
@@ -55,6 +73,13 @@ export function storeUserSteps(input: {
}, },
{ {
id: 'onsale', 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', title: 'Know where the day’s takings are',
todo: 'Sales shows counter and app orders together, for this branch.', todo: 'Sales shows counter and app orders together, for this branch.',
cta: 'Open sales', cta: 'Open sales',

View File

@@ -49,6 +49,24 @@ 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 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 { export interface SetupInput {
@@ -72,6 +90,16 @@ export function setupSteps(input: SetupInput): SetupStep[] {
return [ return [
{ {
id: 'profile', 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', title: 'Complete your shop profile',
todo: 'Add your shop photo and licence — this is what shoppers see.', todo: 'Add your shop photo and licence — this is what shoppers see.',
cta: 'Add your shop details', cta: 'Add your shop details',
@@ -80,6 +108,15 @@ export function setupSteps(input: SetupInput): SetupStep[] {
}, },
{ {
id: 'people', 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', title: 'Add your people',
todo: 'Add whoever will run your shops. You can add them before a branch exists.', todo: 'Add whoever will run your shops. You can add them before a branch exists.',
cta: 'Add a person', cta: 'Add a person',
@@ -91,6 +128,15 @@ export function setupSteps(input: SetupInput): SetupStep[] {
}, },
{ {
id: 'branch', 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', title: 'Open your first branch',
todo: 'Commission an outlet and say who runs it.', todo: 'Commission an outlet and say who runs it.',
cta: 'Open a branch', cta: 'Open a branch',
@@ -100,6 +146,15 @@ export function setupSteps(input: SetupInput): SetupStep[] {
}, },
{ {
id: 'products', 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', title: 'Get your products in',
todo: 'Import from the catalogue, or upload your own spreadsheet.', todo: 'Import from the catalogue, or upload your own spreadsheet.',
cta: 'Add products', cta: 'Add products',
@@ -117,6 +172,15 @@ export function setupSteps(input: SetupInput): SetupStep[] {
}, },
{ {
id: 'priced', 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', title: 'Price and release them',
todo: 'A product with no price cannot be rung up, and one not released reaches no shop.', todo: 'A product with no price cannot be rung up, and one not released reaches no shop.',
cta: 'Price and release', cta: 'Price and release',
@@ -128,6 +192,15 @@ export function setupSteps(input: SetupInput): SetupStep[] {
}, },
{ {
id: 'stocked', 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', title: 'Put stock on the shelf',
todo: 'Record what you actually hold — nothing sells at a balance of zero.', todo: 'Record what you actually hold — nothing sells at a balance of zero.',
cta: 'Add stock', cta: 'Add stock',
@@ -139,6 +212,15 @@ export function setupSteps(input: SetupInput): SetupStep[] {
}, },
{ {
id: 'onsale', 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', title: 'See it in the app',
todo: 'Once a product is priced, released, stocked and in a category, shoppers can buy it.', todo: 'Once a product is priced, released, stocked and in a category, shoppers can buy it.',
cta: 'Check the app view', cta: 'Check the app view',