返回 JoyAI-Echo
QuestionCards.tsx
1 import { useCallback, useState } from "react";
2 import { Check } from "lucide-react";
3 import { cn } from "@/lib/utils";
4 import type { UIQuestionCard } from "@/lib/types";
5
6 interface QuestionCardsProps {
7 cards: UIQuestionCard[];
8 onAnswer: (cardId: string, value: string) => void;
9 /** Agent turn not finished yet (waiting for stream_end resuming:false). */
10 disabled?: boolean;
11 }
12
13 export function QuestionCards({
14 cards,
15 onAnswer,
16 disabled = false,
17 }: QuestionCardsProps) {
18 return (
19 <div
20 className={cn(
21 "mt-3 flex flex-col gap-3",
22 disabled && "pointer-events-none opacity-60",
23 )}
24 aria-disabled={disabled || undefined}
25 >
26 {cards.map((card) => (
27 <SingleQuestionCard
28 key={card.id}
29 card={card}
30 disabled={disabled}
31 onAnswer={(value) => onAnswer(card.id, value)}
32 />
33 ))}
34 </div>
35 );
36 }
37
38 function SingleQuestionCard({
39 card,
40 onAnswer,
41 disabled = false,
42 }: {
43 card: UIQuestionCard;
44 onAnswer: (value: string) => void;
45 disabled?: boolean;
46 }) {
47 const isAnswered = card.answered != null;
48 const isLocked = disabled || isAnswered;
49 const [customValue, setCustomValue] = useState("");
50 const [showCustom, setShowCustom] = useState(false);
51
52 const handleSelect = useCallback(
53 (label: string) => {
54 if (isLocked) return;
55 onAnswer(label);
56 },
57 [isLocked, onAnswer],
58 );
59
60 const handleCustomSubmit = useCallback(() => {
61 if (!customValue.trim() || isLocked) return;
62 onAnswer(customValue.trim());
63 }, [customValue, isLocked, onAnswer]);
64
65 return (
66 <div
67 className={cn(
68 "rounded-xl border border-border/50 bg-card/30 p-3.5 transition-all duration-300",
69 "animate-in fade-in-0 slide-in-from-bottom-1 duration-300",
70 isAnswered && "opacity-75",
71 )}
72 >
73 <p className="mb-2.5 text-[13px] font-medium text-foreground/80">
74 {card.question}
75 </p>
76
77 <div className="flex flex-wrap gap-2">
78 {card.options.map((opt) => {
79 const isSelected = card.answered === opt.label;
80 return (
81 <button
82 key={opt.label}
83 type="button"
84 disabled={isLocked}
85 onClick={() => handleSelect(opt.label)}
86 className={cn(
87 "inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5",
88 "text-[12.5px] font-medium transition-all duration-200",
89 "border border-border/60",
90 !isLocked &&
91 !isSelected && [
92 "bg-background hover:bg-foreground/[0.06] hover:border-foreground/20",
93 "active:scale-[0.97]",
94 ],
95 isSelected && [
96 "bg-foreground text-background border-foreground",
97 "shadow-sm",
98 ],
99 isLocked && !isSelected && "opacity-40",
100 "disabled:cursor-default",
101 )}
102 >
103 {isSelected && <Check className="h-3 w-3" />}
104 {opt.label}
105 </button>
106 );
107 })}
108 </div>
109
110 {card.allowCustom && !isAnswered && !disabled && (
111 <div className="mt-2.5">
112 {!showCustom ? (
113 <button
114 type="button"
115 onClick={() => setShowCustom(true)}
116 className="text-[11.5px] text-muted-foreground/70 transition-colors hover:text-foreground/70"
117 >
118 + Custom response
119 </button>
120 ) : (
121 <div className="flex items-center gap-2">
122 <input
123 type="text"
124 value={customValue}
125 onChange={(e) => setCustomValue(e.target.value)}
126 onBlur={() => {
127 if (!customValue.trim()) {
128 setShowCustom(false);
129 }
130 }}
131 onKeyDown={(e) => {
132 if (e.key === "Enter" && !e.nativeEvent.isComposing) {
133 e.preventDefault();
134 handleCustomSubmit();
135 }
136 }}
137 placeholder="Enter your response..."
138 className={cn(
139 "flex-1 rounded-lg border border-border/60 bg-background px-3 py-1.5",
140 "text-[12.5px] text-foreground placeholder:text-muted-foreground/50",
141 "focus:border-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/10",
142 "transition-all duration-150",
143 )}
144 autoFocus
145 />
146 <button
147 type="button"
148 onClick={handleCustomSubmit}
149 disabled={!customValue.trim()}
150 className={cn(
151 "rounded-lg px-3 py-1.5 text-[12px] font-medium",
152 "bg-foreground text-background",
153 "transition-all duration-150",
154 "hover:bg-foreground/90 active:scale-[0.97]",
155 "disabled:opacity-40 disabled:cursor-default",
156 )}
157 >
158 Confirm
159 </button>
160 </div>
161 )}
162 </div>
163 )}
164
165 {card.allowCustom &&
166 isAnswered &&
167 !card.options.some((o) => o.label === card.answered) && (
168 <div className="mt-2 flex items-center gap-1.5">
169 <Check className="h-3 w-3 text-foreground/60" />
170 <span className="text-[12px] text-foreground/70">
171 {card.answered}
172 </span>
173 </div>
174 )}
175 </div>
176 );
177 }
178
178 lines Plain Text