177 lines
6.6 KiB
TypeScript
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;
|
|
}
|