setup onboarding page
This commit is contained in:
201
src/features/onboarding/onboardingState.ts
Normal file
201
src/features/onboarding/onboardingState.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user