setup onboarding page
This commit is contained in:
@@ -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>
|
||||
|
||||
51
src/features/onboarding/OnboardingGate.tsx
Normal file
51
src/features/onboarding/OnboardingGate.tsx
Normal 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;
|
||||
}
|
||||
344
src/features/onboarding/OnboardingPage.tsx
Normal file
344
src/features/onboarding/OnboardingPage.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Store setup, as a page inside the console rather than a modal over it.
|
||||
*
|
||||
* It renders through the shell's `<Outlet />`, so the existing top navigation,
|
||||
* branding and account menu stay exactly where they are and the flow reads as
|
||||
* part of the product. No sidebar, no second navigation: the progress belongs
|
||||
* under the header, and the header is the one the rest of the console uses.
|
||||
*
|
||||
* ── What is stored where ────────────────────────────────────────────────────
|
||||
*
|
||||
* Answers go to Fiesta as each step is completed — the shop record through
|
||||
* `tenants/updatetenant`, delivery through `tenants/updatelocation` — so setup
|
||||
* is not a pile of state that only becomes real at the end. Position and
|
||||
* half-typed drafts live in the browser, because an unsent draft is not
|
||||
* something to publish to a shop's record on every keystroke.
|
||||
*
|
||||
* ── Why the steps do not block ──────────────────────────────────────────────
|
||||
*
|
||||
* Store details are required: nothing else is meaningful without a name and an
|
||||
* address. Catalogue and inventory are not — a shop whose product list is not
|
||||
* ready yet should be able to finish and come back, and a wall there is how
|
||||
* somebody abandons setup on their first afternoon.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { tenantsApi } from '@/api/tenants';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { PageBody } from '@/components/PageBody';
|
||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||
import { useLocationProducts, useOwnTenant } from '@/queries/hooks';
|
||||
import { Stepper } from './Stepper';
|
||||
import {
|
||||
completeStep,
|
||||
progressOf,
|
||||
readOnboarding,
|
||||
resumeStep,
|
||||
skipStep,
|
||||
writeOnboarding,
|
||||
type OnboardingState,
|
||||
type StepId,
|
||||
} from './onboardingState';
|
||||
import { isValid, normalisePhone, validateDelivery, validateStoreInfo, type Errors } from './validation';
|
||||
import { WelcomeStep } from './steps/WelcomeStep';
|
||||
import { StoreInfoStep } from './steps/StoreInfoStep';
|
||||
import { CatalogueStep } from './steps/CatalogueStep';
|
||||
import { InventoryStep } from './steps/InventoryStep';
|
||||
import { DeliveryStep } from './steps/DeliveryStep';
|
||||
import { ReviewStep } from './steps/ReviewStep';
|
||||
import { DoneStep } from './steps/DoneStep';
|
||||
|
||||
const HEADINGS: Record<StepId, { title: string; blurb: string }> = {
|
||||
welcome: { title: 'Set up your store', blurb: '' },
|
||||
store: { title: "Let's set up your store", blurb: 'Tell us a few details about your shop.' },
|
||||
catalogue: {
|
||||
title: 'How would you like to add your products?',
|
||||
blurb: 'Choose whichever matches what you already have.',
|
||||
},
|
||||
inventory: {
|
||||
title: "Let's set up your stock",
|
||||
blurb: 'Keep what customers see in step with what is on your shelf.',
|
||||
},
|
||||
delivery: {
|
||||
title: 'Set up your delivery',
|
||||
blurb: 'Tell us how you would like to get orders to customers.',
|
||||
},
|
||||
review: {
|
||||
title: 'Review your setup',
|
||||
blurb: 'Check everything over before you finish.',
|
||||
},
|
||||
done: { title: 'Setup complete', blurb: '' },
|
||||
};
|
||||
|
||||
/** Forward order. `done` has no next; `welcome` has no back. */
|
||||
const ORDER: StepId[] = ['welcome', 'store', 'catalogue', 'inventory', 'delivery', 'review', 'done'];
|
||||
|
||||
export function OnboardingPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { tenantid, current } = useBranchScope();
|
||||
const userid = user?.userid ?? 0;
|
||||
|
||||
const shop = useOwnTenant(tenantid || undefined);
|
||||
const products = useLocationProducts(tenantid || undefined, undefined, 0, { allBranches: true });
|
||||
|
||||
const [state, setState] = useState<OnboardingState>(() => readOnboarding(userid, tenantid));
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* The shop's own record wins over an empty draft.
|
||||
*
|
||||
* Onboarding already collected a name, phone and address, so presenting blank
|
||||
* fields would ask a merchant to type what the platform is holding two
|
||||
* screens away. Only fields the draft has not touched are filled, so a
|
||||
* half-typed answer is never overwritten by a refetch.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const record = shop.data as unknown as Record<string, unknown> | undefined;
|
||||
if (!record) return;
|
||||
setState((prev) => {
|
||||
const seeded = { ...prev.store };
|
||||
let changed = false;
|
||||
for (const key of [
|
||||
'tenantname', 'tenantimage', 'primaryemail', 'primarycontact',
|
||||
'address', 'city', 'postcode', 'state', 'tenanttype',
|
||||
] as const) {
|
||||
if (seeded[key] === '' && typeof record[key] === 'string' && record[key]) {
|
||||
seeded[key] = record[key] as string;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? { ...prev, store: seeded } : prev;
|
||||
});
|
||||
}, [shop.data]);
|
||||
|
||||
const productCount = (products.data ?? []).length;
|
||||
const step = resumeStep(state);
|
||||
const { done, total } = progressOf(state);
|
||||
|
||||
function persist(next: OnboardingState) {
|
||||
setState(writeOnboarding(userid, tenantid, next));
|
||||
setErrors({});
|
||||
setSaveError(null);
|
||||
}
|
||||
|
||||
function goTo(next: StepId) {
|
||||
persist({ ...state, step: next, started: true });
|
||||
}
|
||||
|
||||
const nextOf = (id: StepId) => ORDER[Math.min(ORDER.indexOf(id) + 1, ORDER.length - 1)] as StepId;
|
||||
const backOf = (id: StepId) => ORDER[Math.max(ORDER.indexOf(id) - 1, 0)] as StepId;
|
||||
|
||||
/** Save what this step owns, then advance. Nothing advances on a failed write. */
|
||||
async function saveAndContinue() {
|
||||
setSaveError(null);
|
||||
|
||||
if (step === 'store') {
|
||||
const found = validateStoreInfo(state.store);
|
||||
setErrors(found);
|
||||
if (!isValid(found)) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await tenantsApi.updateProfile({
|
||||
tenantid,
|
||||
tenantname: state.store.tenantname.trim(),
|
||||
tenantimage: state.store.tenantimage.trim(),
|
||||
primaryemail: state.store.primaryemail.trim(),
|
||||
primarycontact: normalisePhone(state.store.primarycontact),
|
||||
address: state.store.address.trim(),
|
||||
city: state.store.city.trim(),
|
||||
state: state.store.state.trim(),
|
||||
postcode: state.store.postcode.trim(),
|
||||
tenanttype: state.store.tenanttype,
|
||||
});
|
||||
await shop.refetch();
|
||||
} catch (cause) {
|
||||
setSaveError(errorMessage(cause));
|
||||
return;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'delivery') {
|
||||
const found = validateDelivery(state.delivery);
|
||||
setErrors(found);
|
||||
if (!isValid(found)) return;
|
||||
// Delivery is a property of the OUTLET, not the business — a merchant
|
||||
// with three shops can cover different areas from each.
|
||||
if (current?.locationid) {
|
||||
setSaving(true);
|
||||
try {
|
||||
await tenantsApi.updateBranch({
|
||||
locationid: current.locationid,
|
||||
tenantid,
|
||||
...(state.delivery.offersDelivery
|
||||
? {
|
||||
// Metres on the wire; kilometres are what a shopkeeper thinks in.
|
||||
deliveryradius: Math.round(Number(state.delivery.radiusKm) * 1000),
|
||||
deliverymins: Number(state.delivery.mins),
|
||||
}
|
||||
: { deliveryradius: 0, deliverymins: 0 }),
|
||||
});
|
||||
} catch (cause) {
|
||||
setSaveError(errorMessage(cause));
|
||||
return;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'review') {
|
||||
persist({ ...completeStep(state, 'review', 'done'), finished: true });
|
||||
return;
|
||||
}
|
||||
|
||||
persist(completeStep(state, step, nextOf(step)));
|
||||
}
|
||||
|
||||
const heading = HEADINGS[step];
|
||||
const isFirst = step === 'welcome';
|
||||
const isLast = step === 'done';
|
||||
// Catalogue and inventory are the two a shop can genuinely not be ready for.
|
||||
const canSkip = step === 'catalogue' || step === 'inventory';
|
||||
|
||||
return (
|
||||
<PageBody measure="reading">
|
||||
{!isFirst && !isLast ? (
|
||||
<VStack gap={3}>
|
||||
<VStack gap={0.5}>
|
||||
<Text
|
||||
type="display-1"
|
||||
weight="semibold"
|
||||
style={{ fontFamily: 'var(--font-display)', textWrap: 'balance' }}
|
||||
>
|
||||
{heading.title}
|
||||
</Text>
|
||||
{heading.blurb ? (
|
||||
<Text type="body" color="secondary">
|
||||
{heading.blurb}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
<Stepper current={step} completed={state.completed} />
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{step === 'welcome' ? (
|
||||
<WelcomeStep
|
||||
{...(state.store.tenantname ? { shopName: state.store.tenantname } : {})}
|
||||
done={done}
|
||||
total={total}
|
||||
isReturning={state.started && done > 0}
|
||||
onStart={() => goTo(done > 0 ? state.step === 'welcome' ? 'store' : state.step : 'store')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === 'store' ? (
|
||||
<StoreInfoStep
|
||||
value={state.store}
|
||||
errors={errors}
|
||||
onChange={(store) => setState((prev) => writeOnboarding(userid, tenantid, { ...prev, store }))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === 'catalogue' ? (
|
||||
<CatalogueStep
|
||||
productCount={productCount}
|
||||
onUpload={() => navigate('/admin/inventory?tab=products&upload=1')}
|
||||
onManual={() => navigate('/admin/inventory')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === 'inventory' ? (
|
||||
<InventoryStep
|
||||
onDownloadTemplate={() => navigate('/admin/inventory')}
|
||||
onUpload={() => navigate('/admin/inventory')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === 'delivery' ? (
|
||||
<DeliveryStep
|
||||
value={state.delivery}
|
||||
errors={errors}
|
||||
onChange={(delivery) =>
|
||||
setState((prev) => writeOnboarding(userid, tenantid, { ...prev, delivery }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === 'review' ? (
|
||||
<ReviewStep
|
||||
store={state.store}
|
||||
delivery={state.delivery}
|
||||
productCount={productCount}
|
||||
skipped={state.skipped}
|
||||
onEdit={goTo}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === 'done' ? (
|
||||
<DoneStep
|
||||
productCount={productCount}
|
||||
onCatalogue={() => navigate('/admin/inventory')}
|
||||
onInventory={() => navigate('/admin/inventory')}
|
||||
onStorefront={() => navigate('/admin/inventory')}
|
||||
onDashboard={() => navigate('/admin/console')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{saveError ? (
|
||||
<Card padding={3} variant="transparent">
|
||||
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{saveError}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Footer navigation. Back is quiet, Continue is the one action, and Skip
|
||||
sits between them so it is available without competing. */}
|
||||
{!isFirst && !isLast ? (
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap" style={{ paddingTop: 4 }}>
|
||||
<Button
|
||||
label="Back"
|
||||
variant="ghost"
|
||||
icon={<ArrowLeft size={14} />}
|
||||
isDisabled={saving}
|
||||
onClick={() => goTo(backOf(step))}
|
||||
/>
|
||||
<HStack gap={1.5} align="center" wrap="wrap">
|
||||
{canSkip ? (
|
||||
<Button
|
||||
label="Skip for now"
|
||||
variant="ghost"
|
||||
isDisabled={saving}
|
||||
onClick={() => persist(skipStep(state, step, nextOf(step)))}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
label={
|
||||
saving ? 'Saving…' : step === 'review' ? 'Complete setup' : 'Save & continue'
|
||||
}
|
||||
variant="primary"
|
||||
isLoading={saving}
|
||||
isDisabled={saving}
|
||||
endContent={<ArrowRight size={14} />}
|
||||
onClick={() => void saveAndContinue()}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
) : null}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
96
src/features/onboarding/Stepper.tsx
Normal file
96
src/features/onboarding/Stepper.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
src/features/onboarding/onboardingState.test.ts
Normal file
100
src/features/onboarding/onboardingState.test.ts
Normal 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);
|
||||
});
|
||||
201
src/features/onboarding/onboardingState.ts
Normal file
201
src/features/onboarding/onboardingState.ts
Normal 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;
|
||||
}
|
||||
127
src/features/onboarding/steps/CatalogueStep.tsx
Normal file
127
src/features/onboarding/steps/CatalogueStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
src/features/onboarding/steps/DeliveryStep.tsx
Normal file
197
src/features/onboarding/steps/DeliveryStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
src/features/onboarding/steps/DoneStep.tsx
Normal file
120
src/features/onboarding/steps/DoneStep.tsx
Normal 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’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>
|
||||
);
|
||||
}
|
||||
143
src/features/onboarding/steps/InventoryStep.tsx
Normal file
143
src/features/onboarding/steps/InventoryStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
src/features/onboarding/steps/ReviewStep.tsx
Normal file
152
src/features/onboarding/steps/ReviewStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
330
src/features/onboarding/steps/StoreInfoStep.tsx
Normal file
330
src/features/onboarding/steps/StoreInfoStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
src/features/onboarding/steps/WelcomeStep.tsx
Normal file
149
src/features/onboarding/steps/WelcomeStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/features/onboarding/validation.test.ts
Normal file
122
src/features/onboarding/validation.test.ts
Normal 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/);
|
||||
});
|
||||
121
src/features/onboarding/validation.ts
Normal file
121
src/features/onboarding/validation.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,6 @@ export function StoreUserTour() {
|
||||
tenantid={tenantid}
|
||||
steps={steps}
|
||||
home="/store/console"
|
||||
setupHref="/store/setup"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
232
src/index.css
232
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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user