275 lines
12 KiB
TypeScript
275 lines
12 KiB
TypeScript
import type { Product, TenantInfo, TenantLocation } from '@/api/types';
|
||
import { effectivePrice, isPublished, stockOf } from './productState';
|
||
import { isProfileComplete } from './shopProfile';
|
||
import { isUnplaced } from './staffPlacement';
|
||
import type { StaffInfo } from '@/api/types';
|
||
|
||
/**
|
||
* Getting a shop from "signed in" to "on sale", as a checklist that ticks
|
||
* itself.
|
||
*
|
||
* Every step is DERIVED from live data — never a stored "onboarded" flag. Three
|
||
* things follow from that, and each of them is why the flag would have been
|
||
* wrong:
|
||
*
|
||
* - It cannot lie. A flag says what somebody clicked; this says what is true.
|
||
* - Work done out of order still counts. A merchant who imports products
|
||
* before opening a second branch is not held at step 2.
|
||
* - It comes back. A shop that sells out of everything is genuinely no longer
|
||
* at step 5, and should be told so rather than shown a permanent tick.
|
||
*
|
||
* Measured against Kmart on 31 Aug 2026: branches and products done, priced
|
||
* and released 0 of 2 — a real merchant stuck since July with nothing on screen
|
||
* saying which step they were on.
|
||
*/
|
||
export type SetupStepId =
|
||
| 'profile'
|
||
| 'people'
|
||
| 'branch'
|
||
| 'products'
|
||
| 'priced'
|
||
| 'stocked'
|
||
| 'onsale';
|
||
|
||
export interface SetupStep {
|
||
id: SetupStepId;
|
||
title: string;
|
||
/** What to do, when it is not done. Never shown once it is. */
|
||
todo: string;
|
||
/**
|
||
* The button that starts the work.
|
||
*
|
||
* Named for the action, not the destination — "Add your shop details" rather
|
||
* than "Go to profile". A step somebody is being walked through has to say
|
||
* what pressing it does, or it reads as navigation and gets ignored.
|
||
*/
|
||
cta: string;
|
||
done: boolean;
|
||
/** Where the work happens. */
|
||
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.
|
||
*
|
||
* 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 {
|
||
shop: Partial<TenantInfo> | undefined;
|
||
people: readonly StaffInfo[];
|
||
branches: readonly TenantLocation[];
|
||
products: readonly Product[];
|
||
/** Uploads still waiting on the catalogue service to release them. */
|
||
pendingUploads?: number;
|
||
}
|
||
|
||
export function setupSteps(input: SetupInput): SetupStep[] {
|
||
const { shop, people, branches, products, pendingUploads = 0 } = input;
|
||
|
||
const priced = products.filter((product) => effectivePrice(product) > 0 && isPublished(product));
|
||
const stocked = priced.filter((product) => stockOf(product) > 0);
|
||
// The three gates the customer app applies, together. A product missing any
|
||
// of them is invisible to a shopper however complete it looks here.
|
||
const onSale = stocked.filter((product) => Number(product.categoryid ?? 0) > 0);
|
||
|
||
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, licence and a line about what you sell.',
|
||
cta: 'Add your shop details',
|
||
done: isProfileComplete(shop ?? {}),
|
||
href: '/admin/profile',
|
||
},
|
||
{
|
||
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',
|
||
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` }
|
||
: {}),
|
||
},
|
||
{
|
||
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',
|
||
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}` } : {}),
|
||
},
|
||
{
|
||
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',
|
||
done: products.length > 0,
|
||
href: '/admin/inventory',
|
||
/* The waiting state, said plainly. A spreadsheet sits in the catalogue
|
||
service's review queue until one of THEIR admins releases it — usually
|
||
hours — and a step that just sat un-ticked would read as a mistake the
|
||
merchant had made. */
|
||
...(products.length === 0 && pendingUploads > 0
|
||
? { detail: `${pendingUploads} upload${pendingUploads === 1 ? '' : 's'} waiting for the catalogue service` }
|
||
: products.length > 0
|
||
? { detail: `${products.length}` }
|
||
: {}),
|
||
},
|
||
{
|
||
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',
|
||
done: priced.length > 0,
|
||
href: '/admin/inventory',
|
||
...(products.length > 0 && priced.length < products.length
|
||
? { detail: `${priced.length} of ${products.length}` }
|
||
: {}),
|
||
},
|
||
{
|
||
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',
|
||
done: stocked.length > 0,
|
||
href: '/admin/inventory',
|
||
...(priced.length > 0 && stocked.length < priced.length
|
||
? { detail: `${stocked.length} of ${priced.length}` }
|
||
: {}),
|
||
},
|
||
{
|
||
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',
|
||
done: onSale.length > 0,
|
||
href: '/admin/inventory',
|
||
...(onSale.length > 0 ? { detail: `${onSale.length} on sale` } : {}),
|
||
},
|
||
];
|
||
}
|
||
|
||
/** The step a merchant is actually on — the first unfinished one. */
|
||
export function currentStep(steps: readonly SetupStep[]): SetupStep | null {
|
||
return steps.find((step) => !step.done) ?? null;
|
||
}
|
||
|
||
/**
|
||
* True once the shop is actually selling, and the checklist should disappear.
|
||
*
|
||
* Keyed on the LAST step rather than on every step, and the difference is not
|
||
* cosmetic. Measured across the four live merchants on 31 Aug 2026: R mart had
|
||
* 20 products on sale, Suriya 6, Ragul 8 — all trading perfectly — and all
|
||
* three were missing a licence number, because until this week nothing in the
|
||
* platform could write one. Requiring every step would have put a seven-step
|
||
* "Set up your shop" card on three shops that are already set up.
|
||
*
|
||
* A shop with customers buying from it is not onboarding. The profile gap is
|
||
* real and stays visible on the profile page itself; it is not a reason to tell
|
||
* a trading merchant they have not started.
|
||
*
|
||
* Only Kmart — nothing priced, nothing on sale, stuck since July — sees the
|
||
* card, which is exactly who it was built for.
|
||
*/
|
||
export function isSetupComplete(steps: readonly SetupStep[]): boolean {
|
||
return steps.find((step) => step.id === 'onsale')?.done === true;
|
||
}
|