tenant profile
This commit is contained in:
@@ -23,19 +23,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
|
||||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
|
||||||
import { errorMessage } from '@/api/client';
|
import { errorMessage } from '@/api/client';
|
||||||
import { tenantsApi } from '@/api/tenants';
|
import { tenantsApi } from '@/api/tenants';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { PageBody } from '@/components/PageBody';
|
import { PageBody } from '@/components/PageBody';
|
||||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||||
import { useLocationProducts, useOwnTenant } from '@/queries/hooks';
|
import { useLocationProducts, useOwnTenant } from '@/queries/hooks';
|
||||||
|
import { StepFrame } from './StepFrame';
|
||||||
import { Stepper } from './Stepper';
|
import { Stepper } from './Stepper';
|
||||||
import {
|
import {
|
||||||
completeStep,
|
completeStep,
|
||||||
@@ -90,6 +87,7 @@ export function OnboardingPage() {
|
|||||||
const shop = useOwnTenant(tenantid || undefined);
|
const shop = useOwnTenant(tenantid || undefined);
|
||||||
const products = useLocationProducts(tenantid || undefined, undefined, 0, { allBranches: true });
|
const products = useLocationProducts(tenantid || undefined, undefined, 0, { allBranches: true });
|
||||||
|
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
const [state, setState] = useState<OnboardingState>(() => readOnboarding(userid, tenantid));
|
const [state, setState] = useState<OnboardingState>(() => readOnboarding(userid, tenantid));
|
||||||
const [errors, setErrors] = useState<Errors>({});
|
const [errors, setErrors] = useState<Errors>({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -122,6 +120,29 @@ export function OnboardingPage() {
|
|||||||
});
|
});
|
||||||
}, [shop.data]);
|
}, [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 productCount = (products.data ?? []).length;
|
||||||
const step = resumeStep(state);
|
const step = resumeStep(state);
|
||||||
const { done, total } = progressOf(state);
|
const { done, total } = progressOf(state);
|
||||||
@@ -215,25 +236,7 @@ export function OnboardingPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageBody measure="reading">
|
<PageBody measure="reading">
|
||||||
{!isFirst && !isLast ? (
|
{!isFirst && !isLast ? <Stepper current={step} completed={state.completed} /> : null}
|
||||||
<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' ? (
|
{step === 'welcome' ? (
|
||||||
<WelcomeStep
|
<WelcomeStep
|
||||||
@@ -245,6 +248,19 @@ export function OnboardingPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : 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' ? (
|
{step === 'store' ? (
|
||||||
<StoreInfoStep
|
<StoreInfoStep
|
||||||
value={state.store}
|
value={state.store}
|
||||||
@@ -256,15 +272,18 @@ export function OnboardingPage() {
|
|||||||
{step === 'catalogue' ? (
|
{step === 'catalogue' ? (
|
||||||
<CatalogueStep
|
<CatalogueStep
|
||||||
productCount={productCount}
|
productCount={productCount}
|
||||||
onUpload={() => navigate('/admin/inventory?tab=products&upload=1')}
|
onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=catalogue')}
|
||||||
onManual={() => navigate('/admin/inventory')}
|
/* 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}
|
) : null}
|
||||||
|
|
||||||
{step === 'inventory' ? (
|
{step === 'inventory' ? (
|
||||||
<InventoryStep
|
<InventoryStep
|
||||||
onDownloadTemplate={() => navigate('/admin/inventory')}
|
onDownloadTemplate={() => navigate('/admin/inventory?tab=stock&setup=inventory')}
|
||||||
onUpload={() => navigate('/admin/inventory')}
|
onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=inventory')}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -288,57 +307,27 @@ export function OnboardingPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 'done' ? (
|
|
||||||
<DoneStep
|
|
||||||
productCount={productCount}
|
|
||||||
onCatalogue={() => navigate('/admin/inventory')}
|
|
||||||
onInventory={() => navigate('/admin/inventory')}
|
|
||||||
onStorefront={() => navigate('/admin/inventory')}
|
|
||||||
onDashboard={() => navigate('/admin/console')}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{saveError ? (
|
{saveError ? (
|
||||||
<Card padding={3} variant="transparent">
|
<Card padding={2} variant="transparent">
|
||||||
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
|
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||||
{saveError}
|
{saveError}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
</StepFrame>
|
||||||
{/* 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}
|
) : 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>
|
</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 { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
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.
|
* Keeping the online stock in step with the shelf.
|
||||||
@@ -24,12 +34,12 @@ export interface InventoryStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FLOW = [
|
const FLOW = [
|
||||||
'What is on your shelf',
|
{ icon: Store, label: 'What is on your shelf' },
|
||||||
'Written into the stock sheet',
|
{ icon: FileSpreadsheet, label: 'Written into the stock sheet' },
|
||||||
'Uploaded here',
|
{ icon: UploadCloud, label: 'Uploaded here' },
|
||||||
'Checked for mistakes',
|
{ icon: CheckCircle2, label: 'Checked for mistakes' },
|
||||||
'Your stock is updated',
|
{ icon: Database, label: 'Your stock is updated' },
|
||||||
'Customers see what is in stock',
|
{ icon: ShoppingBag, label: 'Customers see what is in stock' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const TIPS = [
|
const TIPS = [
|
||||||
@@ -55,13 +65,13 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
|
|||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
<ol className="ob-flow">
|
<ol className="ob-flow">
|
||||||
{FLOW.map((stage, i) => (
|
{FLOW.map(({ icon: Icon, label }) => (
|
||||||
<li key={stage} className="ob-flow-step">
|
<li key={label} className="ob-flow-step">
|
||||||
<span className="ob-flow-num" aria-hidden>
|
<span className="ob-flow-icon" aria-hidden>
|
||||||
{i + 1}
|
<Icon size={17} />
|
||||||
</span>
|
</span>
|
||||||
<Text type="body" size="sm">
|
<Text type="body" size="sm">
|
||||||
{stage}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -69,6 +79,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<div className="ob-two">
|
||||||
<Card padding={3} elevation="low">
|
<Card padding={3} elevation="low">
|
||||||
<VStack gap={2}>
|
<VStack gap={2}>
|
||||||
<HStack justify="between" align="start" gap={2} wrap="wrap">
|
<HStack justify="between" align="start" gap={2} wrap="wrap">
|
||||||
@@ -117,20 +128,18 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding={3} variant="transparent">
|
<Card padding={3} elevation="low">
|
||||||
<VStack gap={1}>
|
<VStack gap={1.5}>
|
||||||
<Text
|
<Text type="label" size="sm" weight="semibold">
|
||||||
type="label"
|
Tips
|
||||||
size="xsm"
|
|
||||||
color="secondary"
|
|
||||||
style={{ textTransform: 'uppercase', letterSpacing: '.09em' }}
|
|
||||||
>
|
|
||||||
Worth knowing
|
|
||||||
</Text>
|
</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) => (
|
{TIPS.map((tip) => (
|
||||||
<li key={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}
|
{tip}
|
||||||
</Text>
|
</Text>
|
||||||
</li>
|
</li>
|
||||||
@@ -138,6 +147,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
|
|||||||
</ul>
|
</ul>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
</VStack>
|
</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 { Text } from '@astryxdesign/core/Text';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
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';
|
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
|
* A summary, not a second form. The earlier version relisted every field as a
|
||||||
* carries an Edit that goes back to the step that owns it — and a skipped step
|
* label/value pair, which made the last screen the longest one in the flow and
|
||||||
* says it was skipped rather than showing blanks that read like data loss.
|
* 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
|
* products is set up correctly and still has nothing to sell. Better said here
|
||||||
* than discovered on the dashboard.
|
* than discovered on the dashboard.
|
||||||
*/
|
*/
|
||||||
@@ -26,127 +28,116 @@ export interface ReviewStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewStep({ store, delivery, productCount, skipped, onEdit }: 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 (
|
return (
|
||||||
<VStack gap={2} className="ob-panel">
|
<VStack gap={1.5} className="ob-panel">
|
||||||
<Section title="Store information" onEdit={() => onEdit('store')}>
|
<Line
|
||||||
<Row label="Name" value={store.tenantname || '—'} />
|
icon={<Store size={17} />}
|
||||||
<Row
|
title="Store information"
|
||||||
label="Address"
|
detail={storeLine}
|
||||||
value={[store.address, store.city, store.state, store.postcode].filter(Boolean).join(', ') || '—'}
|
hours={`Open ${store.opentime} – ${store.closetime}`}
|
||||||
/>
|
onEdit={() => onEdit('store')}
|
||||||
<Row label="Phone" value={store.primarycontact || '—'} />
|
/>
|
||||||
<Row label="Email" value={store.primaryemail || '—'} />
|
<Line
|
||||||
<Row label="Type" value={store.tenanttype || '—'} />
|
icon={<Boxes size={17} />}
|
||||||
<Row label="Hours" value={`${store.opentime} – ${store.closetime}`} />
|
title="Catalogue"
|
||||||
</Section>
|
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 ? (
|
{productCount === 0 ? (
|
||||||
<Card padding={3} variant="transparent">
|
<div className="ob-warn">
|
||||||
<HStack gap={1.5} align="start">
|
<AlertTriangle size={16} aria-hidden style={{ flex: 'none', marginTop: 1 }} />
|
||||||
<AlertTriangle
|
<Text type="body" size="sm" style={{ lineHeight: 1.55 }}>
|
||||||
size={16}
|
Your store has no products yet, so shoppers will find it empty. Finishing setup is
|
||||||
style={{ color: 'var(--color-warning, #b7860b)', flex: 'none', marginTop: 2 }}
|
fine — add products whenever you are ready and they go on sale straight away.
|
||||||
/>
|
</Text>
|
||||||
<VStack gap={0.5}>
|
</div>
|
||||||
<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}
|
) : null}
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({
|
/** One step, one sentence, one way to change it. */
|
||||||
|
function Line({
|
||||||
|
icon,
|
||||||
title,
|
title,
|
||||||
|
detail,
|
||||||
|
hours,
|
||||||
onEdit,
|
onEdit,
|
||||||
children,
|
|
||||||
}: {
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
hours?: string;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card padding={3} elevation="low">
|
<div className="ob-review">
|
||||||
<VStack gap={1.5}>
|
<span className="ob-review-icon" aria-hidden>
|
||||||
<HStack justify="between" align="center" gap={2}>
|
{icon}
|
||||||
<Text type="label" size="sm" weight="semibold">
|
</span>
|
||||||
{title}
|
<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>
|
</Text>
|
||||||
<Button label="Edit" variant="ghost" size="sm" onClick={onEdit} />
|
) : null}
|
||||||
</HStack>
|
</div>
|
||||||
<VStack gap={0.5}>{children}</VStack>
|
{/* A link rather than a button: it navigates, and three filled buttons
|
||||||
</VStack>
|
down the right of a summary compete with Complete setup. */}
|
||||||
</Card>
|
<button type="button" className="ob-review-edit" onClick={onEdit}>
|
||||||
);
|
Edit<span className="sr-only"> {title}</span>
|
||||||
}
|
</button>
|
||||||
|
</div>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Badge } from '@astryxdesign/core/Badge';
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
@@ -32,6 +33,9 @@ import { useStockRequests, useStockStatement } from '@/queries/hooks';
|
|||||||
import { useBranchScope } from '../BranchScope';
|
import { useBranchScope } from '../BranchScope';
|
||||||
import { CataloguePanel } from '../CataloguePanel';
|
import { CataloguePanel } from '../CataloguePanel';
|
||||||
import { ProductsPanel } from '../ProductsPanel';
|
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';
|
import { branchLabel, count, money } from '../format';
|
||||||
|
|
||||||
type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
|
type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
|
||||||
@@ -53,10 +57,39 @@ type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
|
|||||||
*/
|
*/
|
||||||
export function InventoryPage() {
|
export function InventoryPage() {
|
||||||
const { branches, selected, current, tenantid } = useBranchScope();
|
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
|
// Owned here so the button can live in the tab row while the drawer it opens
|
||||||
// stays inside the panel that uses it.
|
// stays inside the panel that uses it.
|
||||||
const [isUploading, setUploading] = useState(false);
|
const [isUploading, setUploading] = useState(() => params.get('upload') === '1');
|
||||||
|
|
||||||
const requests = useStockRequests(
|
const requests = useStockRequests(
|
||||||
tenantid ? { tenantid, locationid: selected ?? undefined } : undefined,
|
tenantid ? { tenantid, locationid: selected ?? undefined } : undefined,
|
||||||
@@ -69,6 +102,12 @@ export function InventoryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={3}>
|
<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
|
<PageHeader
|
||||||
title="Inventory"
|
title="Inventory"
|
||||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||||
@@ -106,26 +145,26 @@ export function InventoryPage() {
|
|||||||
label="Products"
|
label="Products"
|
||||||
icon={<PackageSearch size={14} />}
|
icon={<PackageSearch size={14} />}
|
||||||
isActive={tab === 'products'}
|
isActive={tab === 'products'}
|
||||||
onClick={() => setTab('products')}
|
onClick={() => chooseTab('products')}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<TabButton
|
||||||
label="Catalogue"
|
label="Catalogue"
|
||||||
icon={<DownloadCloud size={14} />}
|
icon={<DownloadCloud size={14} />}
|
||||||
isActive={tab === 'catalogue'}
|
isActive={tab === 'catalogue'}
|
||||||
onClick={() => setTab('catalogue')}
|
onClick={() => chooseTab('catalogue')}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<TabButton
|
||||||
label="Stock requests"
|
label="Stock requests"
|
||||||
icon={<ClipboardList size={14} />}
|
icon={<ClipboardList size={14} />}
|
||||||
isActive={tab === 'requests'}
|
isActive={tab === 'requests'}
|
||||||
onClick={() => setTab('requests')}
|
onClick={() => chooseTab('requests')}
|
||||||
badge={pending.length || undefined}
|
badge={pending.length || undefined}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<TabButton
|
||||||
label="Stock"
|
label="Stock"
|
||||||
icon={<Layers size={14} />}
|
icon={<Layers size={14} />}
|
||||||
isActive={tab === 'stock'}
|
isActive={tab === 'stock'}
|
||||||
onClick={() => setTab('stock')}
|
onClick={() => chooseTab('stock')}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
}
|
}
|
||||||
|
|||||||
200
src/index.css
200
src/index.css
@@ -1376,17 +1376,16 @@ main {
|
|||||||
background: var(--color-surface-subtle);
|
background: var(--color-surface-subtle);
|
||||||
border: 1px solid var(--color-line);
|
border: 1px solid var(--color-line);
|
||||||
}
|
}
|
||||||
.ob-flow-num {
|
.ob-flow-icon {
|
||||||
width: 22px;
|
/* Sized for a 17px glyph rather than the two digits that used to sit here. */
|
||||||
height: 22px;
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
flex: none;
|
flex: none;
|
||||||
border-radius: 999px;
|
border-radius: 9px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: var(--color-brand-tint);
|
background: var(--color-brand-tint);
|
||||||
color: var(--color-brand);
|
color: var(--color-brand);
|
||||||
font: 600 11px/1 var(--font-sans);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
}
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
.ob-flow { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.ob-flow { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
@@ -1411,3 +1410,192 @@ main {
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.ob-tick { animation: none; }
|
.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