tenant profile
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
71
src/features/onboarding/SetupReturnBar.tsx
Normal file
71
src/features/onboarding/SetupReturnBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
src/features/onboarding/StepFrame.tsx
Normal file
142
src/features/onboarding/StepFrame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
200
src/index.css
200
src/index.css
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user