Files
daily_console_web/src/features/store-admin/setupSteps.ts
2026-09-01 12:32:19 +05:30

177 lines
6.6 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;
}
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',
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;
}