?
Questions
Which store should the sessions live in?
Every request reads the session, so this is the hot path.
A
Postgres — alongside the appB
Redis — expiring keysC
SQLite — one file on the boxD
Signed cookies — no store at allE
Something else...question-card.tsx
globals.css
prompt.md
Collapse
Reset
Copy Code
Download
Run
1
"use client";
2
3
import React, { useState, useRef, useEffect } from 'react';
4
import { motion, AnimatePresence } from 'framer-motion';
5
import { Command, CornerDownLeft, MessageSquare } from 'lucide-react';
6
import { cn } from "@/lib/utils";
7
8
const QUESTIONS = [
9
{
10
title: "Which store should the sessions live in?",
11
subtitle: "Every request reads the session, so this is the hot path.",
12
options: [
13
"Postgres — alongside the app",
14
"Redis — expiring keys",
15
"SQLite — one file on the box",
16
"Signed cookies — no store at all"
17
],
18
allowCustom: true
19
},
20
{
21
title: "Keep the existing tokens working?",
22
subtitle: "People signed in today hold tokens in the old format until they expire.",
23
options: [
24
"Yes — accept both for a while",
25
"No — invalidate on deploy"
26
],
27
allowCustom: true
28
},
29
{
30
title: "Ship behind a feature flag?",
31
subtitle: "A flag lets it reach some of the traffic before all of it.",
32
options: [
33
"Yes — gradual rollout",
34
"No — full release",
35
"Staff accounts first"
36
],
37
allowCustom: true
38
}
39
];
40
41
export function QuestionCard({ className }: { className?: string }) {
42
const [currentIndex, setCurrentIndex] = useState(0);
43
const [answers, setAnswers] = useState<Record<number, string | null>>({});
44
const [isCustomEditing, setIsCustomEditing] = useState(false);
45
const [customText, setCustomText] = useState("");
46
47
const currentQuestion = QUESTIONS[currentIndex];
48
const progress = (currentIndex + 1) / QUESTIONS.length;
49
50
const currentAnswer = answers[currentIndex];
51
52
// Next is disabled if there's no answer, or if the answer is just whitespace (e.g. empty custom input)
53
const isNextDisabled = !currentAnswer || currentAnswer.trim() === "";
54
55
const handleOptionClick = (option: string) => {
56
setAnswers(prev => ({ ...prev, [currentIndex]: option }));
57
setIsCustomEditing(false);
58
};
59
60
const handleCustomClick = () => {
61
setIsCustomEditing(true);
62
// Don't auto-enable next. Just set to current custom text or empty string
63
if (!answers[currentIndex] || currentQuestion.options.includes(answers[currentIndex] as string)) {
64
setAnswers(prev => ({ ...prev, [currentIndex]: customText || "" }));
65
}
66
};
67
68
const handleCustomChange = (e: React.ChangeEvent<HTMLInputElement>) => {
69
const val = e.target.value;
70
setCustomText(val);
71
setAnswers(prev => ({ ...prev, [currentIndex]: val }));
72
};
73
74
const handleNext = () => {
75
if (isNextDisabled) return;
76
if (currentIndex < QUESTIONS.length - 1) {
77
setCurrentIndex(prev => prev + 1);
78
setIsCustomEditing(false);
79
setCustomText("");
80
}
81
};
82
83
const handlePrev = () => {
84
if (currentIndex > 0) {
85
setCurrentIndex(prev => prev - 1);
86
setIsCustomEditing(false);
87
// Reset custom text if going back
88
const prevAnswer = answers[currentIndex - 1];
89
if (prevAnswer && !QUESTIONS[currentIndex - 1].options.includes(prevAnswer)) {
90
setCustomText(prevAnswer);
91
setIsCustomEditing(true);
92
} else {
93
setCustomText("");
94
}
95
}
96
};
97
98
const handleSkip = () => {
99
if (currentIndex < QUESTIONS.length - 1) {
100
setCurrentIndex(prev => prev + 1);
101
setIsCustomEditing(false);
102
setCustomText("");
103
}
104
};
105
106
// Keyboard shortcut listener for CMD+Enter
107
useEffect(() => {
108
const handleKeyDown = (e: KeyboardEvent) => {
109
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
110
handleNext();
111
}
112
};
113
window.addEventListener('keydown', handleKeyDown);
114
return () => window.removeEventListener('keydown', handleKeyDown);
115
}, [currentIndex, isNextDisabled]);
116
117
const allOptions = [...currentQuestion.options];
118
if (currentQuestion.allowCustom) {
119
allOptions.push("Something else...");
120
}
121
122
const getLetter = (index: number) => String.fromCharCode(65 + index);
123
124
return (
125
<div className={cn("w-full max-w-[calc(100vw-32px)] md:max-w-[560px] bg-gradient-to-b from-white from-30% to-[#EBE9EB] dark:from-[#1C1C1E] dark:to-[#0A0A0C] rounded-2xl border border-solid border-black/5 dark:border-white/5 hover:border-[#D6E4FF] dark:hover:border-white/20 shadow-[0_4px_24px_rgba(0,0,0,0.04)] dark:shadow-[0_4px_24px_rgba(0,0,0,0.4)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_12px_40px_rgba(0,0,0,0.6)] p-4 sm:p-5 md:p-6 font-matter transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]", className)}>
126
127
{/* Header */}
128
<div className="flex items-center justify-between mb-5 sm:mb-6 md:mb-8">
129
<div className="flex items-center gap-2.5 sm:gap-3">
130
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center shrink-0">
131
<div className="relative flex items-center justify-center">
132
<MessageSquare className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-blue-500" />
133
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
134
<span className="text-[9px] sm:text-[10px] font-bold text-blue-500 mt-[-2px]">?</span>
135
</div>
136
</div>
137
</div>
138
<h2 className="text-neutral-900 dark:text-[#F5F5F7] font-medium text-[17px] sm:text-[18px] md:text-[20px] leading-snug tracking-normal font-matter">Questions</h2>
139
</div>
140
141
{/* Progress Ring */}
142
<div className="relative w-5 h-5 flex items-center justify-center shrink-0">
143
<svg className="w-5 h-5 transform -rotate-90" viewBox="0 0 20 20">
144
<circle
145
cx="10"
146
cy="10"
147
r="8"
148
fill="none"
149
className="stroke-zinc-200 dark:stroke-[#333]"
150
strokeWidth="2"
151
/>
152
<motion.circle
153
cx="10"
154
cy="10"
155
r="8"
156
fill="none"
157
className="stroke-zinc-400 dark:stroke-white"
158
strokeWidth="2"
159
strokeDasharray="50.26"
160
strokeDashoffset={50.26 * (1 - progress)}
161
strokeLinecap="round"
162
animate={{ strokeDashoffset: 50.26 * (1 - progress) }}
163
transition={{ duration: 0.5, ease: "easeInOut" }}
164
/>
165
</svg>
166
</div>
167
</div>
168
169
<AnimatePresence mode="wait">
170
<motion.div
171
key={currentIndex}
172
initial={{ opacity: 0, x: 10 }}
173
animate={{ opacity: 1, x: 0 }}
174
exit={{ opacity: 0, x: -10 }}
175
transition={{ duration: 0.2 }}
176
>
177
{/* Question Text */}
178
<div className="mb-4 sm:mb-5 md:mb-6">
179
<h3 className="text-[15.5px] sm:text-[17px] font-medium text-zinc-900 dark:text-[#F5F5F7] mb-1 font-matter leading-snug">{currentQuestion.title}</h3>
180
<p className="text-[13px] sm:text-[14px] text-zinc-500 dark:text-[#86868B] leading-relaxed">{currentQuestion.subtitle}</p>
181
</div>
182
183
{/* Options */}
184
<div className="flex flex-col gap-1 sm:gap-1.5 mb-5 sm:mb-6 md:mb-8">
185
{allOptions.map((option, idx) => {
186
const isCustomOption = idx === currentQuestion.options.length;
187
188
// Determine if this specific option is active
189
let isActive = false;
190
if (isCustomOption) {
191
isActive = isCustomEditing;
192
} else {
193
isActive = currentAnswer === option && !isCustomEditing;
194
}
195
196
return (
197
<div
198
key={idx}
199
onClick={() => isCustomOption ? handleCustomClick() : handleOptionClick(option)}
200
className={cn(
201
"flex items-center gap-2.5 sm:gap-3.5 p-2 rounded-xl cursor-pointer transition-all duration-300 border min-w-0",
202
isActive
203
? "bg-black/[0.04] dark:bg-white/[0.08] border-black/10 dark:border-white/20 shadow-sm"
204
: "bg-transparent border-transparent hover:bg-black/[0.02] dark:hover:bg-white/[0.03]"
205
)}
206
>
207
<div
208
className={cn(
209
"w-6 h-6 sm:w-7 sm:h-7 rounded-lg flex items-center justify-center text-[11px] sm:text-[12px] font-medium transition-colors shrink-0",
210
isActive
211
? "bg-white dark:bg-gradient-to-b dark:from-[#F5F5F7] dark:to-[#D1D1D6] text-zinc-900 dark:text-[#111111] font-semibold shadow-sm"
212
: "bg-black/5 dark:bg-white/5 text-zinc-500 dark:text-[#86868B]"
213
)}
214
>
215
{getLetter(idx)}
216
</div>
217
218
{isCustomOption && isActive ? (
219
<input
220
autoFocus
221
type="text"
222
value={customText}
223
onChange={handleCustomChange}
224
placeholder="Type your answer..."
225
className="flex-1 min-w-0 bg-transparent text-[13px] sm:text-[14px] text-zinc-900 dark:text-[#F5F5F7] outline-none placeholder:text-zinc-400 dark:placeholder:text-[#86868B]/60"
226
/>
227
) : (
228
<span className={cn(
229
"text-[13px] sm:text-[14px] transition-colors min-w-0 flex-1 leading-snug",
230
isActive ? "text-zinc-900 dark:text-[#F5F5F7] font-medium" : "text-zinc-600 dark:text-[#86868B]"
231
)}>
232
{option}
233
</span>
234
)}
235
</div>
236
);
237
})}
238
</div>
239
</motion.div>
240
</AnimatePresence>
241
242
{/* Footer Controls */}
243
<div className="flex items-center justify-between pt-2 gap-2">
244
<div>
245
{currentIndex > 0 && (
246
<button
247
onClick={handlePrev}
248
className="px-3 sm:px-3.5 py-1.5 rounded-full bg-gradient-to-b from-white from-30% to-[#EBE9EB] dark:from-[#2C2C2E] dark:to-[#1C1C1E] border border-black/5 dark:border-white/10 hover:from-black hover:to-black hover:text-white hover:border-black dark:hover:from-white dark:hover:to-white text-zinc-700 dark:text-[#F5F5F7] dark:hover:text-black text-[12px] sm:text-[13px] font-medium transition-all duration-300 shadow-sm whitespace-nowrap"
249
>
250
Previous
251
</button>
252
)}
253
</div>
254
255
<div className="flex items-center gap-2 sm:gap-2.5">
256
<button
257
onClick={handleSkip}
258
className="px-3 sm:px-3.5 py-1.5 rounded-full bg-gradient-to-b from-white from-30% to-[#EBE9EB] dark:from-[#2C2C2E] dark:to-[#1C1C1E] border border-black/5 dark:border-white/10 hover:from-black hover:to-black hover:text-white hover:border-black dark:hover:from-white dark:hover:to-white text-zinc-700 dark:text-[#F5F5F7] dark:hover:text-black text-[12px] sm:text-[13px] font-medium transition-all duration-300 shadow-sm whitespace-nowrap"
259
>
260
Skip
261
</button>
262
263
<button
264
onClick={handleNext}
265
disabled={isNextDisabled}
266
className={cn(
267
"flex items-center gap-1.5 sm:gap-2 px-3.5 sm:px-4 py-1.5 rounded-full text-[12px] sm:text-[13px] font-medium transition-all duration-300 shadow-sm whitespace-nowrap shrink-0",
268
"bg-gradient-to-b from-[#2A2C33] to-[#111111] text-white border border-white/10 dark:from-[#F5F5F7] dark:to-[#D1D1D6] dark:text-[#111111] dark:border dark:border-white/20 dark:shadow-[0_2px_12px_rgba(255,255,255,0.12)]",
269
isNextDisabled
270
? "opacity-40 cursor-not-allowed pointer-events-none"
271
: "hover:opacity-90 dark:hover:from-white dark:hover:to-[#E5E5EA]"
272
)}
273
>
274
{currentIndex === QUESTIONS.length - 1 ? 'Continue' : 'Next'}
275
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
276
<div className="flex items-center justify-center w-4 h-4 rounded bg-white/20 dark:bg-black/10">
277
<Command className="w-2.5 h-2.5 text-white/80 dark:text-black/70" />
278
</div>
279
<div className="flex items-center justify-center w-4 h-4 rounded bg-white/20 dark:bg-black/10">
280
<CornerDownLeft className="w-2.5 h-2.5 text-white/80 dark:text-black/70" />
281
</div>
282
</div>
283
</button>
284
</div>
285
</div>
286
287
</div>
288
);
289
}
290

