UI Offers Package

Behaviour-only React components for offer status badges and action bars

Overview

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

Package Details

  • Package Name: @zooly/ui-offers
  • Location: packages/ui-offers
  • Runtime dependencies: @zooly/contracts, @zooly/types
  • Peer dependencies: react: ^18 || ^19

OfferStatusBadge

import { 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:

PropTypeNotes
statusOfferStatusOne of the 12 FSM states
render(args) => ReactNodeRenderer. Receives { status, label, tone }

The render args:

FieldTypeNotes
statusOfferStatusEchoed back for convenience
labelstringHuman-readable label from OFFER_STATUS_LABEL
toneOfferStatusToneAbstract 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).

OfferActionBar

import { 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:

PropTypeNotes
offer{ status: OfferStatus }Minimal — only the status is read
role"brand" | "talent" | "admin"Which role's allowed actions to show
onAction(action) => voidCalled with the action when a button is pressed
renderButton(btn) => ReactNodePer-button renderer
renderContainer(args) => ReactNodeOptional wrapper around the list

The button args:

FieldTypeNotes
actionOfferActionTyped action from @zooly/contracts
labelstringLabel from OFFER_ACTION_LABEL
intent"primary" | "secondary" | "danger"Styling hint; apps map to their own button variants
onClick() => voidPre-wired — invokes onAction(action)

The component renders exactly the buttons returned by allowedActions(offer, role), in order — no extras, no omissions.

Pure helper

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

Stack compatibility

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.

Testing

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

  • Contracts PackageallowedActions and the OfferAction / OfferRole enums this component is driven by.
  • API Client Package — call the matching endpoint when a button is pressed.