diff --git a/src/App.tsx b/src/App.tsx
index 3740703..5466f45 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -43,8 +43,7 @@ const UsersPage = named('UsersPage', () => import('@/features/store-admin/pages/
const TerminalsPage = named('TerminalsPage', () => import('@/features/store-admin/pages/TerminalsPage'));
const AdminUploadsPage = named('UploadsPage', () => import('@/features/store-admin/pages/UploadsPage'));
const ShopProfilePage = named('ShopProfilePage', () => import('@/features/store-admin/pages/ShopProfilePage'));
-const StoreAdminSetupPage = named('StoreAdminSetupPage', () => import('@/features/setup/StoreAdminSetupPage'));
-const StoreUserSetupPage = named('StoreUserSetupPage', () => import('@/features/setup/StoreUserSetupPage'));
+const OnboardingPage = named('OnboardingPage', () => import('@/features/onboarding/OnboardingPage'));
/* The Store user workspace reuses the merchant's four pages, pinned to one
branch by `BranchScopeProvider pin=`. Only what a shop does differently is
@@ -128,7 +127,7 @@ export function App() {
} />
} />
} />
- } />
+ } />
{/* Catches `/admin/dashboard` and anything else that does not resolve.
Without this, an unknown sub-path escapes to the global `*`, which
redirects to this role's HOME_ROUTE — and if that is itself an
@@ -160,7 +159,6 @@ export function App() {
} />
} />
} />
- } />
} />
} />
diff --git a/src/features/onboarding/OnboardingGate.tsx b/src/features/onboarding/OnboardingGate.tsx
new file mode 100644
index 0000000..e6ca607
--- /dev/null
+++ b/src/features/onboarding/OnboardingGate.tsx
@@ -0,0 +1,51 @@
+import { useEffect } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { useAuth } from '@/auth/AuthContext';
+import { useBranchScope } from '@/features/store-admin/BranchScope';
+import { useOwnTenant } from '@/queries/hooks';
+import { readOnboarding } from './onboardingState';
+
+/**
+ * Sends a first-time merchant to setup instead of the dashboard.
+ *
+ * Renders nothing — it is a decision, not a screen, and it makes that decision
+ * once. Three rules keep it from becoming a trap:
+ *
+ * - **Only once.** After the first redirect, `started` is set and nobody is
+ * ever sent again. Somebody who leaves setup has left it.
+ * - **Only from the landing page.** A merchant who deep-links to Sales, or is
+ * already reading Inventory, is not hauled away from what they opened.
+ * - **Only when there is something to do.** A shop whose profile is already
+ * filled in is not a first-time user, however new the account.
+ *
+ * The redirect waits for the shop record. Judging "incomplete" while the query
+ * is still in flight would redirect every merchant on every first paint.
+ */
+export function OnboardingGate() {
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
+ const { user } = useAuth();
+ const { tenantid } = useBranchScope();
+ const shop = useOwnTenant(tenantid || undefined);
+
+ useEffect(() => {
+ if (!user?.userid || !tenantid || shop.isLoading || !shop.data) return;
+ if (pathname !== '/admin/console') return;
+
+ const state = readOnboarding(user.userid, tenantid);
+ if (state.started || state.finished) return;
+
+ // "Set up" means the shop can be found and described. Products and stock
+ // come later and are not a reason to interrupt somebody.
+ const record = shop.data as unknown as Record;
+ const hasBasics =
+ String(record['tenantname'] ?? '').trim() !== '' &&
+ String(record['address'] ?? '').trim() !== '' &&
+ String(record['primarycontact'] ?? '').trim() !== '';
+ if (hasBasics) return;
+
+ navigate('/admin/onboarding', { replace: true });
+ }, [user?.userid, tenantid, shop.isLoading, shop.data, pathname, navigate]);
+
+ return null;
+}
diff --git a/src/features/onboarding/OnboardingPage.tsx b/src/features/onboarding/OnboardingPage.tsx
new file mode 100644
index 0000000..dec1e38
--- /dev/null
+++ b/src/features/onboarding/OnboardingPage.tsx
@@ -0,0 +1,344 @@
+/**
+ * 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 } 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 = {
+ 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(() => 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]);
+
+ 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 ? (
+
+
+
+ {heading.title}
+
+ {heading.blurb ? (
+
+ {heading.blurb}
+
+ ) : null}
+
+
+
+ ) : null}
+
+ {step === 'welcome' ? (
+ 0}
+ onStart={() => goTo(done > 0 ? state.step === 'welcome' ? 'store' : state.step : 'store')}
+ />
+ ) : null}
+
+ {step === 'store' ? (
+ setState((prev) => writeOnboarding(userid, tenantid, { ...prev, store }))}
+ />
+ ) : null}
+
+ {step === 'catalogue' ? (
+ navigate('/admin/inventory?tab=products&upload=1')}
+ onManual={() => navigate('/admin/inventory')}
+ />
+ ) : null}
+
+ {step === 'inventory' ? (
+ navigate('/admin/inventory')}
+ onUpload={() => navigate('/admin/inventory')}
+ />
+ ) : null}
+
+ {step === 'delivery' ? (
+
+ setState((prev) => writeOnboarding(userid, tenantid, { ...prev, delivery }))
+ }
+ />
+ ) : null}
+
+ {step === 'review' ? (
+
+ ) : null}
+
+ {step === 'done' ? (
+ navigate('/admin/inventory')}
+ onInventory={() => navigate('/admin/inventory')}
+ onStorefront={() => navigate('/admin/inventory')}
+ onDashboard={() => navigate('/admin/console')}
+ />
+ ) : null}
+
+ {saveError ? (
+
+
+ {saveError}
+
+
+ ) : null}
+
+ {/* Footer navigation. Back is quiet, Continue is the one action, and Skip
+ sits between them so it is available without competing. */}
+ {!isFirst && !isLast ? (
+
+ }
+ isDisabled={saving}
+ onClick={() => goTo(backOf(step))}
+ />
+
+ {canSkip ? (
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/features/onboarding/Stepper.tsx b/src/features/onboarding/Stepper.tsx
new file mode 100644
index 0000000..1555b9e
--- /dev/null
+++ b/src/features/onboarding/Stepper.tsx
@@ -0,0 +1,96 @@
+/**
+ * Where am I, and how much is left.
+ *
+ * Two shapes for two widths, and the small one is not a squeezed version of the
+ * large one. Five labelled nodes with connectors work on a desktop and become
+ * unreadable at 360px, so below `sm` this collapses to "Step 2 of 5" and a bar
+ * — which answers the same question in the space actually available.
+ *
+ * Brand purple marks the current step, a tick marks a finished one, and
+ * everything else stays on the line colour. No new palette: `--color-brand`,
+ * `--color-line` and the ink scale are what the rest of the console is built
+ * from.
+ */
+
+import { Check } from 'lucide-react';
+import { PROGRESS_STEPS, STEP_LABEL, type StepId } from './onboardingState';
+
+export interface StepperProps {
+ current: StepId;
+ completed: readonly StepId[];
+}
+
+export function Stepper({ current, completed }: StepperProps) {
+ const index = PROGRESS_STEPS.indexOf(current);
+ // Welcome sits before the five and Done after them, so neither has a node.
+ // Clamped rather than hidden: a bar that disappears on the first screen makes
+ // the flow look like it started somewhere else.
+ const position = index < 0 ? (current === 'done' ? PROGRESS_STEPS.length : 0) : index + 1;
+ const doneCount = PROGRESS_STEPS.filter((id) => completed.includes(id)).length;
+ const pct = Math.round((doneCount / PROGRESS_STEPS.length) * 100);
+
+ return (
+ <>
+ {/* ── Compact: phones ─────────────────────────────────────────────── */}
+
+
+ {isDone ? : i + 1}
+
+ {STEP_LABEL[id]}
+ {i < PROGRESS_STEPS.length - 1 ? (
+
+ ) : null}
+ {/* The state is carried in the markup for a screen reader rather
+ than left to colour and a tick glyph. */}
+
+ {isDone ? ' — completed' : isCurrent ? ' — current step' : ' — not started'}
+
+
+ );
+ })}
+
+ >
+ );
+}
diff --git a/src/features/onboarding/onboardingState.test.ts b/src/features/onboarding/onboardingState.test.ts
new file mode 100644
index 0000000..0c7ad34
--- /dev/null
+++ b/src/features/onboarding/onboardingState.test.ts
@@ -0,0 +1,100 @@
+/**
+ * Setup progress, and the promise it makes.
+ *
+ * "You can save your progress and continue later" is printed on the welcome
+ * screen, so a refresh, a closed laptop or a phone call in the middle of the
+ * address field must not cost somebody their answers. These cover the state
+ * rules; the storage itself degrades to empty when a browser refuses it.
+ */
+import assert from 'node:assert/strict';
+import { test, beforeEach } from 'node:test';
+import {
+ completeStep,
+ progressOf,
+ readOnboarding,
+ resumeStep,
+ skipStep,
+ PROGRESS_STEPS,
+ type OnboardingState,
+} from './onboardingState';
+
+beforeEach(() => {
+ delete (globalThis as { localStorage?: unknown }).localStorage;
+});
+
+const base = (): OnboardingState => readOnboarding(1, 1);
+
+test('a new shop starts at the welcome screen with nothing done', () => {
+ const s = base();
+ assert.equal(s.step, 'welcome');
+ assert.equal(s.started, false);
+ assert.deepEqual(progressOf(s), { done: 0, total: 5 });
+});
+
+test('completing a step ticks it and moves on', () => {
+ const s = completeStep(base(), 'store', 'catalogue');
+ assert.equal(s.step, 'catalogue');
+ assert.ok(s.completed.includes('store'));
+ assert.deepEqual(progressOf(s), { done: 1, total: 5 });
+});
+
+// Skipping is a real answer — a shop with no products yet should not be held at
+// the catalogue step — but it is not progress, and the review screen must not
+// report it as such.
+test('skipping advances without counting as done', () => {
+ const s = skipStep(base(), 'catalogue', 'inventory');
+ assert.equal(s.step, 'inventory');
+ assert.ok(s.skipped.includes('catalogue'));
+ assert.ok(!s.completed.includes('catalogue'));
+ assert.deepEqual(progressOf(s), { done: 0, total: 5 });
+});
+
+/*
+Somebody who skips the catalogue, then comes back and uploads one, has done the
+work. Leaving it marked "skipped" would make the review screen report a gap
+that no longer exists.
+*/
+test('coming back and finishing a skipped step clears the skip', () => {
+ const skipped = skipStep(base(), 'catalogue', 'inventory');
+ const done = completeStep(skipped, 'catalogue', 'inventory');
+ assert.ok(done.completed.includes('catalogue'));
+ assert.ok(!done.skipped.includes('catalogue'));
+});
+
+test('completing the same step twice is not an error', () => {
+ const once = completeStep(base(), 'store', 'catalogue');
+ const twice = completeStep(once, 'store', 'catalogue');
+ assert.equal(twice.completed.filter((id) => id === 'store').length, 1);
+});
+
+// Welcome is not an achievement and Done is the result of the others. Counting
+// either would show progress before anything had been done.
+test('only the five real steps count towards progress', () => {
+ assert.equal(PROGRESS_STEPS.length, 5);
+ const s = completeStep(completeStep(base(), 'welcome', 'store'), 'done', 'done');
+ assert.deepEqual(progressOf(s), { done: 0, total: 5 });
+});
+
+test('returning resumes where they left off', () => {
+ const s = completeStep(base(), 'store', 'catalogue');
+ assert.equal(resumeStep(s), 'catalogue');
+});
+
+// A finished setup never reopens mid-flow, however the stored step reads.
+test('a finished setup always resumes on the success screen', () => {
+ const s = { ...completeStep(base(), 'store', 'catalogue'), finished: true };
+ assert.equal(resumeStep(s), 'done');
+});
+
+/*
+Storage the browser refuses must not take the page down, and a state written by
+an older build must not arrive with `undefined` where a form expects a string —
+that is what silently switches a React input to uncontrolled.
+*/
+test('unreadable storage reads as a clean start, with every field present', () => {
+ const s = readOnboarding(9, 9);
+ assert.equal(s.step, 'welcome');
+ assert.equal(typeof s.store.tenantname, 'string');
+ assert.equal(typeof s.delivery.radiusKm, 'string');
+ assert.equal(s.delivery.offersDelivery, null);
+});
diff --git a/src/features/onboarding/onboardingState.ts b/src/features/onboarding/onboardingState.ts
new file mode 100644
index 0000000..ba08001
--- /dev/null
+++ b/src/features/onboarding/onboardingState.ts
@@ -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 = {
+ 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 {
+ try {
+ const raw = localStorage.getItem(KEY);
+ const parsed: unknown = raw ? JSON.parse(raw) : {};
+ return parsed && typeof parsed === 'object' ? (parsed as Record) : {};
+ } 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;
+}
diff --git a/src/features/onboarding/steps/CatalogueStep.tsx b/src/features/onboarding/steps/CatalogueStep.tsx
new file mode 100644
index 0000000..c0234cc
--- /dev/null
+++ b/src/features/onboarding/steps/CatalogueStep.tsx
@@ -0,0 +1,127 @@
+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 { FileSpreadsheet, PackagePlus } from 'lucide-react';
+
+/**
+ * How products get in — a choice between two routes, not a form.
+ *
+ * Both are real and already built: the spreadsheet goes to the catalogue
+ * service, and adding by hand is the Inventory screen. This step's whole job is
+ * to help somebody pick the one that matches what they already have, then send
+ * them to it.
+ *
+ * Skipping is offered and deliberately quiet. A shop with no product list yet
+ * should not be stuck here — but a "Skip" styled like the primary action is how
+ * every merchant ends up with an empty catalogue.
+ */
+export interface CatalogueStepProps {
+ productCount: number;
+ onUpload: () => void;
+ onManual: () => void;
+}
+
+export function CatalogueStep({ productCount, onUpload, onManual }: CatalogueStepProps) {
+ return (
+
+ {productCount > 0 ? (
+
+
+
+ You already have {productCount} product{productCount === 1 ? '' : 's'}
+
+
+ You can add more now, or carry on and come back to this later.
+
+
+
+ ) : null}
+
+
+ }
+ title="Upload a spreadsheet"
+ body="Already have your product list? Upload the file and we will bring it in for you."
+ cta="Upload spreadsheet"
+ recommended
+ onClick={onUpload}
+ />
+ }
+ title="Add products by hand"
+ body="Add them one at a time, or pick from the shared catalogue of known brands."
+ cta="Add by hand"
+ onClick={onManual}
+ />
+
+
+
+
+
+ No product list yet?
+
+
+ That is fine — this is the one step you can leave for later. Use{' '}
+ Skip for now below and add products whenever you are ready. Nothing
+ else in setup depends on it.
+
+
+
+
+ );
+}
+
+function Choice({
+ icon,
+ title,
+ body,
+ cta,
+ recommended,
+ onClick,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ body: string;
+ cta: string;
+ recommended?: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+
+ {icon}
+
+ {recommended ? (
+
+ Recommended
+
+ ) : null}
+
+
+
+ {title}
+
+
+ {body}
+
+
+
+ {cta} →
+
+
+
+ );
+}
diff --git a/src/features/onboarding/steps/DeliveryStep.tsx b/src/features/onboarding/steps/DeliveryStep.tsx
new file mode 100644
index 0000000..bcb0277
--- /dev/null
+++ b/src/features/onboarding/steps/DeliveryStep.tsx
@@ -0,0 +1,197 @@
+import { Card } from '@astryxdesign/core/Card';
+import { HStack } from '@astryxdesign/core/HStack';
+import { Text } from '@astryxdesign/core/Text';
+import { TextInput } from '@astryxdesign/core/TextInput';
+import { TimeInput } from '@astryxdesign/core/TimeInput';
+import { createISOTimeString, type ISOTimeString } from '@astryxdesign/core/utils';
+import { VStack } from '@astryxdesign/core/VStack';
+import { Bike, Store } from 'lucide-react';
+import type { DeliveryDraft } from '../onboardingState';
+import type { Errors } from '../validation';
+
+/**
+ * One question, and then only the fields it makes relevant.
+ *
+ * A shop that does not deliver is asked nothing further — showing eight
+ * greyed-out delivery fields to a counter-only shop is how a form teaches
+ * somebody that most of it does not apply to them, and they stop reading the
+ * parts that do.
+ *
+ * The radius is the field that matters most and the least obvious: it decides
+ * which addresses the customer app will serve at all, so it says so rather than
+ * sitting there as a number.
+ */
+export interface DeliveryStepProps {
+ value: DeliveryDraft;
+ errors: Errors;
+ onChange: (next: DeliveryDraft) => void;
+}
+
+function asTime(value: string): ISOTimeString | undefined {
+ return createISOTimeString(value) ?? undefined;
+}
+
+export function DeliveryStep({ value, errors, onChange }: DeliveryStepProps) {
+ const set = (key: K) => (next: DeliveryDraft[K]) =>
+ onChange({ ...value, [key]: next });
+
+ return (
+
+
+
+ Do you deliver to customers?
+
+
+
+ }
+ title="Yes, we deliver"
+ body="Customers can order to their address within the area you cover."
+ isSelected={value.offersDelivery === true}
+ onSelect={() => set('offersDelivery')(true)}
+ />
+ }
+ title="No, collection only"
+ body="Customers order and come to the shop to pick up."
+ isSelected={value.offersDelivery === false}
+ onSelect={() => set('offersDelivery')(false)}
+ />
+
+
+ {errors['offersDelivery'] ? (
+
+ {errors['offersDelivery']}
+
+ ) : null}
+
+
+ {/* Only when it applies. Nothing below exists for a collection-only shop,
+ and rendering it disabled would be showing work that is not theirs. */}
+ {value.offersDelivery === true ? (
+
+
+
+
+ Delivery settings
+
+
+ Only the first two are required. The rest can be set later.
+
+
+
+
+
+
+
+ ) : null}
+
+ {value.offersDelivery === false ? (
+
+
+ Your shop will be listed for collection. You can turn delivery on later from your shop
+ settings without redoing any of this.
+
+
+ ) : null}
+
+ );
+}
+
+function ChoiceCard({
+ icon,
+ title,
+ body,
+ isSelected,
+ onSelect,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ body: string;
+ isSelected: boolean;
+ onSelect: () => void;
+}) {
+ return (
+
+
+
+
+ {icon}
+
+
+ {title}
+
+
+
+ {body}
+
+
+
+ );
+}
diff --git a/src/features/onboarding/steps/DoneStep.tsx b/src/features/onboarding/steps/DoneStep.tsx
new file mode 100644
index 0000000..7df8679
--- /dev/null
+++ b/src/features/onboarding/steps/DoneStep.tsx
@@ -0,0 +1,120 @@
+import { Button } from '@astryxdesign/core/Button';
+import { HStack } from '@astryxdesign/core/HStack';
+import { Text } from '@astryxdesign/core/Text';
+import { VStack } from '@astryxdesign/core/VStack';
+import { ArrowRight, Boxes, Check, LayoutDashboard, PackageSearch } from 'lucide-react';
+
+/**
+ * The end.
+ *
+ * One quiet animation — a tick that draws itself once — and then straight to
+ * what comes next. Confetti on a business tool is a celebration the user did
+ * not ask for, and it delays the three things they actually came here to do.
+ *
+ * The three cards are the real next actions, not a tour: each is a screen that
+ * exists and does something.
+ */
+export interface DoneStepProps {
+ productCount: number;
+ onCatalogue: () => void;
+ onInventory: () => void;
+ onStorefront: () => void;
+ onDashboard: () => void;
+}
+
+export function DoneStep({
+ productCount,
+ onCatalogue,
+ onInventory,
+ onStorefront,
+ onDashboard,
+}: DoneStepProps) {
+ return (
+
+
+
+
+
+
+ You’re all set 🎉
+
+
+ {productCount > 0
+ ? 'Your store is set up and your products are ready for customers.'
+ : 'Your store is set up. Add some products and they will be ready for customers.'}
+
+
+
+
+ }
+ title="Manage your catalogue"
+ body="Add products, set prices, and release them to your shops."
+ cta="Open catalogue"
+ onClick={onCatalogue}
+ />
+ }
+ title="Update your stock"
+ body="Upload your latest counts so customers see what is really there."
+ cta="Update stock"
+ onClick={onInventory}
+ />
+ }
+ title="See your shop"
+ body="Check what a customer sees, and which products are on sale."
+ cta="View products"
+ onClick={onStorefront}
+ />
+
+
+
+ }
+ onClick={onDashboard}
+ />
+
+
+ );
+}
+
+function NextCard({
+ icon,
+ title,
+ body,
+ cta,
+ onClick,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ body: string;
+ cta: string;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+ {icon}
+
+
+ {title}
+
+
+ {body}
+
+
+ {cta} →
+
+
+
+ );
+}
diff --git a/src/features/onboarding/steps/InventoryStep.tsx b/src/features/onboarding/steps/InventoryStep.tsx
new file mode 100644
index 0000000..0698c91
--- /dev/null
+++ b/src/features/onboarding/steps/InventoryStep.tsx
@@ -0,0 +1,143 @@
+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 { ArrowRight, Download, Upload } from 'lucide-react';
+
+/**
+ * Keeping the online stock in step with the shelf.
+ *
+ * Most of these shops have no till system, so the honest answer is a
+ * spreadsheet — and the risk with a spreadsheet is that it reads as a technical
+ * chore. The flow is therefore drawn rather than described: six short stages
+ * from "what is on your shelf" to "what a customer sees", so a shopkeeper can
+ * see where their file goes before deciding to make one.
+ *
+ * Nothing here uploads. The step explains the routine and hands over to the
+ * screen that does it, because stock is a thing done every week and setup is a
+ * thing done once.
+ */
+export interface InventoryStepProps {
+ onDownloadTemplate: () => void;
+ onUpload: () => void;
+}
+
+const FLOW = [
+ 'What is on your shelf',
+ 'Written into the stock sheet',
+ 'Uploaded here',
+ 'Checked for mistakes',
+ 'Your stock is updated',
+ 'Customers see what is in stock',
+] as const;
+
+const TIPS = [
+ 'Start from the template — the columns have to match.',
+ 'Keep the SKU or product code exactly as it appears in your catalogue.',
+ 'Send the whole current count, not just what changed.',
+ 'Read the check results before confirming — a rejected row is a product that will not sell.',
+ 'Upload again whenever the shelf changes. There is no limit.',
+] as const;
+
+export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepProps) {
+ return (
+
+
+
+
+
+ How stock reaches your customers
+
+
+ You do not need a till system. A spreadsheet is enough.
+
+
+
+
+ {FLOW.map((stage, i) => (
+
+
+ {i + 1}
+
+
+ {stage}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ Stock by spreadsheet
+
+
+ Upload your latest counts whenever they change. We check the file first and tell you
+ about any row we could not read.
+
+
+
+ Recommended
+
+
+
+
+ }
+ onClick={onDownloadTemplate}
+ />
+ }
+ endContent={}
+ onClick={onUpload}
+ />
+
+
+
+
+
+
+
+ Worth knowing
+
+
+ {TIPS.map((tip) => (
+
+
+ {tip}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/features/onboarding/steps/ReviewStep.tsx b/src/features/onboarding/steps/ReviewStep.tsx
new file mode 100644
index 0000000..384e2c0
--- /dev/null
+++ b/src/features/onboarding/steps/ReviewStep.tsx
@@ -0,0 +1,152 @@
+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 { AlertTriangle } from 'lucide-react';
+import type { DeliveryDraft, StepId, StoreDraft } from '../onboardingState';
+
+/**
+ * Everything about to be saved, in the merchant's own words.
+ *
+ * A review screen earns its place only if it can be acted on, so every card
+ * carries an Edit that goes back to the step that owns it — and a skipped step
+ * says it was skipped rather than showing blanks that read like data loss.
+ *
+ * It also names the one consequence people do not expect: a shop with no
+ * products is set up correctly and still has nothing to sell. Better said here
+ * than discovered on the dashboard.
+ */
+export interface ReviewStepProps {
+ store: StoreDraft;
+ delivery: DeliveryDraft;
+ productCount: number;
+ skipped: readonly StepId[];
+ onEdit: (step: StepId) => void;
+}
+
+export function ReviewStep({ store, delivery, productCount, skipped, onEdit }: ReviewStepProps) {
+ const money = (v: string) => (v.trim() === '' ? '—' : `₹${v}`);
+
+ return (
+
+ onEdit('store')}>
+
+
+
+
+
+
+
+
+ onEdit('catalogue')}>
+ {skipped.includes('catalogue') && productCount === 0 ? (
+
+ ) : (
+ 0 ? `${productCount} in your catalogue` : 'None yet'}
+ />
+ )}
+
+
+ onEdit('inventory')}>
+ {skipped.includes('inventory') ? (
+
+ ) : (
+
+ )}
+
+
+ onEdit('delivery')}>
+ {delivery.offersDelivery === true ? (
+ <>
+
+
+
+
+
+
+
+ >
+ ) : delivery.offersDelivery === false ? (
+
+ ) : (
+
+ )}
+
+
+ {/* The consequence nobody expects: setup can be complete and the shop
+ still has nothing a customer can buy. */}
+ {productCount === 0 ? (
+
+
+
+
+
+ Your shop has no products yet
+
+
+ You can finish setup now — but customers will not see anything until you add
+ products, price them and record some stock. That is all available from Inventory
+ whenever you are ready.
+
+
+
+
+ ) : null}
+
+ );
+}
+
+function Section({
+ title,
+ onEdit,
+ children,
+}: {
+ title: string;
+ onEdit: () => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {title}
+
+
+
+ {children}
+
+
+ );
+}
+
+function Row({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+/* A skipped step says so. Blank rows read as data that failed to save. */
+function Skipped({ what }: { what: string }) {
+ return (
+
+ {what} You can do it any time from the console.
+
+ );
+}
diff --git a/src/features/onboarding/steps/StoreInfoStep.tsx b/src/features/onboarding/steps/StoreInfoStep.tsx
new file mode 100644
index 0000000..aa4b19a
--- /dev/null
+++ b/src/features/onboarding/steps/StoreInfoStep.tsx
@@ -0,0 +1,330 @@
+import { useRef, useState } from 'react';
+import { Card } from '@astryxdesign/core/Card';
+import { HStack } from '@astryxdesign/core/HStack';
+import { Selector } from '@astryxdesign/core/Selector';
+import { Text } from '@astryxdesign/core/Text';
+import { TextInput } from '@astryxdesign/core/TextInput';
+import { TimeInput } from '@astryxdesign/core/TimeInput';
+import { createISOTimeString, type ISOTimeString } from '@astryxdesign/core/utils';
+import { VStack } from '@astryxdesign/core/VStack';
+import { ImagePlus, Upload } from 'lucide-react';
+import type { StoreDraft } from '../onboardingState';
+import type { Errors } from '../validation';
+
+/**
+ * The one step that is a form, kept to three short groups.
+ *
+ * Grouped rather than listed because twelve fields in a column reads as a tax
+ * return. Basics, then where the shop is, then how it trades — each of which a
+ * shopkeeper can answer without looking anything up.
+ *
+ * Fields are marked required with an asterisk and validated on submit, not on
+ * every keystroke: telling somebody their email is invalid while they are on
+ * the third character is correcting them for not having finished typing.
+ */
+
+export interface StoreInfoStepProps {
+ value: StoreDraft;
+ errors: Errors;
+ onChange: (next: StoreDraft) => void;
+}
+
+/** Matches `tenants.tenanttype`, which the platform already stores. */
+const STORE_TYPES = [
+ { value: 'grocery', label: 'Grocery / kirana' },
+ { value: 'supermarket', label: 'Supermarket' },
+ { value: 'pharmacy', label: 'Pharmacy' },
+ { value: 'bakery', label: 'Bakery' },
+ { value: 'restaurant', label: 'Restaurant' },
+ { value: 'other', label: 'Something else' },
+];
+
+const MAX_LOGO_BYTES = 2 * 1024 * 1024;
+
+/**
+ * "08:00" as the picker wants it.
+ *
+ * The draft stores plain strings so it can be serialised into localStorage and
+ * posted to Fiesta unchanged; TimeInput wants a branded ISOTimeString. Undefined
+ * for anything unparseable, which shows an empty picker rather than throwing on
+ * a value somebody's older draft is carrying.
+ */
+function asTime(value: string): ISOTimeString | undefined {
+ return createISOTimeString(value) ?? undefined;
+}
+
+export function StoreInfoStep({ value, errors, onChange }: StoreInfoStepProps) {
+ const set = (key: K) => (next: StoreDraft[K]) =>
+ onChange({ ...value, [key]: next });
+
+ return (
+
+
+
+ Store name as unknown as string}
+ value={value.tenantname}
+ onChange={set('tenantname')}
+ placeholder="e.g. Suriya Stores"
+ description="This is the name shoppers see."
+ {...(errors['tenantname'] ? { error: errors['tenantname'] } : {})}
+ />
+ Phone number as unknown as string}
+ value={value.primarycontact}
+ onChange={set('primarycontact')}
+ placeholder="98765 43210"
+ {...(errors['primarycontact'] ? { error: errors['primarycontact'] } : {})}
+ />
+
+ Store type as unknown as string}
+ value={value.tenanttype}
+ onChange={set('tenanttype')}
+ placeholder="Choose one"
+ options={STORE_TYPES}
+ {...(errors['tenanttype'] ? { error: errors['tenanttype'] } : {})}
+ />
+
+ City as unknown as string}
+ value={value.city}
+ onChange={set('city')}
+ {...(errors['city'] ? { error: errors['city'] } : {})}
+ />
+ Pincode as unknown as string}
+ value={value.postcode}
+ onChange={set('postcode')}
+ placeholder="641001"
+ {...(errors['postcode'] ? { error: errors['postcode'] } : {})}
+ />
+
+
+
+
+
+
+
+ Opens at as unknown as string}
+ value={asTime(value.opentime)}
+ onChange={(next) => set('opentime')(String(next ?? value.opentime))}
+ {...(errors['opentime'] ? { description: errors['opentime'] } : {})}
+ />
+ Closes at as unknown as string}
+ value={asTime(value.closetime)}
+ onChange={(next) => set('closetime')(String(next ?? value.closetime))}
+ {...(errors['closetime'] ? { description: errors['closetime'] } : {})}
+ />
+
+
+
+ );
+}
+
+/* ── Pieces ───────────────────────────────────────────────────────────────── */
+
+function Group({
+ title,
+ note,
+ children,
+}: {
+ title: string;
+ note?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {title}
+
+ {note ? (
+
+ {note}
+
+ ) : null}
+
+ {children}
+
+
+ );
+}
+
+/** The asterisk carries a text alternative, so it is not colour-and-glyph only. */
+function Required({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}{' '}
+
+ *
+
+ (required)
+
+ );
+}
+
+/**
+ * The logo.
+ *
+ * Read as a data URI rather than uploaded, deliberately: `tenants.tenantimage`
+ * holds a string and there is no image endpoint for a merchant to post to — the
+ * console's own S3 helper is disabled ("S3 not enabled, skipping image store").
+ * A URL box would be the honest alternative and is offered underneath, but a
+ * shopkeeper photographing their shopfront has a file, not a link.
+ *
+ * 2 MB is enforced here because the field is a database column: a 6 MB photo
+ * base64s to 8 MB of text and fails on write, long after the person has moved
+ * on.
+ */
+function LogoField({ value, onChange }: { value: string; onChange: (next: string) => void }) {
+ const input = useRef(null);
+ const [problem, setProblem] = useState(null);
+ const [isReading, setIsReading] = useState(false);
+
+ function take(file: File | undefined) {
+ if (!file) return;
+ setProblem(null);
+ if (!/^image\/(png|jpe?g|webp)$/.test(file.type)) {
+ setProblem('That is not an image. Choose a JPG, PNG or WebP file.');
+ return;
+ }
+ if (file.size > MAX_LOGO_BYTES) {
+ setProblem(
+ `That file is ${(file.size / 1024 / 1024).toFixed(1)} MB. The limit is 2 MB — try a smaller photo.`,
+ );
+ return;
+ }
+ setIsReading(true);
+ const reader = new FileReader();
+ reader.onload = () => {
+ onChange(String(reader.result ?? ''));
+ setIsReading(false);
+ };
+ reader.onerror = () => {
+ setProblem('That file could not be read. Try another one.');
+ setIsReading(false);
+ };
+ reader.readAsDataURL(file);
+ }
+
+ return (
+
+
+ Store logo
+
+
+ {value ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ input.current?.click()}
+ disabled={isReading}
+ style={{
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 7,
+ height: 32,
+ padding: '0 12px',
+ borderRadius: 12,
+ border: '1px solid var(--color-line)',
+ background: 'var(--color-surface)',
+ color: 'var(--color-ink-1)',
+ font: '500 13px/1 var(--font-sans)',
+ cursor: isReading ? 'progress' : 'pointer',
+ }}
+ >
+
+ {isReading ? 'Reading…' : value ? 'Change logo' : 'Upload logo'}
+
+
+ JPG, PNG or WebP, up to 2 MB. Optional.
+
+
+
+ take(event.target.files?.[0])}
+ style={{ display: 'none' }}
+ aria-hidden
+ tabIndex={-1}
+ />
+
+
+ {/* The honest alternative: the field is a string, so a hosted image works
+ just as well and costs the database nothing. */}
+
+
+ {problem ? (
+
+ {problem}
+
+ ) : null}
+
+ );
+}
diff --git a/src/features/onboarding/steps/WelcomeStep.tsx b/src/features/onboarding/steps/WelcomeStep.tsx
new file mode 100644
index 0000000..2d33bb8
--- /dev/null
+++ b/src/features/onboarding/steps/WelcomeStep.tsx
@@ -0,0 +1,149 @@
+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 { ArrowRight, Boxes, Rocket, Store } from 'lucide-react';
+
+/**
+ * The first screen, and the only one whose job is not to collect anything.
+ *
+ * It exists to get somebody to begin, so it says what setup is worth and what
+ * it costs, and nothing else. The temptation is to explain the whole product
+ * here; a shopkeeper who has just signed in has no way to use any of it yet,
+ * and a wall of text before the first field is what makes people close the tab.
+ *
+ * The one promise made in words is the one the state layer actually keeps:
+ * progress is saved, so leaving is safe.
+ */
+export interface WelcomeStepProps {
+ shopName?: string;
+ done: number;
+ total: number;
+ isReturning: boolean;
+ onStart: () => void;
+}
+
+const BENEFITS = [
+ {
+ icon: Rocket,
+ title: 'Quick setup',
+ body: 'Five short steps. Most shops are through it in a few minutes.',
+ },
+ {
+ icon: Boxes,
+ title: 'Manage your catalogue',
+ body: 'Upload a spreadsheet or add products one at a time — whichever you have.',
+ },
+ {
+ icon: Store,
+ title: 'Ready for customers',
+ body: 'Finish, and your products are on sale in the app.',
+ },
+] as const;
+
+export function WelcomeStep({ shopName, done, total, isReturning, onStart }: WelcomeStepProps) {
+ const pct = total === 0 ? 0 : Math.round((done / total) * 100);
+
+ return (
+
+
+ {/* A mark rather than an illustration. A stock drawing would be the one
+ thing on screen not drawn from the console's own design language. */}
+
+
+
+
+
+ {isReturning ? 'Welcome back 👋' : `Welcome${shopName ? ` to ${shopName}` : ''}! 👋`}
+
+
+ {isReturning
+ ? `You have completed ${done} of ${total} setup steps. Pick up where you left off.`
+ : "Let's get your store ready to start selling."}
+
+
+
+
+
+
+
+
+ }
+ onClick={onStart}
+ />
+
+
+ You can save your progress and continue later.
+
+
+
+ );
+}
diff --git a/src/features/onboarding/validation.test.ts b/src/features/onboarding/validation.test.ts
new file mode 100644
index 0000000..004c905
--- /dev/null
+++ b/src/features/onboarding/validation.test.ts
@@ -0,0 +1,122 @@
+/**
+ * Setup validation.
+ *
+ * The rule every message here follows: say what is wrong AND how to fix it. A
+ * store owner who is told "invalid input" has learned nothing they did not
+ * already know, and the person it fails is the least technical user on the
+ * platform — somebody setting up a shop for the first time.
+ */
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { isValid, validateDelivery, validateStoreInfo } from './validation';
+
+const store = (over: Partial[0]> = {}) =>
+ validateStoreInfo({
+ tenantname: 'Testmart',
+ primarycontact: '9876543210',
+ primaryemail: 'owner@testmart.in',
+ address: '1 Test Street',
+ city: 'Coimbatore',
+ postcode: '641001',
+ opentime: '08:00',
+ closetime: '22:00',
+ ...over,
+ });
+
+test('a complete store passes', () => {
+ assert.equal(isValid(store()), true);
+});
+
+test('every required field is named when missing', () => {
+ const e = store({ tenantname: '', primarycontact: '', address: '', city: '', postcode: '' });
+ for (const field of ['tenantname', 'primarycontact', 'address', 'city', 'postcode']) {
+ assert.ok(e[field], `${field} was accepted empty`);
+ }
+});
+
+/*
+Counting the digits back is the difference between a message somebody can act
+on and one they argue with. "Please enter a valid phone number" on a number that
+looks fine is the worst kind of error.
+*/
+test('a wrong-length phone says how many digits it got', () => {
+ assert.match(String(store({ primarycontact: '98765' })['primarycontact']), /5 digits/);
+ assert.match(String(store({ postcode: '6410' })['postcode']), /4 digits/);
+});
+
+// Pasted numbers carry spaces, +91 and brackets. Those are the user's habit,
+// not an error.
+test('a formatted phone number is accepted', () => {
+ assert.equal(store({ primarycontact: '+91 98765 43210' })['primarycontact'], undefined);
+});
+
+test('email is optional but must be usable when given', () => {
+ assert.equal(store({ primaryemail: '' })['primaryemail'], undefined);
+ assert.ok(store({ primaryemail: 'owner@' })['primaryemail']);
+ assert.ok(store({ primaryemail: 'owner.testmart.in' })['primaryemail']);
+});
+
+// The example from the brief, and a real ordering mistake: a shop that closes
+// before it opens takes no orders all day and nothing explains why.
+test('closing time must be after opening time', () => {
+ const e = store({ opentime: '22:00', closetime: '08:00' });
+ assert.match(String(e['closetime']), /later than opening/);
+});
+
+/* ── Delivery ────────────────────────────────────────────────────────────── */
+
+const delivery = (over: Partial[0]> = {}) =>
+ validateDelivery({
+ offersDelivery: true,
+ radiusKm: '5',
+ minOrder: '199',
+ charge: '30',
+ freeAbove: '499',
+ mins: '45',
+ ...over,
+ });
+
+test('a complete delivery setup passes', () => {
+ assert.equal(isValid(delivery()), true);
+});
+
+test('the delivery question itself must be answered', () => {
+ assert.ok(validateDelivery({
+ offersDelivery: null, radiusKm: '', minOrder: '', charge: '', freeAbove: '', mins: '',
+ })['offersDelivery']);
+});
+
+/*
+A shop that does not deliver must not be held up by delivery fields it cannot
+see. Validating hidden inputs is how a form refuses to submit for reasons
+nobody can find on screen.
+*/
+test('saying no to delivery asks for nothing else', () => {
+ const e = validateDelivery({
+ offersDelivery: false, radiusKm: '', minOrder: '', charge: '', freeAbove: '', mins: '',
+ });
+ assert.equal(isValid(e), true);
+});
+
+test('radius and delivery time are required when delivering', () => {
+ assert.ok(delivery({ radiusKm: '' })['radiusKm']);
+ assert.ok(delivery({ mins: '' })['mins']);
+ assert.ok(delivery({ radiusKm: '0' })['radiusKm']);
+});
+
+test('the money fields are optional', () => {
+ assert.equal(isValid(delivery({ minOrder: '', charge: '', freeAbove: '' })), true);
+});
+
+test('negative money is refused', () => {
+ assert.ok(delivery({ charge: '-10' })['charge']);
+});
+
+/*
+Free delivery below the minimum order means every order qualifies — the opposite
+of what the shopkeeper is trying to set up, and it would only be noticed in the
+takings.
+*/
+test('free-delivery threshold below the minimum order is caught', () => {
+ assert.match(String(delivery({ minOrder: '500', freeAbove: '200' })['freeAbove']), /every order/);
+});
diff --git a/src/features/onboarding/validation.ts b/src/features/onboarding/validation.ts
new file mode 100644
index 0000000..d9b9a64
--- /dev/null
+++ b/src/features/onboarding/validation.ts
@@ -0,0 +1,121 @@
+/**
+ * Field rules for store setup.
+ *
+ * Pure and separate from the forms so they can be tested without rendering, and
+ * so every message can be read in one place. The rule the whole file follows:
+ * an error says what is wrong AND what to do about it. "Invalid input" tells a
+ * shopkeeper nothing they did not already know.
+ */
+
+export type Errors = Record;
+
+/** Every digit, and nothing else. */
+export function digitsOnly(value: string): string {
+ return value.replace(/\D/g, '');
+}
+
+/**
+ * Ten digits, however it was pasted.
+ *
+ * The same rule `normaliseMobile` already applies when creating a person: an
+ * Indian mobile is ten digits, and anything longer is a country code somebody's
+ * contact list added. Refusing `+91 98765 43210` would be refusing a correct
+ * number because of how it was copied, which is the kind of validation people
+ * argue with rather than fix.
+ */
+export function normalisePhone(value: string): string {
+ const digits = digitsOnly(value);
+ return digits.length > 10 ? digits.slice(-10) : digits;
+}
+
+export function validateStoreInfo(v: {
+ tenantname: string;
+ primarycontact: string;
+ primaryemail: string;
+ address: string;
+ city: string;
+ postcode: string;
+ opentime: string;
+ closetime: string;
+}): Errors {
+ const e: Errors = {};
+
+ if (!v.tenantname.trim()) e['tenantname'] = 'Your shop needs a name — this is what shoppers see.';
+
+ const phone = normalisePhone(v.primarycontact);
+ if (!phone) e['primarycontact'] = 'A phone number is required so customers can reach you.';
+ else if (phone.length !== 10)
+ e['primarycontact'] = `That is ${phone.length} digits. An Indian mobile number is 10.`;
+
+ // Optional, but if given it has to be usable — a typo here is how a shop
+ // stops receiving order emails without noticing.
+ if (v.primaryemail.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v.primaryemail.trim()))
+ e['primaryemail'] = 'That does not look like an email address. Check for a missing @ or dot.';
+
+ if (!v.address.trim()) e['address'] = 'An address is required — it is how deliveries find you.';
+ if (!v.city.trim()) e['city'] = 'City is required.';
+
+ const pin = digitsOnly(v.postcode);
+ if (!pin) e['postcode'] = 'A pincode is required.';
+ else if (pin.length !== 6) e['postcode'] = `That is ${pin.length} digits. An Indian pincode is 6.`;
+
+ if (!v.opentime) e['opentime'] = 'Opening time is required.';
+ if (!v.closetime) e['closetime'] = 'Closing time is required.';
+ if (v.opentime && v.closetime && v.closetime <= v.opentime)
+ e['closetime'] = 'Closing time must be later than opening time.';
+
+ return e;
+}
+
+export function validateDelivery(v: {
+ offersDelivery: boolean | null;
+ radiusKm: string;
+ minOrder: string;
+ charge: string;
+ freeAbove: string;
+ mins: string;
+}): Errors {
+ const e: Errors = {};
+
+ if (v.offersDelivery === null) e['offersDelivery'] = 'Choose one so we know how to serve your customers.';
+ // Nothing below applies to a shop that does not deliver, and validating
+ // hidden fields is how a form refuses to submit for reasons nobody can see.
+ if (v.offersDelivery !== true) return e;
+
+ const num = (s: string) => (s.trim() === '' ? NaN : Number(s));
+
+ const radius = num(v.radiusKm);
+ if (Number.isNaN(radius)) e['radiusKm'] = 'How far will you deliver? Enter a distance in kilometres.';
+ else if (radius <= 0) e['radiusKm'] = 'A delivery radius has to be more than zero.';
+ else if (radius > 50) e['radiusKm'] = 'That is over 50 km. Enter the distance you can reliably cover.';
+
+ const mins = num(v.mins);
+ if (Number.isNaN(mins)) e['mins'] = 'Roughly how long does a delivery take?';
+ else if (mins <= 0) e['mins'] = 'Delivery time has to be more than zero minutes.';
+
+ for (const [key, label] of [
+ ['minOrder', 'Minimum order'],
+ ['charge', 'Delivery charge'],
+ ['freeAbove', 'Free delivery above'],
+ ] as const) {
+ const raw = v[key];
+ if (raw.trim() === '') continue; // all three are optional
+ const n = Number(raw);
+ if (Number.isNaN(n)) e[key] = `${label} must be a number.`;
+ else if (n < 0) e[key] = `${label} cannot be negative.`;
+ }
+
+ // A free-delivery threshold below the minimum order can never be reached in
+ // the way the shopkeeper intends — every order would qualify.
+ const min = num(v.minOrder);
+ const free = num(v.freeAbove);
+ if (!Number.isNaN(min) && !Number.isNaN(free) && free > 0 && free < min)
+ e['freeAbove'] = 'Free delivery starts below your minimum order, so every order would qualify.';
+
+ return e;
+}
+
+/** True when a set of errors is empty — reads better than `.length === 0` at call sites. */
+export function isValid(errors: Errors): boolean {
+ return Object.keys(errors).length === 0;
+}
diff --git a/src/features/setup/SetupPage.tsx b/src/features/setup/SetupPage.tsx
deleted file mode 100644
index e911ea8..0000000
--- a/src/features/setup/SetupPage.tsx
+++ /dev/null
@@ -1,302 +0,0 @@
-/**
- * The whole journey from "signed in" to "selling", on one screen.
- *
- * The walkthrough started as a strip along the top of the console, and a strip
- * is the wrong shape for this. It can show one step, so a merchant sees a
- * sentence and a button with no idea what they have agreed to, how much is
- * left, or why the thing they just finished was followed by something they were
- * never shown. Onboarding completes two of the seven steps before anybody signs
- * in, and the strip leapt over both in silence.
- *
- * So the journey gets a page. Every step is visible with its real state, the
- * current one is open with its guidance, and a step that was finished before
- * the merchant arrived says so instead of vanishing. The strip stays, but only
- * as a way back here while the work is being done on another screen.
- *
- * Nothing here is stored. Every tick is derived from live data, so the page
- * cannot claim work that was not done, and re-reading it after a change is what
- * moves it on.
- */
-
-import { useMemo } 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 { ArrowRight, Check, Info } from 'lucide-react';
-import { PageBody } from '@/components/PageBody';
-import { PageHeader } from '@/components/PageHeader';
-import type { SetupStep } from '@/features/store-admin/setupSteps';
-
-export interface SetupPageProps {
- steps: readonly SetupStep[];
- /** Steps the merchant has waved past. Never counted as done. */
- skipped: readonly string[];
- isLoading?: boolean;
- home: string;
- onStart: () => void;
- onSkip: (id: string) => void;
- onExit: () => void;
- /** True while the walkthrough is running, which changes the primary action. */
- isActive: boolean;
-}
-
-export function SetupPage({
- steps,
- skipped,
- isLoading = false,
- home,
- onStart,
- onSkip,
- onExit,
- isActive,
-}: SetupPageProps) {
- const navigate = useNavigate();
-
- const focus = useMemo(
- () => steps.find((step) => !step.done && !skipped.includes(step.id)) ?? null,
- [steps, skipped],
- );
-
- const done = steps.filter((step) => step.done).length;
- const pct = steps.length === 0 ? 0 : Math.round((done / steps.length) * 100);
-
- if (isLoading) {
- return (
-
-
-
-
- Checking what is already done…
-
-
-
- );
- }
-
- return (
-
-
-
- {/* Progress as a bar, not just a fraction. "2 of 7" tells somebody how far
- they are; a bar tells them at a glance whether this is nearly over. */}
-
-
-
-
-
- {steps.map((step, index) => (
- {
- if (!isActive) onStart();
- navigate(step.href);
- }}
- onSkip={() => onSkip(step.id)}
- />
- ))}
-
-
-
- {focus ? (
- }
- onClick={() => {
- if (!isActive) onStart();
- navigate(focus.href);
- }}
- />
- ) : (
- navigate(home)} />
- )}
- {isActive ? : null}
-
-
- );
-}
-
-/* ── One step in the journey ──────────────────────────────────────────────── */
-
-function StepRow({
- step,
- index,
- isLast,
- isFocus,
- isSkipped,
- isActive,
- onGo,
- onSkip,
-}: {
- step: SetupStep;
- index: number;
- isLast: boolean;
- isFocus: boolean;
- isSkipped: boolean;
- isActive: boolean;
- onGo: () => void;
- onSkip: () => void;
-}) {
- const marker = step.done
- ? { bg: 'var(--color-success, #10b981)', fg: '#fff', ring: 'transparent' }
- : isFocus
- ? { bg: 'var(--color-brand)', fg: '#fff', ring: 'transparent' }
- : { bg: 'transparent', fg: 'var(--color-ink-4)', ring: 'var(--color-line)' };
-
- return (
-
- {/* The rail: a numbered marker with a line joining it to the next, so the
- seven read as one sequence rather than seven cards. */}
-
-
- {step.done ? : index + 1}
-
- {!isLast ? (
-
- ) : null}
-
-
-
-
-
- {step.title}
-
- {step.detail ? (
-
- {step.detail}
-
- ) : null}
- {isSkipped ? (
-
- · skipped
-
- ) : null}
-
-
- {/* A step finished before the merchant arrived says why. Two of the
- seven are done by onboarding, and silently ticking them looks like
- the walkthrough inventing progress. */}
- {step.done && step.doneNote ? (
-
- {step.doneNote}
-
- ) : null}
-
- {/* Only the current step opens. Seven expanded panels is a manual, not
- a walkthrough. */}
- {isFocus ? (
-
-
- {step.why}
-
-
-
-
- What to do
-
-
- {step.how.map((line) => (
-
-
- {line}
-
-
- ))}
-
-
-
- {step.gotcha ? (
-
-
-
- {step.gotcha}
-
-
- ) : null}
-
-
- }
- onClick={onGo}
- />
- {/* Skipping sits after the action, never before it. */}
- {isActive ? (
-
- ) : null}
-
-
- ) : null}
-
-
- );
-}
diff --git a/src/features/setup/SetupTour.tsx b/src/features/setup/SetupTour.tsx
index 235a793..4586040 100644
--- a/src/features/setup/SetupTour.tsx
+++ b/src/features/setup/SetupTour.tsx
@@ -43,11 +43,9 @@ export interface SetupTourProps {
steps: readonly SetupStep[];
/** Where "finished" lands. The console, for both roles. */
home: string;
- /** The journey page — where the walkthrough starts and can be reviewed. */
- setupHref: string;
}
-export function SetupTour({ userid, tenantid, steps, home, setupHref }: SetupTourProps) {
+export function SetupTour({ userid, tenantid, steps, home }: SetupTourProps) {
const navigate = useNavigate();
const { pathname } = useLocation();
const [tour, setTour] = useState(() => readTour(userid, tenantid));
@@ -107,7 +105,6 @@ export function SetupTour({ userid, tenantid, steps, home, setupHref }: SetupTou
// To the journey first, not to step one. Somebody agreeing to a
// walkthrough should see what they agreed to — how many steps,
// which are already done, and why.
- navigate(setupHref);
}}
onCancel={() => setTour(declineTour(userid, tenantid))}
/>
@@ -154,12 +151,6 @@ export function SetupTour({ userid, tenantid, steps, home, setupHref }: SetupTou
between the header and the work on every screen. Open, it stays
open across steps — somebody who wants the detail wants it for
all of them. */}
- navigate(setupHref)}
- />
(() => readTour(userid, tenantid));
-
- return (
- setTour(startTour(userid, tenantid))}
- onSkip={(id) => setTour(skipInTour(userid, tenantid, id))}
- onExit={() => {
- setTour(endTour(userid, tenantid));
- navigate('/admin/console');
- }}
- />
- );
-}
diff --git a/src/features/setup/StoreAdminTour.tsx b/src/features/setup/StoreAdminTour.tsx
deleted file mode 100644
index 7693913..0000000
--- a/src/features/setup/StoreAdminTour.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * The merchant's walkthrough strip, mounted in the Store Admin shell.
- *
- * Its own component rather than props on the shell, because it has to sit
- * INSIDE `BranchScopeProvider` to read the branches — and because the two roles
- * feed the walkthrough from entirely different data, which a single shared
- * mount would turn into a pile of conditionals.
- *
- * Every hook it uses already existed. The walkthrough needed no new backend.
- */
-
-import { useStoreAdminSteps } from './useSetupSteps';
-import { SetupTour } from './SetupTour';
-
-export function StoreAdminTour() {
- const { steps, isLoading, tenantid, userid } = useStoreAdminSteps();
- if (isLoading || !userid) return null;
-
- return (
-
- );
-}
diff --git a/src/features/setup/StoreUserSetupPage.tsx b/src/features/setup/StoreUserSetupPage.tsx
deleted file mode 100644
index 96cc109..0000000
--- a/src/features/setup/StoreUserSetupPage.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * The branch user’s setup journey, at /store/setup.
- *
- * Reachable at any time, not only during the walkthrough — somebody who
- * dismissed the offer, or finished half of it last week, needs a way back to
- * the list without being asked again.
- */
-
-import { useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { SetupPage } from './SetupPage';
-import { useStoreUserSteps } from './useSetupSteps';
-import { endTour, readTour, skipInTour, startTour, type TourState } from './tourState';
-
-export function StoreUserSetupPage() {
- const navigate = useNavigate();
- const { steps, isLoading, tenantid, userid } = useStoreUserSteps();
- const [tour, setTour] = useState(() => readTour(userid, tenantid));
-
- return (
- setTour(startTour(userid, tenantid))}
- onSkip={(id) => setTour(skipInTour(userid, tenantid, id))}
- onExit={() => {
- setTour(endTour(userid, tenantid));
- navigate('/store/console');
- }}
- />
- );
-}
diff --git a/src/features/setup/StoreUserTour.tsx b/src/features/setup/StoreUserTour.tsx
index 39bc870..fc0d13e 100644
--- a/src/features/setup/StoreUserTour.tsx
+++ b/src/features/setup/StoreUserTour.tsx
@@ -22,7 +22,6 @@ export function StoreUserTour() {
tenantid={tenantid}
steps={steps}
home="/store/console"
- setupHref="/store/setup"
/>
);
}
diff --git a/src/features/store-admin/StoreAdminShell.tsx b/src/features/store-admin/StoreAdminShell.tsx
index ed4ebc3..fb86fce 100644
--- a/src/features/store-admin/StoreAdminShell.tsx
+++ b/src/features/store-admin/StoreAdminShell.tsx
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { Check, ChevronDown, FileSpreadsheet, Monitor, Store, Users } from 'lucide-react';
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
-import { StoreAdminTour } from '@/features/setup/StoreAdminTour';
+import { OnboardingGate } from '@/features/onboarding/OnboardingGate';
import { BranchScopeProvider, useBranchScope } from './BranchScope';
import { useLiveEvents } from '@/queries/useLiveEvents';
@@ -80,13 +80,13 @@ export function StoreAdminShell() {
return (
+ }
manageItems={MANAGE}
- banner={}
/>
);
diff --git a/src/index.css b/src/index.css
index 55bbb30..3b7352f 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1179,3 +1179,235 @@ main {
.demo-flag:hover .demo-flag-exit {
background: #6b4600;
}
+
+/* ─────────────────────────────────────────────────────────────────────────
+ Store setup
+
+ Layout only. Every colour is an existing token — brand purple for the
+ current step, the ink scale for text, --color-line for anything at rest —
+ so the flow reads as part of the console rather than a microsite bolted
+ onto it.
+ ───────────────────────────────────────────────────────────────────────── */
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* The compact indicator is the phone's, and the full one everything above it.
+ Not a squeeze of the same markup: five labelled nodes are unreadable at
+ 360px, and "Step 2 of 5" answers the same question in the space there is. */
+.ob-stepper-compact { display: block; }
+.ob-stepper { display: none; }
+
+@media (min-width: 640px) {
+ .ob-stepper-compact { display: none; }
+ .ob-stepper {
+ display: flex;
+ align-items: flex-start;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ gap: 0;
+ }
+}
+
+.ob-step {
+ position: relative;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.ob-step-node {
+ width: 30px;
+ height: 30px;
+ border-radius: 999px;
+ display: grid;
+ place-items: center;
+ font: 600 12px/1 var(--font-sans);
+ font-variant-numeric: tabular-nums;
+ border: 1px solid var(--color-line);
+ background: var(--color-surface);
+ color: var(--color-ink-4);
+ transition: background .2s, border-color .2s, color .2s, box-shadow .2s;
+}
+
+.ob-step-label {
+ font: 500 12.5px/1.3 var(--font-sans);
+ color: var(--color-ink-4);
+ text-align: center;
+ transition: color .2s;
+}
+
+/* The connector sits behind the nodes and stops short of both, so it reads as
+ a join rather than an underline. */
+.ob-step-line {
+ position: absolute;
+ top: 15px;
+ left: calc(50% + 22px);
+ right: calc(-50% + 22px);
+ height: 1px;
+ background: var(--color-line);
+ transition: background .3s;
+}
+.ob-step-line[data-filled='yes'] { background: var(--color-brand); }
+
+.ob-step[data-state='current'] .ob-step-node {
+ background: var(--color-brand);
+ border-color: var(--color-brand);
+ color: #fff;
+ /* A soft ring rather than a heavier border: it marks the position without
+ making the node look like a button waiting to be pressed. */
+ box-shadow: 0 0 0 4px var(--color-brand-tint);
+}
+.ob-step[data-state='current'] .ob-step-label {
+ color: var(--color-ink-1);
+ font-weight: 600;
+}
+
+.ob-step[data-state='done'] .ob-step-node {
+ background: var(--color-brand);
+ border-color: var(--color-brand);
+ color: #fff;
+}
+.ob-step[data-state='done'] .ob-step-label { color: var(--color-ink-2); }
+
+/* Selectable cards — the catalogue choice and the delivery question. Hover and
+ selection are carried by the border and a tint, never by a shadow that would
+ make one card float above the others. */
+.ob-choice {
+ display: block;
+ width: 100%;
+ text-align: left;
+ padding: 20px;
+ border: 1px solid var(--color-line);
+ border-radius: 14px;
+ background: var(--color-surface);
+ cursor: pointer;
+ transition: border-color .18s, background .18s, transform .18s;
+}
+.ob-choice:hover { border-color: var(--color-brand); background: var(--color-brand-tint); }
+.ob-choice:focus-visible {
+ outline: 2px solid var(--color-brand);
+ outline-offset: 2px;
+}
+.ob-choice[aria-pressed='true'],
+.ob-choice[data-selected='true'] {
+ border-color: var(--color-brand);
+ background: var(--color-brand-tint);
+}
+
+/* The drop zone. The dashed edge is the only place in the console that uses
+ one, and it earns it: it says "put something here" before any label is
+ read. */
+.ob-drop {
+ border: 1.5px dashed var(--color-slate-300);
+ border-radius: 14px;
+ background: var(--color-surface-subtle);
+ padding: 32px 20px;
+ text-align: center;
+ transition: border-color .18s, background .18s;
+}
+.ob-drop[data-over='true'] {
+ border-color: var(--color-brand);
+ background: var(--color-brand-tint);
+}
+
+/* One step in, one step out. Short enough not to be waited on. */
+@keyframes ob-step-in {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: none; }
+}
+.ob-panel { animation: ob-step-in .22s cubic-bezier(.4,0,.2,1); }
+
+@media (prefers-reduced-motion: reduce) {
+ .ob-panel { animation: none; }
+ .ob-step-line, .ob-step-node, .ob-choice { transition: none; }
+}
+
+/* Three benefit cards on the welcome screen: one column on a phone, three
+ across from tablet. Same grid rhythm as .card-grid elsewhere. */
+.ob-benefits {
+ display: grid;
+ gap: 16px;
+ grid-template-columns: minmax(0, 1fr);
+}
+@media (min-width: 720px) {
+ .ob-benefits { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+
+/* Two large choices — upload or add by hand. Stacked on a phone so neither is
+ the cramped one. */
+.ob-choices {
+ display: grid;
+ gap: 16px;
+ grid-template-columns: minmax(0, 1fr);
+}
+@media (min-width: 720px) {
+ .ob-choices { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+/* The stock routine, drawn. Vertical on a phone so each stage reads as a line;
+ a row from tablet, where six across still fits without shrinking the text. */
+.ob-flow {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 10px;
+}
+.ob-flow-step {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border-radius: 10px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-line);
+}
+.ob-flow-num {
+ width: 22px;
+ height: 22px;
+ flex: none;
+ border-radius: 999px;
+ display: grid;
+ place-items: center;
+ background: var(--color-brand-tint);
+ color: var(--color-brand);
+ font: 600 11px/1 var(--font-sans);
+ font-variant-numeric: tabular-nums;
+}
+@media (min-width: 900px) {
+ .ob-flow { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+
+/* One tick, drawn once. A business tool does not need confetti, and anything
+ longer than this delays the three actions the screen exists to offer. */
+.ob-tick {
+ width: 68px;
+ height: 68px;
+ border-radius: 999px;
+ display: grid;
+ place-items: center;
+ background: #10b981;
+ color: #fff;
+ animation: ob-pop .38s cubic-bezier(.34, 1.56, .64, 1);
+}
+@keyframes ob-pop {
+ 0% { transform: scale(.6); opacity: 0; }
+ 100% { transform: scale(1); opacity: 1; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .ob-tick { animation: none; }
+}