diff --git a/src/features/onboarding/OnboardingPage.tsx b/src/features/onboarding/OnboardingPage.tsx index dec1e38..3056dd2 100644 --- a/src/features/onboarding/OnboardingPage.tsx +++ b/src/features/onboarding/OnboardingPage.tsx @@ -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(() => readOnboarding(userid, tenantid)); const [errors, setErrors] = useState({}); 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=` 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 ( - {!isFirst && !isLast ? ( - - - - {heading.title} - - {heading.blurb ? ( - - {heading.blurb} - - ) : null} - - - - ) : null} + {!isFirst && !isLast ? : null} {step === 'welcome' ? ( ) : null} + {!isFirst && !isLast ? ( + goTo(backOf(step))} + {...(canSkip ? { onSkip: () => persist(skipStep(state, step, nextOf(step))) } : {})} + onContinue={() => void saveAndContinue()} + onExit={() => navigate('/admin/console')} + > {step === 'store' ? ( 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' ? ( 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' ? ( - navigate('/admin/inventory')} - onInventory={() => navigate('/admin/inventory')} - onStorefront={() => navigate('/admin/inventory')} - onDashboard={() => navigate('/admin/console')} - /> - ) : null} - {saveError ? ( - + {saveError} ) : null} - - {/* Footer navigation. Back is quiet, Continue is the one action, and Skip - sits between them so it is available without competing. */} - {!isFirst && !isLast ? ( - - + ); } diff --git a/src/features/store-admin/pages/InventoryPage.tsx b/src/features/store-admin/pages/InventoryPage.tsx index ad284b8..39b6591 100644 --- a/src/features/store-admin/pages/InventoryPage.tsx +++ b/src/features/store-admin/pages/InventoryPage.tsx @@ -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('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(() => + 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 ( + {/* 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 ? ( + + ) : null} + } isActive={tab === 'products'} - onClick={() => setTab('products')} + onClick={() => chooseTab('products')} /> } isActive={tab === 'catalogue'} - onClick={() => setTab('catalogue')} + onClick={() => chooseTab('catalogue')} /> } isActive={tab === 'requests'} - onClick={() => setTab('requests')} + onClick={() => chooseTab('requests')} badge={pending.length || undefined} /> } isActive={tab === 'stock'} - onClick={() => setTab('stock')} + onClick={() => chooseTab('stock')} /> } diff --git a/src/index.css b/src/index.css index 3b7352f..565d4fe 100644 --- a/src/index.css +++ b/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; +}