/** * Store setup, as a page inside the console rather than a modal over it. * * It renders through the shell's ``, 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 = { 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(() => readOnboarding(userid, tenantid)); const [errors, setErrors] = useState({}); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(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 | 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=` 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 ( {!isFirst && !isLast ? : null} {step === 'welcome' ? ( 0} onStart={() => goTo(done > 0 ? state.step === 'welcome' ? 'store' : state.step : 'store')} /> ) : null} {!isFirst && !isLast ? ( goTo(backOf(step))} {...(canSkip ? { onSkip: () => persist(skipStep(state, step, nextOf(step))) } : {})} onContinue={() => void saveAndContinue()} onExit={() => navigate('/admin/console')} > {step === 'store' ? ( setState((prev) => writeOnboarding(userid, tenantid, { ...prev, store }))} /> ) : null} {step === 'catalogue' ? ( 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' ? ( navigate('/admin/inventory?tab=stock&setup=inventory')} onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=inventory')} /> ) : null} {step === 'delivery' ? ( setState((prev) => writeOnboarding(userid, tenantid, { ...prev, delivery })) } /> ) : null} {step === 'review' ? ( ) : null} {saveError ? ( {saveError} ) : null} ) : null} {step === 'done' ? ( navigate('/admin/inventory?tab=catalogue')} onInventory={() => navigate('/admin/inventory?tab=stock')} onStorefront={() => navigate('/admin/inventory?tab=products')} onDashboard={() => navigate('/admin/console')} /> ) : null} ); }