setup onboarding page
This commit is contained in:
344
src/features/onboarding/OnboardingPage.tsx
Normal file
344
src/features/onboarding/OnboardingPage.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* 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 } from 'react-router-dom';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
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 { 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 [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]);
|
||||
|
||||
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 ? (
|
||||
<VStack gap={3}>
|
||||
<VStack gap={0.5}>
|
||||
<Text
|
||||
type="display-1"
|
||||
weight="semibold"
|
||||
style={{ fontFamily: 'var(--font-display)', textWrap: 'balance' }}
|
||||
>
|
||||
{heading.title}
|
||||
</Text>
|
||||
{heading.blurb ? (
|
||||
<Text type="body" color="secondary">
|
||||
{heading.blurb}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
<Stepper current={step} completed={state.completed} />
|
||||
</VStack>
|
||||
) : 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}
|
||||
|
||||
{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')}
|
||||
onManual={() => navigate('/admin/inventory')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === 'inventory' ? (
|
||||
<InventoryStep
|
||||
onDownloadTemplate={() => navigate('/admin/inventory')}
|
||||
onUpload={() => navigate('/admin/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}
|
||||
|
||||
{step === 'done' ? (
|
||||
<DoneStep
|
||||
productCount={productCount}
|
||||
onCatalogue={() => navigate('/admin/inventory')}
|
||||
onInventory={() => navigate('/admin/inventory')}
|
||||
onStorefront={() => navigate('/admin/inventory')}
|
||||
onDashboard={() => navigate('/admin/console')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{saveError ? (
|
||||
<Card padding={3} variant="transparent">
|
||||
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{saveError}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Footer navigation. Back is quiet, Continue is the one action, and Skip
|
||||
sits between them so it is available without competing. */}
|
||||
{!isFirst && !isLast ? (
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap" style={{ paddingTop: 4 }}>
|
||||
<Button
|
||||
label="Back"
|
||||
variant="ghost"
|
||||
icon={<ArrowLeft size={14} />}
|
||||
isDisabled={saving}
|
||||
onClick={() => goTo(backOf(step))}
|
||||
/>
|
||||
<HStack gap={1.5} align="center" wrap="wrap">
|
||||
{canSkip ? (
|
||||
<Button
|
||||
label="Skip for now"
|
||||
variant="ghost"
|
||||
isDisabled={saving}
|
||||
onClick={() => persist(skipStep(state, step, nextOf(step)))}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
label={
|
||||
saving ? 'Saving…' : step === 'review' ? 'Complete setup' : 'Save & continue'
|
||||
}
|
||||
variant="primary"
|
||||
isLoading={saving}
|
||||
isDisabled={saving}
|
||||
endContent={<ArrowRight size={14} />}
|
||||
onClick={() => void saveAndContinue()}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
) : null}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user