{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "modal-access-template",
  "title": "Modal Access Template",
  "description": "Gates one section of an already-visible page — the content stays mounted and blurred behind a centered dialog instead of disappearing entirely.",
  "registryDependencies": [
    "https://knock.codes/r/react/knock-codes-hook.json",
    "https://knock.codes/r/react/cx-util.json",
    "https://knock.codes/r/react/pin-input.json"
  ],
  "files": [
    {
      "path": "packages/react/ModalAccessTemplate.tsx",
      "content": "// Modal Access Template v1.0.0\n\"use client\";\n\nimport { useEffect, useState, type ReactNode } from \"react\";\nimport { useKnockCodes } from \"./useKnockCodes.ts\";\nimport { PinInput } from \"./PinInput.tsx\";\nimport { DEFAULT_LABELS, type KnockCodesConfig, type KnockCodesLabels } from \"./types.ts\";\nimport { cx } from \"./cx.ts\";\n\n// Shakes the dialog on a failed attempt — inlined via a plain `<style>` tag\n// (not a Tailwind config keyframe) so this file works standalone in a host\n// project that has no matching keyframe of its own.\n// Wrapped in the reduced-motion query rather than toggled from JS: under\n// `prefers-reduced-motion: reduce` this keyframe name simply doesn't exist,\n// so the `animation: modal-access-shake …` utility below resolves to no\n// visual effect.\nconst MODAL_ACCESS_SHAKE_KEYFRAMES = `@media (prefers-reduced-motion: no-preference) {\n  @keyframes modal-access-shake {\n    10%, 90% { transform: translateX(-1px); }\n    20%, 80% { transform: translateX(2px); }\n    30%, 50%, 70% { transform: translateX(-4px); }\n    40%, 60% { transform: translateX(4px); }\n  }\n}`;\n\nexport interface ModalAccessTemplateLabels extends KnockCodesLabels {\n  description?: string;\n  supportLabel?: string;\n}\n\nexport interface ModalAccessTemplateProps extends KnockCodesConfig {\n  /**\n   * The section being gated. Unlike the other templates, this stays mounted\n   * (blurred and inert) behind the dialog while locked, instead of being\n   * swapped out entirely — for gating one section of an already-visible\n   * page rather than taking over the whole screen.\n   */\n  children: ReactNode;\n  /** Rendered above the heading — your own logo/wordmark. Omitted entirely if not passed. */\n  logo?: ReactNode;\n  labels?: ModalAccessTemplateLabels;\n  /** Renders \"Contact support\" as a link. Ignored if `onContactSupport` is also set. */\n  supportHref?: string;\n  /** Renders \"Contact support\" as a button instead of a link. */\n  onContactSupport?: () => void;\n  /** Set true if this is the entire page, so the wrapper takes the full viewport height. @default false */\n  fullPage?: boolean;\n  /** Forces light or dark presentation, independent of any ancestor \".dark\" class. Omit to follow one if it exists. */\n  theme?: \"light\" | \"dark\";\n  /**\n   * Persist the unlocked session across reloads within the same tab via\n   * sessionStorage. Not a security boundary — clearable from DevTools or a\n   * private window, same as any other client-side storage. @default undefined (off)\n   */\n  remember?: \"session\";\n  autoFocus?: boolean;\n  className?: string;\n}\n\nconst TEMPLATE_LABELS: Required<ModalAccessTemplateLabels> = {\n  ...DEFAULT_LABELS,\n  heading: \"This section is restricted\",\n  description: \"Enter your access code to view it.\",\n  supportLabel: \"Contact support\",\n};\n\nfunction SuccessDialogBody() {\n  return (\n    <div className=\"flex flex-col items-center gap-2 py-2 text-center\">\n      <div className=\"flex h-10 w-10 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-500/10 dark:text-green-400\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} className=\"h-5 w-5\">\n          <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" />\n        </svg>\n      </div>\n      <p className=\"text-sm font-medium text-gray-700 dark:text-gray-200\">Access granted</p>\n    </div>\n  );\n}\n\n/**\n * Gates one section of an already-visible page: the content stays mounted\n * and blurred behind a centered dialog instead of disappearing entirely.\n * For a full-page takeover instead, use `<KnockCodesTemplate>` or\n * `<MinimalAccessTemplate>`. Same `useKnockCodes` contract as every other\n * block.\n */\nexport function ModalAccessTemplate({\n  children,\n  logo,\n  labels,\n  supportHref,\n  onContactSupport,\n  fullPage = false,\n  theme,\n  remember,\n  autoFocus = true,\n  className,\n  ...config\n}: ModalAccessTemplateProps) {\n  const merged = { ...TEMPLATE_LABELS, ...labels };\n  const { state, error, submit, ready } = useKnockCodes({\n    ...config,\n    storage: remember === \"session\" ? \"sessionStorage\" : config.storage,\n  });\n  const [code, setCode] = useState(\"\");\n  const [shakeSeed, setShakeSeed] = useState(0);\n  const [showChildren, setShowChildren] = useState(false);\n  const locked = state !== \"unlocked\";\n  // Keeps the dialog open with a success message for a beat after unlocking\n  // instead of an instant swap to the now-unblurred `children`.\n  const celebrating = state === \"unlocked\" && !showChildren;\n  const showOverlay = locked || celebrating;\n\n  useEffect(() => {\n    if (error) setShakeSeed((seed) => seed + 1);\n  }, [error]);\n\n  useEffect(() => {\n    if (state !== \"unlocked\") {\n      setShowChildren(false);\n      return;\n    }\n    const timer = setTimeout(() => setShowChildren(true), 550);\n    return () => clearTimeout(timer);\n  }, [state]);\n\n  if (!ready) return null;\n\n  const handleSubmit = async () => {\n    if (!code || state === \"submitting\") return;\n    await submit(code);\n    setCode(\"\");\n  };\n\n  const content = (\n    <div className={cx(\"relative w-full\", fullPage ? \"min-h-[100dvh]\" : \"h-full\", className)}>\n      <div aria-hidden={showOverlay} inert={showOverlay || undefined} className={cx(showOverlay && \"pointer-events-none blur-sm select-none\")}>\n        {children}\n      </div>\n\n      {showOverlay && (\n        <div className=\"absolute inset-0 flex items-center justify-center bg-black/50 p-4 backdrop-blur-[1px]\">\n          <div\n            key={shakeSeed}\n            role=\"dialog\"\n            aria-modal=\"true\"\n            aria-label={merged.heading}\n            style={{ fontFamily: \"var(--ag-font, inherit)\" }}\n            className={cx(\n              \"w-full max-w-sm rounded-[var(--ag-radius,1rem)] bg-[var(--ag-card,#ffffff)] p-6 shadow-2xl dark:bg-[var(--ag-card-dark,#030712)]\",\n              shakeSeed > 0 && !celebrating && \"animate-[modal-access-shake_0.4s_ease-in-out]\"\n            )}\n          >\n            <style>{MODAL_ACCESS_SHAKE_KEYFRAMES}</style>\n            {celebrating ? (\n              <SuccessDialogBody />\n            ) : (\n              <>\n                {logo && <div className=\"mb-4\">{logo}</div>}\n\n                <h1 className=\"text-lg font-semibold text-gray-900 dark:text-gray-50\">{merged.heading}</h1>\n                <p className=\"mt-1 text-sm text-gray-500 dark:text-gray-400\">{merged.description}</p>\n\n                <div className=\"mt-4\">\n                  <PinInput\n                    value={code}\n                    onChange={setCode}\n                    onSubmit={() => void handleSubmit()}\n                    submitting={state === \"submitting\"}\n                    error={error}\n                    labels={labels}\n                    autoFocus={autoFocus}\n                  />\n                </div>\n\n                {(supportHref || onContactSupport) && (\n                  <div className=\"mt-3 text-center\">\n                    {onContactSupport ? (\n                      <button type=\"button\" onClick={onContactSupport} className=\"text-xs font-medium text-[var(--ag-primary,#2563eb)] hover:underline dark:text-[var(--ag-primary-dark,#60a5fa)]\">\n                        {merged.supportLabel}\n                      </button>\n                    ) : (\n                      <a href={supportHref} className=\"text-xs font-medium text-[var(--ag-primary,#2563eb)] hover:underline dark:text-[var(--ag-primary-dark,#60a5fa)]\">\n                        {merged.supportLabel}\n                      </a>\n                    )}\n                  </div>\n                )}\n              </>\n            )}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n\n  return theme === \"dark\" ? <div className=\"dark h-full w-full\">{content}</div> : content;\n}\n",
      "type": "registry:file",
      "target": "components/knock-codes/react/ModalAccessTemplate.tsx"
    }
  ],
  "type": "registry:block"
}