Questionnaire
Free, copy-and-go Questionnaire components built on the SevenUI Questionnaire primitive.Read the primitive docs.
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
const answers = [
{ value: "found-it", label: "Yes, first try" },
{ value: "after-refining", label: "Yes, after refining my search" },
{ value: "partially", label: "Only partially" },
{ value: "not-found", label: "No, I gave up" },
];
export default function Questionnaire01() {
const [answer, setAnswer] = React.useState<string | null>(null);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setAnswer(String(data.get("search-result")));
}
if (answer) {
const label = answers.find((item) => item.value === answer)?.label;
return (
<div
className="flex w-full max-w-sm flex-col items-start gap-3"
aria-live="polite"
>
<p className="text-base font-medium">Thanks, that helps.</p>
<p className="text-sm text-muted-foreground">
You answered{" "}
<span className="font-medium text-foreground">{label}</span>. We use
this to tune search ranking for everyone on your team.
</p>
<Button
variant="ghost"
size="sm"
className="-ms-2.5"
onClick={() => setAnswer(null)}
>
Change answer
</Button>
</div>
);
}
return (
<Questionnaire onSubmit={handleSubmit} className="max-w-sm">
<QuestionnaireItem name="search-result" required>
<QuestionnaireTitle>
Did you find what you were looking for?
</QuestionnaireTitle>
<QuestionnaireDescription>
Your last search was “invoice template”.
</QuestionnaireDescription>
<QuestionnaireChoices>
{answers.map((item) => (
<QuestionnaireChoice key={item.value} value={item.value}>
{item.label}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnaireSubmit>Send feedback</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
);
}
npx shadcn@latest add @sevenui/component/questionnaire-01pnpm dlx shadcn@latest add @sevenui/component/questionnaire-01yarn dlx shadcn@latest add @sevenui/component/questionnaire-01bunx --bun shadcn@latest add @sevenui/component/questionnaire-01"use client";
import * as React from "react";
import {
BriefcaseIcon,
CodeIcon,
GraduationCapIcon,
PaletteIcon,
RocketIcon,
UserIcon,
UsersIcon,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
const useCases = [
{
value: "product",
icon: RocketIcon,
label: "Ship a product",
description: "Roadmaps, sprints, and release notes",
},
{
value: "design",
icon: PaletteIcon,
label: "Run design reviews",
description: "Share files and collect feedback",
},
{
value: "engineering",
icon: CodeIcon,
label: "Track engineering work",
description: "Issues, pull requests, and incidents",
},
{
value: "school",
icon: GraduationCapIcon,
label: "Organize coursework",
description: "Assignments, notes, and group projects",
},
];
const teamSizes = [
{ value: "solo", icon: UserIcon, label: "Just me", description: "A personal workspace" },
{ value: "small", icon: UsersIcon, label: "2–10 people", description: "A small team" },
{
value: "company",
icon: BriefcaseIcon,
label: "11 or more",
description: "Several teams under one company",
},
];
type Option = (typeof useCases)[number];
function IconChoice({ option }: { option: Option }) {
const Icon = option.icon;
return (
<QuestionnaireChoice value={option.value}>
<span className="flex items-center gap-2 font-medium">
<Icon aria-hidden="true" className="size-4 text-muted-foreground" />
{option.label}
</span>
<QuestionnaireChoiceDescription className="ps-6">
{option.description}
</QuestionnaireChoiceDescription>
</QuestionnaireChoice>
);
}
export default function Questionnaire02() {
const [done, setDone] = React.useState(false);
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Set up your workspace</CardTitle>
<CardDescription>
Two quick questions so we can load the right templates. Press a letter
key to pick an answer.
</CardDescription>
</CardHeader>
<CardContent>
{done ? (
<p className="rounded-lg bg-muted px-3 py-2.5 text-sm" aria-live="polite">
Templates are ready in your sidebar.
</p>
) : (
<Questionnaire
shortcuts="letters"
onSubmit={(event) => {
event.preventDefault();
setDone(true);
}}
>
<QuestionnaireProgress />
<QuestionnaireItem name="use-case" required>
<QuestionnaireTitle>What will you use the workspace for?</QuestionnaireTitle>
<QuestionnaireChoices>
{useCases.map((option) => (
<IconChoice key={option.value} option={option} />
))}
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireItem name="team-size" required>
<QuestionnaireTitle>How many people will join?</QuestionnaireTitle>
<QuestionnaireChoices>
{teamSizes.map((option) => (
<IconChoice key={option.value} option={option} />
))}
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnairePrevious>Back</QuestionnairePrevious>
<QuestionnaireNext>Continue</QuestionnaireNext>
<QuestionnaireSubmit>Create workspace</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
)}
</CardContent>
</Card>
);
}
npx shadcn@latest add @sevenui/component/questionnaire-02pnpm dlx shadcn@latest add @sevenui/component/questionnaire-02yarn dlx shadcn@latest add @sevenui/component/questionnaire-02bunx --bun shadcn@latest add @sevenui/component/questionnaire-02"use client";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
const MAX_TOPICS = 3;
const topics = [
{ value: "ai", label: "AI and machine learning" },
{ value: "design-systems", label: "Design systems" },
{ value: "frontend", label: "Frontend performance" },
{ value: "security", label: "Security advisories" },
{ value: "databases", label: "Databases" },
{ value: "devops", label: "DevOps and CI" },
];
const frequencies = [
{ value: "daily", label: "Every morning" },
{ value: "weekly", label: "Monday digest" },
{ value: "monthly", label: "First of the month" },
];
export default function Questionnaire03() {
const [selected, setSelected] = React.useState<string[]>(["design-systems"]);
const [summary, setSummary] = React.useState<string | null>(null);
const atLimit = selected.length >= MAX_TOPICS;
function toggle(value: string, checked: boolean) {
setSelected((current) =>
checked ? [...current, value] : current.filter((item) => item !== value),
);
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
const frequency = frequencies.find((item) => item.value === data.get("frequency"));
setSummary(`${data.getAll("topics").length} topics, ${frequency?.label.toLowerCase()}`);
}
return (
<div className="flex w-full max-w-lg flex-col gap-4">
<Questionnaire onSubmit={handleSubmit}>
<QuestionnaireProgress />
<QuestionnaireItem name="topics" multiple required>
<QuestionnaireTitle>What should your digest cover?</QuestionnaireTitle>
<QuestionnaireDescription className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span>Pick up to {MAX_TOPICS}. You can change them later in settings.</span>
<Badge
variant={atLimit ? "default" : "secondary"}
className="tabular-nums"
aria-live="polite"
>
{selected.length} of {MAX_TOPICS} picked
</Badge>
</QuestionnaireDescription>
<QuestionnaireChoices className="sm:grid-cols-2">
{topics.map((topic) => {
const checked = selected.includes(topic.value);
return (
<QuestionnaireChoice
key={topic.value}
value={topic.value}
checked={checked}
disabled={atLimit && !checked}
onChange={(event) => toggle(topic.value, event.target.checked)}
>
{topic.label}
</QuestionnaireChoice>
);
})}
</QuestionnaireChoices>
<QuestionnaireError>Pick at least one topic to continue.</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireItem name="frequency" required>
<QuestionnaireTitle>How often should it arrive?</QuestionnaireTitle>
<QuestionnaireChoices>
{frequencies.map((frequency) => (
<QuestionnaireChoice
key={frequency.value}
value={frequency.value}
defaultChecked={frequency.value === "weekly"}
>
{frequency.label}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireNext />
<QuestionnaireSubmit>Save digest</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
{summary ? (
<p className="text-sm text-muted-foreground" aria-live="polite">
Saved: {summary}.
</p>
) : null}
</div>
);
}
npx shadcn@latest add @sevenui/component/questionnaire-03pnpm dlx shadcn@latest add @sevenui/component/questionnaire-03yarn dlx shadcn@latest add @sevenui/component/questionnaire-03bunx --bun shadcn@latest add @sevenui/component/questionnaire-03"use client";
import * as React from "react";
import { CircleCheckIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
const questions = [
{
name: "area",
title: "Where did the bug happen?",
required: true,
choices: [
{ value: "editor", label: "Editor" },
{ value: "comments", label: "Comments" },
{ value: "export", label: "Export to PDF" },
{ value: "billing", label: "Billing" },
],
},
{
name: "severity",
title: "How much is it blocking you?",
required: true,
choices: [
{ value: "blocker", label: "I can't work at all" },
{ value: "major", label: "I have a slow workaround" },
{ value: "minor", label: "It's annoying but fine" },
],
},
{
name: "frequency",
title: "Can you reproduce it?",
required: false,
choices: [
{ value: "always", label: "Every time" },
{ value: "sometimes", label: "Sometimes" },
{ value: "once", label: "It happened once" },
],
},
];
// Compact density: shorter rows, smaller type, and small action buttons.
const compactChoice = "min-h-9 py-2 text-[0.8125rem]";
export default function Questionnaire04() {
const [sent, setSent] = React.useState(false);
if (sent) {
return (
<div
className="flex w-full max-w-sm flex-col items-start gap-3 rounded-xl bg-muted/50 p-4 text-sm"
aria-live="polite"
>
<CircleCheckIcon aria-hidden="true" className="size-5 text-success" />
<div className="flex flex-col gap-1">
<p className="font-medium">Report sent</p>
<p className="text-[0.8125rem] text-muted-foreground">
We'll reply by email once someone on the team reproduces it.
</p>
</div>
<Button size="sm" variant="ghost" className="-ms-2.5" onClick={() => setSent(false)}>
Report another bug
</Button>
</div>
);
}
return (
<div className="w-full max-w-sm rounded-xl bg-muted/50 p-4">
<Questionnaire
shortcuts="numbers"
className="gap-3"
onSubmit={(event) => {
event.preventDefault();
setSent(true);
}}
>
<QuestionnaireProgress
className="w-full"
render={(props, state) => (
<div {...props}>
<div className="flex items-center justify-between">
<span>Bug report</span>
<span>
{state.current}/{state.total}
</span>
</div>
<div aria-hidden="true" className="mt-2 flex gap-1">
{Array.from({ length: state.total }, (_, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: segments are positional
key={index}
data-filled={index < state.current ? "" : undefined}
className="h-1 flex-1 rounded-full bg-border transition-colors duration-300 data-filled:bg-primary"
/>
))}
</div>
</div>
)}
/>
{questions.map((question) => (
<QuestionnaireItem
key={question.name}
name={question.name}
required={question.required}
className="gap-3"
>
<QuestionnaireTitle className="text-sm [&:not(:has(~[data-slot=questionnaire-description]))]:mb-1">
{question.title}
</QuestionnaireTitle>
<QuestionnaireChoices className="gap-1.5">
{question.choices.map((choice) => (
<QuestionnaireChoice
key={choice.value}
value={choice.value}
className={compactChoice}
>
{choice.label}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError className="mt-0 text-xs" />
</QuestionnaireItem>
))}
<QuestionnaireActions>
<QuestionnairePrevious size="sm" variant="ghost" />
<QuestionnaireSkip size="sm" variant="ghost" />
<QuestionnaireNext size="sm" />
<QuestionnaireSubmit size="sm">Send report</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
</div>
);
}
npx shadcn@latest add @sevenui/component/questionnaire-04pnpm dlx shadcn@latest add @sevenui/component/questionnaire-04yarn dlx shadcn@latest add @sevenui/component/questionnaire-04bunx --bun shadcn@latest add @sevenui/component/questionnaire-04"use client";
import * as React from "react";
import { CircleAlertIcon, CircleCheckIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
const FREE_EMAIL_DOMAINS = [
"gmail.com",
"yahoo.com",
"outlook.com",
"hotmail.com",
"icloud.com",
];
function isPersonalEmail(value: string) {
const domain = value.split("@")[1]?.trim().toLowerCase();
return domain ? FREE_EMAIL_DOMAINS.includes(domain) : false;
}
// The questionnaire skips native constraint validation, so check the format here.
function isEmailAddress(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
}
function ErrorMessage({ children }: { children?: React.ReactNode }) {
return (
<QuestionnaireError className="flex items-center gap-1.5">
<CircleAlertIcon aria-hidden="true" className="size-4 shrink-0" />
{children}
</QuestionnaireError>
);
}
export default function Questionnaire05() {
const [email, setEmail] = React.useState("");
const [submittedEmail, setSubmittedEmail] = React.useState<string | null>(null);
const [checked, setChecked] = React.useState(false);
const personal = isPersonalEmail(email);
const malformed = email.trim() !== "" && !isEmailAddress(email);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (malformed) {
setChecked(true);
return;
}
if (personal) return;
setSubmittedEmail(email.trim());
}
if (submittedEmail) {
return (
<div
className="flex w-full max-w-md flex-col items-start gap-3 rounded-xl border p-6"
aria-live="polite"
>
<CircleCheckIcon aria-hidden="true" className="size-5 text-success" />
<div className="flex flex-col gap-1">
<p className="font-medium">You're on the list</p>
<p className="text-sm text-muted-foreground">
We'll email <span className="text-foreground">{submittedEmail}</span> when a
beta seat opens for your team, usually within 5 business days.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setSubmittedEmail(null);
setEmail("");
setChecked(false);
}}
>
Submit another request
</Button>
</div>
);
}
return (
<Questionnaire
onSubmit={handleSubmit}
className="max-w-md rounded-xl border p-6"
>
<QuestionnaireProgress />
<QuestionnaireItem name="deployment" required>
<QuestionnaireTitle>Where would you deploy the beta?</QuestionnaireTitle>
<QuestionnaireChoices>
<QuestionnaireChoice value="cloud">
Hosted cloud
<QuestionnaireChoiceDescription>
Available in US and EU regions
</QuestionnaireChoiceDescription>
</QuestionnaireChoice>
<QuestionnaireChoice value="vpc">
Your own VPC
<QuestionnaireChoiceDescription>AWS and GCP only</QuestionnaireChoiceDescription>
</QuestionnaireChoice>
<QuestionnaireChoice value="on-prem" disabled>
On-premises
<QuestionnaireChoiceDescription>Not part of this beta</QuestionnaireChoiceDescription>
</QuestionnaireChoice>
</QuestionnaireChoices>
<ErrorMessage>Pick a deployment target to continue.</ErrorMessage>
</QuestionnaireItem>
<QuestionnaireItem name="email" required invalid={personal || (checked && malformed)}>
<QuestionnaireTitle>What's your work email?</QuestionnaireTitle>
<QuestionnaireDescription>
Beta seats are issued per company domain.
</QuestionnaireDescription>
<QuestionnaireInput
type="email"
aria-label="Work email"
autoComplete="email"
placeholder="name@company.com"
value={email}
onChange={(event) => setEmail(event.target.value)}
onBlur={() => setChecked(true)}
/>
<ErrorMessage>
{personal
? "Use your company address. Personal domains can't claim a team seat."
: checked && malformed
? "Enter a full email address, like name@company.com."
: "Enter your work email to request access."}
</ErrorMessage>
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireNext />
<QuestionnaireSubmit>Request access</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
);
}
npx shadcn@latest add @sevenui/component/questionnaire-05pnpm dlx shadcn@latest add @sevenui/component/questionnaire-05yarn dlx shadcn@latest add @sevenui/component/questionnaire-05bunx --bun shadcn@latest add @sevenui/component/questionnaire-05Q3 security refresher
3 questions · pass with 2 correct · about 2 minutes
"use client";
import * as React from "react";
import { CircleCheckIcon, CircleXIcon, ShieldCheckIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
const PASSING_SCORE = 2;
const questions = [
{
name: "phishing",
title:
"An email from “IT Support” asks you to confirm your password through a link. What do you do?",
multiple: false,
correct: ["report"],
explanation:
"IT never asks for your password. Reporting it warns the security team and pulls the email from other inboxes.",
choices: [
{ value: "confirm", label: "Open the link and confirm it" },
{ value: "reply", label: "Reply and ask if it's real" },
{ value: "report", label: "Use Report phishing in your mail app" },
{ value: "ignore", label: "Delete it and move on" },
],
},
{
name: "password",
title: "Which of these passwords is hardest to crack?",
multiple: false,
correct: ["passphrase"],
explanation:
"Length beats symbol swaps. Four random words are longer and far less predictable than a dictionary word with a year.",
choices: [
{ value: "season", label: "Summer2026!" },
{ value: "passphrase", label: "lantern-orbit-velvet-quarry" },
{ value: "leet", label: "P@ssw0rd123" },
],
},
{
name: "ai-paste",
title: "Which of these are fine to paste into a public AI chatbot?",
description: "Select every answer that applies.",
multiple: true,
correct: ["blog-draft", "stack-trace"],
explanation:
"Public drafts and errors from open-source libraries are fine. Customer data and credentials never leave approved tools.",
choices: [
{ value: "blog-draft", label: "A draft of an already public blog post" },
{ value: "customer-list", label: "A CSV export of customer emails" },
{
value: "stack-trace",
label: "A stack trace from an open-source library",
},
{ value: "api-key", label: "A production API key, to debug a request" },
],
},
];
type Result = { name: string; correct: boolean };
function grade(data: FormData): Result[] {
return questions.map((question) => {
const picked = data.getAll(question.name).map(String).sort();
const expected = [...question.correct].sort();
return {
name: question.name,
correct:
picked.length === expected.length &&
picked.every((value, index) => value === expected[index]),
};
});
}
export default function Questionnaire06() {
const [results, setResults] = React.useState<Result[] | null>(null);
if (results) {
const score = results.filter((result) => result.correct).length;
const passed = score >= PASSING_SCORE;
return (
<div className="flex w-full max-w-md flex-col gap-5 rounded-xl border bg-card p-6 text-card-foreground">
<div className="flex flex-col gap-2" aria-live="polite">
<div className="flex items-center justify-between gap-3">
<p className="text-2xl font-semibold tabular-nums">
{score} of {questions.length} correct
</p>
<Badge variant={passed ? "secondary" : "destructive"}>
{passed ? "Passed" : "Not passed"}
</Badge>
</div>
<p className="text-sm text-pretty text-muted-foreground">
{passed
? "Your Q3 security refresher is complete. We logged it on your training record."
: `You need ${PASSING_SCORE} correct answers to pass. Read the notes below and try again.`}
</p>
</div>
<ol className="flex flex-col gap-4 border-t pt-4">
{questions.map((question, index) => {
const correct = results[index]?.correct;
const Icon = correct ? CircleCheckIcon : CircleXIcon;
return (
<li key={question.name} className="flex gap-3 text-sm">
<Icon
aria-hidden="true"
className={
correct
? "mt-0.5 size-4 shrink-0 text-success"
: "mt-0.5 size-4 shrink-0 text-destructive"
}
/>
<div className="flex min-w-0 flex-col gap-1">
<p className="font-medium text-pretty">
<span className="sr-only">
{correct ? "Correct: " : "Incorrect: "}
</span>
{question.title}
</p>
<p className="text-pretty text-muted-foreground">
{question.explanation}
</p>
</div>
</li>
);
})}
</ol>
<Button
variant={passed ? "outline" : "default"}
className="w-fit"
onClick={() => setResults(null)}
>
{passed ? "Review questions" : "Retake quiz"}
</Button>
</div>
);
}
return (
<div className="flex w-full max-w-md flex-col gap-5 rounded-xl border bg-card p-6 text-card-foreground">
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted">
<ShieldCheckIcon
aria-hidden="true"
className="size-4 text-muted-foreground"
/>
</span>
<div className="flex min-w-0 flex-col gap-0.5">
<h3 className="text-sm font-medium">Q3 security refresher</h3>
<p className="text-xs text-muted-foreground">
3 questions · pass with {PASSING_SCORE} correct · about 2 minutes
</p>
</div>
</div>
<Questionnaire
onSubmit={(event) => {
event.preventDefault();
setResults(grade(new FormData(event.currentTarget)));
}}
>
<QuestionnaireProgress />
{questions.map((question) => (
<QuestionnaireItem
key={question.name}
name={question.name}
multiple={question.multiple}
required
>
<QuestionnaireTitle>{question.title}</QuestionnaireTitle>
{question.description ? (
<QuestionnaireDescription>
{question.description}
</QuestionnaireDescription>
) : null}
<QuestionnaireChoices>
{question.choices.map((choice) => (
<QuestionnaireChoice key={choice.value} value={choice.value}>
{choice.label}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>Choose an answer to continue.</QuestionnaireError>
</QuestionnaireItem>
))}
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireNext />
<QuestionnaireSubmit>Check answers</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
</div>
);
}
npx shadcn@latest add @sevenui/component/questionnaire-06pnpm dlx shadcn@latest add @sevenui/component/questionnaire-06yarn dlx shadcn@latest add @sevenui/component/questionnaire-06bunx --bun shadcn@latest add @sevenui/component/questionnaire-06"use client";
import * as React from "react";
import { ArrowLeftIcon, ArrowRightIcon, SparklesIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
import { Spinner } from "@/components/ui/spinner";
const questions = [
{
name: "seats",
title: "How many people need an account?",
choices: [
{ value: "1", label: "Only me", description: "Personal projects", points: 0 },
{ value: "10", label: "Up to 10", description: "One product team", points: 1 },
{ value: "50", label: "More than 10", description: "Multiple teams", points: 2 },
],
},
{
name: "storage",
title: "How much do you upload each month?",
choices: [
{ value: "light", label: "Under 5 GB", description: "Docs and screenshots", points: 0 },
{ value: "medium", label: "5 to 100 GB", description: "Design files and recordings", points: 1 },
{ value: "heavy", label: "Over 100 GB", description: "Raw video or datasets", points: 2 },
],
},
{
name: "security",
title: "Which security features are must-haves?",
choices: [
{ value: "none", label: "Nothing special", description: "Two-factor login is enough", points: 0 },
{ value: "sso", label: "Single sign-on", description: "Google or Okta login", points: 1 },
{ value: "audit", label: "SSO and audit logs", description: "Required by compliance", points: 2 },
],
},
];
const plans = [
{ name: "Starter", price: "$0", note: "Free forever for one seat and 5 GB." },
{ name: "Team", price: "$12", note: "Per seat per month, 1 TB shared storage and SSO." },
{ name: "Business", price: "$24", note: "Per seat per month, unlimited storage and audit logs." },
];
// Each step fades and rises in when it becomes the active item.
const enter =
"transition-[opacity,translate] duration-300 ease-out starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none";
function recommend(data: FormData) {
const total = questions.reduce((sum, question) => {
const choice = question.choices.find((item) => item.value === data.get(question.name));
return sum + (choice?.points ?? 0);
}, 0);
return plans[total >= 4 ? 2 : total >= 2 ? 1 : 0];
}
export default function Questionnaire07() {
const [pending, setPending] = React.useState(false);
const [plan, setPlan] = React.useState<(typeof plans)[number] | null>(null);
const [trialStarted, setTrialStarted] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const result = recommend(new FormData(event.currentTarget));
setPending(true);
// Stand-in for a pricing API call.
timer.current = setTimeout(() => {
setPending(false);
setPlan(result);
}, 900);
}
if (plan) {
return (
<div
className={`flex w-full max-w-md flex-col gap-4 rounded-xl border bg-card p-6 text-card-foreground ${enter}`}
aria-live="polite"
>
<Badge variant="secondary">
<SparklesIcon data-icon="inline-start" aria-hidden="true" />
Recommended for you
</Badge>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-semibold">{plan.name}</p>
<p className="text-sm text-muted-foreground tabular-nums">{plan.price}</p>
</div>
<p className="text-sm text-muted-foreground">{plan.note}</p>
<div className="flex flex-wrap gap-2">
<Button disabled={trialStarted} onClick={() => setTrialStarted(true)}>
{trialStarted ? "Trial started" : "Start 14-day trial"}
</Button>
<Button
variant="ghost"
onClick={() => {
setPlan(null);
setTrialStarted(false);
}}
>
Retake quiz
</Button>
</div>
</div>
);
}
return (
<Questionnaire
aria-busy={pending || undefined}
// Lock the whole form while the plan loads. Disabling the choices instead
// makes the item read as unanswered and flashes its error.
inert={pending}
onSubmit={handleSubmit}
className="max-w-md rounded-xl border bg-card p-6 text-card-foreground"
>
<QuestionnaireProgress
className="w-full"
render={(props, state) => (
<div {...props}>
<span className="text-xs">
Step {state.current} of {state.total}
</span>
<span aria-hidden="true" className="mt-2 block h-1 overflow-hidden rounded-full bg-muted">
<span
className="block h-full rounded-full bg-primary transition-[width] duration-500 ease-out motion-reduce:transition-none"
style={{ width: `${state.total ? (state.current / state.total) * 100 : 0}%` }}
/>
</span>
</div>
)}
/>
{questions.map((question) => (
<QuestionnaireItem key={question.name} name={question.name} required className={enter}>
<QuestionnaireTitle>{question.title}</QuestionnaireTitle>
<QuestionnaireChoices>
{question.choices.map((choice) => (
<QuestionnaireChoice
key={choice.value}
value={choice.value}
className="transition-[background-color,border-color,scale] duration-150 active:scale-[0.99] motion-reduce:transition-none"
>
{choice.label}
<QuestionnaireChoiceDescription>{choice.description}</QuestionnaireChoiceDescription>
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
))}
<QuestionnaireActions>
<QuestionnairePrevious variant="ghost" disabled={pending}>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Back
</QuestionnairePrevious>
<QuestionnaireNext>
Next
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</QuestionnaireNext>
<QuestionnaireSubmit disabled={pending}>
{pending ? (
<>
<Spinner data-icon="inline-start" aria-hidden="true" />
Finding your plan
</>
) : (
"See my plan"
)}
</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
);
}
npx shadcn@latest add @sevenui/component/questionnaire-07pnpm dlx shadcn@latest add @sevenui/component/questionnaire-07yarn dlx shadcn@latest add @sevenui/component/questionnaire-07bunx --bun shadcn@latest add @sevenui/component/questionnaire-07Quick feedback · 30 seconds
"use client";
import { HeartHandshakeIcon, XIcon } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
const scores = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"];
function followUpFor(score: number | null) {
if (score === null) return "Tell us more about your score";
if (score <= 6) return "What's the one thing we should fix first?";
if (score <= 8) return "What would make Lumen a 10 for you?";
return "What do you rely on Lumen for most?";
}
export default function Questionnaire08() {
const [open, setOpen] = useState(true);
const [score, setScore] = useState<number | null>(null);
const [sent, setSent] = useState(false);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSent(true);
}
function restart() {
setScore(null);
setSent(false);
setOpen(true);
}
if (!open) {
return (
<div className="flex w-full max-w-sm flex-col items-start gap-3 text-sm">
<p className="text-muted-foreground">
Survey dismissed. We won't ask again for 90 days.
</p>
<Button variant="outline" size="sm" onClick={restart}>
Show survey again
</Button>
</div>
);
}
return (
<section
aria-labelledby="questionnaire-08-heading"
className="relative w-full max-w-sm rounded-xl border bg-popover p-4 text-popover-foreground shadow-lg"
>
<div className="mb-3 flex items-center justify-between gap-2">
<h3
id="questionnaire-08-heading"
className="text-xs font-medium text-muted-foreground"
>
Quick feedback · 30 seconds
</h3>
<Button
variant="ghost"
size="icon-sm"
aria-label="Dismiss survey"
onClick={() => setOpen(false)}
className="-me-1.5"
>
<XIcon aria-hidden="true" />
</Button>
</div>
{sent ? (
<div className="flex flex-col items-start gap-3 py-2">
<HeartHandshakeIcon
aria-hidden="true"
className="size-5 text-muted-foreground"
/>
<p className="font-medium">Thanks, that helps a lot.</p>
<p className="text-sm text-muted-foreground">
Your score of {score} was shared with the Lumen product team.
</p>
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
Close
</Button>
</div>
) : (
<Questionnaire onSubmit={handleSubmit}>
<QuestionnaireItem name="score" required>
<QuestionnaireTitle>
How likely are you to recommend Lumen to a colleague?
</QuestionnaireTitle>
<QuestionnaireChoices className="grid-cols-6 gap-1.5 sm:grid-cols-11 sm:gap-1">
{scores.map((value) => (
<QuestionnaireChoice
key={value}
value={value}
onChange={(event) => setScore(Number(event.target.value))}
className="min-h-10 items-center justify-center px-0 py-0 text-center font-medium tabular-nums data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary [&>[data-slot=questionnaire-choice-indicator]]:hidden [&_[data-slot=questionnaire-choice-label]]:items-center"
>
{value}
<span className="sr-only"> out of 10</span>
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<div
aria-hidden="true"
className="-mt-2 flex justify-between text-xs text-muted-foreground"
>
<span>Not likely</span>
<span>Very likely</span>
</div>
<QuestionnaireError>Pick a score from 0 to 10.</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireItem name="reason">
<QuestionnaireTitle>{followUpFor(score)}</QuestionnaireTitle>
<QuestionnaireDescription>
Optional. Skip it and we'll still record your score.
</QuestionnaireDescription>
<QuestionnaireInput
aria-label="Reason for your score"
placeholder="e.g. Faster funnel reports"
/>
<QuestionnaireError>
Add a few words, or choose Skip & send.
</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnairePrevious size="sm" variant="ghost">
Back
</QuestionnairePrevious>
<QuestionnaireSkip size="sm" variant="ghost">
Skip & send
</QuestionnaireSkip>
<QuestionnaireNext size="sm" />
<QuestionnaireSubmit size="sm">Send feedback</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
)}
</section>
);
}
npx shadcn@latest add @sevenui/component/questionnaire-08pnpm dlx shadcn@latest add @sevenui/component/questionnaire-08yarn dlx shadcn@latest add @sevenui/component/questionnaire-08bunx --bun shadcn@latest add @sevenui/component/questionnaire-08"use client";
import {
ChevronLeftIcon,
DumbbellIcon,
FlameIcon,
FootprintsIcon,
HeartPulseIcon,
SparklesIcon,
} from "lucide-react";
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
const goals = [
{ value: "endurance", label: "Build endurance", icon: FootprintsIcon },
{ value: "strength", label: "Get stronger", icon: DumbbellIcon },
{ value: "weight", label: "Lose weight", icon: FlameIcon },
{ value: "health", label: "Feel healthier", icon: HeartPulseIcon },
];
const levels = [
{
value: "new",
label: "Just starting",
description: "I work out less than once a week.",
},
{
value: "returning",
label: "Getting back into it",
description: "I used to train and want to rebuild the habit.",
},
{
value: "regular",
label: "Training regularly",
description: "Three or more sessions most weeks.",
},
];
const days = [
{ value: "mon", short: "M", label: "Monday" },
{ value: "tue", short: "T", label: "Tuesday" },
{ value: "wed", short: "W", label: "Wednesday" },
{ value: "thu", short: "T", label: "Thursday" },
{ value: "fri", short: "F", label: "Friday" },
{ value: "sat", short: "S", label: "Saturday" },
{ value: "sun", short: "S", label: "Sunday" },
];
const tileClassName =
"min-h-24 items-stretch p-3 [&_[data-slot=questionnaire-choice-label]]:gap-3 [&>[data-slot=questionnaire-choice-indicator]]:absolute [&>[data-slot=questionnaire-choice-indicator]]:end-3 [&>[data-slot=questionnaire-choice-indicator]]:top-3";
const dayClassName =
"min-h-11 items-center justify-center rounded-full px-0 py-0 font-medium data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary [&>[data-slot=questionnaire-choice-indicator]]:hidden [&_[data-slot=questionnaire-choice-label]]:items-center";
type Plan = { goal: string; days: string[] };
export default function Questionnaire09() {
const [plan, setPlan] = useState<Plan | null>(null);
const [showWeek, setShowWeek] = useState(false);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
const goal = goals.find((item) => item.value === data.get("goal"));
setPlan({
goal: goal?.label ?? "Your goal",
days: days
.filter((day) => data.getAll("days").includes(day.value))
.map((day) => day.label),
});
setShowWeek(false);
}
return (
<div className="mx-auto flex h-[560px] w-full max-w-[360px] flex-col overflow-hidden rounded-[2rem] border-4 border-muted bg-background shadow-lg">
<div className="flex h-6 shrink-0 items-center justify-center">
<span aria-hidden="true" className="h-1 w-12 rounded-full bg-muted" />
</div>
{plan ? (
<div className="flex min-h-0 flex-1 flex-col justify-between gap-6 p-5">
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pt-10">
<SparklesIcon aria-hidden="true" className="size-6 text-primary" />
<h3 className="cn-font-heading text-2xl font-semibold text-balance">
Your 4-week plan is ready
</h3>
<p className="text-sm text-pretty text-muted-foreground">
{plan.goal}, {plan.days.length}{" "}
{plan.days.length === 1 ? "session" : "sessions"} a week. Your
first workout unlocks tomorrow at 7:00 AM.
</p>
{showWeek ? (
<ol className="flex flex-col divide-y rounded-lg border text-sm">
{plan.days.map((day, index) => (
<li
key={day}
className="flex items-center justify-between gap-3 px-3 py-2.5"
>
<span className="font-medium">{day}</span>
<span className="text-muted-foreground">
Session {index + 1} · 25 min
</span>
</li>
))}
</ol>
) : null}
</div>
<div className="flex flex-col gap-2">
<Button
size="lg"
className="h-11 w-full"
aria-expanded={showWeek}
onClick={() => setShowWeek((value) => !value)}
>
{showWeek ? "Hide my first week" : "See my first week"}
</Button>
<Button
size="lg"
variant="ghost"
className="h-11 w-full"
onClick={() => setPlan(null)}
>
Change answers
</Button>
</div>
</div>
) : (
<Questionnaire
onSubmit={handleSubmit}
className="min-h-0 flex-1 gap-0"
>
<div className="flex shrink-0 items-center gap-2 px-3 pb-2">
<div className="size-9 shrink-0">
<QuestionnairePrevious
variant="ghost"
size="icon-lg"
aria-label="Previous question"
className="min-h-0"
>
<ChevronLeftIcon aria-hidden="true" />
</QuestionnairePrevious>
</div>
<QuestionnaireProgress
className="min-w-0 flex-1"
render={(props, state) => (
<div {...props}>
<div aria-hidden="true" className="flex gap-1">
{Array.from({ length: state.total }, (_, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: segments are positional
key={index}
data-done={index < state.current ? "" : undefined}
className="h-1 flex-1 rounded-full bg-muted transition-colors duration-300 data-done:bg-primary"
/>
))}
</div>
</div>
)}
/>
<div className="size-9 shrink-0" />
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 pt-4">
<QuestionnaireItem name="goal" required>
<QuestionnaireTitle className="text-xl font-semibold">
What's your main goal?
</QuestionnaireTitle>
<QuestionnaireDescription>
We'll shape every workout around it.
</QuestionnaireDescription>
<QuestionnaireChoices className="grid-cols-2">
{goals.map((goal) => (
<QuestionnaireChoice
key={goal.value}
value={goal.value}
className={tileClassName}
>
<goal.icon
aria-hidden="true"
className="size-5 text-muted-foreground group-data-checked/questionnaire-choice:text-primary"
/>
<span className="mt-auto font-medium">{goal.label}</span>
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>Pick the goal that fits best.</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireItem name="level" required>
<QuestionnaireTitle className="text-xl font-semibold">
How active are you right now?
</QuestionnaireTitle>
<QuestionnaireDescription>
Be honest. We start easy and adjust every week.
</QuestionnaireDescription>
<QuestionnaireChoices>
{levels.map((level) => (
<QuestionnaireChoice
key={level.value}
value={level.value}
className="py-3.5"
>
<span className="font-medium">{level.label}</span>
<QuestionnaireChoiceDescription>
{level.description}
</QuestionnaireChoiceDescription>
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireItem name="days" multiple required>
<QuestionnaireTitle className="text-xl font-semibold">
Which days can you train?
</QuestionnaireTitle>
<QuestionnaireDescription>
Most people stick with 3 days. You can change this anytime.
</QuestionnaireDescription>
<QuestionnaireChoices className="grid-cols-7 gap-1">
{days.map((day) => (
<QuestionnaireChoice
key={day.value}
value={day.value}
className={dayClassName}
>
<span aria-hidden="true">{day.short}</span>
<span className="sr-only">{day.label}</span>
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>Pick at least one day.</QuestionnaireError>
</QuestionnaireItem>
</div>
<QuestionnaireActions className="shrink-0 grid-cols-1 p-5 pt-3">
<QuestionnaireNext size="lg" className="col-start-1 h-11 w-full">
Continue
</QuestionnaireNext>
<QuestionnaireSubmit size="lg" className="col-start-1 h-11 w-full">
Build my plan
</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
)}
</div>
);
}
npx shadcn@latest add @sevenui/component/questionnaire-09pnpm dlx shadcn@latest add @sevenui/component/questionnaire-09yarn dlx shadcn@latest add @sevenui/component/questionnaire-09bunx --bun shadcn@latest add @sevenui/component/questionnaire-09Acorn Support
Typically replies in under 2 hours
"use client";
import { ChevronUpIcon, MinusIcon, TicketIcon } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
const topics = [
{ value: "billing", label: "Billing or invoices" },
{ value: "bug", label: "Something isn't working" },
{ value: "access", label: "I can't sign in" },
{ value: "other", label: "Something else" },
];
const urgencies = [
{
value: "blocked",
label: "My team is blocked",
description: "Routed to on-call. Reply within 1 hour.",
},
{
value: "degraded",
label: "It's slowing us down",
description: "Reply within 4 business hours.",
},
{
value: "question",
label: "Just a question",
description: "Reply within 1 business day.",
},
];
type Ticket = { topic: string; urgency: string; reference: string };
export default function Questionnaire10() {
const [ticket, setTicket] = useState<Ticket | null>(null);
const [minimized, setMinimized] = useState(false);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setTicket({
topic:
topics.find((topic) => topic.value === data.get("topic"))?.label ?? "",
urgency:
urgencies.find((urgency) => urgency.value === data.get("urgency"))
?.description ?? "",
reference: String(data.get("reference") ?? ""),
});
}
return (
<div
className={`flex w-full max-w-sm flex-col overflow-hidden rounded-2xl border bg-background shadow-lg ${
minimized ? "" : "h-[600px] sm:h-[520px]"
}`}
>
<header
className={`flex items-center gap-3 px-4 py-3 ${minimized ? "" : "border-b"}`}
>
<Avatar>
<AvatarFallback>AS</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<h3 className="truncate text-sm font-medium">Acorn Support</h3>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span aria-hidden="true" className="size-1.5 rounded-full bg-success" />
Typically replies in under 2 hours
</p>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label={minimized ? "Expand chat" : "Minimize chat"}
aria-expanded={!minimized}
onClick={() => setMinimized((value) => !value)}
>
{minimized ? (
<ChevronUpIcon aria-hidden="true" />
) : (
<MinusIcon aria-hidden="true" />
)}
</Button>
</header>
<div
hidden={minimized}
role="log"
aria-label="Support conversation"
className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4"
>
<BubbleGroup>
<Bubble variant="muted">
<BubbleContent>
Hi Sam! Three quick questions and I'll route you to the right
person.
</BubbleContent>
</Bubble>
</BubbleGroup>
{ticket ? (
<>
<BubbleGroup>
<Bubble align="end">
<BubbleContent>
{ticket.topic} · {ticket.reference}
</BubbleContent>
</Bubble>
</BubbleGroup>
<div className="flex flex-col gap-2 rounded-xl border bg-card p-3 text-sm text-card-foreground">
<p className="flex items-center gap-2 font-medium">
<TicketIcon aria-hidden="true" className="size-4 text-muted-foreground" />
Ticket ACR-4821 opened
</p>
<p className="text-muted-foreground">{ticket.urgency}</p>
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => setTicket(null)}
>
Edit request
</Button>
</div>
</>
) : (
<Questionnaire
shortcuts="numbers"
onSubmit={handleSubmit}
className="shrink-0 gap-3 rounded-xl border bg-card p-3 text-card-foreground"
>
<QuestionnaireProgress className="text-[0.6875rem]" />
<QuestionnaireItem name="topic" required className="gap-3">
<QuestionnaireTitle className="text-sm">
What do you need help with?
</QuestionnaireTitle>
<QuestionnaireChoices className="gap-1.5">
{topics.map((topic) => (
<QuestionnaireChoice
key={topic.value}
value={topic.value}
className="min-h-10 py-2"
>
{topic.label}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError className="mt-0" />
</QuestionnaireItem>
<QuestionnaireItem name="urgency" required className="gap-3">
<QuestionnaireTitle className="text-sm">
How much is this affecting you?
</QuestionnaireTitle>
<QuestionnaireChoices className="gap-1.5">
{urgencies.map((urgency) => (
<QuestionnaireChoice
key={urgency.value}
value={urgency.value}
className="py-2"
>
{urgency.label}
<QuestionnaireChoiceDescription className="text-xs">
{urgency.description}
</QuestionnaireChoiceDescription>
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError className="mt-0" />
</QuestionnaireItem>
<QuestionnaireItem name="reference" required className="gap-3">
<QuestionnaireTitle className="text-sm">
Which workspace is this about?
</QuestionnaireTitle>
<QuestionnaireDescription className="mt-1 text-xs">
Find it under Settings → General.
</QuestionnaireDescription>
<QuestionnaireInput
aria-label="Workspace URL"
placeholder="acorn.app/northwind"
autoComplete="off"
/>
<QuestionnaireError className="mt-0">
Add the workspace URL so we can look it up.
</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireActions className="sm:min-h-7">
<QuestionnairePrevious size="sm" variant="ghost">
Back
</QuestionnairePrevious>
<QuestionnaireNext size="sm" />
<QuestionnaireSubmit size="sm">Open ticket</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
)}
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/questionnaire-10pnpm dlx shadcn@latest add @sevenui/component/questionnaire-10yarn dlx shadcn@latest add @sevenui/component/questionnaire-10bunx --bun shadcn@latest add @sevenui/component/questionnaire-10Declare an incident
On call: Priya Nair"use client";
import { SirenIcon } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
const severities = [
{
value: "sev1",
label: "SEV1 · Critical",
description: "Customer data at risk or the product is down for everyone.",
},
{
value: "sev2",
label: "SEV2 · Major",
description: "A core flow is broken for a large share of customers.",
},
{
value: "sev3",
label: "SEV3 · Minor",
description: "Degraded experience with a workaround available.",
},
];
const services = [
{ value: "checkout-api", health: "down" },
{ value: "payments-worker", health: "degraded" },
{ value: "search-indexer", health: "healthy" },
{ value: "auth-gateway", health: "healthy" },
{ value: "notifications", health: "degraded" },
{ value: "web-storefront", health: "healthy" },
] as const;
const healthLabel = {
down: "Failing health checks",
degraded: "Elevated error rate",
healthy: "Healthy",
};
const healthDot = {
down: "bg-destructive",
degraded: "bg-warning",
healthy: "bg-success",
};
type Declared = { severity: string; services: string[]; startedAt: string };
export default function Questionnaire11() {
const [severity, setSeverity] = useState<string | null>(null);
const [affected, setAffected] = useState<string[]>([]);
const [declared, setDeclared] = useState<Declared | null>(null);
function toggleService(value: string, checked: boolean) {
setAffected((current) =>
checked ? [...current, value] : current.filter((item) => item !== value),
);
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setDeclared({
severity: String(data.get("severity")).toUpperCase(),
services: data.getAll("services").map(String),
startedAt: String(data.get("started-at")).replace("T", " "),
});
}
function reset() {
setDeclared(null);
setSeverity(null);
setAffected([]);
}
return (
<div className="w-full max-w-lg overflow-hidden rounded-xl border bg-card text-card-foreground">
<div className="flex items-center gap-2 border-b px-4 py-3">
<SirenIcon aria-hidden="true" className="size-4 text-destructive" />
<h3 className="shrink-0 text-sm font-medium">Declare an incident</h3>
<span className="ms-auto truncate text-xs text-muted-foreground">
<span className="max-sm:hidden">On call: </span>Priya Nair
</span>
</div>
{declared ? (
<div className="flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm font-medium">INC-2291</span>
<Badge variant="destructive">{declared.severity}</Badge>
<span className="text-xs text-muted-foreground">
started {declared.startedAt}
</span>
</div>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Channel</dt>
<dd className="font-mono">#inc-2291</dd>
<dt className="text-muted-foreground">Services</dt>
<dd className="font-mono break-words">
{declared.services.join(", ")}
</dd>
<dt className="text-muted-foreground">Paged</dt>
<dd>Priya Nair, Commerce on-call</dd>
</dl>
<Button variant="outline" size="sm" className="w-fit" onClick={reset}>
Declare another
</Button>
</div>
) : (
<Questionnaire onSubmit={handleSubmit} className="gap-0">
<div className="flex flex-col gap-4 p-4">
<QuestionnaireProgress />
<QuestionnaireItem name="severity" required>
<QuestionnaireTitle>How severe is it?</QuestionnaireTitle>
<QuestionnaireDescription>
Severity decides who gets paged and how often status updates go
out.
</QuestionnaireDescription>
<QuestionnaireChoices>
{severities.map((option) => (
<QuestionnaireChoice
key={option.value}
value={option.value}
onChange={(event) => setSeverity(event.target.value)}
>
<span className="font-medium">{option.label}</span>
<QuestionnaireChoiceDescription>
{option.description}
</QuestionnaireChoiceDescription>
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>
Pick a severity. You can change it later in the incident
channel.
</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireItem name="services" multiple required>
<QuestionnaireTitle>Which services are affected?</QuestionnaireTitle>
<QuestionnaireDescription>
Health comes from the last 5 minutes of checks.
</QuestionnaireDescription>
<QuestionnaireChoices className="sm:grid-cols-2">
{services.map((service) => (
<QuestionnaireChoice
key={service.value}
value={service.value}
onChange={(event) =>
toggleService(service.value, event.target.checked)
}
>
<span className="font-mono text-[0.8125rem]">
{service.value}
</span>
<QuestionnaireChoiceDescription className="flex items-center gap-1.5 text-xs">
<span
aria-hidden="true"
className={`size-1.5 shrink-0 rounded-full ${healthDot[service.health]}`}
/>
{healthLabel[service.health]}
</QuestionnaireChoiceDescription>
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>Select at least one service.</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireItem name="started-at" required>
<QuestionnaireTitle>When did it start?</QuestionnaireTitle>
<QuestionnaireDescription>
Your local time. Use the first alert, not when you noticed.
</QuestionnaireDescription>
<QuestionnaireInput
type="datetime-local"
aria-label="Incident start time"
defaultValue="2026-09-25T14:05"
className="font-mono"
/>
<QuestionnaireError>Add a start time.</QuestionnaireError>
</QuestionnaireItem>
</div>
<div className="flex flex-col gap-3 border-t bg-muted/40 px-4 py-3">
<p
aria-live="polite"
className="min-h-[1lh] truncate font-mono text-xs text-muted-foreground"
>
{severity || affected.length > 0
? `draft: ${severity?.toUpperCase() ?? "SEV?"}${
affected.length ? ` · ${affected.join(", ")}` : ""
}`
: "draft: nothing selected yet"}
</p>
<QuestionnaireActions>
<QuestionnairePrevious size="sm" />
<QuestionnaireNext size="sm" />
<QuestionnaireSubmit size="sm" variant="destructive">
Declare incident
</QuestionnaireSubmit>
</QuestionnaireActions>
</div>
</Questionnaire>
)}
</div>
);
}
npx shadcn@latest add @sevenui/component/questionnaire-11pnpm dlx shadcn@latest add @sevenui/component/questionnaire-11yarn dlx shadcn@latest add @sevenui/component/questionnaire-11bunx --bun shadcn@latest add @sevenui/component/questionnaire-11"use client";
import {
CheckIcon,
MountainIcon,
RotateCcwIcon,
ShoppingBagIcon,
SignpostIcon,
TimerIcon,
} from "lucide-react";
import { type FormEvent, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
const terrains = [
{ value: "road", label: "Road", icon: SignpostIcon },
{ value: "trail", label: "Trail", icon: MountainIcon },
{ value: "treadmill", label: "Treadmill", icon: TimerIcon },
];
const distances = [
{ value: "short", label: "Under 15 km a week" },
{ value: "medium", label: "15 to 40 km a week" },
{ value: "long", label: "More than 40 km a week" },
];
const feels = [
{
value: "firm",
label: "Firm and fast",
description: "Close to the ground, snappy on tempo days.",
},
{
value: "balanced",
label: "Balanced",
description: "Soft enough for long runs, stable enough for speed.",
},
{
value: "plush",
label: "Max cushion",
description: "Protective for high mileage and recovery runs.",
},
];
const shoes = {
road: { name: "Northpeak Glide 4", price: 140 },
trail: { name: "Northpeak Ridge GTX", price: 165 },
treadmill: { name: "Northpeak Pace Lite", price: 120 },
};
const tileClassName =
"min-h-20 items-stretch justify-center px-2 pt-7 pb-3 text-center sm:p-3 [&>[data-slot=questionnaire-choice-indicator]]:hidden [&>[data-slot=questionnaire-choice-shortcut]]:absolute [&>[data-slot=questionnaire-choice-shortcut]]:end-2 [&>[data-slot=questionnaire-choice-shortcut]]:top-2 [&_[data-slot=questionnaire-choice-label]]:items-center [&_[data-slot=questionnaire-choice-label]]:justify-center [&_[data-slot=questionnaire-choice-label]]:gap-2";
type Match = {
terrain: keyof typeof shoes;
distance: string;
feel: string;
};
export default function Questionnaire12() {
const [match, setMatch] = useState<Match | null>(null);
const [added, setAdded] = useState(false);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setMatch({
terrain: data.get("terrain") as Match["terrain"],
distance:
distances.find((item) => item.value === data.get("distance"))?.label ??
"",
feel: feels.find((item) => item.value === data.get("feel"))?.label ?? "",
});
}
function retake() {
setMatch(null);
setAdded(false);
}
if (match) {
const shoe = shoes[match.terrain];
return (
<div className="flex w-full max-w-md flex-col gap-4 rounded-xl border bg-card p-4 text-card-foreground">
<div className="flex gap-4">
<img
src="/placeholder.svg"
alt={shoe.name}
className="size-24 shrink-0 rounded-lg bg-muted object-cover"
/>
<div className="flex min-w-0 flex-col gap-1">
<Badge variant="secondary">96% match</Badge>
<h3 className="font-medium">{shoe.name}</h3>
<p className="text-sm tabular-nums text-muted-foreground">
${shoe.price}
</p>
</div>
</div>
<ul className="flex flex-col gap-1.5 text-sm">
{[match.distance, `${match.feel} ride`, "Free 30-day returns"].map(
(reason) => (
<li key={reason} className="flex items-center gap-2">
<CheckIcon
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
{reason}
</li>
),
)}
</ul>
<div className="flex flex-wrap gap-2">
<Button
className="flex-1"
onClick={() => setAdded(true)}
disabled={added}
>
<ShoppingBagIcon aria-hidden="true" />
{added ? "Added to bag" : "Add to bag"}
</Button>
<Button variant="outline" onClick={retake}>
<RotateCcwIcon aria-hidden="true" />
Retake
</Button>
</div>
</div>
);
}
return (
<Questionnaire
shortcuts="letters"
onSubmit={handleSubmit}
className="max-w-md rounded-xl border bg-card p-5 text-card-foreground"
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">Shoe finder</p>
<QuestionnaireProgress className="w-auto min-w-0 text-end" />
</div>
<QuestionnaireItem name="terrain" required>
<QuestionnaireTitle>Where do you run most?</QuestionnaireTitle>
<QuestionnaireChoices className="grid-cols-3">
{terrains.map((terrain) => (
<QuestionnaireChoice
key={terrain.value}
value={terrain.value}
className={tileClassName}
>
<terrain.icon
aria-hidden="true"
className="size-5 text-muted-foreground group-data-checked/questionnaire-choice:text-foreground"
/>
<span className="font-medium">{terrain.label}</span>
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>Pick where you run most often.</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireItem name="distance" required>
<QuestionnaireTitle>How far do you run in a typical week?</QuestionnaireTitle>
<QuestionnaireChoices>
{distances.map((distance) => (
<QuestionnaireChoice key={distance.value} value={distance.value}>
{distance.label}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireItem name="feel" required>
<QuestionnaireTitle>How should the shoe feel underfoot?</QuestionnaireTitle>
<QuestionnaireDescription>
Not sure? Balanced suits most runners.
</QuestionnaireDescription>
<QuestionnaireChoices>
{feels.map((feel) => (
<QuestionnaireChoice key={feel.value} value={feel.value}>
{feel.label}
<QuestionnaireChoiceDescription>
{feel.description}
</QuestionnaireChoiceDescription>
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnairePrevious variant="ghost" />
<QuestionnaireNext />
<QuestionnaireSubmit>Show my match</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
);
}
npx shadcn@latest add @sevenui/component/questionnaire-12pnpm dlx shadcn@latest add @sevenui/component/questionnaire-12yarn dlx shadcn@latest add @sevenui/component/questionnaire-12bunx --bun shadcn@latest add @sevenui/component/questionnaire-12"use client";
import {
CalendarCheckIcon,
CalendarIcon,
ClockIcon,
GlobeIcon,
VideoIcon,
} from "lucide-react";
import { type FormEvent, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
const days = [
{ value: "2026-09-29", weekday: "Tue", date: "Sep 29" },
{ value: "2026-09-30", weekday: "Wed", date: "Sep 30" },
{ value: "2026-10-01", weekday: "Thu", date: "Oct 1" },
{ value: "2026-10-02", weekday: "Fri", date: "Oct 2" },
];
const slotsByDay: Record<string, string[]> = {
"2026-09-29": ["9:30 AM", "11:00 AM", "2:00 PM", "4:30 PM"],
"2026-09-30": ["10:00 AM", "1:30 PM", "3:00 PM"],
"2026-10-01": ["9:00 AM", "9:30 AM", "12:00 PM", "2:30 PM", "5:00 PM"],
"2026-10-02": ["11:30 AM", "3:30 PM"],
};
const teamSizes = [
{ value: "1-10", label: "1–10 people" },
{ value: "11-50", label: "11–50 people" },
{ value: "51-200", label: "51–200 people" },
{ value: "200+", label: "More than 200" },
];
// The questionnaire skips native constraint validation, so check the format here.
function isEmailAddress(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
}
const chipClassName =
"min-h-11 items-center justify-center px-2 py-2 text-center data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary [&>[data-slot=questionnaire-choice-indicator]]:hidden [&_[data-slot=questionnaire-choice-label]]:items-center";
export default function Questionnaire13() {
const [day, setDay] = useState<string | null>(null);
const [time, setTime] = useState<string | null>(null);
const [booked, setBooked] = useState(false);
const [inCalendar, setInCalendar] = useState(false);
const [email, setEmail] = useState("");
const [emailChecked, setEmailChecked] = useState(false);
const malformed = email.trim() !== "" && !isEmailAddress(email);
const selectedDay = days.find((item) => item.value === day);
const slots = day ? (slotsByDay[day] ?? []) : [];
const when = selectedDay
? `${selectedDay.weekday}, ${selectedDay.date}${time ? ` · ${time}` : ""}`
: "Pick a day";
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (malformed) {
setEmailChecked(true);
return;
}
setBooked(true);
}
function reset() {
setDay(null);
setTime(null);
setBooked(false);
setInCalendar(false);
setEmail("");
setEmailChecked(false);
}
return (
<div className="grid w-full max-w-2xl overflow-hidden rounded-xl border bg-card text-card-foreground sm:grid-cols-[220px_minmax(0,1fr)]">
<aside className="flex flex-col gap-4 border-b bg-muted/40 p-5 sm:border-e sm:border-b-0">
<Avatar size="lg">
<AvatarFallback>DK</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground">Daniel Kim · Solutions</p>
<h3 className="cn-font-heading font-semibold text-balance">
Product demo for your team
</h3>
</div>
<ul className="flex flex-col gap-2 text-sm text-muted-foreground">
<li className="flex items-center gap-2">
<ClockIcon aria-hidden="true" className="size-4 shrink-0" />
30 minutes
</li>
<li className="flex items-center gap-2">
<VideoIcon aria-hidden="true" className="size-4 shrink-0" />
Video call, link by email
</li>
<li className="flex items-center gap-2">
<GlobeIcon aria-hidden="true" className="size-4 shrink-0" />
Central European Time
</li>
<li className="flex items-center gap-2 font-medium text-foreground">
<CalendarIcon aria-hidden="true" className="size-4 shrink-0" />
<span aria-live="polite">{when}</span>
</li>
</ul>
</aside>
<div className="p-5">
{booked ? (
<div className="flex h-full flex-col justify-center gap-3">
<CalendarCheckIcon
aria-hidden="true"
className="size-6 text-success"
/>
<h3 className="font-medium">You're booked for {when}</h3>
<p className="text-sm text-pretty text-muted-foreground">
A calendar invite with the video link is on its way. Daniel will
tailor the demo to your answers.
</p>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={inCalendar}
onClick={() => setInCalendar(true)}
>
{inCalendar ? "Added to calendar" : "Add to calendar"}
</Button>
<Button size="sm" variant="outline" onClick={reset}>
Reschedule
</Button>
</div>
</div>
) : (
<Questionnaire onSubmit={handleSubmit}>
<QuestionnaireProgress />
<QuestionnaireItem name="day" required>
<QuestionnaireTitle>Which day works for you?</QuestionnaireTitle>
<QuestionnaireChoices className="grid-cols-2 sm:grid-cols-4">
{days.map((item) => (
<QuestionnaireChoice
key={item.value}
value={item.value}
className={chipClassName}
onChange={(event) => {
setDay(event.target.value);
setTime(null);
}}
>
<span className="text-xs opacity-80">{item.weekday}</span>
<span className="font-medium">{item.date}</span>
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>Choose a day to see open times.</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireItem name="time" required>
<QuestionnaireTitle>Pick a time</QuestionnaireTitle>
<QuestionnaireDescription>
{selectedDay
? `${slots.length} open slots on ${selectedDay.weekday}, ${selectedDay.date}.`
: "Choose a day first."}
</QuestionnaireDescription>
<QuestionnaireChoices
key={day ?? "none"}
className="grid-cols-2 sm:grid-cols-3"
>
{slots.map((slot) => (
<QuestionnaireChoice
key={slot}
value={slot}
className={`${chipClassName} tabular-nums`}
onChange={(event) => setTime(event.target.value)}
>
{slot}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>Choose a time slot.</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireItem name="team-size" required>
<QuestionnaireTitle>How big is your team?</QuestionnaireTitle>
<QuestionnaireDescription>
Helps Daniel pick the right plan to walk through.
</QuestionnaireDescription>
<QuestionnaireChoices className="sm:grid-cols-2">
{teamSizes.map((size) => (
<QuestionnaireChoice key={size.value} value={size.value}>
{size.label}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireItem
name="email"
required
invalid={emailChecked && malformed}
>
<QuestionnaireTitle>Where should we send the invite?</QuestionnaireTitle>
<QuestionnaireInput
type="email"
aria-label="Work email"
autoComplete="email"
placeholder="you@company.com"
value={email}
onChange={(event) => setEmail(event.target.value)}
onBlur={() => setEmailChecked(true)}
/>
<QuestionnaireError>
{emailChecked && malformed
? "Enter a full email address, like you@company.com."
: "Enter your work email."}
</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireNext />
<QuestionnaireSubmit>Confirm booking</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
)}
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/questionnaire-13pnpm dlx shadcn@latest add @sevenui/component/questionnaire-13yarn dlx shadcn@latest add @sevenui/component/questionnaire-13bunx --bun shadcn@latest add @sevenui/component/questionnaire-13"use client";
import { CheckIcon, UserMinusIcon } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
type Status = "unanswered" | "answered" | "skipped";
const steps = [
{ name: "member", label: "Teammate" },
{ name: "owner", label: "New owner" },
{ name: "timing", label: "Access" },
{ name: "cleanup", label: "Cleanup" },
];
const members = [
{
value: "leo",
name: "Leo Martins",
initials: "LM",
role: "Admin",
owns: "14 docs · 3 projects",
},
{
value: "ana",
name: "Ana Petrova",
initials: "AP",
role: "Editor",
owns: "6 docs · 1 project",
},
{
value: "tom",
name: "Tom Okafor",
initials: "TO",
role: "Editor",
owns: "22 docs · 5 projects",
},
{
value: "mei",
name: "Mei Tanaka",
initials: "MT",
role: "Viewer",
owns: "No owned content",
},
];
const timings = [
{
value: "now",
label: "Remove access now",
description: "Signs them out everywhere within a minute.",
},
{
value: "end-of-day",
label: "At the end of today",
description: "Access ends at 6:00 PM workspace time.",
},
{
value: "suspend",
label: "Suspend, don't remove",
description: "Keeps their seat and history. Restore anytime.",
},
];
const cleanupTasks = [
{
value: "api-keys",
label: "Revoke 2 personal API keys",
description: "Integrations using them will stop working.",
},
{
value: "shared-links",
label: "Disable 5 public share links",
},
{
value: "calendar",
label: "Cancel their recurring meetings",
},
];
type Summary = {
member: string;
owner: string;
timing: string;
cleanup: number;
};
function memberName(value: FormDataEntryValue | null) {
return members.find((member) => member.value === value)?.name ?? "";
}
export default function Questionnaire14() {
const [current, setCurrent] = useState(steps[0].name);
const [statuses, setStatuses] = useState<Record<string, Status>>({});
const [leaving, setLeaving] = useState<string | null>(null);
const [owner, setOwner] = useState<string | null>(null);
const [summary, setSummary] = useState<Summary | null>(null);
const currentIndex = steps.findIndex((step) => step.name === current);
function trackStatus(name: string) {
return (status: Status) =>
setStatuses((previous) => ({ ...previous, [name]: status }));
}
function canVisit(index: number) {
if (index <= currentIndex) return true;
return steps
.slice(0, index)
.every((step) => statuses[step.name] && statuses[step.name] !== "unanswered");
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setSummary({
member: memberName(data.get("member")),
owner: memberName(data.get("owner")),
timing:
timings.find((timing) => timing.value === data.get("timing"))?.label ??
"",
cleanup: data.getAll("cleanup").length,
});
}
function reset() {
setSummary(null);
setStatuses({});
setLeaving(null);
setOwner(null);
setCurrent(steps[0].name);
}
if (summary) {
return (
<div className="flex w-full max-w-2xl flex-col gap-4 rounded-xl border bg-card p-6 text-card-foreground">
<div className="flex items-center gap-2">
<UserMinusIcon aria-hidden="true" className="size-4 text-muted-foreground" />
<h3 className="font-medium">{summary.member} is being offboarded</h3>
</div>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-6 gap-y-2 text-sm">
<dt className="text-muted-foreground">Content moves to</dt>
<dd>{summary.owner}</dd>
<dt className="text-muted-foreground">Access</dt>
<dd>{summary.timing}</dd>
<dt className="text-muted-foreground">Cleanup</dt>
<dd>
{summary.cleanup > 0
? `${summary.cleanup} ${summary.cleanup === 1 ? "task" : "tasks"} queued`
: "Nothing extra"}
</dd>
</dl>
<p className="text-sm text-muted-foreground">
We emailed a copy of this summary to the workspace owners.
</p>
<Button variant="outline" size="sm" className="w-fit" onClick={reset}>
Offboard someone else
</Button>
</div>
);
}
return (
<div className="grid w-full max-w-2xl overflow-hidden rounded-xl border bg-card text-card-foreground sm:grid-cols-[180px_minmax(0,1fr)]">
<nav
aria-label="Offboarding steps"
className="hidden border-e bg-muted/40 p-4 sm:block"
>
<h3 className="mb-4 px-2 text-sm font-medium">Offboard teammate</h3>
<ol className="flex flex-col gap-1">
{steps.map((step, index) => {
const status = statuses[step.name];
const done =
index !== currentIndex &&
(status === "answered" || status === "skipped");
const active = index === currentIndex;
return (
<li key={step.name}>
<button
type="button"
disabled={!canVisit(index)}
aria-current={active ? "step" : undefined}
onClick={() => setCurrent(step.name)}
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-start text-sm text-muted-foreground transition-colors outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-[current=step]:bg-background aria-[current=step]:font-medium aria-[current=step]:text-foreground aria-[current=step]:shadow-xs"
>
<span
aria-hidden="true"
data-done={done ? "" : undefined}
className="flex size-5 shrink-0 items-center justify-center rounded-full border text-[0.6875rem] tabular-nums data-done:border-primary data-done:bg-primary data-done:text-primary-foreground"
>
{done ? <CheckIcon className="size-3" /> : index + 1}
</span>
{step.label}
{status === "skipped" && (
<span className="sr-only">(skipped)</span>
)}
</button>
</li>
);
})}
</ol>
</nav>
<Questionnaire
item={current}
onItemChange={setCurrent}
onSubmit={handleSubmit}
className="p-5"
>
<QuestionnaireProgress className="sm:hidden" />
<QuestionnaireItem
name="member"
required
onStatusChange={trackStatus("member")}
>
<QuestionnaireTitle>Who is leaving the workspace?</QuestionnaireTitle>
<QuestionnaireDescription>
Northwind Studio · 4 of 12 seats shown
</QuestionnaireDescription>
<QuestionnaireChoices>
{members.map((member) => (
<QuestionnaireChoice
key={member.value}
value={member.value}
onChange={(event) => {
const value = event.target.value;
setLeaving(value);
// Someone who is leaving can't receive their own content.
if (owner === value) setOwner(null);
}}
className="items-center [&>[data-slot=questionnaire-choice-indicator]]:translate-y-0"
>
<span className="flex min-w-0 items-center gap-3">
<Avatar size="sm" className="max-sm:hidden">
<AvatarFallback>{member.initials}</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{member.name}</span>
<QuestionnaireChoiceDescription className="text-xs sm:truncate">
{member.owns}
</QuestionnaireChoiceDescription>
</span>
<Badge variant="outline" className="ms-auto">
{member.role}
</Badge>
</span>
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>Choose the teammate who is leaving.</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireItem
name="owner"
required
onStatusChange={trackStatus("owner")}
>
<QuestionnaireTitle>Who should own their work?</QuestionnaireTitle>
<QuestionnaireDescription>
Docs, projects, and automations transfer with edit history intact.
</QuestionnaireDescription>
<QuestionnaireChoices className="sm:grid-cols-2">
{members.map((member) => (
<QuestionnaireChoice
key={member.value}
value={member.value}
checked={owner === member.value}
onChange={(event) => setOwner(event.target.value)}
disabled={member.value === leaving}
className="items-center [&>[data-slot=questionnaire-choice-indicator]]:translate-y-0"
>
<span className="flex min-w-0 items-center gap-2.5">
<Avatar size="sm">
<AvatarFallback>{member.initials}</AvatarFallback>
</Avatar>
<span className="truncate">{member.name}</span>
</span>
{member.value === leaving && (
<QuestionnaireChoiceDescription className="text-xs">
Leaving
</QuestionnaireChoiceDescription>
)}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>Pick someone to receive their content.</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireItem
name="timing"
required
onStatusChange={trackStatus("timing")}
>
<QuestionnaireTitle>When should their access end?</QuestionnaireTitle>
<QuestionnaireChoices>
{timings.map((timing) => (
<QuestionnaireChoice key={timing.value} value={timing.value}>
{timing.label}
<QuestionnaireChoiceDescription>
{timing.description}
</QuestionnaireChoiceDescription>
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireItem
name="cleanup"
multiple
onStatusChange={trackStatus("cleanup")}
>
<QuestionnaireTitle>Clean up anything else?</QuestionnaireTitle>
<QuestionnaireDescription>
Optional. Skip to leave these as they are.
</QuestionnaireDescription>
<QuestionnaireChoices>
{cleanupTasks.map((task) => (
<QuestionnaireChoice key={task.value} value={task.value}>
{task.label}
{task.description && (
<QuestionnaireChoiceDescription>
{task.description}
</QuestionnaireChoiceDescription>
)}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
<QuestionnaireError>
Pick at least one task, or choose Skip & finish.
</QuestionnaireError>
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireSkip>Skip & finish</QuestionnaireSkip>
<QuestionnaireNext />
<QuestionnaireSubmit variant="destructive">
Offboard teammate
</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
</div>
);
}
npx shadcn@latest add @sevenui/component/questionnaire-14pnpm dlx shadcn@latest add @sevenui/component/questionnaire-14yarn dlx shadcn@latest add @sevenui/component/questionnaire-14bunx --bun shadcn@latest add @sevenui/component/questionnaire-14