{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "donations-1",
  "title": "Donations 1: donation page",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A donation page for a charity: what each amount does, once or every month, which cause, an optional card-fee cover, anonymous giving, a progress bar to the goal, and a thank you that says exactly what was given.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/donations-1/donations-1.tsx",
      "content": "// Ballmac UI: Donations 1. https://ui.ballmac.com/blocks/donations-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Heart, Loader2 } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Donations1Cause = { id: string; name: string; /** What the money does. */ detail: string }\ntype Donations1Gift = { amount: number; cause: string; monthly: boolean; coverFees: boolean; name: string; email: string; anonymous: boolean }\n\ntype Donations1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    /** What a gift is worth in plain words, by amount: the amount in the preset list says what it does. */\n    presets?: { amount: number; impact: string }[]\n    causes?: Donations1Cause[]\n    title?: string\n    description?: string\n    /** Raised so far and the goal, for the progress bar. */\n    raised?: number\n    goal?: number\n    donors?: number\n    /** ISO currency code. */\n    currency?: string\n    /** Share of a gift that card fees take, as a fraction. Offered as \"cover the fees\". */\n    feeRate?: number\n    /** Called with the gift. Reject and the form stays filled with the message. */\n    onDonate?: (gift: Donations1Gift) => void | Promise<void>\n  }\n\nconst presets = [\n  { amount: 10, impact: \"feeds a family for two days\" },\n  { amount: 25, impact: \"stocks a school library shelf\" },\n  { amount: 50, impact: \"pays for a week of clean water for a village\" },\n  { amount: 100, impact: \"trains a community health worker\" },\n]\nconst causes: Donations1Cause[] = [\n  { id: \"water\", name: \"Clean water\", detail: \"Wells and filters for villages without a safe supply.\" },\n  { id: \"school\", name: \"Schools\", detail: \"Books, desks and teacher training.\" },\n  { id: \"any\", name: \"Where it is needed most\", detail: \"We put it where the gap is biggest this month.\" },\n]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]\">\n      <Skeleton className=\"h-64 rounded-2xl\" />\n      <Skeleton className=\"h-96 rounded-2xl\" />\n    </div>\n  )\n}\n\n/**\n * A donation page: what a gift does at each amount, once or every month, which cause, and whether to cover the card fees.\n * The total is always spelled out before the button, a custom amount is checked, and the thank you says what was given.\n */\nfunction Donations1({ presets: ps = presets, causes: cs = causes, title = \"Help us finish the job\", description = \"Every gift goes to the work. We publish where it went each quarter.\", raised = 38400, goal = 50000, donors = 612, currency = \"USD\", feeRate = 0.03, onDonate, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Donations1Props) {\n  const id = React.useId()\n  const [monthly, setMonthly] = React.useState(false)\n  const [preset, setPreset] = React.useState<number | \"other\">(ps[1]?.amount ?? 25)\n  const [custom, setCustom] = React.useState(\"\")\n  const [cause, setCause] = React.useState(cs[0]?.id ?? \"\")\n  const [fees, setFees] = React.useState(true)\n  const [name, setName] = React.useState(\"\")\n  const [email, setEmail] = React.useState(\"\")\n  const [anon, setAnon] = React.useState(false)\n  const [tried, setTried] = React.useState(false)\n  const [busy, setBusy] = React.useState(false)\n  const [result, setResult] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const view = state === \"ready\" && ps.length === 0 ? \"empty\" : state\n  const money = (n: number) => new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency, minimumFractionDigits: Number.isInteger(n) ? 0 : 2 }).format(n)\n\n  const amount = preset === \"other\" ? Number.parseFloat(custom) : preset\n  const validAmount = Number.isFinite(amount) && amount >= 1\n  const feeAmount = validAmount ? Math.round(amount * feeRate * 100) / 100 : 0\n  const total = validAmount ? Math.round((amount + (fees ? feeAmount : 0)) * 100) / 100 : 0\n  const impact = ps.find((p) => p.amount === preset)?.impact\n  const pct = Math.min(100, Math.round((raised / Math.max(goal, 1)) * 100))\n  const emailBad = !/^\\S+@\\S+\\.\\S+$/.test(email)\n  const nameBad = !anon && !name.trim()\n\n  async function give(e: React.FormEvent) {\n    e.preventDefault()\n    setTried(true)\n    if (!validAmount || emailBad || nameBad) return\n    setBusy(true)\n    setResult(null)\n    try {\n      await onDonate?.({ amount, cause, monthly, coverFees: fees, name: anon ? \"\" : name.trim(), email, anonymous: anon })\n      setResult({ ok: true, text: `Thank you. ${money(total)}${monthly ? \" a month\" : \"\"} is on its way${impact && preset !== \"other\" ? `, and it ${impact}` : \"\"}. A receipt is coming to ${email}.` })\n    } catch (err) {\n      setResult({ ok: false, text: err instanceof Error ? err.message : \"The gift was not made and you were not charged.\" })\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  const input = \"bg-background focus-visible:ring-ring/50 h-11 w-full rounded-lg border px-3 text-sm outline-none focus-visible:ring-[3px]\"\n  const pill = \"bg-card peer-checked:border-foreground peer-checked:bg-accent/40 peer-focus-visible:ring-ring/50 block rounded-xl border px-4 py-3 text-center text-sm font-medium transition-colors peer-focus-visible:ring-[3px] motion-reduce:transition-none\"\n\n  return (\n    <section data-slot=\"donations-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the donation page\" loading={<Skel />} empty={{ title: \"Giving is not open yet\", description: \"There are no gift amounts set up.\", ...emptyState }} error={{ title: \"The donation page could not be loaded\", description: \"You were not charged. Check your connection and try again.\", ...errorState }}>\n        <div className=\"grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:gap-12\">\n          <div className=\"grid gap-6\">\n            <div>\n              <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance sm:text-4xl\">{title}</h1>\n              <p className=\"text-muted-foreground mt-3 text-pretty\">{description}</p>\n            </div>\n            <div className=\"bg-card rounded-2xl border p-5\">\n              <div className=\"flex items-baseline justify-between gap-3\">\n                <p className=\"text-2xl font-semibold tabular-nums\">{money(raised)}</p>\n                <p className=\"text-muted-foreground text-sm tabular-nums\">of {money(goal)}</p>\n              </div>\n              <div role=\"progressbar\" aria-label=\"Raised so far\" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-valuetext={`${pct}% of the goal`} className=\"bg-muted mt-3 h-2.5 overflow-hidden rounded-full\">\n                <div className=\"bg-foreground h-full rounded-full transition-[width] motion-reduce:transition-none\" style={{ width: `${pct}%` }} />\n              </div>\n              <p className=\"text-muted-foreground mt-3 text-sm tabular-nums\">{pct}% there · {donors.toLocaleString(\"en-US\")} people have given</p>\n            </div>\n          </div>\n\n          <form onSubmit={(e) => void give(e)} noValidate className=\"bg-card grid gap-6 rounded-2xl border p-5 sm:p-6\">\n            <fieldset>\n              <legend className=\"sr-only\">How often</legend>\n              <div className=\"bg-muted grid grid-cols-2 gap-1 rounded-xl p-1\">\n                {[false, true].map((m) => (\n                  <label key={String(m)} className=\"relative cursor-pointer\">\n                    <input type=\"radio\" name={`${id}-freq`} checked={monthly === m} onChange={() => setMonthly(m)} className=\"peer sr-only\" />\n                    <span className=\"peer-checked:bg-background peer-checked:shadow-xs peer-focus-visible:ring-ring/50 block rounded-lg py-2 text-center text-sm font-medium transition-colors peer-focus-visible:ring-[3px] motion-reduce:transition-none\">{m ? \"Every month\" : \"Once\"}</span>\n                  </label>\n                ))}\n              </div>\n            </fieldset>\n\n            <fieldset>\n              <legend className=\"mb-3 text-sm font-medium\">Amount</legend>\n              <div className=\"grid grid-cols-2 gap-2.5 sm:grid-cols-5\">\n                {ps.map((p) => (\n                  <label key={p.amount} className=\"relative cursor-pointer\">\n                    <input type=\"radio\" name={`${id}-amt`} checked={preset === p.amount} onChange={() => setPreset(p.amount)} className=\"peer sr-only\" />\n                    <span className={cn(pill, \"tabular-nums\")}>{money(p.amount)}</span>\n                  </label>\n                ))}\n                <label className=\"relative cursor-pointer\">\n                  <input type=\"radio\" name={`${id}-amt`} checked={preset === \"other\"} onChange={() => setPreset(\"other\")} className=\"peer sr-only\" />\n                  <span className={pill}>Other</span>\n                </label>\n              </div>\n              {preset === \"other\" && (\n                <div className=\"mt-3\">\n                  <label htmlFor={`${id}-custom`} className=\"text-muted-foreground mb-1.5 block text-xs\">Your amount</label>\n                  <input id={`${id}-custom`} inputMode=\"decimal\" autoFocus value={custom} onChange={(e) => setCustom(e.target.value)} aria-invalid={tried && !validAmount} aria-describedby={tried && !validAmount ? `${id}-amt-err` : undefined} className={cn(input, \"tabular-nums\")} placeholder=\"35\" />\n                  {tried && !validAmount && <p id={`${id}-amt-err`} role=\"alert\" className=\"text-destructive mt-1.5 text-sm font-medium\">Enter an amount of at least {money(1)}.</p>}\n                </div>\n              )}\n              <p aria-live=\"polite\" className=\"text-muted-foreground mt-3 flex min-h-5 items-center gap-2 text-sm\"><Heart aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />{impact && preset !== \"other\" ? `${money(preset as number)} ${impact}.` : \"Every gift counts.\"}</p>\n            </fieldset>\n\n            {cs.length > 1 && (\n              <div>\n                <label htmlFor={`${id}-cause`} className=\"mb-1.5 block text-sm font-medium\">Where it goes</label>\n                <select id={`${id}-cause`} value={cause} onChange={(e) => setCause(e.target.value)} className={input}>\n                  {cs.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}\n                </select>\n                <p className=\"text-muted-foreground mt-1.5 text-xs\">{cs.find((c) => c.id === cause)?.detail}</p>\n              </div>\n            )}\n\n            <div className=\"grid gap-3 sm:grid-cols-2\">\n              <div>\n                <label htmlFor={`${id}-name`} className=\"mb-1.5 block text-sm font-medium\">Name</label>\n                <input id={`${id}-name`} autoComplete=\"name\" disabled={anon} value={name} onChange={(e) => setName(e.target.value)} aria-invalid={tried && nameBad} aria-describedby={tried && nameBad ? `${id}-name-err` : undefined} className={cn(input, \"disabled:opacity-50\")} />\n                {tried && nameBad && <p id={`${id}-name-err`} role=\"alert\" className=\"text-destructive mt-1.5 text-sm font-medium\">Enter your name, or give anonymously.</p>}\n              </div>\n              <div>\n                <label htmlFor={`${id}-email`} className=\"mb-1.5 block text-sm font-medium\">Email for the receipt</label>\n                <input id={`${id}-email`} type=\"email\" autoComplete=\"email\" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={tried && emailBad} aria-describedby={tried && emailBad ? `${id}-email-err` : undefined} className={input} />\n                {tried && emailBad && <p id={`${id}-email-err`} role=\"alert\" className=\"text-destructive mt-1.5 text-sm font-medium\">Enter an email address like name@example.com.</p>}\n              </div>\n            </div>\n\n            <div className=\"grid gap-2.5 text-sm\">\n              <label className=\"flex items-start gap-2.5\"><input type=\"checkbox\" checked={anon} onChange={(e) => setAnon(e.target.checked)} className=\"mt-0.5 size-4\" /><span>Give anonymously. Your name is not shown or kept.</span></label>\n              <label className=\"flex items-start gap-2.5\"><input type=\"checkbox\" checked={fees} onChange={(e) => setFees(e.target.checked)} className=\"mt-0.5 size-4\" /><span>Add {validAmount ? money(feeAmount) : `${Math.round(feeRate * 100)}%`} to cover card fees, so all of {validAmount ? money(amount) : \"your gift\"} reaches the work.</span></label>\n            </div>\n\n            <div className=\"border-t pt-4\">\n              <p className=\"flex items-baseline justify-between gap-3 text-sm\"><span className=\"text-muted-foreground\">You give</span><span className=\"text-lg font-semibold tabular-nums\">{validAmount ? money(total) : \"–\"}{monthly && validAmount ? \" a month\" : \"\"}</span></p>\n              <Button type=\"submit\" size=\"lg\" className=\"mt-3 w-full\" disabled={busy || result?.ok === true}>\n                {busy ? <Loader2 aria-hidden=\"true\" className=\"animate-spin\" /> : <Heart aria-hidden=\"true\" />} {monthly ? \"Give every month\" : \"Give now\"}\n              </Button>\n              <p aria-live=\"polite\" role={result && !result.ok ? \"alert\" : undefined} className={cn(\"mt-3 text-sm\", result && !result.ok ? \"text-destructive font-medium\" : \"text-muted-foreground\")}>{result?.text}</p>\n            </div>\n          </form>\n        </div>\n      </StateView>\n    </section>\n  )\n}\n\nexport { Donations1, type Donations1Cause, type Donations1Gift, type Donations1Props }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/donations-1/donations-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "donations",
      "charity",
      "nonprofit",
      "fundraising",
      "giving",
      "checkout",
      "monthly",
      "impact"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "Donation page. <Donations1 presets={[{ amount, impact }]} causes={[{ id, name, detail }]} raised goal donors currency='USD' feeRate={0.03} onDonate={({ amount, cause, monthly, coverFees, name, email, anonymous }) => Promise} />. The total, including the fee cover, is always shown before the button. Giving anonymously removes the name requirement and sends no name.",
      "whenToUse": [
        "A charity or open source project asking for gifts",
        "Any pay-what-you-choose page with a clear impact"
      ],
      "whenNotToUse": [
        "A store checkout; use billing-1",
        "A fixed plan subscription; use pricing-1"
      ],
      "composesWith": [
        "billing-1",
        "trust-1",
        "testimonials-1"
      ],
      "a11y": [
        {
          "keys": "Amount",
          "action": "Radio group of presets plus Other; the custom amount is a labelled field with its error as an alert"
        },
        {
          "keys": "Impact",
          "action": "What the amount does is a polite live region"
        },
        {
          "keys": "Progress",
          "action": "A progressbar with the percentage written as text"
        },
        {
          "keys": "Total",
          "action": "Written before the button, including the fee cover and a monthly marker"
        }
      ],
      "customization": [
        "presets, causes, goal and raised",
        "feeRate",
        "onDonate"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "donations-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton progress and form under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "Giving is not open yet",
        "example": "donations-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says giving is not open yet; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "donations-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying you were not charged, with Try again (onRetry). A gift that fails keeps the form filled and says why."
      }
    ],
    "examples": [
      "donations-1-demo",
      "donations-1-monthly",
      "donations-1-loading",
      "donations-1-empty",
      "donations-1-error"
    ],
    "url": "https://ui.ballmac.com/components/donations-1"
  },
  "categories": [
    "blocks",
    "donations"
  ],
  "type": "registry:block"
}