Recommendation Card

AI & Agents

Agent suggestion with a confidence meter and actions.

Interactive Live Preview
components/ui/recommendation-card.tsxTypeScript · React 19
1"use client";
2
3import { useState } from "react";
4import { Button, type ButtonVariant } from "@/components/atoms/Button";
5
6/* ─────────────────────────────────────────────────────────
7 * RECOMMENDATION CARD
8 * The card holds its shape. Pressing "Alternatives" opens a
9 * new drawer listing the other options; picking one promotes
10 * it to the recommendation. The primary action confirms.
11 * ───────────────────────────────────────────────────────── */
12
13type 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};
23
24/* 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 <span
29 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}
38
39/* 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 <span
43 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}
56
57const 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];
103
104function 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 <span
109 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}
117
118export default function RecommendationCard() {
119 const [selected, setSelected] = useState(0);
120 const [open, setOpen] = useState(false);
121 const [accepted, setAccepted] = useState(false);
122
123 const active = OPTIONS[selected];
124 const others = OPTIONS.map((o, i) => ({ o, i })).filter(({ i }) => i !== selected);
125
126 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 <p
133 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>
140
141 {/* alternatives drawer — a distinctly new section of the card */}
142 <div
143 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 options
154 </p>
155 {others.map(({ o, i }) => (
156 <button
157 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.5
164 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>
174
175 <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>
180
181 <span className="-mr-0.5 flex items-center gap-2">
182 <Button
183 variant="secondary"
184 size="sm"
185 aria-expanded={open}
186 onClick={() => setOpen((current) => !current)}
187 className="px-2.5 text-[12.5px]"
188 >
189 Alternatives
190 </Button>
191 <Button
192 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

PropTypeDefaultDescription
variantstring&quot;default&quot;Visual state and layout presentation mode.
classNamestring""Optional Tailwind CSS wrapper class overrides.

Related Components in AI & Agents