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