Survey 02
Four-question feedback card built on the Questionnaire: single choice, pick up to 3, a 1 to 5 rating and an optional comment you can skip. Required questions validate inline, and a review step lets you edit any answer before submitting.
Preview
Installation
npx shadcn@latest add https://hirael.com/r/survey-02.jsonCode
components/blocks/survey-02.tsx
'use client';
import * as React from 'react';
import { ChevronLeft, RotateCcw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from '@/registry/hirael/bases/radix/ui/questionnaire';
const EASE = 'ease-[cubic-bezier(0.22,1,0.36,1)]';
const ENTER = `animate-in fade-in slide-in-from-bottom-4 duration-500 ${EASE} fill-mode-both motion-reduce:animate-none`;
const SWAP = `animate-in fade-in duration-300 ${EASE} fill-mode-both motion-reduce:animate-none`;
const ITEM_IN = `data-active:animate-in data-active:fade-in-0 data-active:slide-in-from-bottom-2 data-active:duration-300 ${EASE} motion-reduce:data-active:animate-none`;
type QuestionId = 'source' | 'features' | 'setup' | 'notes';
interface Answers {
source: string;
features: string[];
setup: string;
notes: string;
}
const QUESTIONS: readonly { name: QuestionId; title: string; required: boolean }[] = [
{ name: 'source', title: 'How did you first hear about Hirael?', required: true },
{ name: 'features', title: 'Which features does your team use most?', required: true },
{ name: 'setup', title: 'How easy was it to set up your workspace?', required: true },
{ name: 'notes', title: 'Anything else we should know?', required: false },
];
const LAST_QUESTION = QUESTIONS[QUESTIONS.length - 1].name;
const SOURCES = [
{ value: 'colleague', label: 'A colleague recommended it' },
{ value: 'search', label: 'A search engine' },
{ value: 'review-site', label: 'A review site like G2' },
{ value: 'newsletter', label: 'A newsletter or blog post' },
{ value: 'other', label: 'Somewhere else' },
] as const;
const FEATURES = [
{ value: 'shared-inbox', label: 'Shared inbox' },
{ value: 'saved-replies', label: 'Saved replies' },
{ value: 'assignment', label: 'Assignment rules' },
{ value: 'comments', label: 'Inline comments' },
{ value: 'reports', label: 'Reports' },
{ value: 'integrations', label: 'Integrations' },
] as const;
const MAX_FEATURES = 3;
const SETUP_LABELS = ['Very hard', 'Hard', 'Okay', 'Easy', 'Very easy'] as const;
const EMPTY_ANSWERS: Answers = { source: '', features: [], setup: '', notes: '' };
const formatAnswer = (name: QuestionId, answers: Answers): string | null => {
switch (name) {
case 'source':
return SOURCES.find((option) => option.value === answers.source)?.label ?? null;
case 'features':
return answers.features.length
? FEATURES.filter((option) => answers.features.includes(option.value))
.map((option) => option.label)
.join(', ')
: null;
case 'setup':
return answers.setup ? `${answers.setup} of 5, ${SETUP_LABELS[Number(answers.setup) - 1]}` : null;
case 'notes':
return answers.notes.trim() || null;
}
};
// Progress fills by translating left, which starts from the wrong edge in RTL; this track scales from the start edge.
const ProgressTrack = ({ value }: { value: number }) => (
<span
data-slot="feedback-survey-track"
aria-hidden
className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted"
>
<span
className="block h-full origin-left rounded-full bg-primary transition-transform duration-300 ease-out motion-reduce:transition-none rtl:origin-right"
style={{ transform: `scaleX(${value})` }}
/>
</span>
);
const Survey02 = () => {
const [round, setRound] = React.useState(0);
const [item, setItem] = React.useState<string>(QUESTIONS[0].name);
const [features, setFeatures] = React.useState<string[]>([]);
const [answers, setAnswers] = React.useState<Answers>(EMPTY_ANSWERS);
const [reviewing, setReviewing] = React.useState(false);
const [returnToReview, setReturnToReview] = React.useState(false);
const [submitted, setSubmitted] = React.useState(false);
const atFeatureLimit = features.length >= MAX_FEATURES;
const toggleFeature = (value: string, checked: boolean) =>
setFeatures((current) =>
checked ? [...current, value].slice(0, MAX_FEATURES) : current.filter((feature) => feature !== value),
);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
setAnswers({
source: String(data.get('source') ?? ''),
features,
setup: String(data.get('setup') ?? ''),
notes: String(data.get('notes') ?? ''),
});
setReturnToReview(false);
setReviewing(true);
};
const edit = (name: QuestionId) => {
setReturnToReview(true);
setReviewing(false);
setItem(name);
};
const backToQuestions = () => {
setReviewing(false);
setItem(LAST_QUESTION);
};
// Submitting re-validates every item and lands back on the review step.
const saveAndReview = (event: React.MouseEvent<HTMLButtonElement>) => {
if (!returnToReview) return;
event.preventDefault();
event.currentTarget.form?.requestSubmit();
};
const restart = () => {
setRound((current) => current + 1);
setItem(QUESTIONS[0].name);
setFeatures([]);
setAnswers(EMPTY_ANSWERS);
setReviewing(false);
setReturnToReview(false);
setSubmitted(false);
};
return (
<section data-slot="feedback-survey" aria-labelledby="survey-02-heading" className="bg-background py-16 sm:py-24">
<div className="mx-auto w-full max-w-xl px-6">
<div
data-slot="feedback-survey-card"
className={cn(
ENTER,
'overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-xs',
)}
>
{submitted ? (
<div data-slot="feedback-survey-done" role="status" className={cn(SWAP, 'flex flex-col gap-6 p-6 sm:p-8')}>
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground uppercase">Response sent</span>
<h2 id="survey-02-heading" className="text-xl font-semibold tracking-tight">
Thanks, your feedback is in
</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
We read every response. If we ship something you asked for, we will let you know by email.
</p>
</div>
<Button type="button" variant="outline" className="w-fit" onClick={restart}>
<RotateCcw aria-hidden />
Start over
</Button>
</div>
) : (
<Questionnaire key={round} item={item} items={QUESTIONS} onItemChange={setItem} onSubmit={handleSubmit}>
{/* One child, so the root's gap never splits the card's hairline sections. */}
<div data-slot="feedback-survey-body" className="flex flex-col">
<div
data-slot="feedback-survey-header"
className="flex items-center gap-4 border-b border-border px-6 py-4"
>
<h2 id="survey-02-heading" className="shrink-0 text-sm font-medium">
Hirael feedback
</h2>
<QuestionnaireProgress
className="flex-1"
render={(props, state) => (
<div {...props}>
<span className="flex items-center gap-4">
<ProgressTrack value={reviewing ? 1 : state.current / (state.total || 1)} />
<span className="shrink-0">
{reviewing ? 'Review' : `Question ${state.current} of ${state.total}`}
</span>
</span>
</div>
)}
/>
</div>
<div data-slot="feedback-survey-step" hidden={reviewing} className="min-h-80 px-6 py-6">
<QuestionnaireItem name="source" required className={ITEM_IN}>
<QuestionnaireTitle>{QUESTIONS[0].title}</QuestionnaireTitle>
<QuestionnaireDescription>Pick one.</QuestionnaireDescription>
<QuestionnaireChoices>
{SOURCES.map((option) => (
<QuestionnaireChoice key={option.value} value={option.value}>
{option.label}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>Pick one option to continue.</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireItem name="features" multiple required className={ITEM_IN}>
<QuestionnaireTitle>{QUESTIONS[1].title}</QuestionnaireTitle>
<QuestionnaireDescription aria-live="polite">
Pick up to {MAX_FEATURES}.{' '}
<span className="tabular-nums">
{features.length} of {MAX_FEATURES} picked.
</span>
</QuestionnaireDescription>
<QuestionnaireChoices className="sm:grid-cols-2">
{FEATURES.map((option) => {
const checked = features.includes(option.value);
return (
<QuestionnaireChoice
key={option.value}
value={option.value}
checked={checked}
disabled={!checked && atFeatureLimit}
onChange={(event) => toggleFeature(option.value, event.target.checked)}
>
{option.label}
</QuestionnaireChoice>
);
})}
</QuestionnaireChoices>
<QuestionnaireError>Pick at least one feature.</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireItem name="setup" required className={ITEM_IN}>
<QuestionnaireTitle>{QUESTIONS[2].title}</QuestionnaireTitle>
<QuestionnaireDescription>From 1, very hard, to 5, very easy.</QuestionnaireDescription>
<QuestionnaireChoices>
{SETUP_LABELS.map((label, index) => (
<QuestionnaireChoice key={label} value={String(index + 1)}>
<span className="flex items-baseline gap-2">
<span className="font-medium tabular-nums">{index + 1}</span>{' '}
<QuestionnaireChoiceDescription>{label}</QuestionnaireChoiceDescription>
</span>
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>Pick a rating from 1 to 5.</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireItem name="notes" className={ITEM_IN}>
<QuestionnaireTitle>{QUESTIONS[3].title}</QuestionnaireTitle>
<QuestionnaireDescription>
Optional. A bug, an idea, or something that got in your way.
</QuestionnaireDescription>
<QuestionnaireInput
aria-label="Your comment"
maxLength={600}
placeholder="The Zendesk import skipped our closed tickets."
/>
<QuestionnaireError>Write a comment or skip this question.</QuestionnaireError>
</QuestionnaireItem>
</div>
{reviewing ? (
<div
data-slot="feedback-survey-review"
className={cn(SWAP, 'flex min-h-80 flex-col gap-4 px-6 py-6')}
>
<div className="flex flex-col gap-1">
<h3 className="text-base font-medium">Check your answers</h3>
<p className="text-sm text-muted-foreground">Nothing is sent until you submit.</p>
</div>
<dl className="flex flex-col divide-y divide-border border-y border-border">
{QUESTIONS.map((question, index) => {
const answer = formatAnswer(question.name, answers);
return (
<div
key={question.name}
data-slot="feedback-survey-review-item"
className="flex items-start justify-between gap-4 py-3"
>
<div className="flex min-w-0 flex-col gap-1">
<dt className="text-sm text-muted-foreground">{question.title}</dt>
<dd className={cn('text-sm', answer ? 'font-medium' : 'text-muted-foreground')}>
{answer ?? 'Skipped'}
</dd>
</div>
<Button
type="button"
variant="ghost"
size="sm"
aria-label={`Edit answer to question ${index + 1}`}
onClick={() => edit(question.name)}
>
Edit
</Button>
</div>
);
})}
</dl>
</div>
) : null}
<div data-slot="feedback-survey-footer" className="border-t border-border px-6 py-4">
{reviewing ? (
<div className="flex items-center justify-between gap-3">
<Button type="button" variant="ghost" onClick={backToQuestions}>
<ChevronLeft aria-hidden className="rtl:rotate-180" />
Back
</Button>
<Button type="button" onClick={() => setSubmitted(true)}>
Submit
</Button>
</div>
) : (
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireSkip />
<QuestionnaireNext onClick={saveAndReview}>
{returnToReview ? 'Save and review' : 'Next'}
</QuestionnaireNext>
<QuestionnaireSubmit>{returnToReview ? 'Save and review' : 'Review'}</QuestionnaireSubmit>
</QuestionnaireActions>
)}
</div>
</div>
</Questionnaire>
)}
</div>
</div>
</section>
);
};
export default Survey02;
Dependencies
shadcn registry
buttonquestionnaire
npm
lucide-react
Shipped