Behaviour-only React components for offer status badges and action bars
@zooly/ui-offers exposes two thin React components used by both apps to render offer status and the row of actions a given role can take on an offer:
OfferStatusBadge — resolves a status to a label + tone.OfferActionBar — resolves an (offer, role) pair to the list of wired button configs via allowedActions from @zooly/contracts.Behaviour only. The package ships no styled primitives. Each app plugs in its own rendering — Tailwind + cva in zooly-app, MUI + Radix in zooly-app2. This avoids the impossible task of shipping one styled component that works natively under two different UI stacks.
@zooly/ui-offerspackages/ui-offers@zooly/contracts, @zooly/typesreact: ^18 || ^19OfferStatusBadgeimport { OfferStatusBadge } from "@zooly/ui-offers";
import type { OfferStatus } from "@zooly/types";
<OfferStatusBadge
status={offer.status}
render={({ label, tone }) => (
<span className={TONE_CLASS[tone]}>{label}</span>
)}
/>
Props:
| Prop | Type | Notes |
|---|---|---|
status | OfferStatus | One of the 12 FSM states |
render | (args) => ReactNode | Renderer. Receives { status, label, tone } |
The render args:
| Field | Type | Notes |
|---|---|---|
status | OfferStatus | Echoed back for convenience |
label | string | Human-readable label from OFFER_STATUS_LABEL |
tone | OfferStatusTone | Abstract palette key — e.g. "accepted", "disputed". Distinct per status. Apps map this to their own palette |
The 12 tones are distinct per status so each app can paint each status a distinct colour (W1-01 AC).
OfferActionBarimport { OfferActionBar } from "@zooly/ui-offers";
<OfferActionBar
offer={offer}
role="brand"
onAction={(action) => handle(action)}
renderButton={({ action, label, intent, onClick }) => (
<button key={action} className={INTENT_CLASS[intent]} onClick={onClick}>
{label}
</button>
)}
/>
Props:
| Prop | Type | Notes |
|---|---|---|
offer | { status: OfferStatus } | Minimal — only the status is read |
role | "brand" | "talent" | "admin" | Which role's allowed actions to show |
onAction | (action) => void | Called with the action when a button is pressed |
renderButton | (btn) => ReactNode | Per-button renderer |
renderContainer | (args) => ReactNode | Optional wrapper around the list |
The button args:
| Field | Type | Notes |
|---|---|---|
action | OfferAction | Typed action from @zooly/contracts |
label | string | Label from OFFER_ACTION_LABEL |
intent | "primary" | "secondary" | "danger" | Styling hint; apps map to their own button variants |
onClick | () => void | Pre-wired — invokes onAction(action) |
The component renders exactly the buttons returned by allowedActions(offer, role), in order — no extras, no omissions.
For apps that need a non-React layout (server-rendered summary, markdown render, admin dashboard table), use buildOfferActionButtons directly:
import { buildOfferActionButtons } from "@zooly/ui-offers";
const buttons = buildOfferActionButtons(offer, "brand", handleAction);
// → [{ action, label, intent, onClick }, …] matching allowedActions exactly
Verified by two smoke files that get typechecked on every package build:
apps/zooly-app/app/_smoke/ui-offers-smoke.tsx — Tailwind v4 + cva (React 19)apps/zooly-app2/src/_smoke/ui-offers-smoke.tsx — MUI v7 + Radix (React 18)Both compile clean with strict TypeScript. The package exposes no React-19-only APIs, so it is safe to import in either stack.
cd packages/ui-offers
pnpm test
9 tests cover: label/tone completeness for all 12 statuses, tone distinctness (per-status colours), action-bar wiring against allowedActions, onClick → onAction propagation, empty-list guarantee for terminal statuses, and within-cell label uniqueness (so a user never sees two buttons labelled the same).
allowedActions and the OfferAction / OfferRole enums this component is driven by.