skip button

This commit is contained in:
2026-09-01 12:54:40 +05:30
parent fda463f123
commit 22c9f45b44
13 changed files with 614 additions and 376 deletions

View File

@@ -1,184 +0,0 @@
/**
* Walking a new merchant from "signed in" to "on sale", one step at a time.
*
* It began as a plain checklist and that was not enough: seven lines all
* demanding attention equally is a list of homework, not guidance. Somebody who
* has never used the console does not need to be told there are seven things —
* they need to be told the ONE thing to do now, with a button that does it.
*
* So one step is in front of you at a time, with a real action and a way past
* it. The rest are a progress rail: visible, so nobody feels tricked about how
* far there is to go, but quiet.
*
* Every tick is derived from live data (see `setupSteps.ts`), so the card can
* never claim work that was not done. The only stored thing is what somebody
* chose to skip, and skipping never marks a step done — an unpriced catalogue
* does not start selling because a card was dismissed.
*/
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { ArrowRight, Check } from 'lucide-react';
import { setupSteps, isSetupComplete, type SetupInput, type SetupStep } from './setupSteps';
import { focusStep, isPaused, resumeAll, skipStep, skippedSteps } from './setupProgress';
export function SetupChecklist(input: SetupInput & { tenantid: number }) {
const { tenantid, ...data } = input;
const navigate = useNavigate();
const [skipped, setSkipped] = useState(() => skippedSteps(tenantid));
const steps = useMemo(() => setupSteps(data), [data]);
const done = steps.filter((step) => step.done).length;
const focus = focusStep(steps, skipped);
const paused = isPaused(steps, skipped);
// Gone once the shop is selling. A card that outlives its purpose becomes
// furniture and stops being read the next time it matters.
if (isSetupComplete(steps)) return null;
/* Everything outstanding was waved past. One quiet line rather than nothing:
the work is still undone, and a card that vanished entirely would leave no
way back to it. */
if (paused || !focus) {
return (
<Card padding={2} variant="transparent">
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="body" size="sm" color="secondary">
Setup paused — {done} of {steps.length} done.
</Text>
<Button
label="Resume"
variant="ghost"
size="sm"
onClick={() => setSkipped(resumeAll(tenantid))}
/>
</HStack>
</Card>
);
}
const position = steps.findIndex((step) => step.id === focus.id) + 1;
return (
<Card padding={3} elevation="low">
<VStack gap={2}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
Set up your shop
</Text>
<Text type="body" size="sm" color="secondary" hasTabularNumbers>
Step {position} of {steps.length}
</Text>
</HStack>
{/* The one thing to do now. Everything else on this card is context. */}
<VStack gap={1}>
<Text type="large" weight="semibold">
{focus.title}
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6, maxWidth: '60ch' }}>
{focus.todo}
</Text>
{focus.detail ? (
<Text type="body" size="xsm" color="secondary">
{focus.detail}
</Text>
) : null}
</VStack>
<HStack gap={1.5} align="center" wrap="wrap">
<Button
label={focus.cta}
variant="primary"
size="sm"
endContent={<ArrowRight size={14} />}
onClick={() => navigate(focus.href)}
/>
{/* Skipping is offered, not hidden. Somebody who cannot do this step
today — no licence to hand, no staff hired yet — should be able to
get on with the rest rather than abandon the guidance entirely.
The step stays open in the rail. */}
<Button
label="Skip for now"
variant="ghost"
size="sm"
onClick={() => setSkipped(skipStep(tenantid, focus.id))}
/>
</HStack>
<ProgressRail steps={steps} focusId={focus.id} skipped={skipped} />
</VStack>
</Card>
);
}
/**
* How far there is to go, without making it the point of the card.
*
* Names rather than bare pips: "step 4 of 7" tells somebody how much is left
* but not what is coming, and a merchant deciding whether to start now wants
* both. Muted enough that the focused step above still reads first.
*/
function ProgressRail({
steps,
focusId,
skipped,
}: {
steps: readonly SetupStep[];
focusId: string;
skipped: readonly string[];
}) {
return (
<VStack gap={0} style={{ borderTop: '1px solid var(--color-line)', paddingTop: 12 }}>
{steps.map((step) => {
const isFocus = step.id === focusId;
const isSkipped = !step.done && skipped.includes(step.id);
return (
<HStack key={step.id} gap={1} align="center" style={{ padding: '4px 0' }}>
{step.done ? (
<Check size={13} style={{ color: 'var(--color-success, #10b981)', flex: 'none' }} />
) : (
<span
aria-hidden
style={{
width: 13,
display: 'grid',
placeItems: 'center',
flex: 'none',
color: isFocus ? 'var(--color-brand)' : 'var(--color-ink-4)',
fontSize: 11,
}}
>
●
</span>
)}
<Text
type="body"
size="xsm"
{...(step.done || !isFocus ? { color: 'secondary' as const } : {})}
{...(isFocus ? { weight: 'semibold' as const } : {})}
style={isSkipped ? { opacity: 0.55 } : undefined}
>
{step.title}
</Text>
{isSkipped ? (
<Text type="body" size="xsm" color="secondary">
skipped
</Text>
) : null}
</HStack>
);
})}
</VStack>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { Check, ChevronDown, FileSpreadsheet, Monitor, Store, Users } from 'lucide-react';
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
import { StoreAdminTour } from '@/features/setup/StoreAdminTour';
import { BranchScopeProvider, useBranchScope } from './BranchScope';
import { useLiveEvents } from '@/queries/useLiveEvents';
@@ -85,6 +86,7 @@ export function StoreAdminShell() {
navLabel="Store Admin"
scopeControl={<BranchSelector />}
manageItems={MANAGE}
banner={<StoreAdminTour />}
/>
</BranchScopeProvider>
);

View File

@@ -21,16 +21,11 @@ import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import {
useLocationProducts,
useLocationSummary,
useOwnTenant,
usePosHealthByBranch,
usePosSalesByBranch,
useStaff,
useStockRequests,
useUploads,
} from '@/queries/hooks';
import { SetupChecklist } from '../SetupChecklist';
import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, percent, share } from '../format';
@@ -73,17 +68,6 @@ export function ConsolePage() {
const orders = useLocationSummary(tenantid || undefined);
const posSales = usePosSalesByBranch(branchIds, range);
const posHealth = usePosHealthByBranch(branchIds);
/* The checklist's data. All existing hooks — nothing new was needed on the
backend for it, which is most of why it is worth having. */
const shop = useOwnTenant(tenantid || undefined);
const people = useStaff(tenantid || undefined);
const products = useLocationProducts(tenantid || undefined, undefined, 0, { allBranches: true });
const uploads = useUploads(tenantid || undefined);
/* Drops the catalogue service has not released yet — the wait that makes
step 4 look stuck when it is simply not our turn. */
const pendingUploads = (uploads.data ?? []).filter(
(receipt) => receipt.laststatus === 'pending' && !receipt.runid,
).length;
const requests = useStockRequests(
tenantid ? { tenantid, locationid: selected ?? undefined, status: 'Pending' } : undefined,
@@ -167,21 +151,6 @@ export function ConsolePage() {
}
/>
{/* Setup, before revenue — but only for the merchant, and only until they
are trading. A Store user cannot open a branch, hire anybody or edit
the shop profile, so the same card in their workspace would be a list
of things they must ask somebody else to do. */}
{!isPinned ? (
<SetupChecklist
tenantid={tenantid}
shop={shop.data}
people={people.data ?? []}
branches={branches}
products={products.data ?? []}
pendingUploads={pendingUploads}
/>
) : null}
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
<VStack gap={1.5}>
<SectionHeader

View File

@@ -1,77 +0,0 @@
/**
* Which step a merchant is walked through, and what skipping does.
*
* The rule that matters: skipping moves the guidance on, it never marks work
* done. A shop whose catalogue is unpriced does not start selling because
* somebody pressed "Skip for now", and a card that implied otherwise would be
* worse than no card.
*/
import assert from 'node:assert/strict';
import { test, beforeEach } from 'node:test';
import { focusStep, isPaused } from './setupProgress';
import type { SetupStep, SetupStepId } from './setupSteps';
const step = (id: SetupStepId, done: boolean): SetupStep => ({
id,
title: id,
todo: '',
cta: '',
done,
href: '/',
});
const steps = [
step('profile', false),
step('people', false),
step('branch', true),
step('products', false),
];
beforeEach(() => {
// `localStorage` does not exist under the test runner, and the module must
// survive that — a private window and a browser refusing site data reach the
// same code path.
delete (globalThis as { localStorage?: unknown }).localStorage;
});
test('the focus is the first step that is neither done nor skipped', () => {
assert.equal(focusStep(steps, [])?.id, 'profile');
assert.equal(focusStep(steps, ['profile'])?.id, 'people');
assert.equal(focusStep(steps, ['profile', 'people'])?.id, 'products');
});
// Done steps are stepped over whether or not they were skipped — 'branch' is
// already complete and never becomes the focus.
test('a completed step is never focused', () => {
assert.notEqual(focusStep(steps, ['profile', 'people'])?.id, 'branch');
});
test('skipping everything outstanding leaves nothing to focus', () => {
assert.equal(focusStep(steps, ['profile', 'people', 'products']), null);
assert.equal(isPaused(steps, ['profile', 'people', 'products']), true);
});
/*
Paused is not finished. Every outstanding step being skipped means the merchant
waved the guidance past — the work is still undone, which is why the card shows
a resume line rather than disappearing.
*/
test('paused is false while any outstanding step is unskipped', () => {
assert.equal(isPaused(steps, ['profile']), false);
assert.equal(isPaused(steps, []), false);
});
// A shop with nothing left to do is not "paused" either — there is simply
// nothing outstanding to have skipped.
test('a finished shop is not reported as paused', () => {
const finished = [step('profile', true), step('people', true)];
assert.equal(isPaused(finished, []), false);
assert.equal(isPaused(finished, ['profile']), false);
});
// Storage the browser refuses must not take the page down, and must fail
// towards showing the guidance rather than silently hiding it.
test('unavailable storage reads as nothing skipped', async () => {
const { skippedSteps } = await import('./setupProgress');
assert.deepEqual(skippedSteps(1141), []);
});

View File

@@ -1,84 +0,0 @@
import type { SetupStep, SetupStepId } from './setupSteps';
/**
* Which step a merchant is being walked through, and which they have waved
* past.
*
* Skipping is a preference, not a fact, so it is the one thing here that IS
* stored rather than derived — in `localStorage`, per browser, per shop. It
* deliberately does not travel: "not now" is a statement about this afternoon,
* not a decision about the business, and a colleague opening the console should
* still be shown what is outstanding.
*
* A skipped step is never marked done. It drops out of the guided sequence and
* stays visibly incomplete in the list, because the work still has to happen —
* an unpriced catalogue does not start selling because somebody dismissed a
* card.
*/
const KEY = 'nearle.setup.skipped';
type SkipMap = Record<string, SetupStepId[]>;
function read(): SkipMap {
try {
const raw = localStorage.getItem(KEY);
const parsed: unknown = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === 'object' ? (parsed as SkipMap) : {};
} catch {
// A private window, cleared site data, or storage the browser refuses.
// Nothing skipped is the safe answer: the merchant sees the guidance again
// rather than losing it silently.
return {};
}
}
export function skippedSteps(tenantid: number): SetupStepId[] {
return read()[String(tenantid)] ?? [];
}
export function skipStep(tenantid: number, id: SetupStepId): SetupStepId[] {
const all = read();
const key = String(tenantid);
const next = [...new Set([...(all[key] ?? []), id])];
try {
localStorage.setItem(KEY, JSON.stringify({ ...all, [key]: next }));
} catch {
/* Storage refused. The skip applies to this render either way — losing it
on reload is a smaller failure than the write throwing mid-click. */
}
return next;
}
export function resumeAll(tenantid: number): SetupStepId[] {
const all = read();
try {
localStorage.setItem(KEY, JSON.stringify({ ...all, [String(tenantid)]: [] }));
} catch {
/* As above. */
}
return [];
}
/**
* The one step to put in front of somebody: the first that is neither done nor
* skipped.
*
* Null when there is nothing left to guide — either everything is done, or
* everything outstanding has been waved past. The two are different states and
* the card renders them differently; this only says there is no step to focus.
*/
export function focusStep(
steps: readonly SetupStep[],
skipped: readonly SetupStepId[],
): SetupStep | null {
return steps.find((step) => !step.done && !skipped.includes(step.id)) ?? null;
}
/** True when work remains but every outstanding step has been waved past. */
export function isPaused(
steps: readonly SetupStep[],
skipped: readonly SetupStepId[],
): boolean {
const outstanding = steps.filter((step) => !step.done);
return outstanding.length > 0 && outstanding.every((step) => skipped.includes(step.id));
}