@spark-web/design-system
Version:
--- title: Design System ---
310 lines (239 loc) • 15.9 kB
Markdown
# Vendor admin — vendor-portal consumer overlay
This is the consumer overlay for the **vendor-portal** repo, per the Consumer
overlays convention in
`node_modules/@spark-web/design-system/patterns/CLAUDE.md`. Apply these
substitutions only when working in the vendor-portal repo. This file overrides
pattern files AND component-level docs ONLY where it explicitly says so (each
heading names what it overrides); every other surface and pattern rule applies
to vendor-portal unchanged.
---
## Pagination (overrides vendor-admin CLAUDE.md "List loading rule" — pagination control)
vendor-portal supplies its own `TablePagination` at
`src/components/TablePagination.tsx` — the consumer substitution for the surface
rules' "List loading rule" pagination control
(`// COMPONENT GAP: TablePagination needed — not yet in Spark`).
It wraps `rc-pagination` and renders a `Text` summary
`Show {dataShowing} of {total} Results` beside the page controls. Props used by
the patterns:
| Prop | Type | Notes |
| ------------- | -------------------------- | ------------------------------------------------ |
| `total` | `number` | Total record count — from a count query |
| `current` | `number` | Current page number |
| `pageSize` | `number` | Defaults to `DATA_PER_PAGE` (`@/util/constants`) |
| `dataShowing` | `number` | Number of rows on the current page |
| `onChange` | `(page, pageSize) => void` | Page change handler |
`TablePagination` returns `null` when `total` is falsy — never render an empty
pagination bar.
---
## Side panel (overrides vendor-admin CLAUDE.md "Detail interaction rule" — side panel implementation)
vendor-portal supplies its own `SidePanel` at `src/components/SidePanel.tsx` —
the consumer substitution for the surface rules' "Detail interaction rule"
drawer (`// COMPONENT GAP: SidePanel/Drawer needed — not yet in Spark`;
verified: the only overlay primitive published is `@spark-web/modal-dialog`, a
centered modal, not a right-anchored drawer).
It is an absolute-positioned right slide-in built on `@spark-web/stack` /
`@spark-web/box`, `90vw` wide on mobile up to `maxWidth: 500px` from tablet,
offset below the header (`top: 64`), with an optional `heading` + close
(`XIcon`) header row and a built-in `loading` skeleton state. Props:
| Prop | Type | Notes |
| ----------- | ------------ | --------------------------------------------------------------------------------- |
| `children` | `ReactNode` | Panel body |
| `heading` | `string` | Optional — header title (renders header only when `heading` AND `onHide` are set) |
| `onHide` | `() => void` | Optional — close handler; renders the close button |
| `loading` | `boolean` | Optional — shows the skeleton placeholder state |
| `fullWidth` | `boolean` | Optional — removes horizontal body padding |
| `...props` | `StackProps` | Spread onto the outer `Stack` |
---
## Multi-select filter (overrides list-page.md Section 4 filter usage — substitution note)
The canonical vendor-admin choice for a multi-select filter dropdown is
`@spark-web/multi-select` (verified current export: it exports `MultiSelect`,
not `MultiSelectField`). vendor-portal currently ships a local
`MultiselectCheckbox` at `src/components/MultiselectCheckbox.tsx`.
**Prefer `MultiSelect` from `@spark-web/multi-select`** for new work — the two
have effectively the same public API, so the substitution is direct:
| Prop | `MultiselectCheckbox` (local) | `@spark-web/multi-select` `MultiSelect` |
| ---------------- | ------------------------------------------------- | --------------------------------------- |
| `options` | `Options` (`Array<{ label, options: Option[] }>`) | same grouped shape |
| `onChange` | `(selected: SelectedOptions) => void` | same — grouped by group label |
| `placeholder` | `string` (defaults `"Filter By..."`) | same |
| `defaultOptions` | `DefaultOption[]` (`{ label, value }`) | `Option[]` — same shape |
Both group selections into `SelectedOptions` keyed by group label (e.g.
`{ Months: ["12", "24"] }` in `ProductSettings`). Because the shapes match, swap
the import and component name with no call-site changes. The local
`MultiselectCheckbox` is retained only where already wired; do not reach for it
in new code.
---
## Form fields (overrides @spark-web/field CLAUDE.md "controlled usage" — react-hook-form wrapper)
vendor-portal wraps `@spark-web/field` in a local `FormField` exported as
`Field` (`src/components/FormField.tsx`). It maps a react-hook-form
`errors[name]` entry to the field's error presentation:
```tsx
import { Field } from '@/components/FormField';
<Field label="Email" name="email" errors={errors}>
<TextInput {...register('email')} />
</Field>;
```
Wrapper signature: `FieldProps & { name: string; errors?: FieldErrors }`. When
`errors[name]` is present it surfaces `errors[name].message`; otherwise it falls
back to a passed `message`. Either way the wrapper sets `tone="critical"` — its
`(!!errors && errors[name]) || { message }` expression is always truthy. Note: a
static help `message` with no validation error still renders with critical tone
— a known quirk in the consumer wrapper. Wire forms with `react-hook-form` and
Zod schemas via `@hookform/resolvers` (`zodResolver`) — pass the resolver's
`errors` straight into `Field`. Prefer this `Field` wrapper over hand-wiring
`@spark-web/field` with manual error props on vendor-portal form pages.
---
## Section / setting wrapper (overrides detail/form section grouping)
vendor-portal uses a local `SettingsPanel`
(`src/components/settings/SettingsPanel.tsx`) for settings sections: a 2-column
`Columns` layout with a left `heading` (`Heading level="4"`) + `description`
(`Text`) and a right control slot (`children`).
```tsx
import { SettingsPanel } from '@/components/settings/SettingsPanel';
<SettingsPanel heading="Terms" description="Enable the terms available...">
<MultiSelect options={termsOptionList} onChange={handleTermUpdates} />
</SettingsPanel>;
```
| Prop | Type | Notes |
| ------------- | ----------------------- | ---------------------------- |
| `heading` | `string` | Required — left-column title |
| `description` | `string \| JSX.Element` | Required — left-column body |
| `children` | `ReactNode` | Right-column control slot |
**Relationship to `@spark-web/section-card`:** these are NOT interchangeable.
`SectionCard` is a bordered card with `header` / `footer` / `children` slots
(compose with `@spark-web/section-header`) — use it for record-detail section
**cards**. `SettingsPanel` is a label-beside-control settings row with no card
chrome. Canonically, prefer `@spark-web/section-card` for detail-page section
cards; keep `SettingsPanel` for the settings-page label/description/control
layout it was built for. Do not substitute one for the other.
---
## Feedback (overrides @spark-web/alert usage — local FlashMessage)
vendor-portal's `FlashMessage` (`src/components/settings/FlashMessage.tsx`) is a
thin wrapper over `@spark-web/alert`:
```tsx
<Alert
tone={success ? 'positive' : 'critical'}
onClose={onClose}
closeLabel="Dismiss"
>
{message}
</Alert>
```
It maps `{ success, message, onClose }` to an `Alert` with `tone="positive"`
(success) or `tone="critical"` (failure). The canonical feedback component is
`@spark-web/alert` (`node_modules/@spark-web/alert/CLAUDE.md`); `FlashMessage`
is just the local success/error binding. Prefer `Alert` directly for new
inline-feedback needs; reuse `FlashMessage` only where the success/error toggle
is convenient.
---
## Read-only display (overrides detail read-only display — local InfoBox to portal-table)
vendor-portal's profile page uses a local `InfoBox` (`src/pages/profile.tsx`)
that renders an icon + `label` + `value` row for read-only display. This is the
label/value display the canonical pattern fulfils with `@spark-web/portal-table`
(`PortalTable`), whose rows are `DescriptionListItem`s (`{ label, value }`,
where `value` may be a `ReactNode`). Prefer `@spark-web/portal-table` for
read-only label/value record displays; the local `InfoBox` is retained only for
its icon-decorated profile layout.
---
## Data conventions (vendor-portal data fetching)
vendor-portal is a Next.js Pages-Router app with two data layers:
- **Server-side GraphQL** via Apollo Client inside `getServerSideProps`. Create
the client with `createClient(session.access_token)` from
`@/lib/apollo-client` and run queries (`@/queries/*`) there; pass results to
the page as props.
- **Client-side** via `@tanstack/react-query`. Use `useInfiniteQuery` for
infinite-scroll lists (e.g. leads) and `useQuery` for bounded reads. Mutations
live in custom hooks under `src/hooks/**` that wrap `useMutation` (typically
`fetch`-ing a Next.js API route) and expose `mutate` / `mutateAsync`.
These conventions are consumer logic, not a design-system rule — but follow them
when wiring vendor-portal screens rather than introducing a new data layer.
---
## Legacy / embedded screens (overlay note — NOT a Layer-2 pattern)
Some vendor-portal routes do not build a Spark component tree at all:
- **Legacy iframe fallback** — `PortalIframe`
(`src/components/portal-iframe.tsx`) embeds the legacy portal in an
`<iframe>`. Used (often gated by a LaunchDarkly feature flag) on
`applications/[id]`, `leads/[id]`, `users`, `users/add`, and as a fallback on
`applications/index` (e.g. behind `vendorPortalOwnApplicationPage`).
- **Dynamic micro-frontend embed** — `referrals/add`
(`src/pages/referrals/add.tsx`) injects a remote `vendor_form` script and
mounts it into a container `div` (version pinned by a flag).
These screens have **no Layer-2 pattern**. Do not try to reconstruct them from a
pattern file. When working on them, follow the consumer's existing iframe /
micro-frontend convention (mount container, feature-flag gate, session token
plumbing) — they are intentionally outside the pattern system.
---
## Dashboard pieces (overrides dashboard.md Sections 1-3 placeholders — consumer substitutions)
vendor-portal supplies its own dashboard components in
`src/components/dashboard/**` and `src/components/**`. These are the consumer
substitutions for the placeholders the dashboard pattern flags as COMPONENT
GAPs. Use them in vendor-portal in place of the primitives placeholders; in new
code outside vendor-portal, the pattern's placeholders remain the stop-gap until
a Spark component exists.
### Metrics — `DashboardMetrics` + `MetricCard` (overrides dashboard.md Section 1)
`DashboardMetrics` (`src/components/dashboard/DashboardMetrics.tsx`) renders a
`@spark-web/columns` `Columns` (`collapseBelow="wide"`) of local `MetricCard`s
(`src/components/MetricCard.tsx`). This is the consumer substitution for the
**MetricCard COMPONENT GAP** (dashboard.md Section 1).
`MetricCard` props:
| Prop | Type | Notes |
| ---------- | ------------ | -------------------------------------------------- |
| `header` | `string` | The headline number (the pattern's `value`) |
| `title` | `string` | The card label (the pattern's `label`) |
| `subTitle` | `string` | Optional secondary line (the pattern's `subtitle`) |
| `onClick` | `() => void` | Click-through (the pattern's `onClick`/`href`) |
`DashboardMetrics` props: `data` (`TMetricSummary[]`) and
`onMetricClick(metric)`. Note the local `MetricCard` renders the headline number
with a raw `style={{ fontSize: '34px' }}` — that inline `fontSize` is a known
deviation from the pattern (which mandates a `Text` size token); do not copy it
into new cards.
### Resources — `DashboardAds` / `AdCard` + `DocumentsDialog` (overrides dashboard.md Section 2)
`DashboardAds` (`src/components/dashboard/DashBoardAds.tsx`) renders a `Columns`
of local `AdCard`s (`src/components/AdCard.tsx`) plus a `DocumentsDialog`. These
are the consumer's resource/promo cards — the pattern treats resource cards as
buildable from primitives (no gap flag), so these are a convenience factoring,
not a substitution for a missing Spark primitive.
`AdCard` props: `mediaSrc`, `mediaAlt`, `cardContent` (`ReactNode`),
`cardActions` (`ReactNode`). It composes `@spark-web/box` + `@spark-web/text` +
a `next/image` media region.
`DocumentsDialog` (`src/components/dashboard/DocumentsDialog.tsx`) is the
document-list modal — built **canonically** on `@spark-web/modal-dialog`
`ContentDialog` (`size="small"`, `showCloseButton={false}`) with a body of
`next/link` items. Props: `links` (`Array<{ title; link }>`), `isOpen`,
`onClose`. (Canonically the dashboard pattern uses `@spark-web/text-link`
`TextLink` for the link rows; the local dialog uses raw `next/link` — prefer
`TextLink` in new code.)
### Announcements feed — `DashboardProductAnnouncements` (overrides dashboard.md Section 3)
`DashboardProductAnnouncements`
(`src/components/dashboard/DashboardProductAnnouncements.tsx`) is the consumer
substitution for the **AnnouncementsFeed COMPONENT GAP** (dashboard.md Section
3). It renders the "What's New" timeline (article items built from
`@spark-web/box as="article"` / `@spark-web/stack` / `@spark-web/text`) and the
**"Load More"** `@spark-web/button` `Button` (button-triggered incremental
loading — the third list-loading mode), with a `@tanstack/react-query`
`useQuery` driving the incremental fetch on the `/dashboard/articles` page.
Props: `data` (the announcements array) and `apiUrl`. The feed is gated by the
`newsFeedVendorPortal` LaunchDarkly flag in `src/pages/dashboard/index.tsx`
(consumer gating). The connector-line / `next/image` chrome is consumer styling
applied via `@emotion/css` `css(...)` — outside vendor-portal, keep to the
pattern's semantic placeholder.
### Consent modal — `ContentDialog` (no substitution)
The first-run consent modal in `src/pages/dashboard/index.tsx` uses
`@spark-web/modal-dialog` `ContentDialog` **canonically**
(`showCloseButton={false}`, a `Button tone="primary"` Continue footer that POSTs
`initialized: true` to `/api/user-profile`, gated on
`userProfile && !userProfile.initialized`). There is **no consumer
substitution** — build it exactly as the pattern's Section 4 describes.
### Dashboard route gating
The whole dashboard is gated by the `vendorPortalOwnDashboard` LaunchDarkly
flag; when off, `src/pages/dashboard/index.tsx` falls back to a `PortalIframe`
(`users/dashboard`) — a legacy embed with **no Layer-2 pattern** (see "Legacy /
embedded screens" above). This flag gating is consumer logic, not a
design-system rule.
---
## No `@brighte/ui-components`
Unlike the portal-hub overlay, vendor-portal does **NOT** use
`@brighte/ui-components` — it is not a dependency (verified: absent from
`package.json`). Do not import from `@brighte/ui-components` in vendor-portal.
Filter and form fields come from `@spark-web/*` (and the local `Field` /
`MultiselectCheckbox` wrappers documented above), not from that package.