setup onboarding page

This commit is contained in:
2026-09-02 11:26:00 +05:30
parent b72bbd2f12
commit 7df8a49e5f
23 changed files with 2490 additions and 417 deletions

View File

@@ -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() {
<Route path="terminals" element={<TerminalsPage />} />
<Route path="uploads" element={<AdminUploadsPage />} />
<Route path="profile" element={<ShopProfilePage />} />
<Route path="setup" element={<StoreAdminSetupPage />} />
<Route path="onboarding" element={<OnboardingPage />} />
{/* 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() {
<Route path="terminals" element={<TerminalsPage />} />
<Route path="staff" element={<StoreStaffPage />} />
<Route path="uploads" element={<StoreUploadsPage />} />
<Route path="setup" element={<StoreUserSetupPage />} />
<Route path="account" element={<StoreAccountPage />} />
<Route path="*" element={<Navigate to="/store/console" replace />} />
</Route>

View File

@@ -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<string, unknown>;
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;
}

View 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>
);
}

View File

@@ -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 ─────────────────────────────────────────────── */}
<div className="ob-stepper-compact">
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
marginBottom: 8,
}}
>
<span style={{ font: '600 13px/1.3 var(--font-sans)', color: 'var(--color-ink-1)' }}>
{position > 0 ? `Step ${position} of ${PROGRESS_STEPS.length}` : 'Getting started'}
</span>
<span style={{ font: '500 12px/1.3 var(--font-sans)', color: 'var(--color-ink-3)' }}>
{STEP_LABEL[current]}
</span>
</div>
<div
role="progressbar"
aria-valuenow={doneCount}
aria-valuemin={0}
aria-valuemax={PROGRESS_STEPS.length}
aria-label="Setup progress"
style={{ height: 4, borderRadius: 999, background: 'var(--color-line)', overflow: 'hidden' }}
>
<div
style={{
width: `${pct}%`,
height: '100%',
background: 'var(--color-brand)',
transition: 'width .35s cubic-bezier(.4,0,.2,1)',
}}
/>
</div>
</div>
{/* ── Full: tablet and up ─────────────────────────────────────────── */}
<ol className="ob-stepper" aria-label="Setup steps">
{PROGRESS_STEPS.map((id, i) => {
const isDone = completed.includes(id);
const isCurrent = id === current;
const state = isDone ? 'done' : isCurrent ? 'current' : 'todo';
return (
<li key={id} className="ob-step" data-state={state}>
<span className="ob-step-node" aria-hidden>
{isDone ? <Check size={13} strokeWidth={3} /> : i + 1}
</span>
<span className="ob-step-label">{STEP_LABEL[id]}</span>
{i < PROGRESS_STEPS.length - 1 ? (
<span className="ob-step-line" data-filled={isDone ? 'yes' : 'no'} aria-hidden />
) : null}
{/* The state is carried in the markup for a screen reader rather
than left to colour and a tick glyph. */}
<span className="sr-only">
{isDone ? ' — completed' : isCurrent ? ' — current step' : ' — not started'}
</span>
</li>
);
})}
</ol>
</>
);
}

View File

@@ -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);
});

View File

@@ -0,0 +1,201 @@
/**
* Where somebody has got to in store setup, and what they have typed so far.
*
* Two different things are kept, for two different reasons:
*
* - **Progress** — which steps are finished. Derived from the backend wherever
* it can be (a shop with a name and a licence has done Store Information),
* because a stored flag can claim work that was never done. What cannot be
* derived is *intent*: which steps were deliberately skipped, and whether
* setup was finished or abandoned.
* - **The draft** — half-typed answers. A form somebody spent five minutes on
* must survive a refresh, a closed laptop, or a phone call. This is the only
* place drafts live until they are saved.
*
* Per browser, per person, per shop. Deliberately not synced: an unsent draft
* is a private half-thought, and two people editing one shop's setup from
* different desks should not overwrite each other's typing.
*/
const KEY = 'nearle.onboarding';
export type StepId = 'welcome' | 'store' | 'catalogue' | 'inventory' | 'delivery' | 'review' | 'done';
/** The five that appear in the progress indicator. Welcome and Done bracket them. */
export const PROGRESS_STEPS: readonly StepId[] = ['store', 'catalogue', 'inventory', 'delivery', 'review'];
export const STEP_LABEL: Record<StepId, string> = {
welcome: 'Welcome',
store: 'Store',
catalogue: 'Catalogue',
inventory: 'Inventory',
delivery: 'Delivery',
review: 'Review',
done: 'Done',
};
export interface StoreDraft {
tenantname: string;
tenantimage: string;
primaryemail: string;
primarycontact: string;
address: string;
city: string;
postcode: string;
state: string;
tenanttype: string;
opentime: string;
closetime: string;
}
export interface DeliveryDraft {
offersDelivery: boolean | null;
radiusKm: string;
minOrder: string;
charge: string;
freeAbove: string;
mins: string;
startTime: string;
endTime: string;
}
export interface OnboardingState {
/** Where to put them back when they return. */
step: StepId;
/** Steps they finished, so the indicator can tick them. */
completed: StepId[];
/** Steps they chose to pass over. Never counted as completed. */
skipped: StepId[];
/** True once they have reached the end — the offer is not made again. */
finished: boolean;
/** True once they have been offered setup at all. */
started: boolean;
store: StoreDraft;
delivery: DeliveryDraft;
}
export const EMPTY_STORE: StoreDraft = {
tenantname: '',
tenantimage: '',
primaryemail: '',
primarycontact: '',
address: '',
city: '',
postcode: '',
state: '',
tenanttype: '',
// Sensible for a neighbourhood shop, and both are required — an empty time
// picker asks a question the shopkeeper has to think about before they have
// decided anything else.
opentime: '08:00',
closetime: '22:00',
};
export const EMPTY_DELIVERY: DeliveryDraft = {
offersDelivery: null,
radiusKm: '',
minOrder: '',
charge: '',
freeAbove: '',
mins: '',
startTime: '09:00',
endTime: '21:00',
};
const EMPTY: OnboardingState = {
step: 'welcome',
completed: [],
skipped: [],
finished: false,
started: false,
store: EMPTY_STORE,
delivery: EMPTY_DELIVERY,
};
function scope(userid: number, tenantid: number): string {
return `${userid}:${tenantid}`;
}
function readAll(): Record<string, OnboardingState> {
try {
const raw = localStorage.getItem(KEY);
const parsed: unknown = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === 'object' ? (parsed as Record<string, OnboardingState>) : {};
} catch {
// A private window, cleared site data, or storage the browser refuses.
// Losing a draft is a nuisance; throwing on read would take the page down.
return {};
}
}
export function readOnboarding(userid: number, tenantid: number): OnboardingState {
const stored = readAll()[scope(userid, tenantid)];
if (!stored) return EMPTY;
// Merged over the defaults so a state written by an older build — before a
// field existed — does not arrive with `undefined` where a form expects a
// string and React switches the input to uncontrolled.
return {
...EMPTY,
...stored,
store: { ...EMPTY_STORE, ...(stored.store ?? {}) },
delivery: { ...EMPTY_DELIVERY, ...(stored.delivery ?? {}) },
completed: Array.isArray(stored.completed) ? stored.completed : [],
skipped: Array.isArray(stored.skipped) ? stored.skipped : [],
};
}
export function writeOnboarding(
userid: number,
tenantid: number,
next: OnboardingState,
): OnboardingState {
const all = readAll();
try {
localStorage.setItem(KEY, JSON.stringify({ ...all, [scope(userid, tenantid)]: next }));
} catch {
/* Storage refused. The change still applies to this session; losing it on
reload is a smaller failure than the write throwing mid-keystroke. */
}
return next;
}
/** Mark a step finished and move on. Completing twice is not an error. */
export function completeStep(state: OnboardingState, id: StepId, next: StepId): OnboardingState {
return {
...state,
step: next,
completed: [...new Set([...state.completed, id])],
// Finishing a step it was previously skipped clears the skip — the work is
// done, and leaving it marked "skipped" would misreport the review page.
skipped: state.skipped.filter((entry) => entry !== id),
started: true,
};
}
/** Pass over a step without doing it. Never counts as completed. */
export function skipStep(state: OnboardingState, id: StepId, next: StepId): OnboardingState {
return {
...state,
step: next,
skipped: [...new Set([...state.skipped, id])],
started: true,
};
}
/**
* How far along, for the welcome screen and the progress bar.
*
* Counts only the five real steps. Welcome is not an achievement and Done is
* the result of the others, so including either would show progress before
* anything had been done.
*/
export function progressOf(state: OnboardingState): { done: number; total: number } {
const done = PROGRESS_STEPS.filter((id) => state.completed.includes(id)).length;
return { done, total: PROGRESS_STEPS.length };
}
/** The step to resume on: where they left off, unless that is already finished. */
export function resumeStep(state: OnboardingState): StepId {
if (state.finished) return 'done';
return state.step;
}

View File

@@ -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 (
<VStack gap={3} className="ob-panel">
{productCount > 0 ? (
<Card padding={3} elevation="low">
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
You already have {productCount} product{productCount === 1 ? '' : 's'}
</Text>
<Text type="body" size="sm" color="secondary">
You can add more now, or carry on and come back to this later.
</Text>
</VStack>
</Card>
) : null}
<div className="ob-choices">
<Choice
icon={<FileSpreadsheet size={22} />}
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}
/>
<Choice
icon={<PackagePlus size={22} />}
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}
/>
</div>
<Card padding={3} variant="transparent">
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
No product list yet?
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
That is fine — this is the one step you can leave for later. Use{' '}
<strong>Skip for now</strong> below and add products whenever you are ready. Nothing
else in setup depends on it.
</Text>
</VStack>
</Card>
</VStack>
);
}
function Choice({
icon,
title,
body,
cta,
recommended,
onClick,
}: {
icon: React.ReactNode;
title: string;
body: string;
cta: string;
recommended?: boolean;
onClick: () => void;
}) {
return (
<button type="button" className="ob-choice" onClick={onClick}>
<VStack gap={1.5}>
<HStack justify="between" align="start" gap={1}>
<span style={{ color: 'var(--color-brand)' }} aria-hidden>
{icon}
</span>
{recommended ? (
<span
style={{
font: '600 10.5px/1 var(--font-sans)',
letterSpacing: '.06em',
textTransform: 'uppercase',
color: 'var(--color-brand)',
background: 'var(--color-brand-soft)',
padding: '4px 8px',
borderRadius: 999,
}}
>
Recommended
</span>
) : null}
</HStack>
<VStack gap={0.5}>
<Text type="label" size="lg" weight="semibold">
{title}
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{body}
</Text>
</VStack>
<Text type="label" size="sm" weight="semibold" style={{ color: 'var(--color-brand)' }}>
{cta} →
</Text>
</VStack>
</button>
);
}

View File

@@ -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 = <K extends keyof DeliveryDraft>(key: K) => (next: DeliveryDraft[K]) =>
onChange({ ...value, [key]: next });
return (
<VStack gap={3} className="ob-panel">
<VStack gap={1.5}>
<Text type="label" size="sm" weight="semibold">
Do you deliver to customers?
</Text>
<div className="ob-choices" role="radiogroup" aria-label="Do you deliver to customers?">
<ChoiceCard
icon={<Bike size={20} />}
title="Yes, we deliver"
body="Customers can order to their address within the area you cover."
isSelected={value.offersDelivery === true}
onSelect={() => set('offersDelivery')(true)}
/>
<ChoiceCard
icon={<Store size={20} />}
title="No, collection only"
body="Customers order and come to the shop to pick up."
isSelected={value.offersDelivery === false}
onSelect={() => set('offersDelivery')(false)}
/>
</div>
{errors['offersDelivery'] ? (
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
{errors['offersDelivery']}
</Text>
) : null}
</VStack>
{/* 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 ? (
<Card padding={3} elevation="low" className="ob-panel">
<VStack gap={2}>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
Delivery settings
</Text>
<Text type="body" size="xsm" color="secondary">
Only the first two are required. The rest can be set later.
</Text>
</VStack>
<div className="form-grid">
<TextInput
label="How far do you deliver? (km) *"
value={value.radiusKm}
onChange={set('radiusKm')}
placeholder="5"
description="Addresses beyond this are not offered your shop at all."
{...(errors['radiusKm'] ? { error: errors['radiusKm'] } : {})}
/>
<TextInput
label="Typical delivery time (minutes) *"
value={value.mins}
onChange={set('mins')}
placeholder="45"
description="Shown to the customer before they order."
{...(errors['mins'] ? { error: errors['mins'] } : {})}
/>
<TextInput
label="Minimum order (₹)"
value={value.minOrder}
onChange={set('minOrder')}
placeholder="199"
{...(errors['minOrder'] ? { error: errors['minOrder'] } : {})}
/>
<TextInput
label="Delivery charge (₹)"
value={value.charge}
onChange={set('charge')}
placeholder="30"
{...(errors['charge'] ? { error: errors['charge'] } : {})}
/>
<TextInput
label="Free delivery above (₹)"
value={value.freeAbove}
onChange={set('freeAbove')}
placeholder="499"
description="Leave empty if you always charge."
{...(errors['freeAbove'] ? { error: errors['freeAbove'] } : {})}
/>
</div>
<VStack gap={1}>
<Text type="label" size="xsm" color="secondary">
When do you deliver?
</Text>
<div className="form-grid">
<TimeInput
label="From"
value={asTime(value.startTime)}
onChange={(next) => set('startTime')(String(next ?? value.startTime))}
/>
<TimeInput
label="Until"
value={asTime(value.endTime)}
onChange={(next) => set('endTime')(String(next ?? value.endTime))}
/>
</div>
</VStack>
</VStack>
</Card>
) : null}
{value.offersDelivery === false ? (
<Card padding={3} variant="transparent" className="ob-panel">
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
Your shop will be listed for collection. You can turn delivery on later from your shop
settings without redoing any of this.
</Text>
</Card>
) : null}
</VStack>
);
}
function ChoiceCard({
icon,
title,
body,
isSelected,
onSelect,
}: {
icon: React.ReactNode;
title: string;
body: string;
isSelected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
role="radio"
aria-checked={isSelected}
data-selected={isSelected ? 'true' : 'false'}
className="ob-choice"
onClick={onSelect}
>
<VStack gap={1}>
<HStack gap={1} align="center">
<span
aria-hidden
style={{ color: isSelected ? 'var(--color-brand)' : 'var(--color-ink-3)' }}
>
{icon}
</span>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
</HStack>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{body}
</Text>
</VStack>
</button>
);
}

View File

@@ -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 (
<VStack gap={4} className="ob-panel">
<VStack gap={1.5} style={{ textAlign: 'center', alignItems: 'center' }}>
<span className="ob-tick" aria-hidden>
<Check size={30} strokeWidth={3} />
</span>
<Text
type="display-2"
weight="semibold"
style={{ fontFamily: 'var(--font-display)', textWrap: 'balance' }}
>
You&rsquo;re all set 🎉
</Text>
<Text type="body" color="secondary" style={{ maxWidth: '48ch', lineHeight: 1.65 }}>
{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.'}
</Text>
</VStack>
<div className="ob-benefits">
<NextCard
icon={<Boxes size={20} />}
title="Manage your catalogue"
body="Add products, set prices, and release them to your shops."
cta="Open catalogue"
onClick={onCatalogue}
/>
<NextCard
icon={<PackageSearch size={20} />}
title="Update your stock"
body="Upload your latest counts so customers see what is really there."
cta="Update stock"
onClick={onInventory}
/>
<NextCard
icon={<LayoutDashboard size={20} />}
title="See your shop"
body="Check what a customer sees, and which products are on sale."
cta="View products"
onClick={onStorefront}
/>
</div>
<HStack justify="center">
<Button
label="Go to the console"
variant="primary"
size="lg"
endContent={<ArrowRight size={15} />}
onClick={onDashboard}
/>
</HStack>
</VStack>
);
}
function NextCard({
icon,
title,
body,
cta,
onClick,
}: {
icon: React.ReactNode;
title: string;
body: string;
cta: string;
onClick: () => void;
}) {
return (
<button type="button" className="ob-choice" onClick={onClick}>
<VStack gap={1}>
<span style={{ color: 'var(--color-brand)' }} aria-hidden>
{icon}
</span>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{body}
</Text>
<Text type="label" size="sm" weight="semibold" style={{ color: 'var(--color-brand)' }}>
{cta} →
</Text>
</VStack>
</button>
);
}

View File

@@ -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 (
<VStack gap={3} className="ob-panel">
<Card padding={3} elevation="low">
<VStack gap={2}>
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
How stock reaches your customers
</Text>
<Text type="body" size="sm" color="secondary">
You do not need a till system. A spreadsheet is enough.
</Text>
</VStack>
<ol className="ob-flow">
{FLOW.map((stage, i) => (
<li key={stage} className="ob-flow-step">
<span className="ob-flow-num" aria-hidden>
{i + 1}
</span>
<Text type="body" size="sm">
{stage}
</Text>
</li>
))}
</ol>
</VStack>
</Card>
<Card padding={3} elevation="low">
<VStack gap={2}>
<HStack justify="between" align="start" gap={2} wrap="wrap">
<VStack gap={0.5}>
<Text type="label" size="lg" weight="semibold">
Stock by spreadsheet
</Text>
<Text type="body" size="sm" color="secondary" style={{ maxWidth: '52ch', lineHeight: 1.6 }}>
Upload your latest counts whenever they change. We check the file first and tell you
about any row we could not read.
</Text>
</VStack>
<span
style={{
font: '600 10.5px/1 var(--font-sans)',
letterSpacing: '.06em',
textTransform: 'uppercase',
color: 'var(--color-brand)',
background: 'var(--color-brand-soft)',
padding: '4px 8px',
borderRadius: 999,
flex: 'none',
}}
>
Recommended
</span>
</HStack>
<HStack gap={1.5} wrap="wrap">
<Button
label="Download the template"
variant="secondary"
size="sm"
icon={<Download size={14} />}
onClick={onDownloadTemplate}
/>
<Button
label="Upload a stock file"
variant="primary"
size="sm"
icon={<Upload size={14} />}
endContent={<ArrowRight size={13} />}
onClick={onUpload}
/>
</HStack>
</VStack>
</Card>
<Card padding={3} variant="transparent">
<VStack gap={1}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '.09em' }}
>
Worth knowing
</Text>
<ul style={{ margin: 0, paddingLeft: 20, display: 'grid', gap: 6 }}>
{TIPS.map((tip) => (
<li key={tip}>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{tip}
</Text>
</li>
))}
</ul>
</VStack>
</Card>
</VStack>
);
}

View File

@@ -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 (
<VStack gap={2} className="ob-panel">
<Section title="Store information" onEdit={() => onEdit('store')}>
<Row label="Name" value={store.tenantname || '—'} />
<Row
label="Address"
value={[store.address, store.city, store.state, store.postcode].filter(Boolean).join(', ') || '—'}
/>
<Row label="Phone" value={store.primarycontact || '—'} />
<Row label="Email" value={store.primaryemail || '—'} />
<Row label="Type" value={store.tenanttype || '—'} />
<Row label="Hours" value={`${store.opentime} – ${store.closetime}`} />
</Section>
<Section title="Catalogue" onEdit={() => onEdit('catalogue')}>
{skipped.includes('catalogue') && productCount === 0 ? (
<Skipped what="You chose to add products later." />
) : (
<Row
label="Products"
value={productCount > 0 ? `${productCount} in your catalogue` : 'None yet'}
/>
)}
</Section>
<Section title="Inventory" onEdit={() => onEdit('inventory')}>
{skipped.includes('inventory') ? (
<Skipped what="You chose to set up stock later." />
) : (
<Row label="Method" value="Stock spreadsheet" />
)}
</Section>
<Section title="Delivery" onEdit={() => onEdit('delivery')}>
{delivery.offersDelivery === true ? (
<>
<Row label="Delivery" value="Yes" />
<Row label="Area covered" value={delivery.radiusKm ? `${delivery.radiusKm} km` : '—'} />
<Row label="Typical time" value={delivery.mins ? `${delivery.mins} minutes` : '—'} />
<Row label="Minimum order" value={money(delivery.minOrder)} />
<Row label="Charge" value={money(delivery.charge)} />
<Row label="Free above" value={money(delivery.freeAbove)} />
<Row label="Hours" value={`${delivery.startTime} – ${delivery.endTime}`} />
</>
) : delivery.offersDelivery === false ? (
<Row label="Delivery" value="Collection only" />
) : (
<Skipped what="Not answered yet." />
)}
</Section>
{/* The consequence nobody expects: setup can be complete and the shop
still has nothing a customer can buy. */}
{productCount === 0 ? (
<Card padding={3} variant="transparent">
<HStack gap={1.5} align="start">
<AlertTriangle
size={16}
style={{ color: 'var(--color-warning, #b7860b)', flex: 'none', marginTop: 2 }}
/>
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Your shop has no products yet
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
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.
</Text>
</VStack>
</HStack>
</Card>
) : null}
</VStack>
);
}
function Section({
title,
onEdit,
children,
}: {
title: string;
onEdit: () => void;
children: React.ReactNode;
}) {
return (
<Card padding={3} elevation="low">
<VStack gap={1.5}>
<HStack justify="between" align="center" gap={2}>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
<Button label="Edit" variant="ghost" size="sm" onClick={onEdit} />
</HStack>
<VStack gap={0.5}>{children}</VStack>
</VStack>
</Card>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<HStack gap={2} align="start" justify="between" wrap="wrap">
<Text type="body" size="sm" color="secondary" style={{ flex: 'none', minWidth: 120 }}>
{label}
</Text>
<Text type="body" size="sm" style={{ textAlign: 'right', minWidth: 0 }}>
{value}
</Text>
</HStack>
);
}
/* A skipped step says so. Blank rows read as data that failed to save. */
function Skipped({ what }: { what: string }) {
return (
<Text type="body" size="sm" color="secondary">
{what} You can do it any time from the console.
</Text>
);
}

View File

@@ -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 = <K extends keyof StoreDraft>(key: K) => (next: StoreDraft[K]) =>
onChange({ ...value, [key]: next });
return (
<VStack gap={3} className="ob-panel">
<Group title="Basic information">
<div className="form-grid">
<TextInput
label={<Required>Store name</Required> 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'] } : {})}
/>
<TextInput
label={<Required>Phone number</Required> as unknown as string}
value={value.primarycontact}
onChange={set('primarycontact')}
placeholder="98765 43210"
{...(errors['primarycontact'] ? { error: errors['primarycontact'] } : {})}
/>
<TextInput
label="Store email"
type="email"
value={value.primaryemail}
onChange={set('primaryemail')}
placeholder="orders@yourshop.in"
description="Optional. Where order notifications go."
{...(errors['primaryemail'] ? { error: errors['primaryemail'] } : {})}
/>
<Selector
label={<Required>Store type</Required> as unknown as string}
value={value.tenanttype}
onChange={set('tenanttype')}
placeholder="Choose one"
options={STORE_TYPES}
{...(errors['tenanttype'] ? { error: errors['tenanttype'] } : {})}
/>
</div>
<LogoField value={value.tenantimage} onChange={set('tenantimage')} />
</Group>
<Group title="Store address" note="How deliveries and customers find you.">
<VStack gap={2}>
<TextInput
label={<Required>Address</Required> as unknown as string}
value={value.address}
onChange={set('address')}
placeholder="Shop number, street, area"
{...(errors['address'] ? { error: errors['address'] } : {})}
/>
<div className="form-grid">
<TextInput
label={<Required>City</Required> as unknown as string}
value={value.city}
onChange={set('city')}
{...(errors['city'] ? { error: errors['city'] } : {})}
/>
<TextInput
label={<Required>Pincode</Required> as unknown as string}
value={value.postcode}
onChange={set('postcode')}
placeholder="641001"
{...(errors['postcode'] ? { error: errors['postcode'] } : {})}
/>
<TextInput label="State" value={value.state} onChange={set('state')} />
</div>
</VStack>
</Group>
<Group title="Opening hours" note="When your shop takes orders.">
<div className="form-grid">
<TimeInput
label={<Required>Opens at</Required> as unknown as string}
value={asTime(value.opentime)}
onChange={(next) => set('opentime')(String(next ?? value.opentime))}
{...(errors['opentime'] ? { description: errors['opentime'] } : {})}
/>
<TimeInput
label={<Required>Closes at</Required> as unknown as string}
value={asTime(value.closetime)}
onChange={(next) => set('closetime')(String(next ?? value.closetime))}
{...(errors['closetime'] ? { description: errors['closetime'] } : {})}
/>
</div>
</Group>
</VStack>
);
}
/* ── Pieces ───────────────────────────────────────────────────────────────── */
function Group({
title,
note,
children,
}: {
title: string;
note?: string;
children: React.ReactNode;
}) {
return (
<Card padding={3} elevation="low">
<VStack gap={2}>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
{note ? (
<Text type="body" size="xsm" color="secondary">
{note}
</Text>
) : null}
</VStack>
{children}
</VStack>
</Card>
);
}
/** The asterisk carries a text alternative, so it is not colour-and-glyph only. */
function Required({ children }: { children: React.ReactNode }) {
return (
<span>
{children}{' '}
<span style={{ color: 'var(--color-error, #d64545)' }} aria-hidden>
*
</span>
<span className="sr-only"> (required)</span>
</span>
);
}
/**
* 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<HTMLInputElement>(null);
const [problem, setProblem] = useState<string | null>(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 (
<VStack gap={1}>
<Text type="label" size="xsm" color="secondary">
Store logo
</Text>
<HStack gap={2} align="center" wrap="wrap">
{value ? (
<img
src={value}
alt="Your store logo"
style={{
width: 72,
height: 72,
borderRadius: 14,
objectFit: 'cover',
border: '1px solid var(--color-line)',
flex: 'none',
}}
/>
) : (
<span
aria-hidden
style={{
width: 72,
height: 72,
borderRadius: 14,
display: 'grid',
placeItems: 'center',
border: '1.5px dashed var(--color-slate-300)',
color: 'var(--color-ink-4)',
background: 'var(--color-surface-subtle)',
flex: 'none',
}}
>
<ImagePlus size={22} />
</span>
)}
<VStack gap={0.5}>
<button
type="button"
onClick={() => 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',
}}
>
<Upload size={13} />
{isReading ? 'Reading…' : value ? 'Change logo' : 'Upload logo'}
</button>
<Text type="body" size="xsm" color="secondary">
JPG, PNG or WebP, up to 2 MB. Optional.
</Text>
</VStack>
<input
ref={input}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => take(event.target.files?.[0])}
style={{ display: 'none' }}
aria-hidden
tabIndex={-1}
/>
</HStack>
{/* The honest alternative: the field is a string, so a hosted image works
just as well and costs the database nothing. */}
<TextInput
label="…or paste an image link"
isLabelHidden
size="sm"
value={value.startsWith('data:') ? '' : value}
onChange={onChange}
placeholder="https://…"
/>
{problem ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }} role="alert">
{problem}
</Text>
) : null}
</VStack>
);
}

View File

@@ -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 (
<VStack gap={4} className="ob-panel">
<VStack gap={1.5} style={{ textAlign: 'center', alignItems: 'center' }}>
{/* 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. */}
<span
aria-hidden
style={{
width: 64,
height: 64,
borderRadius: 20,
display: 'grid',
placeItems: 'center',
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
}}
>
<Store size={28} />
</span>
<Text
type="display-2"
weight="semibold"
style={{ fontFamily: 'var(--font-display)', textWrap: 'balance' }}
>
{isReturning ? 'Welcome back 👋' : `Welcome${shopName ? ` to ${shopName}` : ''}! 👋`}
</Text>
<Text
type="body"
color="secondary"
style={{ maxWidth: '46ch', lineHeight: 1.65 }}
>
{isReturning
? `You have completed ${done} of ${total} setup steps. Pick up where you left off.`
: "Let's get your store ready to start selling."}
</Text>
</VStack>
<div className="ob-benefits">
{BENEFITS.map(({ icon: Icon, title, body }) => (
<Card key={title} padding={3} elevation="low">
<VStack gap={1}>
<span style={{ color: 'var(--color-brand)' }} aria-hidden>
<Icon size={20} />
</span>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{body}
</Text>
</VStack>
</Card>
))}
</div>
<VStack gap={1} style={{ alignItems: 'center' }}>
<Text type="body" size="sm" color="secondary" hasTabularNumbers>
{done} of {total} steps completed
</Text>
<div
role="progressbar"
aria-valuenow={done}
aria-valuemin={0}
aria-valuemax={total}
aria-label="Setup progress"
style={{
width: 'min(320px, 100%)',
height: 6,
borderRadius: 999,
background: 'var(--color-line)',
overflow: 'hidden',
}}
>
<div
style={{
width: `${pct}%`,
height: '100%',
background: 'var(--color-brand)',
transition: 'width .35s cubic-bezier(.4,0,.2,1)',
}}
/>
</div>
</VStack>
<VStack gap={1} style={{ alignItems: 'center' }}>
<HStack justify="center">
<Button
label={isReturning ? 'Continue setup' : 'Start setup'}
variant="primary"
size="lg"
endContent={<ArrowRight size={15} />}
onClick={onStart}
/>
</HStack>
<Text type="body" size="xsm" color="secondary">
You can save your progress and continue later.
</Text>
</VStack>
</VStack>
);
}

View File

@@ -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<Parameters<typeof validateStoreInfo>[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<Parameters<typeof validateDelivery>[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/);
});

View File

@@ -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<string, string>;
/** 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;
}

View File

@@ -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 (
<PageBody measure="reading">
<PageHeader title="Set up your shop" />
<Card padding={4} elevation="low">
<Text type="body" color="secondary">
Checking what is already done…
</Text>
</Card>
</PageBody>
);
}
return (
<PageBody measure="reading">
<PageHeader
title="Set up your shop"
count={`${done} of ${steps.length}`}
description={
focus
? 'Work through these and your products will be on sale in the app. You can skip any of them and come back.'
: 'Everything is done — your shop is set up.'
}
/>
{/* 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. */}
<div
aria-hidden
style={{
height: 4,
borderRadius: 999,
background: 'var(--color-line)',
overflow: 'hidden',
}}
>
<div
style={{
width: `${pct}%`,
height: '100%',
background: 'var(--color-brand)',
transition: 'width .3s ease',
}}
/>
</div>
<VStack gap={0}>
{steps.map((step, index) => (
<StepRow
key={step.id}
step={step}
index={index}
isLast={index === steps.length - 1}
isFocus={step.id === focus?.id}
isSkipped={!step.done && skipped.includes(step.id)}
isActive={isActive}
onGo={() => {
if (!isActive) onStart();
navigate(step.href);
}}
onSkip={() => onSkip(step.id)}
/>
))}
</VStack>
<HStack gap={1.5} wrap="wrap" style={{ paddingTop: 8 }}>
{focus ? (
<Button
label={isActive ? `Continue — ${focus.cta}` : 'Start setup'}
variant="primary"
endContent={<ArrowRight size={14} />}
onClick={() => {
if (!isActive) onStart();
navigate(focus.href);
}}
/>
) : (
<Button label="Go to the console" variant="primary" onClick={() => navigate(home)} />
)}
{isActive ? <Button label="Finish later" variant="ghost" onClick={onExit} /> : null}
</HStack>
</PageBody>
);
}
/* ── 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 (
<HStack gap={2} align="start">
{/* The rail: a numbered marker with a line joining it to the next, so the
seven read as one sequence rather than seven cards. */}
<VStack gap={0} align="center" style={{ flex: 'none', width: 28 }}>
<span
style={{
width: 28,
height: 28,
borderRadius: 999,
display: 'grid',
placeItems: 'center',
background: marker.bg,
color: marker.fg,
border: `1px solid ${marker.ring}`,
fontSize: 12,
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
}}
>
{step.done ? <Check size={14} /> : index + 1}
</span>
{!isLast ? (
<span
aria-hidden
style={{
width: 1,
flex: 1,
minHeight: isFocus ? 120 : 28,
background: 'var(--color-line)',
}}
/>
) : null}
</VStack>
<VStack gap={0.5} style={{ flex: 1, minWidth: 0, paddingBottom: isLast ? 0 : 18 }}>
<HStack gap={1} align="center" wrap="wrap">
<Text
type="label"
size={isFocus ? 'lg' : 'sm'}
weight="semibold"
{...(step.done || isSkipped ? { color: 'secondary' as const } : {})}
>
{step.title}
</Text>
{step.detail ? (
<Text type="body" size="xsm" color="secondary" hasTabularNumbers>
{step.detail}
</Text>
) : null}
{isSkipped ? (
<Text type="body" size="xsm" color="secondary">
· skipped
</Text>
) : null}
</HStack>
{/* 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 ? (
<Text type="body" size="xsm" color="secondary">
{step.doneNote}
</Text>
) : null}
{/* Only the current step opens. Seven expanded panels is a manual, not
a walkthrough. */}
{isFocus ? (
<VStack gap={1.5} style={{ paddingTop: 6 }}>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
{step.why}
</Text>
<VStack gap={0.5}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
What to do
</Text>
<ol style={{ margin: 0, paddingLeft: 20, display: 'grid', gap: 4 }}>
{step.how.map((line) => (
<li key={line}>
<Text type="body" size="sm" style={{ lineHeight: 1.6 }}>
{line}
</Text>
</li>
))}
</ol>
</VStack>
{step.gotcha ? (
<HStack gap={1} align="start">
<Info
size={14}
style={{ color: 'var(--color-warning, #b7860b)', flex: 'none', marginTop: 3 }}
/>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{step.gotcha}
</Text>
</HStack>
) : null}
<HStack gap={1} align="center" wrap="wrap">
<Button
label={step.cta}
variant="primary"
size="sm"
endContent={<ArrowRight size={13} />}
onClick={onGo}
/>
{/* Skipping sits after the action, never before it. */}
{isActive ? (
<Button label="Skip this step" variant="ghost" size="sm" onClick={onSkip} />
) : null}
</HStack>
</VStack>
) : null}
</VStack>
</HStack>
);
}

View File

@@ -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<TourState>(() => 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. */}
<Button
label="All steps"
variant="ghost"
size="sm"
onClick={() => navigate(setupHref)}
/>
<Button
label={isOpen ? 'Hide guide' : 'How do I do this?'}
variant="ghost"

View File

@@ -1,35 +0,0 @@
/**
* The merchant's setup journey, at /admin/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 { useStoreAdminSteps } from './useSetupSteps';
import { endTour, readTour, skipInTour, startTour, type TourState } from './tourState';
export function StoreAdminSetupPage() {
const navigate = useNavigate();
const { steps, isLoading, tenantid, userid } = useStoreAdminSteps();
const [tour, setTour] = useState<TourState>(() => readTour(userid, tenantid));
return (
<SetupPage
steps={steps}
skipped={tour.skipped}
isLoading={isLoading}
isActive={tour.active}
home="/admin/console"
onStart={() => setTour(startTour(userid, tenantid))}
onSkip={(id) => setTour(skipInTour(userid, tenantid, id))}
onExit={() => {
setTour(endTour(userid, tenantid));
navigate('/admin/console');
}}
/>
);
}

View File

@@ -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 (
<SetupTour
userid={userid}
tenantid={tenantid}
steps={steps}
home="/admin/console"
setupHref="/admin/setup"
/>
);
}

View File

@@ -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<TourState>(() => readTour(userid, tenantid));
return (
<SetupPage
steps={steps}
skipped={tour.skipped}
isLoading={isLoading}
isActive={tour.active}
home="/store/console"
onStart={() => setTour(startTour(userid, tenantid))}
onSkip={(id) => setTour(skipInTour(userid, tenantid, id))}
onExit={() => {
setTour(endTour(userid, tenantid));
navigate('/store/console');
}}
/>
);
}

View File

@@ -22,7 +22,6 @@ export function StoreUserTour() {
tenantid={tenantid}
steps={steps}
home="/store/console"
setupHref="/store/setup"
/>
);
}

View File

@@ -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 (
<BranchScopeProvider>
<LiveWatch />
<OnboardingGate />
<AppShell
nav={NAV}
home="/admin/console"
navLabel="Store Admin"
scopeControl={<BranchSelector />}
manageItems={MANAGE}
banner={<StoreAdminTour />}
/>
</BranchScopeProvider>
);

View File

@@ -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; }
}