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; } 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', title: 'Complete your shop profile', todo: 'Add your shop photo and licence — this is what shoppers see.', cta: 'Add your shop details', done: isProfileComplete(shop ?? {}), href: '/admin/profile', }, { id: 'people', 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.some(isUnplaced) ? { detail: `${people.filter(isUnplaced).length} not at a shop yet` } : {}), }, { id: 'branch', 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 ? { detail: `${branches.length}` } : {}), }, { id: 'products', 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', 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', 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', 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; }