Files
daily_console_web/src/features/store-admin/setupSteps.ts
2026-09-02 10:48:47 +05:30

275 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}