Recommendation Card
AI & AgentsAgent suggestion with a confidence meter and actions.
Interactive Live Preview
components/ui/recommendation-card.tsxTypeScript · React 19
1"use client";23import { useState } from "react";4import { Button, type ButtonVariant } from "@/components/atoms/Button";56/* ─────────────────────────────────────────────────────────7 * RECOMMENDATION CARD8 * The card holds its shape. Pressing "Alternatives" opens a9 * new drawer listing the other options; picking one promotes10 * it to the recommendation. The primary action confirms.11 * ───────────────────────────────────────────────────────── */1213type Option = {14 key: string;15 body: React.ReactNode;16 short: string;17 signal: number;18 tone: string;19 label: string;20 cta: string;21 ctaVariant: ButtonVariant;22};2324/* a supplier as a little logo chip — circular mark + name, no external arrow */25function VendorChip({ name, color = "#e08a3c" }: { name: string; color?: string }) {26 return (27 <span className="mx-0.5 inline-flex items-center gap-1 rounded-full bg-field py-px pl-[3px] pr-1.5 align-middle shadow-hairline">28 <span29 className="flex size-4 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold leading-none text-white"30 style={{ background: color }}31 >32 {name.charAt(0)}33 </span>34 <span className="text-[12px] font-medium text-ink">{name}</span>35 </span>36 );37}3839/* a plain value as a soft rounded badge — not a mono token */40function Pill({ children, tone = "neutral" }: { children: React.ReactNode; tone?: "neutral" | "green" }) {41 return (42 <span43 className={`mx-0.5 inline-flex items-center rounded-full px-1.5 py-0 align-middle text-[12px] font-medium ${44 tone === "green" ? "bg-green-tint text-green" : "bg-field text-ink-2"45 }`}46 style={{47 boxShadow: tone === "green"48 ? "0 0 0 1px color-mix(in oklch, var(--green) 28%, transparent)"49 : "var(--shadow-hairline)",50 }}51 >52 {children}53 </span>54 );55}5657const OPTIONS: Option[] = [58 {59 key: "high",60 body: (61 <>62 Reorder waffle cones from{" "}63 <VendorChip name="Cone King" />{" "}64 with lead time <Pill tone="green">7 days</Pill>65 </>66 ),67 short: "Reorder from Cone King · 7-day lead",68 signal: 3,69 tone: "var(--green)",70 label: "High confidence",71 cta: "Accept",72 ctaVariant: "accent",73 },74 {75 key: "review",76 body: (77 <>78 Switch vanilla to <Pill>Vanilla Madagascar</Pill> for peak season.79 </>80 ),81 short: "Switch to Vanilla Madagascar",82 signal: 2,83 tone: "var(--orange)",84 label: "Needs review",85 cta: "Configure",86 ctaVariant: "primary",87 },88 {89 key: "none",90 body: (91 <>92 Fall back to a <span className="font-medium text-ink">full restock</span> across every SKU.93 </>94 ),95 short: "Full restock across every SKU",96 signal: 0,97 tone: "var(--ink-3)",98 label: "No signal",99 cta: "Accept full restock",100 ctaVariant: "primary",101 },102];103104function Meter({ signal, tone }: { signal: number; tone: string }) {105 return (106 <span className="flex items-end gap-0.5">107 {[0, 1, 2].map((bar) => (108 <span109 key={bar}110 className="w-1 rounded-full transition-colors duration-300"111 style={{ height: 10, background: bar < signal ? tone : "var(--line-strong)" }}112 />113 ))}114 </span>115 );116}117118export default function RecommendationCard() {119 const [selected, setSelected] = useState(0);120 const [open, setOpen] = useState(false);121 const [accepted, setAccepted] = useState(false);122123 const active = OPTIONS[selected];124 const others = OPTIONS.map((o, i) => ({ o, i })).filter(({ i }) => i !== selected);125126 return (127 <div className="w-full max-w-95 overflow-hidden rounded-card bg-surface shadow-card">128 <div className="primitive-card-pad">129 <span className="text-[14px] font-medium text-ink">130 Want me to place this restock order?131 </span>132 <p133 key={active.key}134 className="mt-1.5 min-h-12 text-[13px] leading-relaxed text-ink-2"135 style={{ animation: "fade-in 180ms ease-out both" }}136 >137 {active.body}138 </p>139 </div>140141 {/* alternatives drawer — a distinctly new section of the card */}142 <div143 className="grid transition-[grid-template-rows,opacity] duration-300"144 style={{145 gridTemplateRows: open ? "1fr" : "0fr",146 opacity: open ? 1 : 0,147 transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)",148 }}149 >150 <div className="overflow-hidden">151 <div className="border-t border-line bg-surface px-2 py-2">152 <p className="px-1.5 pb-1 text-[11px] font-medium text-ink-3">153 Other options154 </p>155 {others.map(({ o, i }) => (156 <button157 key={o.key}158 type="button"159 onClick={() => {160 setSelected(i);161 setAccepted(false);162 }}163 className="flex w-full items-center gap-2.5 rounded-control px-1.5 py-1.5164 text-left transition-colors duration-100 hover:bg-hover"165 >166 <Meter signal={o.signal} tone={o.tone} />167 <span className="min-w-0 flex-1 truncate text-[12.5px] text-ink">{o.short}</span>168 <span className="shrink-0 text-[11px] text-ink-3">{o.label}</span>169 </button>170 ))}171 </div>172 </div>173 </div>174175 <div className="primitive-card-footer flex items-center justify-between gap-3 bg-surface">176 <span className="flex items-center gap-2">177 <Meter signal={active.signal} tone={active.tone} />178 <span className="text-[12.5px] font-medium text-ink-2">{active.label}</span>179 </span>180181 <span className="-mr-0.5 flex items-center gap-2">182 <Button183 variant="secondary"184 size="sm"185 aria-expanded={open}186 onClick={() => setOpen((current) => !current)}187 className="px-2.5 text-[12.5px]"188 >189 Alternatives190 </Button>191 <Button192 variant={accepted ? "success" : active.ctaVariant}193 size="sm"194 onClick={() => setAccepted(true)}195 className="text-[12.5px]"196 >197 {accepted ? "Accepted" : active.cta}198 </Button>199 </span>200 </div>201 </div>202 );203}204
Props & Specification
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | string | "default" | Visual state and layout presentation mode. |
| className | string | "" | Optional Tailwind CSS wrapper class overrides. |