tenant profile

This commit is contained in:
2026-09-02 12:29:17 +05:30
parent 52be0e30ac
commit d22b79e109
7 changed files with 651 additions and 221 deletions

View File

@@ -23,19 +23,16 @@
*/
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@astryxdesign/core/Button';
import { useNavigate, useSearchParams } from 'react-router-dom';
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 { StepFrame } from './StepFrame';
import { Stepper } from './Stepper';
import {
completeStep,
@@ -90,6 +87,7 @@ export function OnboardingPage() {
const shop = useOwnTenant(tenantid || undefined);
const products = useLocationProducts(tenantid || undefined, undefined, 0, { allBranches: true });
const [params, setParams] = useSearchParams();
const [state, setState] = useState<OnboardingState>(() => readOnboarding(userid, tenantid));
const [errors, setErrors] = useState<Errors>({});
const [saving, setSaving] = useState(false);
@@ -122,6 +120,29 @@ export function OnboardingPage() {
});
}, [shop.data]);
/**
* Coming back from the errand, one step further on.
*
* A merchant sent to Inventory to add products used to have no way back into
* setup but the browser's Back button — which returns them to the step they
* already finished, so setup looked stuck. `SetupReturnBar` sends them here
* with `?advance=<step>` instead, and this is the half that honours it.
*
* The parameter is consumed as it is read. Left in the URL it would re-fire on
* every render and on a refresh, pushing somebody through steps they never
* looked at.
*/
useEffect(() => {
const advance = params.get('advance') as StepId | null;
if (!advance) return;
const carry = new URLSearchParams(params);
carry.delete('advance');
setParams(carry, { replace: true });
setState((prev) =>
writeOnboarding(userid, tenantid, completeStep(prev, advance, nextOf(advance))),
);
}, [params, setParams, userid, tenantid]);
const productCount = (products.data ?? []).length;
const step = resumeStep(state);
const { done, total } = progressOf(state);
@@ -215,25 +236,7 @@ export function OnboardingPage() {
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}
{!isFirst && !isLast ? <Stepper current={step} completed={state.completed} /> : null}
{step === 'welcome' ? (
<WelcomeStep
@@ -245,6 +248,19 @@ export function OnboardingPage() {
/>
) : null}
{!isFirst && !isLast ? (
<StepFrame
step={step}
title={heading.title}
{...(heading.blurb ? { blurb: heading.blurb } : {})}
continueLabel={step === 'review' ? 'Complete setup' : 'Save & continue'}
isSaving={saving}
canSkip={canSkip}
onBack={() => goTo(backOf(step))}
{...(canSkip ? { onSkip: () => persist(skipStep(state, step, nextOf(step))) } : {})}
onContinue={() => void saveAndContinue()}
onExit={() => navigate('/admin/console')}
>
{step === 'store' ? (
<StoreInfoStep
value={state.store}
@@ -256,15 +272,18 @@ export function OnboardingPage() {
{step === 'catalogue' ? (
<CatalogueStep
productCount={productCount}
onUpload={() => navigate('/admin/inventory?tab=products&upload=1')}
onManual={() => navigate('/admin/inventory')}
onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=catalogue')}
/* The catalogue tab, not the products list. This pointed at
`/admin/inventory` bare, which lands on Products — so "import from
the catalogue" showed a merchant their own empty product list. */
onManual={() => navigate('/admin/inventory?tab=catalogue&setup=catalogue')}
/>
) : null}
{step === 'inventory' ? (
<InventoryStep
onDownloadTemplate={() => navigate('/admin/inventory')}
onUpload={() => navigate('/admin/inventory')}
onDownloadTemplate={() => navigate('/admin/inventory?tab=stock&setup=inventory')}
onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=inventory')}
/>
) : null}
@@ -288,57 +307,27 @@ export function OnboardingPage() {
/>
) : 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">
<Card padding={2} 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>
</StepFrame>
) : null}
{step === 'done' ? (
<DoneStep
productCount={productCount}
onCatalogue={() => navigate('/admin/inventory?tab=catalogue')}
onInventory={() => navigate('/admin/inventory?tab=stock')}
onStorefront={() => navigate('/admin/inventory?tab=products')}
onDashboard={() => navigate('/admin/console')}
/>
) : null}
</PageBody>
);
}

View File

@@ -0,0 +1,71 @@
import { useNavigate } from 'react-router-dom';
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, Check } from 'lucide-react';
import { PROGRESS_STEPS, STEP_LABEL, type StepId } from './onboardingState';
/**
* The way back out of a working screen and into setup.
*
* Setup sends a merchant to Inventory to do real work — import from the
* catalogue, upload a sheet — and the flow used to end there. Nothing on the
* destination said where they had come from or how to get back, so the only
* route to the next step was the browser's Back button, which is not a control
* anyone should have to find to finish onboarding.
*
* So the trip carries its return leg with it: `?setup=<step>` on the link is
* what makes this bar appear, and the bar is the way home. It also reports what
* the merchant came to do, which is why it counts products — arriving with 0
* and leaving with 12 is the whole point of the detour, and saying so is what
* turns "am I done?" into an obvious yes.
*/
export interface SetupReturnBarProps {
/** The step the merchant left. Also what gets marked complete on return. */
step: StepId;
/** Products the tenant has now. 0 means the errand is not done yet. */
productCount: number;
}
export function SetupReturnBar({ step, productCount }: SetupReturnBarProps) {
const navigate = useNavigate();
const position = PROGRESS_STEPS.indexOf(step) + 1;
const hasDone = productCount > 0;
return (
<div className="ob-returnbar" data-done={hasDone ? 'yes' : 'no'}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<HStack gap={1.5} align="center">
<span className="ob-returnbar-mark" aria-hidden>
{hasDone ? <Check size={15} strokeWidth={3} /> : position}
</span>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{hasDone
? `${productCount} product${productCount === 1 ? '' : 's'} added`
: `Store setup — ${STEP_LABEL[step]}`}
</Text>
<Text type="body" size="xsm" color="secondary">
{hasDone
? 'Nice work. Carry on with setup whenever you are ready.'
: `Step ${position} of ${PROGRESS_STEPS.length}. Add your products, then head back to setup.`}
</Text>
</VStack>
</HStack>
<Button
label={hasDone ? 'Continue setup' : 'Back to setup'}
variant={hasDone ? 'primary' : 'secondary'}
size="sm"
endContent={<ArrowRight size={14} />}
/* `advance` is explicit rather than inferred. Completing the step
because products exist would also fire for a merchant who wandered
here on their own, marking work done that they never chose to
finish. Pressing this button is the choice. */
onClick={() => navigate(`/admin/onboarding?advance=${step}`)}
/>
</HStack>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import type { ReactNode } from 'react';
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 { ArrowLeft, ArrowRight, LogOut } from 'lucide-react';
import { PROGRESS_STEPS, type StepId } from './onboardingState';
/**
* The frame every collecting step is drawn in.
*
* One component rather than a header repeated seven times, because the parts
* that orient somebody — which step this is, how far along, how to leave — have
* to sit in exactly the same place on every screen. A merchant learns where the
* Continue button is once.
*
* ── What the frame owns ─────────────────────────────────────────────────────
*
* Position ("Step 2 of 5"), the title and its blurb, the progress bar and its
* percentage, Save & Exit, and the Back / Skip / Continue row. The step inside
* owns nothing but its own fields, which is what keeps the steps short.
*
* ── Why the bar measures position, not completions ──────────────────────────
*
* Standing on step 2 of 5 reads as 40% whether or not step 2 has been filled
* in — it answers "how far through am I", which is what somebody looking at a
* progress bar is asking. A completion count answers a different question and
* would show 20% while they are looking at the second screen, which reads as
* the flow having lost their work.
*/
export interface StepFrameProps {
step: StepId;
title: string;
blurb?: string;
/** Primary action label. The Review step ends the flow, so it says so. */
continueLabel: string;
isSaving?: boolean;
canSkip?: boolean;
onBack: () => void;
onSkip?: () => void;
onContinue: () => void;
onExit: () => void;
children: ReactNode;
}
export function StepFrame({
step,
title,
blurb,
continueLabel,
isSaving = false,
canSkip = false,
onBack,
onSkip,
onContinue,
onExit,
children,
}: StepFrameProps) {
const position = PROGRESS_STEPS.indexOf(step) + 1;
const total = PROGRESS_STEPS.length;
const pct = Math.round((position / total) * 100);
return (
<section className="ob-frame">
<header className="ob-frame-head">
<HStack justify="between" align="start" gap={2} wrap="wrap">
<VStack gap={0.5}>
<Text type="label" size="xsm" weight="semibold" className="ob-eyebrow">
Step {position} of {total}
</Text>
<Text
type="display-1"
weight="semibold"
style={{ fontFamily: 'var(--font-display)', textWrap: 'balance' }}
>
{title}
</Text>
{blurb ? (
<Text type="body" size="sm" color="secondary">
{blurb}
</Text>
) : null}
</VStack>
{/* Leaving is a supported outcome, not an escape hatch. Everything is
already written down as it is typed, so this only navigates. */}
<Button
label="Save & Exit"
variant="secondary"
size="sm"
icon={<LogOut size={14} />}
isDisabled={isSaving}
onClick={onExit}
/>
</HStack>
<HStack gap={2} align="center" style={{ marginTop: 14 }}>
<div
className="ob-bar"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Setup progress"
>
<div className="ob-bar-fill" style={{ width: `${pct}%` }} />
</div>
<Text type="body" size="xsm" color="secondary" hasTabularNumbers>
{pct}%
</Text>
</HStack>
</header>
<div className="ob-frame-body">{children}</div>
<footer className="ob-frame-foot">
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Button
label="Back"
variant="ghost"
icon={<ArrowLeft size={14} />}
isDisabled={isSaving}
onClick={onBack}
/>
<HStack gap={1.5} align="center" wrap="wrap">
{canSkip && onSkip ? (
<Button label="Skip for now" variant="ghost" isDisabled={isSaving} onClick={onSkip} />
) : null}
<Button
label={isSaving ? 'Saving…' : continueLabel}
variant="primary"
isLoading={isSaving}
isDisabled={isSaving}
endContent={<ArrowRight size={14} />}
onClick={onContinue}
/>
</HStack>
</HStack>
</footer>
</section>
);
}

View File

@@ -3,7 +3,17 @@ 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';
import {
ArrowRight,
CheckCircle2,
Database,
Download,
FileSpreadsheet,
ShoppingBag,
Store,
Upload,
UploadCloud,
} from 'lucide-react';
/**
* Keeping the online stock in step with the shelf.
@@ -24,12 +34,12 @@ export interface InventoryStepProps {
}
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',
{ icon: Store, label: 'What is on your shelf' },
{ icon: FileSpreadsheet, label: 'Written into the stock sheet' },
{ icon: UploadCloud, label: 'Uploaded here' },
{ icon: CheckCircle2, label: 'Checked for mistakes' },
{ icon: Database, label: 'Your stock is updated' },
{ icon: ShoppingBag, label: 'Customers see what is in stock' },
] as const;
const TIPS = [
@@ -55,13 +65,13 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
</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}
{FLOW.map(({ icon: Icon, label }) => (
<li key={label} className="ob-flow-step">
<span className="ob-flow-icon" aria-hidden>
<Icon size={17} />
</span>
<Text type="body" size="sm">
{stage}
{label}
</Text>
</li>
))}
@@ -69,6 +79,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
</VStack>
</Card>
<div className="ob-two">
<Card padding={3} elevation="low">
<VStack gap={2}>
<HStack justify="between" align="start" gap={2} wrap="wrap">
@@ -117,20 +128,18 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
</VStack>
</Card>
<Card padding={3} variant="transparent">
<VStack gap={1}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '.09em' }}
>
Worth knowing
<Card padding={3} elevation="low">
<VStack gap={1.5}>
<Text type="label" size="sm" weight="semibold">
Tips
</Text>
<ul style={{ margin: 0, paddingLeft: 20, display: 'grid', gap: 6 }}>
{/* A list, but not a bulleted one: each tip is a thing done right, so
it is ticked rather than dotted. */}
<ul className="ob-tips">
{TIPS.map((tip) => (
<li key={tip}>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
<CheckCircle2 size={14} aria-hidden />
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.55 }}>
{tip}
</Text>
</li>
@@ -138,6 +147,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
</ul>
</VStack>
</Card>
</div>
</VStack>
);
}

View File

@@ -1,19 +1,21 @@
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 { AlertTriangle, Boxes, Store, Truck, Warehouse } from 'lucide-react';
import type { DeliveryDraft, StepId, StoreDraft } from '../onboardingState';
/**
* Everything about to be saved, in the merchant's own words.
* Everything about to be saved, one line per step.
*
* 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.
* A summary, not a second form. The earlier version relisted every field as a
* label/value pair, which made the last screen the longest one in the flow and
* asked somebody to re-read what they had just typed. A merchant checking their
* own answers is scanning for the one that looks wrong, so each step gets a
* single sentence and an Edit that goes straight to it.
*
* It also names the one consequence people do not expect: a shop with no
* A skipped step says so. Showing blanks would read like data loss, and the
* distinction matters: nothing was lost, nothing was entered.
*
* 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.
*/
@@ -26,127 +28,116 @@ export interface ReviewStepProps {
}
export function ReviewStep({ store, delivery, productCount, skipped, onEdit }: ReviewStepProps) {
const money = (v: string) => (v.trim() === '' ? '—' : `₹${v}`);
const place = [store.address, store.city, store.state, store.postcode]
.map((part) => part.trim())
.filter(Boolean)
.join(', ');
const storeLine = [store.tenantname.trim(), place].filter(Boolean).join(' · ') || 'Not filled in yet';
const catalogueLine =
productCount > 0
? `${productCount} product${productCount === 1 ? '' : 's'} added`
: skipped.includes('catalogue')
? 'Skipped — you can add products at any time'
: 'No products added yet';
const inventoryLine = skipped.includes('inventory')
? 'Skipped — stock can be uploaded whenever you are ready'
: 'Stock is kept up to date by uploading your sheet';
const deliveryLine =
delivery.offersDelivery === null
? 'Not answered yet'
: delivery.offersDelivery
? [
delivery.radiusKm.trim() ? `Within ${delivery.radiusKm} km` : null,
delivery.mins.trim() ? `about ${delivery.mins} mins` : null,
delivery.minOrder.trim() ? `min order ₹${delivery.minOrder}` : null,
delivery.charge.trim() ? `charge ₹${delivery.charge}` : null,
]
.filter(Boolean)
.join(', ') || 'Delivery on'
: 'No delivery — customers collect from the shop';
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>
<VStack gap={1.5} className="ob-panel">
<Line
icon={<Store size={17} />}
title="Store information"
detail={storeLine}
hours={`Open ${store.opentime} – ${store.closetime}`}
onEdit={() => onEdit('store')}
/>
<Line
icon={<Boxes size={17} />}
title="Catalogue"
detail={catalogueLine}
onEdit={() => onEdit('catalogue')}
/>
<Line
icon={<Warehouse size={17} />}
title="Inventory"
detail={inventoryLine}
onEdit={() => onEdit('inventory')}
/>
<Line
icon={<Truck size={17} />}
title="Delivery"
detail={deliveryLine}
onEdit={() => onEdit('delivery')}
/>
<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>
<div className="ob-warn">
<AlertTriangle size={16} aria-hidden style={{ flex: 'none', marginTop: 1 }} />
<Text type="body" size="sm" style={{ lineHeight: 1.55 }}>
Your store has no products yet, so shoppers will find it empty. Finishing setup is
fine — add products whenever you are ready and they go on sale straight away.
</Text>
</div>
) : null}
</VStack>
);
}
function Section({
/** One step, one sentence, one way to change it. */
function Line({
icon,
title,
detail,
hours,
onEdit,
children,
}: {
icon: React.ReactNode;
title: string;
detail: string;
hours?: 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}
<div className="ob-review">
<span className="ob-review-icon" aria-hidden>
{icon}
</span>
<div style={{ minWidth: 0 }}>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.55 }}>
{detail}
</Text>
{hours ? (
<Text type="body" size="xsm" color="secondary">
{hours}
</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>
) : null}
</div>
{/* A link rather than a button: it navigates, and three filled buttons
down the right of a summary compete with Complete setup. */}
<button type="button" className="ob-review-edit" onClick={onEdit}>
Edit<span className="sr-only"> {title}</span>
</button>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
@@ -32,6 +33,9 @@ import { useStockRequests, useStockStatement } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { CataloguePanel } from '../CataloguePanel';
import { ProductsPanel } from '../ProductsPanel';
import { SetupReturnBar } from '@/features/onboarding/SetupReturnBar';
import type { StepId } from '@/features/onboarding/onboardingState';
import { useLocationProducts } from '@/queries/hooks';
import { branchLabel, count, money } from '../format';
type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
@@ -53,10 +57,39 @@ type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
*/
export function InventoryPage() {
const { branches, selected, current, tenantid } = useBranchScope();
const [tab, setTab] = useState<Tab>('products');
/**
* The tab comes from the URL, so a link can name one.
*
* It could not before — the tab was local state seeded with 'products' and
* nothing ever read the query string. Setup's "add from the catalogue" link
* pointed at `?tab=catalogue` and landed the merchant on Products, which is
* the bug that made that step look broken.
*/
const [params, setParams] = useSearchParams();
const asked = params.get('tab');
const [tab, setTab] = useState<Tab>(() =>
asked === 'catalogue' || asked === 'stock' || asked === 'requests' ? asked : 'products',
);
/* A tab chosen by hand replaces the one in the URL, so a reload or a shared
link shows the panel actually being looked at. `replace` because flipping
between tabs is not navigation and should not fill up the Back button. */
function chooseTab(next: Tab) {
setTab(next);
const carry = new URLSearchParams(params);
carry.set('tab', next);
setParams(carry, { replace: true });
}
/* Set when setup sent them here, and it names the step to return to. */
const fromSetup = params.get('setup') as StepId | null;
const setupProducts = useLocationProducts(tenantid || undefined, undefined, 0, {
allBranches: true,
});
// Owned here so the button can live in the tab row while the drawer it opens
// stays inside the panel that uses it.
const [isUploading, setUploading] = useState(false);
const [isUploading, setUploading] = useState(() => params.get('upload') === '1');
const requests = useStockRequests(
tenantid ? { tenantid, locationid: selected ?? undefined } : undefined,
@@ -69,6 +102,12 @@ export function InventoryPage() {
return (
<VStack gap={3}>
{/* Above the header, because it is about the errand rather than the page:
it is the only thing on screen that knows setup is unfinished. */}
{fromSetup ? (
<SetupReturnBar step={fromSetup} productCount={(setupProducts.data ?? []).length} />
) : null}
<PageHeader
title="Inventory"
count={selected === null ? `${branches.length} branches` : current?.locationname}
@@ -106,26 +145,26 @@ export function InventoryPage() {
label="Products"
icon={<PackageSearch size={14} />}
isActive={tab === 'products'}
onClick={() => setTab('products')}
onClick={() => chooseTab('products')}
/>
<TabButton
label="Catalogue"
icon={<DownloadCloud size={14} />}
isActive={tab === 'catalogue'}
onClick={() => setTab('catalogue')}
onClick={() => chooseTab('catalogue')}
/>
<TabButton
label="Stock requests"
icon={<ClipboardList size={14} />}
isActive={tab === 'requests'}
onClick={() => setTab('requests')}
onClick={() => chooseTab('requests')}
badge={pending.length || undefined}
/>
<TabButton
label="Stock"
icon={<Layers size={14} />}
isActive={tab === 'stock'}
onClick={() => setTab('stock')}
onClick={() => chooseTab('stock')}
/>
</HStack>
}

View File

@@ -1376,17 +1376,16 @@ main {
background: var(--color-surface-subtle);
border: 1px solid var(--color-line);
}
.ob-flow-num {
width: 22px;
height: 22px;
.ob-flow-icon {
/* Sized for a 17px glyph rather than the two digits that used to sit here. */
width: 28px;
height: 28px;
flex: none;
border-radius: 999px;
border-radius: 9px;
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)); }
@@ -1411,3 +1410,192 @@ main {
@media (prefers-reduced-motion: reduce) {
.ob-tick { animation: none; }
}
/* ── Onboarding: the step frame ─────────────────────────────────────────────
A single card per step, in three bands: head (position, title, progress),
body (the step's own fields) and foot (Back / Continue). The bands are
separated by rules rather than gaps so the card reads as one object. */
.ob-frame {
border: 1px solid var(--color-line);
border-radius: 18px;
background: var(--color-surface);
overflow: hidden;
}
.ob-frame-head {
padding: 20px 22px 18px;
border-bottom: 1px solid var(--color-line);
background: var(--color-surface);
}
.ob-eyebrow {
text-transform: uppercase;
letter-spacing: .07em;
color: var(--color-brand);
}
.ob-bar {
flex: 1;
height: 7px;
border-radius: 999px;
background: var(--color-line);
overflow: hidden;
}
.ob-bar-fill {
height: 100%;
border-radius: 999px;
background: var(--color-brand);
transition: width .4s cubic-bezier(.4, 0, .2, 1);
}
.ob-frame-body { padding: 22px; }
.ob-frame-foot {
padding: 16px 22px;
border-top: 1px solid var(--color-line);
/* Tinted, so the action row separates from the fields above it without
needing a heavier rule. */
background: var(--color-surface-subtle);
}
@media (max-width: 640px) {
.ob-frame-head { padding: 16px 16px 14px; }
.ob-frame-body { padding: 16px; }
.ob-frame-foot { padding: 14px 16px; }
/* Full-width actions on a phone: a 96px button in the corner of a 360px
screen is the hardest thing on the page to hit. */
.ob-frame-foot button { flex: 1; justify-content: center; }
}
@media (prefers-reduced-motion: reduce) {
.ob-bar-fill { transition: none; }
}
/* ── Onboarding: the way back from an errand ───────────────────────────────
Shown on Inventory when setup sent the merchant there. Brand-tinted while
the errand is outstanding, and green once products actually exist. */
.ob-returnbar {
padding: 12px 16px;
border: 1px solid var(--color-brand);
border-radius: 14px;
background: var(--color-brand-tint);
}
.ob-returnbar[data-done='yes'] {
border-color: var(--color-success, #1f9d55);
background: var(--color-success-tint, #eaf7ef);
}
.ob-returnbar-mark {
width: 28px;
height: 28px;
flex: none;
border-radius: 999px;
display: grid;
place-items: center;
background: var(--color-brand);
color: #fff;
font: 600 12px/1 var(--font-sans);
}
.ob-returnbar[data-done='yes'] .ob-returnbar-mark {
background: var(--color-success, #1f9d55);
}
/* ── Onboarding: the review summary ────────────────────────────────────────
One row per step: icon, what it says, and a way to change it. Grid rather
than flex so the Edit link stays hard right however long the detail runs. */
.ob-review {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
padding: 14px 16px;
border: 1px solid var(--color-line);
border-radius: 14px;
background: var(--color-surface);
}
/* Text renders inline, so the title and its detail ran together on one line
("Store informationHalfmart · 2 Test Street"). Stacked here rather than by
wrapping each in a div, so the row stays one grid cell. */
.ob-review > div > * { display: block; }
.ob-review-icon {
width: 34px;
height: 34px;
flex: none;
border-radius: 10px;
display: grid;
place-items: center;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.ob-review-edit {
align-self: center;
border: 0;
background: none;
padding: 4px 6px;
border-radius: 8px;
font: 600 13px/1 var(--font-sans);
color: var(--color-brand);
cursor: pointer;
}
.ob-review-edit:hover { background: var(--color-brand-tint); }
.ob-review-edit:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
/* The one consequence people do not expect, called out rather than listed. */
.ob-warn {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--color-warning-line, #e8d9a8);
background: var(--color-warning-tint, #fdf7e6);
color: var(--color-ink-1);
}
@media (max-width: 520px) {
/* The detail wraps under the icon before the Edit link gets squeezed off. */
.ob-review { grid-template-columns: auto minmax(0, 1fr); }
.ob-review-edit { grid-column: 2; justify-self: start; padding-left: 0; }
}
/* ── Onboarding: work beside its guidance ──────────────────────────────────
The upload panel and the tips that govern it, side by side on a desktop and
stacked on a phone — where a narrow second column would be unreadable. */
.ob-two {
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr);
}
@media (min-width: 900px) {
/* Guidance is secondary, so it takes the narrower column. */
.ob-two { grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); }
}
.ob-tips {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 9px;
}
.ob-tips li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: start;
}
.ob-tips svg {
color: var(--color-success, #1f9d55);
margin-top: 2px;
flex: none;
}