/** * Where somebody has got to in store setup, and what they have typed so far. * * Two different things are kept, for two different reasons: * * - **Progress** — which steps are finished. Derived from the backend wherever * it can be (a shop with a name and a licence has done Store Information), * because a stored flag can claim work that was never done. What cannot be * derived is *intent*: which steps were deliberately skipped, and whether * setup was finished or abandoned. * - **The draft** — half-typed answers. A form somebody spent five minutes on * must survive a refresh, a closed laptop, or a phone call. This is the only * place drafts live until they are saved. * * Per browser, per person, per shop. Deliberately not synced: an unsent draft * is a private half-thought, and two people editing one shop's setup from * different desks should not overwrite each other's typing. */ const KEY = 'nearle.onboarding'; export type StepId = 'welcome' | 'store' | 'catalogue' | 'inventory' | 'delivery' | 'review' | 'done'; /** The five that appear in the progress indicator. Welcome and Done bracket them. */ export const PROGRESS_STEPS: readonly StepId[] = ['store', 'catalogue', 'inventory', 'delivery', 'review']; export const STEP_LABEL: Record = { welcome: 'Welcome', store: 'Store', catalogue: 'Catalogue', inventory: 'Inventory', delivery: 'Delivery', review: 'Review', done: 'Done', }; export interface StoreDraft { tenantname: string; tenantimage: string; primaryemail: string; primarycontact: string; address: string; city: string; postcode: string; state: string; tenanttype: string; opentime: string; closetime: string; } export interface DeliveryDraft { offersDelivery: boolean | null; radiusKm: string; minOrder: string; charge: string; freeAbove: string; mins: string; startTime: string; endTime: string; } export interface OnboardingState { /** Where to put them back when they return. */ step: StepId; /** Steps they finished, so the indicator can tick them. */ completed: StepId[]; /** Steps they chose to pass over. Never counted as completed. */ skipped: StepId[]; /** True once they have reached the end — the offer is not made again. */ finished: boolean; /** True once they have been offered setup at all. */ started: boolean; store: StoreDraft; delivery: DeliveryDraft; } export const EMPTY_STORE: StoreDraft = { tenantname: '', tenantimage: '', primaryemail: '', primarycontact: '', address: '', city: '', postcode: '', state: '', tenanttype: '', // Sensible for a neighbourhood shop, and both are required — an empty time // picker asks a question the shopkeeper has to think about before they have // decided anything else. opentime: '08:00', closetime: '22:00', }; export const EMPTY_DELIVERY: DeliveryDraft = { offersDelivery: null, radiusKm: '', minOrder: '', charge: '', freeAbove: '', mins: '', startTime: '09:00', endTime: '21:00', }; const EMPTY: OnboardingState = { step: 'welcome', completed: [], skipped: [], finished: false, started: false, store: EMPTY_STORE, delivery: EMPTY_DELIVERY, }; function scope(userid: number, tenantid: number): string { return `${userid}:${tenantid}`; } function readAll(): Record { try { const raw = localStorage.getItem(KEY); const parsed: unknown = raw ? JSON.parse(raw) : {}; return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; } catch { // A private window, cleared site data, or storage the browser refuses. // Losing a draft is a nuisance; throwing on read would take the page down. return {}; } } export function readOnboarding(userid: number, tenantid: number): OnboardingState { const stored = readAll()[scope(userid, tenantid)]; if (!stored) return EMPTY; // Merged over the defaults so a state written by an older build — before a // field existed — does not arrive with `undefined` where a form expects a // string and React switches the input to uncontrolled. return { ...EMPTY, ...stored, store: { ...EMPTY_STORE, ...(stored.store ?? {}) }, delivery: { ...EMPTY_DELIVERY, ...(stored.delivery ?? {}) }, completed: Array.isArray(stored.completed) ? stored.completed : [], skipped: Array.isArray(stored.skipped) ? stored.skipped : [], }; } export function writeOnboarding( userid: number, tenantid: number, next: OnboardingState, ): OnboardingState { const all = readAll(); try { localStorage.setItem(KEY, JSON.stringify({ ...all, [scope(userid, tenantid)]: next })); } catch { /* Storage refused. The change still applies to this session; losing it on reload is a smaller failure than the write throwing mid-keystroke. */ } return next; } /** Mark a step finished and move on. Completing twice is not an error. */ export function completeStep(state: OnboardingState, id: StepId, next: StepId): OnboardingState { return { ...state, step: next, completed: [...new Set([...state.completed, id])], // Finishing a step it was previously skipped clears the skip — the work is // done, and leaving it marked "skipped" would misreport the review page. skipped: state.skipped.filter((entry) => entry !== id), started: true, }; } /** Pass over a step without doing it. Never counts as completed. */ export function skipStep(state: OnboardingState, id: StepId, next: StepId): OnboardingState { return { ...state, step: next, skipped: [...new Set([...state.skipped, id])], started: true, }; } /** * How far along, for the welcome screen and the progress bar. * * Counts only the five real steps. Welcome is not an achievement and Done is * the result of the others, so including either would show progress before * anything had been done. */ export function progressOf(state: OnboardingState): { done: number; total: number } { const done = PROGRESS_STEPS.filter((id) => state.completed.includes(id)).length; return { done, total: PROGRESS_STEPS.length }; } /** The step to resume on: where they left off, unless that is already finished. */ export function resumeStep(state: OnboardingState): StepId { if (state.finished) return 'done'; return state.step; }