← back to ClawCoder
src/components/onboarding/question-screen.tsx
187 lines
'use client'
import { useState, useCallback, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import type { OnboardingQuestion } from '@/lib/onboarding/questions'
import { ProgressIndicator } from '@/components/ui/progress-indicator'
interface QuestionScreenProps {
question: OnboardingQuestion
totalSteps: number
selectedValue: string | undefined
onSelect: (value: string) => void
onBack: () => void
onNext: () => void
isFirst: boolean
}
/** Detect prefers-reduced-motion at render time. */
function usePrefersReducedMotion() {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
setReduced(mq.matches)
const handler = (e: MediaQueryListEvent) => setReduced(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
return reduced
}
const fadeVariants = {
initial: { opacity: 0, x: 40 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -40 },
}
const fadeFallback = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
}
export function QuestionScreen({
question,
totalSteps,
selectedValue,
onSelect,
onBack,
onNext,
isFirst,
}: QuestionScreenProps) {
const [showHint, setShowHint] = useState(false)
const reducedMotion = usePrefersReducedMotion()
const variants = reducedMotion ? fadeFallback : fadeVariants
const handleSelect = useCallback(
(value: string) => {
onSelect(value)
// Auto-advance after a short delay so the user sees the selection
setTimeout(() => {
onNext()
}, 300)
},
[onSelect, onNext]
)
return (
<div className="flex min-h-screen flex-col">
{/* Main content, centered vertically with padding for bottom bar */}
<div className="flex flex-1 flex-col items-center justify-center px-4 pb-28 pt-8">
<AnimatePresence mode="wait">
<motion.div
key={question.id}
variants={variants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.25, ease: 'easeInOut' }}
className="flex w-full max-w-2xl flex-col items-center"
>
{/* Step label */}
<p className="mb-3 text-sm font-medium uppercase tracking-wider text-slate-400">
Step {question.step} of {totalSteps}
</p>
{/* Question */}
<h1 className="mb-2 text-center text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">
{question.question}
</h1>
{/* Why we ask (collapsible) */}
<button
type="button"
onClick={() => setShowHint((v) => !v)}
className="mb-8 text-sm text-blue-500 transition-colors hover:text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
aria-expanded={showHint}
>
{showHint ? 'Hide' : 'Why do we ask?'}
</button>
<AnimatePresence>
{showHint && (
<motion.p
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="-mt-4 mb-8 max-w-md text-center text-sm text-slate-500"
>
{question.whyWeAsk}
</motion.p>
)}
</AnimatePresence>
{/* Pill options grid */}
<div
className={`grid w-full gap-4 ${
question.options.length <= 3
? 'grid-cols-1 sm:grid-cols-3'
: question.options.length === 4
? 'grid-cols-1 sm:grid-cols-2'
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'
}`}
>
{question.options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleSelect(opt.value)}
className={`group flex min-h-[72px] flex-col items-center justify-center rounded-2xl border-2 px-6 py-4 text-center transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 motion-reduce:transform-none ${
selectedValue === opt.value
? 'border-blue-500 bg-blue-50 shadow-md'
: 'border-slate-200 bg-white hover:border-blue-300 hover:shadow-sm'
}`}
>
<span
className={`text-base font-semibold ${
selectedValue === opt.value
? 'text-blue-700'
: 'text-slate-900 group-hover:text-blue-600'
}`}
>
{opt.label}
</span>
{opt.description && (
<span className="mt-1 text-sm text-slate-500">
{opt.description}
</span>
)}
</button>
))}
</div>
</motion.div>
</AnimatePresence>
</div>
{/* Fixed bottom bar - progress + navigation */}
<div className="fixed bottom-0 left-0 right-0 z-50 border-t border-slate-200 bg-white/80 backdrop-blur">
<div className="mx-auto max-w-2xl px-4 pb-4 pt-3">
<ProgressIndicator current={question.step} total={totalSteps} />
<div className="mt-3 flex justify-between">
{!isFirst ? (
<button
type="button"
onClick={onBack}
className="min-h-[48px] rounded-xl border border-slate-300 px-6 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
Back
</button>
) : (
<div />
)}
<button
type="button"
onClick={onNext}
disabled={!selectedValue}
className="min-h-[48px] rounded-xl bg-blue-600 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{question.step === totalSteps ? 'Finish' : 'Next'}
</button>
</div>
</div>
</div>
</div>
)
}