{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "survey-1",
  "title": "Survey 1: NPS feedback",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A three-question feedback card: the 0 to 10 recommend question as big buttons, a follow-up that changes with the answer, an optional comment, a progress bar, Back that keeps answers, and a failed send that keeps what was typed.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/textarea.json",
    "https://ui.ballmac.com/r/use-resource.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/survey-1/survey-1.tsx",
      "content": "// Ballmac UI: Survey 1. https://ui.ballmac.com/blocks/survey-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowLeft, ArrowRight, CircleAlert, Heart, Loader2, PartyPopper } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Textarea } from \"@/components/ballmac/textarea\"\nimport { formPhase, type FormStateProps } from \"@/lib/ballmac/use-resource\"\nimport { cn } from \"@/lib/utils\"\n\ntype Survey1Result = { score: number; reasons: string[]; comment: string; mayContact: boolean }\n\ntype Survey1Props = Omit<React.ComponentProps<\"section\">, \"title\" | \"onSubmit\"> &\n  FormStateProps & {\n    /** The product the question is about. */\n    product?: string\n    /** Reasons offered to people who gave a low or middling score. */\n    lowReasons?: string[]\n    /** Reasons offered to people who gave a high score. */\n    highReasons?: string[]\n    /** The question shown first (0 to 2). */\n    defaultStep?: 0 | 1 | 2\n    /** A score chosen at first. */\n    defaultScore?: number\n    /** Called with the answers. Return a promise; reject to show an error and keep what was typed. */\n    onSubmit?: (result: Survey1Result) => void | Promise<void>\n  }\n\nconst low = [\"It is hard to learn\", \"Too slow\", \"Missing something I need\", \"Too expensive\", \"Bugs or crashes\", \"Support was slow\"]\nconst high = [\"Easy to use\", \"Saves me time\", \"Looks great\", \"Great support\", \"Good value\", \"Does exactly what I need\"]\n\n// What the score means to the person, in their words rather than a bucket name.\nconst mood = (n: number) => (n <= 6 ? \"We missed the mark\" : n <= 8 ? \"Good, but not great\" : \"You are a fan\")\n\n/**\n * A short feedback survey on one card: the 0 to 10 question as big buttons, then a follow-up that changes with the answer (what went wrong,\n * or what is working), then an optional comment. A progress bar says where you are, Back keeps your answers, and a failed send keeps what\n * you typed.\n */\nfunction Survey1({\n  product = \"Acme\",\n  lowReasons = low,\n  highReasons = high,\n  defaultStep = 0,\n  defaultScore,\n  onSubmit,\n  state: stateProp,\n  errorState,\n  className,\n  ...props\n}: Survey1Props) {\n  const id = React.useId()\n  const [step, setStep] = React.useState<number>(defaultStep)\n  const [score, setScore] = React.useState<number | null>(defaultScore ?? null)\n  const [reasons, setReasons] = React.useState<string[]>([])\n  const [comment, setComment] = React.useState(\"\")\n  const [mayContact, setMayContact] = React.useState(false)\n  const [internal, setInternal] = React.useState<\"idle\" | \"sending\" | \"done\" | \"error\">(\"idle\")\n  const state = formPhase(stateProp) ?? internal\n  const headingRef = React.useRef<HTMLHeadingElement>(null)\n  const first = React.useRef(true)\n  const happy = (score ?? 0) >= 9\n  const choices = happy ? highReasons : lowReasons\n\n  // Move focus to the new step's heading so a keyboard or screen-reader user is not left on a button that is gone.\n  React.useEffect(() => {\n    if (first.current) first.current = false\n    else headingRef.current?.focus()\n  }, [step, state])\n\n  const pickScore = (n: number) => {\n    if (score !== null && (score >= 9) !== (n >= 9)) setReasons([])\n    setScore(n)\n  }\n  const toggle = (r: string) => setReasons((rs) => (rs.includes(r) ? rs.filter((x) => x !== r) : [...rs, r]))\n\n  async function send() {\n    if (score === null) return\n    setInternal(\"sending\")\n    try {\n      if (onSubmit) await onSubmit({ score, reasons, comment: comment.trim(), mayContact })\n      else await new Promise((r) => setTimeout(r, 800))\n      setInternal(\"done\")\n    } catch {\n      setInternal(\"error\")\n    }\n  }\n\n  const sending = state === \"sending\"\n  const progress = state === \"done\" ? 100 : ((step + 1) / 3) * 100\n\n  return (\n    <section data-slot=\"survey-1\" aria-labelledby={`${id}-h`} aria-busy={sending} className={cn(\"mx-auto w-full max-w-2xl px-4 py-12 sm:px-6 sm:py-20\", className)} {...props}>\n      <div className=\"bg-card overflow-hidden rounded-3xl border shadow-sm\">\n        <div role=\"progressbar\" aria-label=\"Survey progress\" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress)} className=\"bg-muted h-1.5\">\n          <div className=\"bg-foreground h-full transition-[width] duration-500 ease-out motion-reduce:transition-none\" style={{ width: `${progress}%` }} />\n        </div>\n\n        <div className=\"p-6 sm:p-10\">\n          {state === \"done\" ? (\n            <div role=\"status\" className=\"py-6 text-center\">\n              <span aria-hidden=\"true\" className=\"bg-chart-2/15 mx-auto flex size-14 items-center justify-center rounded-full\">\n                {happy ? <Heart className=\"size-7\" /> : <PartyPopper className=\"size-7\" />}\n              </span>\n              <h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className=\"mt-6 text-2xl font-semibold tracking-tight outline-none\">\n                Thank you\n              </h2>\n              <p className=\"text-muted-foreground mx-auto mt-2 max-w-sm text-pretty\">\n                {happy ? `That means a lot to the whole ${product} team.` : `Someone on the ${product} team will read this, and ${mayContact ? \"may write back.\" : \"it will shape what we fix next.\"}`}\n              </p>\n            </div>\n          ) : (\n            <>\n              <p className=\"text-muted-foreground text-sm font-medium tabular-nums\">Question {step + 1} of 3</p>\n\n              {step === 0 ? (\n                <div>\n                  <h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className=\"mt-2 text-2xl font-semibold tracking-tight text-balance outline-none sm:text-3xl\">\n                    How likely are you to recommend {product} to a friend or colleague?\n                  </h2>\n                  <div role=\"radiogroup\" aria-labelledby={`${id}-h`} className=\"mt-8 grid grid-cols-6 gap-2 sm:grid-cols-11\">\n                    {Array.from({ length: 11 }, (_, n) => (\n                      <button\n                        key={n}\n                        type=\"button\"\n                        role=\"radio\"\n                        aria-checked={score === n}\n                        aria-label={`${n}${n === 0 ? \", not at all likely\" : n === 10 ? \", extremely likely\" : \"\"}`}\n                        onClick={() => pickScore(n)}\n                        onKeyDown={(e) => {\n                          const d = e.key === \"ArrowRight\" || e.key === \"ArrowDown\" ? 1 : e.key === \"ArrowLeft\" || e.key === \"ArrowUp\" ? -1 : 0\n                          if (!d) return\n                          e.preventDefault()\n                          const next = Math.max(0, Math.min(10, (score ?? n) + d))\n                          pickScore(next)\n                          ;(e.currentTarget.parentElement?.children[next] as HTMLElement | undefined)?.focus()\n                        }}\n                        tabIndex={score === n || (score === null && n === 0) ? 0 : -1}\n                        className={cn(\n                          \"focus-visible:ring-ring/50 aspect-square rounded-xl border text-lg font-semibold tabular-nums outline-none transition-all duration-150 focus-visible:ring-[3px] motion-reduce:transition-none sm:aspect-[3/4]\",\n                          score === n ? \"bg-foreground text-background border-foreground scale-105 shadow-md\" : \"hover:bg-accent hover:border-foreground/30\"\n                        )}\n                      >\n                        {n}\n                      </button>\n                    ))}\n                  </div>\n                  <div aria-hidden=\"true\" className=\"text-muted-foreground mt-3 flex justify-between text-xs\">\n                    <span>Not at all likely</span>\n                    <span>Extremely likely</span>\n                  </div>\n                  <p aria-live=\"polite\" className={cn(\"mt-6 h-6 text-center text-sm font-medium transition-opacity motion-reduce:transition-none\", score === null && \"opacity-0\")}>\n                    {score === null ? \"\" : mood(score)}\n                  </p>\n                </div>\n              ) : null}\n\n              {step === 1 ? (\n                <div>\n                  <h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className=\"mt-2 text-2xl font-semibold tracking-tight text-balance outline-none sm:text-3xl\">\n                    {happy ? \"What do you like most?\" : \"What got in your way?\"}\n                  </h2>\n                  <p className=\"text-muted-foreground mt-2 text-sm\">Choose any that fit.</p>\n                  <div role=\"group\" aria-labelledby={`${id}-h`} className=\"mt-6 flex flex-wrap gap-2.5\">\n                    {choices.map((r) => (\n                      <button\n                        key={r}\n                        type=\"button\"\n                        aria-pressed={reasons.includes(r)}\n                        onClick={() => toggle(r)}\n                        className={cn(\"focus-visible:ring-ring/50 rounded-full border px-4 py-2 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\", reasons.includes(r) ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\")}\n                      >\n                        {r}\n                      </button>\n                    ))}\n                  </div>\n                </div>\n              ) : null}\n\n              {step === 2 ? (\n                <div>\n                  <h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className=\"mt-2 text-2xl font-semibold tracking-tight text-balance outline-none sm:text-3xl\">\n                    Anything you would like to add?\n                  </h2>\n                  <label htmlFor={`${id}-c`} className=\"text-muted-foreground mt-2 block text-sm\">\n                    Optional. A sentence is plenty.\n                  </label>\n                  <Textarea id={`${id}-c`} value={comment} onChange={(e) => setComment(e.target.value)} rows={5} maxLength={1000} disabled={sending} className=\"mt-4\" />\n                  <label className=\"mt-5 flex cursor-pointer items-start gap-3 text-sm\">\n                    <input type=\"checkbox\" checked={mayContact} onChange={(e) => setMayContact(e.target.checked)} disabled={sending} className=\"accent-foreground mt-0.5 size-4\" />\n                    <span>You may contact me about this answer.</span>\n                  </label>\n                  {state === \"error\" ? (\n                    <p role=\"alert\" className=\"text-destructive mt-5 flex items-start gap-2 text-sm\">\n                      <CircleAlert aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n                      {errorState?.description ?? \"We could not send your answers. Nothing you typed was lost, so try again.\"}\n                    </p>\n                  ) : null}\n                </div>\n              ) : null}\n\n              <div className=\"mt-10 flex items-center justify-between gap-3\">\n                <Button variant=\"ghost\" onClick={() => setStep((s) => s - 1)} disabled={step === 0 || sending} className={cn(step === 0 && \"invisible\")}>\n                  <ArrowLeft aria-hidden=\"true\" className=\"rtl:rotate-180\" />\n                  Back\n                </Button>\n                {step < 2 ? (\n                  <Button onClick={() => setStep((s) => s + 1)} disabled={step === 0 && score === null} aria-describedby={step === 0 && score === null ? `${id}-need` : undefined}>\n                    Next\n                    <ArrowRight aria-hidden=\"true\" className=\"rtl:rotate-180\" />\n                  </Button>\n                ) : (\n                  <Button onClick={send} disabled={sending}>\n                    {sending ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : null}\n                    {sending ? \"Sending\" : state === \"error\" ? \"Try again\" : \"Send\"}\n                  </Button>\n                )}\n              </div>\n              {step === 0 && score === null ? (\n                <p id={`${id}-need`} className=\"sr-only\">\n                  Choose a number to continue.\n                </p>\n              ) : null}\n            </>\n          )}\n        </div>\n      </div>\n    </section>\n  )\n}\n\nexport { Survey1, type Survey1Props, type Survey1Result }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/survey-1/survey-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "survey",
      "nps",
      "feedback",
      "score",
      "rating",
      "customer satisfaction",
      "csat",
      "poll",
      "form"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "An NPS survey card. <Survey1 product='Acme' lowReasons highReasons onSubmit={({ score, reasons, comment, mayContact }) => Promise} />. Scores 9 and 10 get the high reasons ('What do you like most?'), everything else the low ones. Reject onSubmit to show the error with the answers kept. Pass state to drive it, or leave it out to run by itself.",
      "whenToUse": [
        "A feedback page linked from an email",
        "Asking for NPS after onboarding or a milestone"
      ],
      "whenNotToUse": [
        "A floating widget in the app (use feedback-widget)",
        "A long, multi-section form"
      ],
      "composesWith": [
        "contact-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Tab, then arrow keys",
          "action": "The scores are a radio group with one tab stop; arrows move and choose, and each is read with its number (0 as not at all likely, 10 as extremely likely)"
        },
        {
          "keys": "Next / Back",
          "action": "Focus moves to the new question's heading so nothing is lost when the step changes"
        },
        {
          "keys": "Progress",
          "action": "A labelled progress bar and 'Question 2 of 3' in text"
        }
      ],
      "customization": [
        "lowReasons and highReasons are plain arrays",
        "The mood line under the scale says what the score means in words",
        "Back keeps every answer; changing between happy and unhappy clears only the reasons, which no longer apply"
      ]
    },
    "states": [
      {
        "id": "submitting",
        "title": "Sending",
        "example": "survey-1-sending",
        "trigger": "state=\"submitting\"",
        "note": "The comment and buttons are disabled, Send shows a spinner and the card is marked busy."
      },
      {
        "id": "success",
        "title": "Thank you",
        "example": "survey-1-sent",
        "trigger": "state=\"success\"",
        "note": "The progress bar fills and the card thanks the person; the words change for a fan and for someone who was unhappy."
      },
      {
        "id": "error",
        "title": "Could not send",
        "example": "survey-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert on the last step saying nothing was lost, with Try again; every answer is still there."
      }
    ],
    "examples": [
      "survey-1-demo",
      "survey-1-sending",
      "survey-1-sent",
      "survey-1-error"
    ],
    "url": "https://ui.ballmac.com/components/survey-1"
  },
  "categories": [
    "blocks",
    "survey"
  ],
  "type": "registry:block"
}