202 lines
6.2 KiB
TypeScript
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;
|
|
}
|