# 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.
