Files
daily_console_web/src/features/onboarding/onboardingState.ts
2026-09-02 11:26:00 +05:30

202 lines
6.2 KiB
TypeScript

/**
* 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<StepId, string> = {
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<string, OnboardingState> {
try {
const raw = localStorage.getItem(KEY);
const parsed: unknown = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === 'object' ? (parsed as Record<string, OnboardingState>) : {};
} 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;
}