Files
daily_console_web/src/features/onboarding/OnboardingPage.tsx
2026-09-02 12:29:17 +05:30

334 lines
12 KiB
TypeScript

/**
* Store setup, as a page inside the console rather than a modal over it.
*
* It renders through the shell's `<Outlet />`, so the existing top navigation,
* branding and account menu stay exactly where they are and the flow reads as
* part of the product. No sidebar, no second navigation: the progress belongs
* under the header, and the header is the one the rest of the console uses.
*
* ── What is stored where ────────────────────────────────────────────────────
*
* Answers go to Fiesta as each step is completed — the shop record through
* `tenants/updatetenant`, delivery through `tenants/updatelocation` — so setup
* is not a pile of state that only becomes real at the end. Position and
* half-typed drafts live in the browser, because an unsent draft is not
* something to publish to a shop's record on every keystroke.
*
* ── Why the steps do not block ──────────────────────────────────────────────
*
* Store details are required: nothing else is meaningful without a name and an
* address. Catalogue and inventory are not — a shop whose product list is not
* ready yet should be able to finish and come back, and a wall there is how
* somebody abandons setup on their first afternoon.
*/
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { errorMessage } from '@/api/client';
import { tenantsApi } from '@/api/tenants';
import { useAuth } from '@/auth/AuthContext';
import { PageBody } from '@/components/PageBody';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { useLocationProducts, useOwnTenant } from '@/queries/hooks';
import { StepFrame } from './StepFrame';
import { Stepper } from './Stepper';
import {
completeStep,
progressOf,
readOnboarding,
resumeStep,
skipStep,
writeOnboarding,
type OnboardingState,
type StepId,
} from './onboardingState';
import { isValid, normalisePhone, validateDelivery, validateStoreInfo, type Errors } from './validation';
import { WelcomeStep } from './steps/WelcomeStep';
import { StoreInfoStep } from './steps/StoreInfoStep';
import { CatalogueStep } from './steps/CatalogueStep';
import { InventoryStep } from './steps/InventoryStep';
import { DeliveryStep } from './steps/DeliveryStep';
import { ReviewStep } from './steps/ReviewStep';
import { DoneStep } from './steps/DoneStep';
const HEADINGS: Record<StepId, { title: string; blurb: string }> = {
welcome: { title: 'Set up your store', blurb: '' },
store: { title: "Let's set up your store", blurb: 'Tell us a few details about your shop.' },
catalogue: {
title: 'How would you like to add your products?',
blurb: 'Choose whichever matches what you already have.',
},
inventory: {
title: "Let's set up your stock",
blurb: 'Keep what customers see in step with what is on your shelf.',
},
delivery: {
title: 'Set up your delivery',
blurb: 'Tell us how you would like to get orders to customers.',
},
review: {
title: 'Review your setup',
blurb: 'Check everything over before you finish.',
},
done: { title: 'Setup complete', blurb: '' },
};
/** Forward order. `done` has no next; `welcome` has no back. */
const ORDER: StepId[] = ['welcome', 'store', 'catalogue', 'inventory', 'delivery', 'review', 'done'];
export function OnboardingPage() {
const navigate = useNavigate();
const { user } = useAuth();
const { tenantid, current } = useBranchScope();
const userid = user?.userid ?? 0;
const shop = useOwnTenant(tenantid || undefined);
const products = useLocationProducts(tenantid || undefined, undefined, 0, { allBranches: true });
const [params, setParams] = useSearchParams();
const [state, setState] = useState<OnboardingState>(() => readOnboarding(userid, tenantid));
const [errors, setErrors] = useState<Errors>({});
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
/**
* The shop's own record wins over an empty draft.
*
* Onboarding already collected a name, phone and address, so presenting blank
* fields would ask a merchant to type what the platform is holding two
* screens away. Only fields the draft has not touched are filled, so a
* half-typed answer is never overwritten by a refetch.
*/
useEffect(() => {
const record = shop.data as unknown as Record<string, unknown> | undefined;
if (!record) return;
setState((prev) => {
const seeded = { ...prev.store };
let changed = false;
for (const key of [
'tenantname', 'tenantimage', 'primaryemail', 'primarycontact',
'address', 'city', 'postcode', 'state', 'tenanttype',
] as const) {
if (seeded[key] === '' && typeof record[key] === 'string' && record[key]) {
seeded[key] = record[key] as string;
changed = true;
}
}
return changed ? { ...prev, store: seeded } : prev;
});
}, [shop.data]);
/**
* Coming back from the errand, one step further on.
*
* A merchant sent to Inventory to add products used to have no way back into
* setup but the browser's Back button — which returns them to the step they
* already finished, so setup looked stuck. `SetupReturnBar` sends them here
* with `?advance=<step>` instead, and this is the half that honours it.
*
* The parameter is consumed as it is read. Left in the URL it would re-fire on
* every render and on a refresh, pushing somebody through steps they never
* looked at.
*/
useEffect(() => {
const advance = params.get('advance') as StepId | null;
if (!advance) return;
const carry = new URLSearchParams(params);
carry.delete('advance');
setParams(carry, { replace: true });
setState((prev) =>
writeOnboarding(userid, tenantid, completeStep(prev, advance, nextOf(advance))),
);
}, [params, setParams, userid, tenantid]);
const productCount = (products.data ?? []).length;
const step = resumeStep(state);
const { done, total } = progressOf(state);
function persist(next: OnboardingState) {
setState(writeOnboarding(userid, tenantid, next));
setErrors({});
setSaveError(null);
}
function goTo(next: StepId) {
persist({ ...state, step: next, started: true });
}
const nextOf = (id: StepId) => ORDER[Math.min(ORDER.indexOf(id) + 1, ORDER.length - 1)] as StepId;
const backOf = (id: StepId) => ORDER[Math.max(ORDER.indexOf(id) - 1, 0)] as StepId;
/** Save what this step owns, then advance. Nothing advances on a failed write. */
async function saveAndContinue() {
setSaveError(null);
if (step === 'store') {
const found = validateStoreInfo(state.store);
setErrors(found);
if (!isValid(found)) return;
setSaving(true);
try {
await tenantsApi.updateProfile({
tenantid,
tenantname: state.store.tenantname.trim(),
tenantimage: state.store.tenantimage.trim(),
primaryemail: state.store.primaryemail.trim(),
primarycontact: normalisePhone(state.store.primarycontact),
address: state.store.address.trim(),
city: state.store.city.trim(),
state: state.store.state.trim(),
postcode: state.store.postcode.trim(),
tenanttype: state.store.tenanttype,
});
await shop.refetch();
} catch (cause) {
setSaveError(errorMessage(cause));
return;
} finally {
setSaving(false);
}
}
if (step === 'delivery') {
const found = validateDelivery(state.delivery);
setErrors(found);
if (!isValid(found)) return;
// Delivery is a property of the OUTLET, not the business — a merchant
// with three shops can cover different areas from each.
if (current?.locationid) {
setSaving(true);
try {
await tenantsApi.updateBranch({
locationid: current.locationid,
tenantid,
...(state.delivery.offersDelivery
? {
// Metres on the wire; kilometres are what a shopkeeper thinks in.
deliveryradius: Math.round(Number(state.delivery.radiusKm) * 1000),
deliverymins: Number(state.delivery.mins),
}
: { deliveryradius: 0, deliverymins: 0 }),
});
} catch (cause) {
setSaveError(errorMessage(cause));
return;
} finally {
setSaving(false);
}
}
}
if (step === 'review') {
persist({ ...completeStep(state, 'review', 'done'), finished: true });
return;
}
persist(completeStep(state, step, nextOf(step)));
}
const heading = HEADINGS[step];
const isFirst = step === 'welcome';
const isLast = step === 'done';
// Catalogue and inventory are the two a shop can genuinely not be ready for.
const canSkip = step === 'catalogue' || step === 'inventory';
return (
<PageBody measure="reading">
{!isFirst && !isLast ? <Stepper current={step} completed={state.completed} /> : null}
{step === 'welcome' ? (
<WelcomeStep
{...(state.store.tenantname ? { shopName: state.store.tenantname } : {})}
done={done}
total={total}
isReturning={state.started && done > 0}
onStart={() => goTo(done > 0 ? state.step === 'welcome' ? 'store' : state.step : 'store')}
/>
) : null}
{!isFirst && !isLast ? (
<StepFrame
step={step}
title={heading.title}
{...(heading.blurb ? { blurb: heading.blurb } : {})}
continueLabel={step === 'review' ? 'Complete setup' : 'Save & continue'}
isSaving={saving}
canSkip={canSkip}
onBack={() => goTo(backOf(step))}
{...(canSkip ? { onSkip: () => persist(skipStep(state, step, nextOf(step))) } : {})}
onContinue={() => void saveAndContinue()}
onExit={() => navigate('/admin/console')}
>
{step === 'store' ? (
<StoreInfoStep
value={state.store}
errors={errors}
onChange={(store) => setState((prev) => writeOnboarding(userid, tenantid, { ...prev, store }))}
/>
) : null}
{step === 'catalogue' ? (
<CatalogueStep
productCount={productCount}
onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=catalogue')}
/* The catalogue tab, not the products list. This pointed at
`/admin/inventory` bare, which lands on Products — so "import from
the catalogue" showed a merchant their own empty product list. */
onManual={() => navigate('/admin/inventory?tab=catalogue&setup=catalogue')}
/>
) : null}
{step === 'inventory' ? (
<InventoryStep
onDownloadTemplate={() => navigate('/admin/inventory?tab=stock&setup=inventory')}
onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=inventory')}
/>
) : null}
{step === 'delivery' ? (
<DeliveryStep
value={state.delivery}
errors={errors}
onChange={(delivery) =>
setState((prev) => writeOnboarding(userid, tenantid, { ...prev, delivery }))
}
/>
) : null}
{step === 'review' ? (
<ReviewStep
store={state.store}
delivery={state.delivery}
productCount={productCount}
skipped={state.skipped}
onEdit={goTo}
/>
) : null}
{saveError ? (
<Card padding={2} variant="transparent">
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
{saveError}
</Text>
</Card>
) : null}
</StepFrame>
) : null}
{step === 'done' ? (
<DoneStep
productCount={productCount}
onCatalogue={() => navigate('/admin/inventory?tab=catalogue')}
onInventory={() => navigate('/admin/inventory?tab=stock')}
onStorefront={() => navigate('/admin/inventory?tab=products')}
onDashboard={() => navigate('/admin/console')}
/>
) : null}
</PageBody>
);
}