@flamingo-stack/openframe-frontend-core 0.0.686-2464.7467.1 → 0.0.686

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/{chunk-JTC4RPS3.js → chunk-2G2CB7ZZ.js} +5 -5
  2. package/dist/{chunk-QIYQMEP3.cjs → chunk-3DO76UAB.cjs} +20 -20
  3. package/dist/{chunk-QIYQMEP3.cjs.map → chunk-3DO76UAB.cjs.map} +1 -1
  4. package/dist/{chunk-S6C5GBQ4.js → chunk-3NIZ2VLQ.js} +3 -3
  5. package/dist/{chunk-P2AE46BQ.cjs → chunk-4LBCUBVL.cjs} +50 -50
  6. package/dist/{chunk-P2AE46BQ.cjs.map → chunk-4LBCUBVL.cjs.map} +1 -1
  7. package/dist/{chunk-U5LNGS5P.js → chunk-4RJQMOQW.js} +8 -8
  8. package/dist/{chunk-TV6YNAVH.cjs → chunk-AF4XOURN.cjs} +3 -3
  9. package/dist/{chunk-TV6YNAVH.cjs.map → chunk-AF4XOURN.cjs.map} +1 -1
  10. package/dist/{chunk-637VBBHC.js → chunk-ASBBGP36.js} +7 -7
  11. package/dist/{chunk-6MDNGGCJ.js → chunk-C5TFPMOM.js} +6 -6
  12. package/dist/{chunk-J5O5KIHL.cjs → chunk-GETQTNRV.cjs} +22 -22
  13. package/dist/{chunk-J5O5KIHL.cjs.map → chunk-GETQTNRV.cjs.map} +1 -1
  14. package/dist/{chunk-XML4CLL3.cjs → chunk-IBUFFKAK.cjs} +411 -411
  15. package/dist/{chunk-XML4CLL3.cjs.map → chunk-IBUFFKAK.cjs.map} +1 -1
  16. package/dist/{chunk-74TJJQMT.js → chunk-IELOPDTA.js} +3 -3
  17. package/dist/{chunk-RMNWN5RC.cjs → chunk-II5GVLTN.cjs} +5 -5
  18. package/dist/{chunk-RMNWN5RC.cjs.map → chunk-II5GVLTN.cjs.map} +1 -1
  19. package/dist/{chunk-D2XLLWIU.cjs → chunk-IJUQQTMV.cjs} +2 -4
  20. package/dist/chunk-IJUQQTMV.cjs.map +1 -0
  21. package/dist/{chunk-LMZW2KZF.js → chunk-JBJZL5PI.js} +4 -4
  22. package/dist/{chunk-WYB27NKC.js → chunk-JNUL3ZWK.js} +3 -5
  23. package/dist/chunk-JNUL3ZWK.js.map +1 -0
  24. package/dist/{chunk-JA6NYUF3.js → chunk-LCOMNQLX.js} +5 -5
  25. package/dist/{chunk-NRH4IHI4.js → chunk-MHTB67ZH.js} +4 -4
  26. package/dist/{chunk-HSWEUDKR.cjs → chunk-MZAZUSPQ.cjs} +10 -10
  27. package/dist/{chunk-HSWEUDKR.cjs.map → chunk-MZAZUSPQ.cjs.map} +1 -1
  28. package/dist/{chunk-IQDUMR3K.cjs → chunk-N3I53B5W.cjs} +5 -5
  29. package/dist/{chunk-IQDUMR3K.cjs.map → chunk-N3I53B5W.cjs.map} +1 -1
  30. package/dist/{chunk-3NBKFWHG.js → chunk-NINO3PI7.js} +3 -3
  31. package/dist/{chunk-CCLO5R5A.cjs → chunk-P4SBHO7U.cjs} +17 -17
  32. package/dist/{chunk-CCLO5R5A.cjs.map → chunk-P4SBHO7U.cjs.map} +1 -1
  33. package/dist/{chunk-BOIYZ2L6.js → chunk-PG65AE3I.js} +5 -5
  34. package/dist/{chunk-SFYBD456.js → chunk-QOH45QSE.js} +4 -4
  35. package/dist/{chunk-KBH7EWFW.cjs → chunk-QTXDXHMD.cjs} +26 -26
  36. package/dist/{chunk-KBH7EWFW.cjs.map → chunk-QTXDXHMD.cjs.map} +1 -1
  37. package/dist/{chunk-3XZHZ4LD.cjs → chunk-RBCMBRI7.cjs} +39 -39
  38. package/dist/{chunk-3XZHZ4LD.cjs.map → chunk-RBCMBRI7.cjs.map} +1 -1
  39. package/dist/{chunk-CJNYQ6FK.cjs → chunk-RKH7DEDF.cjs} +12 -12
  40. package/dist/{chunk-CJNYQ6FK.cjs.map → chunk-RKH7DEDF.cjs.map} +1 -1
  41. package/dist/{chunk-PLLY62XF.js → chunk-RSMAVO2Z.js} +2 -2
  42. package/dist/{chunk-SIAGFCKP.cjs → chunk-SI77HF4U.cjs} +53 -53
  43. package/dist/{chunk-SIAGFCKP.cjs.map → chunk-SI77HF4U.cjs.map} +1 -1
  44. package/dist/{chunk-NMFBC7CC.js → chunk-U6JC3RS2.js} +2 -2
  45. package/dist/{chunk-OWSFP7LX.cjs → chunk-UBXEETWQ.cjs} +80 -80
  46. package/dist/{chunk-OWSFP7LX.cjs.map → chunk-UBXEETWQ.cjs.map} +1 -1
  47. package/dist/{chunk-ZQDJPLRS.js → chunk-V5REIMSE.js} +2 -2
  48. package/dist/{chunk-VAHO2DTJ.js → chunk-WFH52OVQ.js} +6 -6
  49. package/dist/{chunk-GQ4FKLFC.cjs → chunk-WJX3ZSOE.cjs} +43 -43
  50. package/dist/{chunk-GQ4FKLFC.cjs.map → chunk-WJX3ZSOE.cjs.map} +1 -1
  51. package/dist/{chunk-IWJEJBML.cjs → chunk-XF5TJ4P2.cjs} +12 -12
  52. package/dist/{chunk-IWJEJBML.cjs.map → chunk-XF5TJ4P2.cjs.map} +1 -1
  53. package/dist/{chunk-IXLO6K3T.js → chunk-YPXXXX6R.js} +2 -2
  54. package/dist/{chunk-RHBJIFAJ.cjs → chunk-ZS3YCJYY.cjs} +7 -7
  55. package/dist/{chunk-RHBJIFAJ.cjs.map → chunk-ZS3YCJYY.cjs.map} +1 -1
  56. package/dist/components/case-studies/index.cjs +17 -17
  57. package/dist/components/case-studies/index.js +5 -5
  58. package/dist/components/chat/index.cjs +3 -3
  59. package/dist/components/chat/index.js +2 -2
  60. package/dist/components/contact/index.cjs +6 -6
  61. package/dist/components/contact/index.js +5 -5
  62. package/dist/components/docs/index.cjs +9 -9
  63. package/dist/components/docs/index.js +8 -8
  64. package/dist/components/embeds/index.cjs +6 -6
  65. package/dist/components/embeds/index.js +5 -5
  66. package/dist/components/faq/index.cjs +6 -6
  67. package/dist/components/faq/index.js +5 -5
  68. package/dist/components/features/index.cjs +5 -5
  69. package/dist/components/features/index.js +4 -4
  70. package/dist/components/help-center-pages/index.cjs +83 -83
  71. package/dist/components/help-center-pages/index.js +16 -16
  72. package/dist/components/index.cjs +46 -46
  73. package/dist/components/index.js +15 -15
  74. package/dist/components/meeting-scheduler/index.cjs +32 -32
  75. package/dist/components/meeting-scheduler/index.js +4 -4
  76. package/dist/components/navigation/index.cjs +8 -8
  77. package/dist/components/navigation/index.js +7 -7
  78. package/dist/components/onboarding-guides/index.cjs +8 -8
  79. package/dist/components/onboarding-guides/index.js +7 -7
  80. package/dist/components/tickets/index.cjs +9 -9
  81. package/dist/components/tickets/index.js +8 -8
  82. package/dist/components/tool-icon.d.ts.map +1 -1
  83. package/dist/components/ui/index.cjs +5 -5
  84. package/dist/components/ui/index.js +4 -4
  85. package/dist/hooks/index.cjs +3 -3
  86. package/dist/hooks/index.js +2 -2
  87. package/dist/index.cjs +10 -10
  88. package/dist/index.js +8 -8
  89. package/package.json +1 -1
  90. package/src/components/tool-icon.tsx +2 -4
  91. package/dist/chunk-D2XLLWIU.cjs.map +0 -1
  92. package/dist/chunk-WYB27NKC.js.map +0 -1
  93. /package/dist/{chunk-JTC4RPS3.js.map → chunk-2G2CB7ZZ.js.map} +0 -0
  94. /package/dist/{chunk-S6C5GBQ4.js.map → chunk-3NIZ2VLQ.js.map} +0 -0
  95. /package/dist/{chunk-U5LNGS5P.js.map → chunk-4RJQMOQW.js.map} +0 -0
  96. /package/dist/{chunk-637VBBHC.js.map → chunk-ASBBGP36.js.map} +0 -0
  97. /package/dist/{chunk-6MDNGGCJ.js.map → chunk-C5TFPMOM.js.map} +0 -0
  98. /package/dist/{chunk-74TJJQMT.js.map → chunk-IELOPDTA.js.map} +0 -0
  99. /package/dist/{chunk-LMZW2KZF.js.map → chunk-JBJZL5PI.js.map} +0 -0
  100. /package/dist/{chunk-JA6NYUF3.js.map → chunk-LCOMNQLX.js.map} +0 -0
  101. /package/dist/{chunk-NRH4IHI4.js.map → chunk-MHTB67ZH.js.map} +0 -0
  102. /package/dist/{chunk-3NBKFWHG.js.map → chunk-NINO3PI7.js.map} +0 -0
  103. /package/dist/{chunk-BOIYZ2L6.js.map → chunk-PG65AE3I.js.map} +0 -0
  104. /package/dist/{chunk-SFYBD456.js.map → chunk-QOH45QSE.js.map} +0 -0
  105. /package/dist/{chunk-PLLY62XF.js.map → chunk-RSMAVO2Z.js.map} +0 -0
  106. /package/dist/{chunk-NMFBC7CC.js.map → chunk-U6JC3RS2.js.map} +0 -0
  107. /package/dist/{chunk-ZQDJPLRS.js.map → chunk-V5REIMSE.js.map} +0 -0
  108. /package/dist/{chunk-VAHO2DTJ.js.map → chunk-WFH52OVQ.js.map} +0 -0
  109. /package/dist/{chunk-IXLO6K3T.js.map → chunk-YPXXXX6R.js.map} +0 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-IWJEJBML.cjs","../src/hooks/ui/use-auto-limit-tags.ts","../src/hooks/ui/use-deferred-error.ts","../src/hooks/ui/use-drag-and-drop-enabled.ts","../src/hooks/ui/use-focus-trap.ts","../src/hooks/ui/use-horizontal-scrollbar.ts","../src/hooks/ui/use-keyboard-collision-padding.ts","../src/hooks/ui/use-local-storage.ts","../src/utils/storage-event.ts","../src/hooks/ui/use-memoized-callback.ts","../src/hooks/ui/use-notification-permission.ts","../src/hooks/ui/use-onboarding-state.ts","../src/utils/onboarding-storage.ts","../src/hooks/ui/use-search.ts","../src/hooks/ui/use-table-pagination.ts","../src/hooks/ui/use-throttle.ts","../src/hooks/ui/use-window-size.ts","../src/hooks/ui/index.ts","../src/hooks/platform/use-platform-config.ts","../src/utils/platform-config.tsx","../src/hooks/use-toast.ts","../src/components/ui/toaster.tsx","../src/hooks/use-contact-submission.ts","../src/hooks/use-copy-to-clipboard.ts","../src/hooks/use-batch-images.ts","../src/hooks/use-authenticated-image.ts","../src/hooks/state/use-query-params.ts","../src/hooks/state/flatten-schema.ts","../src/hooks/state/graphql-parser.ts","../src/hooks/state/introspection.ts","../src/hooks/state/url-converter.ts","../src/hooks/state/use-api-params.ts","../src/hooks/state/use-cursor-pagination-state.ts","../src/hooks/nats/use-nats-client.ts","../src/hooks/use-access-code-integration.ts","../src/utils/access-code-client.ts","../src/hooks/use-claude-mirror-src.ts","../src/utils/embed-url-converters.ts","../src/hooks/use-scroll-to-hash.ts","../src/hooks/use-humanity-signals.ts","../src/utils/humanity-signals.ts","../src/hooks/use-form-rescue.ts","../src/utils/form-rescue.ts","../src/hooks/use-rescued-form.ts"],"names":["measure","useState","useEffect","useCallback","dismissOnboarding","markMultipleComplete","jsx","useMemo","isInputObjectType","clearParams"],"mappings":"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ACM,IAAA;AACZ,IAAA;AACqB,IAAA;AACQ,MAAA;AACA,QAAA;AACb,QAAA;AACgC,QAAA;AAE8C,QAAA;AAC5F,MAAA;AACF,IAAA;AACD,EAAA;AAEe,EAAA;AACmC,IAAA;AACoC,MAAA;AACpF,IAAA;AACqC,IAAA;AACtB,EAAA;AAEX,EAAA;AACT;A3Cq1GyF;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-IWJEJBML.cjs","sourcesContent":[null,"'use client';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport type { RefObject } from 'react';\n\nexport interface UseAutoLimitTagsOptions {\n /** Total number of tags */\n count: number;\n /** Fixed limit or \"auto\" for DOM-based measurement. Default \"auto\" */\n limitTags?: number | 'auto';\n /** Placeholder text used to reserve input width (only used in \"auto\" mode) */\n placeholder?: string;\n /**\n * Reserve trailing space for a text input (the autocomplete combobox). Set\n * `false` for a pure chip row that has no input — then only the chips and the\n * \"+N\" / \"⋯\" overflow badge compete for the available width. Default `true`.\n */\n reserveInputWidth?: boolean;\n}\n\nexport interface UseAutoLimitTagsReturn {\n /** How many tags to show */\n visibleCount: number;\n /** Ref for the zone that contains tags + input (must have overflow-hidden, gap, padding) */\n middleRef: RefObject<HTMLDivElement | null>;\n /** Ref for the off-screen container that holds measurement copies of ALL tags */\n measureRef: RefObject<HTMLDivElement | null>;\n /** Ref for the hidden span that measures placeholder text width */\n textMeasureRef: RefObject<HTMLSpanElement | null>;\n /** Ref for the \"+N\" badge element (used to measure its width) */\n badgeRef: RefObject<HTMLButtonElement | null>;\n /** Ref for the input element (used to read its min-width) */\n inputRef: RefObject<HTMLInputElement | null>;\n}\n\n/**\n * Calculates how many tags fit in a single-line container.\n *\n * Requires three off-screen measurement elements rendered by the consumer:\n * 1. A `<div ref={measureRef}>` containing Tag copies for every item (to measure widths)\n * 2. A `<span ref={textMeasureRef}>` containing the placeholder text (to reserve input width)\n * 3. The `<button ref={badgeRef}>` for the \"+N\" badge (to measure badge width)\n *\n * The hook reads real CSS values (padding, gap) from the middleRef container,\n * so it works regardless of responsive breakpoints or custom styling.\n */\nexport function useAutoLimitTags({\n count,\n limitTags = 'auto',\n placeholder = '',\n reserveInputWidth = true,\n}: UseAutoLimitTagsOptions): UseAutoLimitTagsReturn {\n const middleRef = useRef<HTMLDivElement>(null);\n const measureRef = useRef<HTMLDivElement>(null);\n const textMeasureRef = useRef<HTMLSpanElement>(null);\n const badgeRef = useRef<HTMLButtonElement>(null);\n const inputRef = useRef<HTMLInputElement>(null);\n // `null` = never measured (no container rendered, or a fixed `limitTags` that\n // needs no measurement). Kept distinct from `0` so the \"show everything\"\n // fallback below stays derivable during render instead of having to be\n // written into state from an effect.\n const [measuredCount, setMeasuredCount] = useState<number | null>(null);\n\n const recalculate = useCallback(() => {\n const middle = middleRef.current;\n const measure = measureRef.current;\n if (!middle || !measure) {\n setMeasuredCount(null);\n return;\n }\n if (count === 0) {\n setMeasuredCount(0);\n return;\n }\n\n // Read real CSS metrics from the middle zone\n const cs = getComputedStyle(middle);\n const padL = parseFloat(cs.paddingLeft) || 0;\n const padR = parseFloat(cs.paddingRight) || 0;\n const gap = parseFloat(cs.gap) || 0;\n const middleW = middle.clientWidth;\n\n // Reserve trailing space for the input (autocomplete only). A pure chip row\n // (`reserveInputWidth: false`) has no input, so nothing — and no gap before\n // it — is reserved.\n let inputReservedW = 0;\n let trailingGap = 0;\n if (reserveInputWidth) {\n const textW = textMeasureRef.current?.offsetWidth ?? 60;\n const inputMinW = inputRef.current ? parseFloat(getComputedStyle(inputRef.current).minWidth) || 60 : 60;\n inputReservedW = Math.max(textW + 8, inputMinW);\n trailingGap = gap;\n }\n\n // Available = middle zone − padding − input reserved − gap before input\n const available = middleW - padL - padR - inputReservedW - trailingGap;\n\n // Measure every tag from the off-screen container\n const tagEls = Array.from(measure.children) as HTMLElement[];\n const widths = tagEls.map(el => el.offsetWidth);\n\n // Fast check: do ALL tags fit?\n let total = 0;\n for (let i = 0; i < widths.length; i++) {\n total += widths[i] + (i > 0 ? gap : 0);\n }\n if (total <= available) {\n setMeasuredCount(count);\n return;\n }\n\n // Not all fit → reserve space for the \"+N\" badge\n const badgeW = badgeRef.current?.offsetWidth ?? 40;\n const spaceWithBadge = available - badgeW - gap;\n\n let used = 0;\n let fitCount = 0;\n for (let i = 0; i < widths.length; i++) {\n const need = widths[i] + (i > 0 ? gap : 0);\n if (used + need > spaceWithBadge) break;\n used += need;\n fitCount++;\n }\n\n setMeasuredCount(Math.max(0, fitCount));\n }, [count, placeholder, reserveInputWidth]);\n\n // ONE effect, not two. `ResizeObserver` delivers an initial notification as\n // soon as it starts observing, so `observe()` IS the mount-time measurement —\n // the separate `recalculate()` call this replaces was a synchronous setState\n // in an effect body, i.e. a guaranteed second render pass on every mount and\n // on every change of `count`. `recalculate`'s identity changes with `count` /\n // `placeholder` / `reserveInputWidth`, so a re-observe (and therefore a fresh\n // measurement) still happens for input changes that do not resize the box.\n useEffect(() => {\n if (limitTags !== 'auto') return undefined;\n const el = middleRef.current;\n if (!el) return undefined;\n const ro = new ResizeObserver(recalculate);\n ro.observe(el);\n return () => ro.disconnect();\n }, [recalculate, limitTags]);\n\n // A fixed limit is pure arithmetic on props and never needed the DOM at all.\n // The `Math.min(…, count)` clamp matters because the measurement lags a\n // shrinking `count` by one commit: unclamped, callers computing\n // `count - visibleCount` for the \"+N\" badge briefly get a NEGATIVE number.\n const visibleCount =\n limitTags !== 'auto' ? Math.min(limitTags, count) : measuredCount === null ? count : Math.min(measuredCount, count);\n\n return { visibleCount, middleRef, measureRef, textMeasureRef, badgeRef, inputRef };\n}\n","'use client';\n\nimport { useState, useEffect, useCallback } from 'react';\n\nexport interface UseDeferredErrorResult {\n /** The message to render (undefined while the reveal is deferred or the field is valid). */\n error: string | undefined;\n /** Attach to the field's onBlur — reveals validation immediately and keeps it live from then on. */\n onBlur: () => void;\n}\n\n/**\n * Defers showing a field validation message while the user is still typing.\n *\n * The message is revealed when the field loses focus (touched) or when the\n * value hasn't changed for `delay` ms; every keystroke restarts the timer.\n * Once touched, validation stays live. A message that clears (the value became\n * valid) disappears immediately.\n */\nexport function useDeferredError(error: string | undefined, value: string, delay = 1500): UseDeferredErrorResult {\n const [touched, setTouched] = useState(false);\n const [paused, setPaused] = useState(false);\n const [pausedFor, setPausedFor] = useState(value);\n\n // Typing hides the message: React's \"adjust state while rendering\" reset, not\n // an effect. The keystroke has already scheduled this render, so clearing\n // `paused` here costs nothing, whereas doing it from the effect below commits\n // a render with the stale message still on screen and then immediately\n // re-renders to remove it.\n if (pausedFor !== value) {\n setPausedFor(value);\n setPaused(false);\n }\n\n // …and restarts the pause timer.\n useEffect(() => {\n if (touched) return undefined;\n const timer = setTimeout(() => setPaused(true), delay);\n return () => clearTimeout(timer);\n }, [value, delay, touched]);\n\n const onBlur = useCallback(() => setTouched(true), []);\n\n return {\n error: error && (touched || paused) ? error : undefined,\n onBlur,\n };\n}\n","'use client';\n\nimport { breakpoints, useMediaQuery } from './use-media-query';\n\n/**\n * The one rule for whether drag-and-drop is initialized at all.\n *\n * Pointer type AND breakpoint, never either alone:\n * - A breakpoint alone fails on tablets: an iPad in landscape is 1024-1366px\n * wide and would pass any sane `min-width` — exactly the case being excluded,\n * because the HTML5 drag-and-drop API Pragmatic builds on does not fire under\n * touch.\n * - `pointer: coarse` alone fails the other way: a touchscreen laptop with a\n * trackpad reports `pointer: fine` and should keep DnD, but in a narrow\n * window its lists collapse to the mobile layout where a drag handle is out\n * of place.\n * - No `'ontouchstart' in window`, no UA sniffing: the first is true on touch\n * laptops, the second is neither accurate nor reactive. A media query\n * re-evaluates live when a mouse is attached or the window resizes.\n *\n * `breakpoints.md` (800px) is the existing tablet step — no new breakpoint.\n */\nexport const DRAG_AND_DROP_MEDIA_QUERY = `(pointer: fine) and (hover: hover) and ${breakpoints.md}`;\n\n/**\n * Whether drag-and-drop should be available on this device/viewport.\n *\n * `false` on touch/narrow screens — there the reordering surfaces render\n * explicit controls (`SortableMoveButtons`) instead of a drag handle, and no\n * DnD is initialized at all. Also `false` during SSR and the first client\n * render (`useMediaQuery` is still `undefined`): defaulting to \"off\" renders\n * no dead drag handle, and the underlying layout effect settles the real\n * answer before first paint, so a desktop user never sees the fallback flash.\n */\nexport function useDragAndDropEnabled(): boolean {\n return useMediaQuery(DRAG_AND_DROP_MEDIA_QUERY) ?? false;\n}\n","'use client';\n\nimport { useEffect, useRef, type RefObject } from 'react';\n\nconst FOCUSABLE_SELECTOR = [\n 'a[href]',\n 'button:not([disabled])',\n 'input:not([disabled])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(', ');\n\nexport interface UseFocusTrapOptions {\n /** Called when Escape is pressed while focus is inside the container.\n * When omitted, Escape passes through untouched so document-level\n * listeners still fire. */\n onEscape?: () => void;\n /**\n * When false, Tab/Shift+Tab are NOT cycled within the container — for\n * non-modal dialogs (e.g. SlidingSidebar, whose z-50 header stays clickable\n * above the open drawer). Escape / initial focus / guarded restore still apply.\n * @default true\n */\n contain?: boolean;\n}\n\n/**\n * Focus management for overlay surfaces (mobile nav panel, sliding sidebar,\n * burger menu, modals): initial focus on activate, container-scoped Tab\n * cycling + Escape, and guarded focus restore on deactivate.\n *\n * CONTRACT: the container element MUST have `tabIndex={-1}`. After a\n * click/tap on a non-focusable area inside the surface, browsers move focus\n * to the nearest focusable ancestor — with the container script-focusable\n * that ancestor is the container itself, so this keydown listener keeps\n * hearing Escape/Tab. Without it, focus falls to `body` and the trap goes\n * deaf.\n *\n * The listener being container-scoped makes it stacking-safe: it only fires\n * while focus is inside the surface, so a modal open above never leaks its\n * Escape into the surface below.\n */\nexport function useFocusTrap(\n containerRef: RefObject<HTMLElement | null>,\n active: boolean,\n { onEscape, contain = true }: UseFocusTrapOptions = {},\n): void {\n // Latest-ref so unstable inline callbacks never tear the trap down and\n // re-yank focus — same stable-callback discipline as `useCloseOnNavigation`.\n // Refreshed after every commit, declared before the trap effect so it wins\n // the same flush. Not in the render body: the reader is a keydown listener,\n // which cannot fire before a commit.\n const onEscapeRef = useRef(onEscape);\n useEffect(() => {\n onEscapeRef.current = onEscape;\n });\n\n useEffect(() => {\n if (!active) return undefined;\n const container = containerRef.current;\n if (!container) return undefined;\n\n const previouslyFocused = document.activeElement as HTMLElement | null;\n\n const focusables = () =>\n Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(\n el => el.getClientRects().length > 0,\n );\n\n const first = focusables()[0];\n (first ?? container).focus({ preventScroll: true });\n\n const handleKeyDown = (e: KeyboardEvent) => {\n if (e.key === 'Escape') {\n // No preventDefault/stopPropagation — pass-through by design.\n onEscapeRef.current?.();\n return;\n }\n if (e.key !== 'Tab' || !contain) return;\n const items = focusables();\n if (items.length === 0) {\n e.preventDefault();\n container.focus({ preventScroll: true });\n return;\n }\n const firstEl = items[0];\n const lastEl = items[items.length - 1];\n const current = document.activeElement;\n if (e.shiftKey && (current === firstEl || current === container)) {\n e.preventDefault();\n lastEl.focus({ preventScroll: true });\n } else if (!e.shiftKey && current === lastEl) {\n e.preventDefault();\n firstEl.focus({ preventScroll: true });\n }\n };\n\n container.addEventListener('keydown', handleKeyDown);\n\n return () => {\n container.removeEventListener('keydown', handleKeyDown);\n // Guarded restore: only when focus is still inside the surface (or fell\n // to body because the surface unmounted) — never steal focus from a\n // dialog that opened meanwhile (e.g. the nav-footer → SSO modal flow).\n const current = document.activeElement;\n if (\n previouslyFocused &&\n previouslyFocused.isConnected &&\n (current === null || current === document.body || container.contains(current))\n ) {\n previouslyFocused.focus({ preventScroll: true });\n }\n };\n }, [containerRef, active, contain]);\n}\n","'use client';\n\nimport { useRef, useState, useCallback } from 'react';\nimport type { MouseEvent, PointerEvent, WheelEvent } from 'react';\n\n/**\n * Custom horizontal scrollbar hook that drives a plain-DOM scrollbar.\n *\n * Returns a callback ref (`scrollRef`) to attach to the scrollable container,\n * plus refs, state, and event handlers for the track + thumb.\n *\n * Uses a **callback ref** so measurement and ResizeObserver setup happen\n * automatically when the scroll element mounts — even if it renders later\n * (e.g. after async data loads).\n */\nexport function useHorizontalScrollbar() {\n const scrollElRef = useRef<HTMLDivElement | null>(null);\n const trackRef = useRef<HTMLDivElement>(null);\n const thumbRef = useRef<HTMLDivElement>(null);\n const roRef = useRef<ResizeObserver | null>(null);\n const moRef = useRef<MutationObserver | null>(null);\n const [thumbRatio, setThumbRatio] = useState(0);\n\n // Edge-fade state\n const [canScrollLeft, setCanScrollLeft] = useState(false);\n const [canScrollRight, setCanScrollRight] = useState(false);\n const prevCanScrollLeftRef = useRef(false);\n const prevCanScrollRightRef = useRef(false);\n\n const isDraggingRef = useRef(false);\n const dragStartRef = useRef({ mouseX: 0, scrollLeft: 0 });\n const rafIdRef = useRef<number>(0);\n\n /** Compute the thumb left% from current scrollLeft and apply to DOM directly */\n const syncThumbToDOM = useCallback(() => {\n const el = scrollElRef.current;\n const thumb = thumbRef.current;\n if (!el || !thumb) return;\n\n const maxScroll = el.scrollWidth - el.clientWidth;\n if (maxScroll <= 0) {\n thumb.style.left = '0%';\n return;\n }\n\n const ratio = el.clientWidth / el.scrollWidth;\n const fraction = Math.min(Math.max(el.scrollLeft, 0), maxScroll) / maxScroll;\n thumb.style.left = `${fraction * (1 - ratio) * 100}%`;\n }, []);\n\n const syncEdgeFades = useCallback(() => {\n const el = scrollElRef.current;\n if (!el) return;\n\n const maxScroll = el.scrollWidth - el.clientWidth;\n const ratio = el.clientWidth / el.scrollWidth;\n if (ratio >= 1 || maxScroll <= 0) {\n if (prevCanScrollLeftRef.current) {\n prevCanScrollLeftRef.current = false;\n setCanScrollLeft(false);\n }\n if (prevCanScrollRightRef.current) {\n prevCanScrollRightRef.current = false;\n setCanScrollRight(false);\n }\n return;\n }\n\n const fraction = Math.min(Math.max(el.scrollLeft, 0), maxScroll) / maxScroll;\n const left = fraction > 0.001;\n const right = fraction < 0.999;\n\n if (left !== prevCanScrollLeftRef.current) {\n prevCanScrollLeftRef.current = left;\n setCanScrollLeft(left);\n }\n if (right !== prevCanScrollRightRef.current) {\n prevCanScrollRightRef.current = right;\n setCanScrollRight(right);\n }\n }, []);\n\n const measure = useCallback(() => {\n const el = scrollElRef.current;\n if (!el) return;\n const ratio = el.clientWidth / el.scrollWidth;\n setThumbRatio(ratio >= 1 ? 0 : ratio);\n syncThumbToDOM();\n syncEdgeFades();\n }, [syncThumbToDOM, syncEdgeFades]);\n\n // Callback ref\n const scrollRef = useCallback(\n (node: HTMLDivElement | null) => {\n // Teardown previous\n if (roRef.current) {\n roRef.current.disconnect();\n roRef.current = null;\n }\n if (moRef.current) {\n moRef.current.disconnect();\n moRef.current = null;\n }\n if (rafIdRef.current) {\n cancelAnimationFrame(rafIdRef.current);\n rafIdRef.current = 0;\n }\n\n scrollElRef.current = node;\n\n if (node) {\n const ratio = node.clientWidth / node.scrollWidth;\n setThumbRatio(ratio >= 1 ? 0 : ratio);\n\n const ro = new ResizeObserver(measure);\n roRef.current = ro;\n const observeAll = () => {\n ro.disconnect();\n ro.observe(node);\n for (const child of Array.from(node.children)) ro.observe(child);\n };\n observeAll();\n\n moRef.current = new MutationObserver(() => {\n observeAll();\n measure();\n });\n moRef.current.observe(node, { childList: true });\n\n requestAnimationFrame(() => {\n syncThumbToDOM();\n syncEdgeFades();\n });\n } else {\n setThumbRatio(0);\n prevCanScrollLeftRef.current = false;\n prevCanScrollRightRef.current = false;\n setCanScrollLeft(false);\n setCanScrollRight(false);\n }\n },\n [measure, syncThumbToDOM, syncEdgeFades],\n );\n\n // RAF-throttled scroll handler\n const onScroll = useCallback(() => {\n if (isDraggingRef.current) return;\n\n if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);\n rafIdRef.current = requestAnimationFrame(() => {\n syncThumbToDOM();\n syncEdgeFades();\n });\n }, [syncThumbToDOM, syncEdgeFades]);\n\n // Track click — read ratio from DOM, smooth scroll\n const onTrackClick = useCallback((e: MouseEvent) => {\n if ((e.target as HTMLElement).closest('[data-scrollbar-thumb]')) return;\n\n const track = trackRef.current;\n const el = scrollElRef.current;\n if (!track || !el) return;\n\n const ratio = el.clientWidth / el.scrollWidth;\n if (ratio >= 1) return;\n\n const rect = track.getBoundingClientRect();\n const thumbWidth = rect.width * ratio;\n const maxThumbOffset = rect.width - thumbWidth;\n if (maxThumbOffset <= 0) return;\n\n const targetFraction = Math.max(0, Math.min(1, (e.clientX - rect.left - thumbWidth / 2) / maxThumbOffset));\n const targetScrollLeft = targetFraction * (el.scrollWidth - el.clientWidth);\n el.scrollTo({ left: targetScrollLeft, behavior: 'smooth' });\n }, []);\n\n // Wheel on track → forward to scroll container\n const onTrackWheel = useCallback((e: WheelEvent) => {\n const el = scrollElRef.current;\n if (!el) return;\n e.preventDefault();\n el.scrollLeft += e.deltaX || e.deltaY;\n }, []);\n\n // Drag: pointer down\n const onThumbPointerDown = useCallback((e: PointerEvent) => {\n e.preventDefault();\n e.stopPropagation();\n const el = scrollElRef.current;\n if (!el) return;\n isDraggingRef.current = true;\n dragStartRef.current = { mouseX: e.clientX, scrollLeft: el.scrollLeft };\n const target = e.currentTarget as HTMLElement;\n target.setPointerCapture(e.pointerId);\n target.style.cursor = 'grabbing';\n }, []);\n\n // Drag: pointer move\n const onThumbPointerMove = useCallback(\n (e: PointerEvent) => {\n if (!isDraggingRef.current) return;\n const el = scrollElRef.current;\n const track = trackRef.current;\n if (!el || !track) return;\n\n const currentThumbRatio = el.clientWidth / el.scrollWidth;\n if (currentThumbRatio >= 1) return;\n\n const trackWidth = track.clientWidth;\n const thumbWidth = trackWidth * currentThumbRatio;\n const maxThumbTravel = trackWidth - thumbWidth;\n if (maxThumbTravel <= 0) return;\n\n const mouseDelta = e.clientX - dragStartRef.current.mouseX;\n const scrollRange = el.scrollWidth - el.clientWidth;\n el.scrollLeft = Math.min(\n Math.max(dragStartRef.current.scrollLeft + (mouseDelta / maxThumbTravel) * scrollRange, 0),\n scrollRange,\n );\n\n syncThumbToDOM();\n syncEdgeFades();\n },\n [syncThumbToDOM, syncEdgeFades],\n );\n\n // Drag: pointer up\n const onThumbPointerUp = useCallback((e: PointerEvent) => {\n isDraggingRef.current = false;\n const target = e.currentTarget as HTMLElement;\n target.releasePointerCapture(e.pointerId);\n target.style.cursor = 'grab';\n }, []);\n\n // Drag: pointer cancel. The browser takes the pointer away mid-drag (a touch\n // that turned into a pan, a palm rejection) and no pointerup ever follows —\n // left as dragging, `onScroll` would skip every sync from then on and the\n // thumb would freeze where it was. Capture is released implicitly right\n // after pointercancel, so there is nothing to release here.\n const onThumbPointerCancel = useCallback((e: PointerEvent) => {\n isDraggingRef.current = false;\n const target = e.currentTarget as HTMLElement;\n target.style.cursor = 'grab';\n }, []);\n\n return {\n scrollRef,\n trackRef,\n thumbRef,\n thumbRatio,\n canScrollLeft,\n canScrollRight,\n onScroll,\n onTrackClick,\n onTrackWheel,\n onThumbPointerDown,\n onThumbPointerMove,\n onThumbPointerUp,\n onThumbPointerCancel,\n };\n}\n","'use client';\n\nimport { useSyncExternalStore } from 'react';\n\n/**\n * Extra `collisionPadding.bottom` (px) a Radix popper needs to clear the\n * software keyboard. 0 on desktop and anywhere the keyboard is down.\n *\n * Radix positions every popper with floating-ui at `strategy: 'fixed'`, and\n * floating-ui takes its collision viewport from `window.visualViewport`.\n * Neither mobile shell shrinks the box a keyboard opens into: iOS runs\n * Capacitor's Keyboard plugin at `resize: 'none'`, which leaves the WKWebView\n * frame alone, and Android's window is edge-to-edge, where the IME is only a\n * WindowInsets type. So `flip`/`shift`/`size` all believe the screen still\n * reaches the bottom, and a dropdown whose own search field just raised the\n * keyboard is placed straight behind it — worst inside modals, which sit near\n * the bottom of the screen by design.\n *\n * The shell publishes the keyboard height as `--of-keyboard-inset` on <html>\n * (openframe-frontend's `keyboard-inset.ts`; unset everywhere else, hence the\n * 0 default). Handing the part floating-ui does not already know about back as\n * collision padding is what makes the top of the keyboard the bottom edge of\n * the screen.\n *\n * ```tsx\n * const keyboardPadding = useKeyboardCollisionPadding()\n * <PopoverPrimitive.Content collisionPadding={{ bottom: keyboardPadding }} />\n * ```\n *\n * Pair it with `max-h-[var(--radix-popper-available-height)]` on the content:\n * padding alone flips a dropdown above its trigger, but a list taller than what\n * is left above the keyboard would then spill back over it.\n */\n\nconst KEYBOARD_INSET_VAR = '--of-keyboard-inset';\n\nfunction measure(): number {\n if (typeof document === 'undefined') return 0;\n const inset = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue(KEYBOARD_INSET_VAR));\n // Also catches the NaN of an unset variable.\n if (!(inset > 0)) return 0;\n // How far floating-ui's own viewport bottom sits below the top of the\n // keyboard. iOS reports the keyboard on the visual viewport and Android does\n // not; measuring the difference rather than branching on the platform keeps\n // both correct and can never reserve the same keyboard twice.\n const layoutHeight = document.documentElement.clientHeight;\n const viewport = window.visualViewport;\n const viewportBottom = viewport ? viewport.offsetTop + viewport.height : layoutHeight;\n return Math.max(0, Math.round(viewportBottom - (layoutHeight - inset)));\n}\n\n// One document-wide subscription for every popper on the page: the keyboard is\n// a single global, and each open dropdown re-reading it would mean another\n// MutationObserver plus a forced style resolve per keystroke-driven re-render.\nconst listeners = new Set<() => void>();\nlet padding = 0;\nlet teardown: (() => void) | null = null;\n\nfunction sync(): void {\n const next = measure();\n if (next === padding) return;\n padding = next;\n for (const notify of listeners) notify();\n}\n\nfunction subscribe(onStoreChange: () => void): () => void {\n listeners.add(onStoreChange);\n if (listeners.size === 1) {\n padding = measure();\n // Two independent signals. The inset lands on <html>'s inline style, so the\n // attribute is the only notification for it; the visual viewport moves on\n // its own schedule (iOS reports the keyboard there, and a pinch-zoom shifts\n // it with no keyboard involved at all) and changes what is left to reserve.\n const observer = new MutationObserver(sync);\n observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });\n const viewport = window.visualViewport;\n viewport?.addEventListener('resize', sync);\n viewport?.addEventListener('scroll', sync);\n teardown = () => {\n observer.disconnect();\n viewport?.removeEventListener('resize', sync);\n viewport?.removeEventListener('scroll', sync);\n };\n }\n return () => {\n listeners.delete(onStoreChange);\n if (listeners.size > 0) return;\n teardown?.();\n teardown = null;\n };\n}\n\nexport function useKeyboardCollisionPadding(): number {\n return useSyncExternalStore(\n subscribe,\n () => padding,\n () => 0,\n );\n}\n","'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\n\nimport { createLocalStorageAdapter } from '../../utils/local-storage-adapter';\nimport { readLocalStorageUpdateDetail } from '../../utils/storage-event';\n\nexport interface UseLocalStorageOptions<T> {\n /**\n * Runtime shape check applied to every value decoded from storage — the\n * first read and each cross-tab update. Returning false discards the stored\n * value and falls back to `initialValue`.\n *\n * Without it the stored JSON is trusted to be a `T`, which only holds while\n * nothing else writes the key and the persisted shape never changes. Pass a\n * guard for anything whose shape has versions.\n *\n * Keep the identity stable (module scope or `useCallback`) — an inline\n * predicate re-subscribes the cross-tab listeners on every render.\n */\n validate?: (parsed: unknown) => parsed is T;\n}\n\n/**\n * Hook to use localStorage with state\n * @param key - localStorage key\n * @param initialValue - Initial value if key doesn't exist\n * @param options - Optional `validate` guard for the stored payload\n * @returns [storedValue, setValue] tuple\n */\nexport function useLocalStorage<T>(\n key: string,\n initialValue: T,\n options?: UseLocalStorageOptions<T>,\n): [T, (value: T | ((val: T) => T)) => void] {\n // Latest `initialValue` for the \"key was cleared elsewhere\" reset path. It\n // cannot be an effect dependency: the listeners register once per `key`, and\n // any caller passing an object or array literal would re-subscribe on every\n // render.\n const initialValueRef = useRef(initialValue);\n useEffect(() => {\n initialValueRef.current = initialValue;\n });\n\n // Single decode path — SSR guard, JSON parse, validation and the quota-safe\n // write all live in the adapter, so nothing here re-implements them.\n const validate = options?.validate;\n const storage = useMemo(\n () => createLocalStorageAdapter<T>({ key, validate, logTag: `[useLocalStorage:${key}]` }),\n [key, validate],\n );\n\n // Lazy initialization reads from localStorage synchronously on first render.\n const [storedValue, setStoredValue] = useState<T>(() => storage.load() ?? initialValue);\n\n // Use a ref to track if we've initialized from localStorage\n const isInitialized = useRef(true); // Set to true since we initialize in useState\n // Use a ref to track if the current state change came from a storage event\n const isFromStorageEvent = useRef(false);\n\n // Listen for storage events to sync with other tabs/components\n useEffect(() => {\n if (!isInitialized.current) return undefined;\n\n // Both events only say \"this key changed\"; the value is read back through\n // the adapter so the payload is decoded and validated in exactly one place.\n // A removed key reads back as null and resets to `initialValue`, whichever\n // event reported it.\n const syncFromStorage = (source: string) => {\n const next = storage.load();\n isFromStorageEvent.current = true;\n setStoredValue(next ?? initialValueRef.current);\n console.log(`🔄 ${next === null ? 'Cleared' : 'Updated'} localStorage key \"${key}\" from ${source}`);\n };\n\n const handleStorageChange = (e: StorageEvent) => {\n if (e.key !== key) return;\n syncFromStorage('storage event');\n };\n\n const handleCustomStorageUpdate = (event: Event) => {\n const detail = readLocalStorageUpdateDetail(event);\n if (detail?.key !== key) return;\n syncFromStorage('custom storage event');\n };\n\n window.addEventListener('storage', handleStorageChange);\n window.addEventListener('localStorageUpdate', handleCustomStorageUpdate);\n\n return () => {\n window.removeEventListener('storage', handleStorageChange);\n window.removeEventListener('localStorageUpdate', handleCustomStorageUpdate);\n };\n }, [key, storage]);\n\n // Update localStorage when state changes, but only after initialization and NOT from storage events\n useEffect(() => {\n if (!isInitialized.current) return;\n\n // Skip localStorage write if this state change came from a storage event\n if (isFromStorageEvent.current) {\n isFromStorageEvent.current = false;\n return;\n }\n\n storage.save(storedValue);\n }, [storage, storedValue]);\n\n // Return a wrapped version of useState's setter function that\n // persists the new value to localStorage\n const setValue = (value: T | ((val: T) => T)) => {\n try {\n // Allow value to be a function so we have same API as useState\n const valueToStore = value instanceof Function ? value(storedValue) : value;\n // Save state\n setStoredValue(valueToStore);\n } catch (error) {\n console.error(`Error setting value for localStorage key \"${key}\":`, error);\n }\n };\n\n return [storedValue, setValue];\n}\n","/**\n * The `localStorageUpdate` CustomEvent contract.\n *\n * `CustomEvent.detail` is typed `any`, and `addEventListener` types a\n * non-standard event name as a bare `Event` — so every listener used to\n * re-derive the payload shape by hand off an `any`. The decode lives here\n * once: an event dispatched by anything on the page is untrusted input, so it\n * is validated rather than asserted.\n *\n * Dispatchers: `saveOnboardingState` (utils/onboarding-storage.ts).\n * Listeners: `useLocalStorage`, `useOnboardingState`.\n */\n\nexport interface LocalStorageUpdateDetail {\n /** Storage key that changed. */\n key: string;\n /** Serialized new value; `null` when the key was removed or cleared. */\n newValue: string | null;\n}\n\n/**\n * Decode a `localStorageUpdate` event payload.\n *\n * Returns `null` — never throws — when the event is not a CustomEvent, carries\n * no object detail, or the detail does not match the contract above. A missing\n * or `undefined` `newValue` is normalized to `null`, which is the \"key was\n * removed\" signal.\n */\nexport function readLocalStorageUpdateDetail(event: Event): LocalStorageUpdateDetail | null {\n if (!('detail' in event)) return null;\n\n const detail: unknown = event.detail;\n if (typeof detail !== 'object' || detail === null) return null;\n if (!('key' in detail) || typeof detail.key !== 'string') return null;\n\n const newValue: unknown = 'newValue' in detail ? detail.newValue : null;\n if (newValue !== null && newValue !== undefined && typeof newValue !== 'string') return null;\n\n return { key: detail.key, newValue: newValue ?? null };\n}\n","'use client';\n\nimport { useCallback, useEffect, useRef } from 'react';\n\n/**\n * A callback whose IDENTITY never changes and whose BODY is always the latest\n * one you passed — the \"latest ref\" pattern.\n *\n * Rewritten 2026-08-25. The previous implementation was broken in three ways\n * that only showed up once the React Compiler rules were switched on:\n *\n * 1. It wrote `callbackRef.current` and `dependenciesRef.current` DURING\n * RENDER. A render that React throws away (Strict Mode, a suspended\n * transition, a re-render that never commits) still left those writes\n * behind, so the \"memoized\" callback could be one from a discarded render.\n * 2. Its dependency array was `[depsChanged]` — a BOOLEAN. That flips true\n * on the render where deps change and false on the next one, so the\n * returned identity changed TWICE per dependency change and was never\n * stable, which is the one thing the hook exists to provide.\n * 3. The doc promised \"deep comparison\"; it compared with `Object.is`.\n *\n * The signature is unchanged, so no call site has to move. `dependencies` is\n * now ignored: the returned function always calls the newest callback, which\n * is what every reader of \"stable callback reference\" expected it to do. If\n * you actually WANT the identity to change with deps, use `useCallback`\n * directly — that is what it is for.\n */\nexport function useMemoizedCallback<T extends (...args: never[]) => unknown>(\n callback: T,\n _dependencies?: readonly unknown[],\n): T {\n const callbackRef = useRef(callback);\n\n // In an effect, not during render: this runs only for renders that commit.\n useEffect(() => {\n callbackRef.current = callback;\n });\n\n const stable = useCallback((...args: Parameters<T>) => callbackRef.current(...args), []);\n return stable as T;\n}\n","'use client';\n\nimport { useCallback, useEffect, useState } from 'react';\n\nexport interface UseNotificationPermissionResult {\n /** Whether the page-context Notification API exists in this browser (false during SSR and on iOS Safari). */\n supported: boolean;\n permission: NotificationPermission;\n /** Prompt the user for permission. Must be called from a user gesture or browsers will auto-deny. */\n request: () => Promise<NotificationPermission>;\n}\n\n/**\n * Tracks the browser's desktop-notification permission, staying in sync when\n * the user changes the site setting externally (Permissions API change event\n * where available, visibility/focus re-read as the Safari fallback).\n */\nexport function useNotificationPermission(): UseNotificationPermissionResult {\n const [supported, setSupported] = useState(false);\n const [permission, setPermission] = useState<NotificationPermission>('default');\n\n useEffect(() => {\n if (typeof window === 'undefined' || !('Notification' in window)) return undefined;\n // Both values are reads of an external system (the browser's permission\n // store), so they belong in the same sync callback rather than as a\n // separate mount-time setState: `supported` cannot be computed during\n // render without a hydration mismatch, and folding it into `sync` means the\n // initial read costs one render instead of two. Re-syncs are free — React\n // bails out when the next value is identical, which it is for every\n // visibility/focus event that did not actually change the permission.\n const sync = () => {\n setSupported(true);\n setPermission(Notification.permission);\n };\n sync();\n\n // The query resolves async; without the flag it would attach the listener after unmount.\n let cancelled = false;\n let status: PermissionStatus | undefined;\n navigator.permissions\n ?.query({ name: 'notifications' })\n .then(s => {\n if (cancelled) return;\n status = s;\n s.addEventListener('change', sync);\n })\n .catch(() => undefined);\n\n document.addEventListener('visibilitychange', sync);\n window.addEventListener('focus', sync);\n return () => {\n cancelled = true;\n status?.removeEventListener('change', sync);\n document.removeEventListener('visibilitychange', sync);\n window.removeEventListener('focus', sync);\n };\n }, []);\n\n const request = useCallback(async () => {\n if (typeof window === 'undefined' || !('Notification' in window)) {\n return 'denied';\n }\n // Older Safari only implements the callback form; resolving from both is harmless.\n // The DOM lib types `requestPermission` as always returning a promise, so testing\n // the value itself is a conditional on something the types call always-truthy —\n // the thenable probe is the check that actually means something at runtime, since\n // the callback-only implementations return undefined here.\n const result = await new Promise<NotificationPermission>(resolve => {\n const maybePromise = Notification.requestPermission(resolve);\n if (typeof maybePromise?.then === 'function') {\n // A rejection here would otherwise leave this promise forever pending, and\n // `request()` never settles: the caller's permission toggle silently stays\n // off. Fall back to whatever the browser currently reports.\n void maybePromise.then(resolve, () => resolve(Notification.permission));\n }\n });\n setPermission(Notification.permission);\n return result;\n }, []);\n\n return { supported, permission, request };\n}\n","'use client';\n\nimport { useState, useEffect, useCallback } from 'react';\nimport type { ReactNode } from 'react';\nimport {\n loadOnboardingState,\n markStepComplete as storageMarkComplete,\n markStepSkipped as storageMarkSkipped,\n dismissOnboarding as storageDismiss,\n markMultipleComplete as storageMarkMultiple,\n resetOnboardingState as storageReset,\n type OnboardingState,\n} from '../../utils/onboarding-storage';\nimport { readLocalStorageUpdateDetail } from '../../utils/storage-event';\n\nexport interface OnboardingStepConfig {\n id: string;\n title: string;\n description: string;\n actionIcon: (color?: string) => ReactNode;\n actionText: string;\n completedText: string;\n onAction: () => void | Promise<void>;\n onSkip?: () => void;\n checkComplete?: () => boolean | Promise<boolean>;\n}\n\nexport type { OnboardingState };\n\n/**\n * Hook for managing onboarding state with localStorage persistence\n * Uses simple storage utilities for atomic updates and reliable re-renders\n */\nexport function useOnboardingState(storageKey: string = 'openframe-onboarding-state') {\n const [state, setState] = useState<OnboardingState>(() => loadOnboardingState(storageKey));\n const [, forceUpdate] = useState(0);\n\n // Listen for storage changes from other tabs\n useEffect(() => {\n const handleStorageUpdate = (event: Event) => {\n const detail = readLocalStorageUpdateDetail(event);\n if (detail?.key !== storageKey) return;\n const newState = loadOnboardingState(storageKey);\n setState(newState);\n forceUpdate(prev => prev + 1);\n };\n\n window.addEventListener('localStorageUpdate', handleStorageUpdate);\n return () => {\n window.removeEventListener('localStorageUpdate', handleStorageUpdate);\n };\n }, [storageKey]);\n\n const markComplete = useCallback(\n (stepId: string) => {\n const newState = storageMarkComplete(storageKey, stepId);\n setState(newState);\n forceUpdate(prev => prev + 1);\n },\n [storageKey],\n );\n\n const markSkipped = useCallback(\n (stepId: string) => {\n const newState = storageMarkSkipped(storageKey, stepId);\n setState(newState);\n forceUpdate(prev => prev + 1);\n },\n [storageKey],\n );\n\n const dismissOnboarding = useCallback(() => {\n const newState = storageDismiss(storageKey);\n setState(newState);\n forceUpdate(prev => prev + 1);\n }, [storageKey]);\n\n const resetOnboarding = useCallback(() => {\n const newState = storageReset(storageKey);\n setState(newState);\n forceUpdate(prev => prev + 1);\n }, [storageKey]);\n\n const markMultipleComplete = useCallback(\n (stepIds: string[]) => {\n const newState = storageMarkMultiple(storageKey, stepIds);\n setState(newState);\n forceUpdate(prev => prev + 1);\n },\n [storageKey],\n );\n\n const isStepComplete = useCallback(\n (stepId: string): boolean => {\n return state.completedSteps.includes(stepId);\n },\n [state.completedSteps],\n );\n\n const isStepSkipped = useCallback(\n (stepId: string): boolean => {\n return state.skippedSteps.includes(stepId);\n },\n [state.skippedSteps],\n );\n\n const allStepsComplete = useCallback(\n (steps: OnboardingStepConfig[]): boolean => {\n return steps.every(step => isStepComplete(step.id) || isStepSkipped(step.id));\n },\n [isStepComplete, isStepSkipped],\n );\n\n return {\n state,\n markComplete,\n markSkipped,\n dismissOnboarding,\n resetOnboarding,\n isStepComplete,\n isStepSkipped,\n allStepsComplete,\n markMultipleComplete,\n };\n}\n","/**\n * Onboarding state storage utilities\n * Simple localStorage wrapper following announcement-storage.ts pattern\n * Provides atomic writes to prevent race conditions\n */\n\nexport interface OnboardingState {\n completedSteps: string[];\n skippedSteps: string[];\n dismissed: boolean;\n lastUpdated: string;\n}\n\nconst DEFAULT_STATE: OnboardingState = {\n completedSteps: [],\n skippedSteps: [],\n dismissed: false,\n lastUpdated: new Date().toISOString(),\n};\n\n/**\n * Save onboarding state to localStorage (atomic write)\n */\nexport function saveOnboardingState(key: string, state: OnboardingState): void {\n if (typeof window === 'undefined') return;\n\n try {\n localStorage.setItem(key, JSON.stringify(state));\n\n // Dispatch custom event for cross-tab sync\n if (typeof window !== 'undefined') {\n window.dispatchEvent(\n new CustomEvent('localStorageUpdate', {\n detail: { key, newValue: JSON.stringify(state) },\n }),\n );\n }\n } catch (err) {\n console.warn('[onboarding-storage] Failed saving to localStorage:', err);\n }\n}\n\n/**\n * Load onboarding state from localStorage\n */\nexport function loadOnboardingState(key: string): OnboardingState {\n if (typeof window === 'undefined') return DEFAULT_STATE;\n\n try {\n const raw = localStorage.getItem(key);\n if (!raw) return DEFAULT_STATE;\n\n const parsed = JSON.parse(raw) as OnboardingState;\n return parsed;\n } catch (err) {\n console.warn('[onboarding-storage] Failed parsing localStorage data:', err);\n return DEFAULT_STATE;\n }\n}\n\n/**\n * Mark multiple steps as complete in a single atomic update\n */\nexport function markMultipleComplete(key: string, stepIds: string[]): OnboardingState {\n const state = loadOnboardingState(key);\n const newState: OnboardingState = {\n ...state,\n completedSteps: [...new Set([...state.completedSteps, ...stepIds])],\n skippedSteps: state.skippedSteps.filter(id => !stepIds.includes(id)),\n lastUpdated: new Date().toISOString(),\n };\n saveOnboardingState(key, newState);\n return newState;\n}\n\n/**\n * Mark a single step as complete\n */\nexport function markStepComplete(key: string, stepId: string): OnboardingState {\n return markMultipleComplete(key, [stepId]);\n}\n\n/**\n * Mark a step as skipped\n */\nexport function markStepSkipped(key: string, stepId: string): OnboardingState {\n const state = loadOnboardingState(key);\n const newState: OnboardingState = {\n ...state,\n skippedSteps: [...new Set([...state.skippedSteps, stepId])],\n completedSteps: state.completedSteps.filter(id => id !== stepId),\n lastUpdated: new Date().toISOString(),\n };\n saveOnboardingState(key, newState);\n return newState;\n}\n\n/**\n * Dismiss the onboarding walkthrough\n */\nexport function dismissOnboarding(key: string): OnboardingState {\n const state = loadOnboardingState(key);\n const newState: OnboardingState = {\n ...state,\n dismissed: true,\n lastUpdated: new Date().toISOString(),\n };\n saveOnboardingState(key, newState);\n return newState;\n}\n\n/**\n * Reset the walkthrough to its initial state: no step completed or skipped,\n * not dismissed. How a dismissed walkthrough is brought back.\n */\nexport function resetOnboardingState(key: string): OnboardingState {\n const newState: OnboardingState = {\n completedSteps: [],\n skippedSteps: [],\n dismissed: false,\n lastUpdated: new Date().toISOString(),\n };\n saveOnboardingState(key, newState);\n return newState;\n}\n\n/**\n * Check if a step is complete\n */\nexport function isStepComplete(key: string, stepId: string): boolean {\n const state = loadOnboardingState(key);\n return state.completedSteps.includes(stepId);\n}\n\n/**\n * Check if a step is skipped\n */\nexport function isStepSkipped(key: string, stepId: string): boolean {\n const state = loadOnboardingState(key);\n return state.skippedSteps.includes(stepId);\n}\n\n/**\n * Check if onboarding is dismissed\n */\nexport function isOnboardingDismissed(key: string): boolean {\n const state = loadOnboardingState(key);\n return state.dismissed;\n}\n","'use client';\n\nimport { useState, useEffect, useCallback, useRef } from 'react';\nimport type { SearchResult } from '../../components/ui/search-input';\nimport { useDebounce } from './use-debounce';\n\nexport interface UseSearchConfig<T> {\n /** Async function that performs the search */\n searchFn: (query: string) => Promise<T[]>;\n /** Maps each raw item to a SearchResult */\n mapResult: (item: T) => SearchResult;\n /** Debounce delay in ms. Default 300 */\n debounceMs?: number;\n /** Minimum characters before searching. Default 2 */\n minQueryLength?: number;\n}\n\nexport interface UseSearchReturn {\n query: string;\n setQuery: (q: string) => void;\n results: SearchResult[];\n isLoading: boolean;\n error: string | null;\n clearResults: () => void;\n}\n\n// One shared empty array: `setResults([])` with a fresh literal is a new\n// identity every time, which re-renders every consumer that keys off `results`.\nconst NO_RESULTS: SearchResult[] = [];\n\n/**\n * Generic search state management hook.\n *\n * Debounces the query, calls `searchFn` when the debounced value meets\n * `minQueryLength`, and maps the raw results via `mapResult`.\n */\nexport function useSearch<T>(config: UseSearchConfig<T>): UseSearchReturn {\n const { searchFn, mapResult, debounceMs = 300, minQueryLength = 2 } = config;\n\n const [query, setQuery] = useState('');\n const [results, setResults] = useState<SearchResult[]>(NO_RESULTS);\n const [isLoading, setIsLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n // `searchFn` and `mapResult` come from the caller and are almost always\n // written inline, so a fresh identity every render. As effect dependencies\n // they would re-fire the search on every parent render; read through refs the\n // effect keys on the QUERY only and still calls the current functions.\n const searchFnRef = useRef(searchFn);\n const mapResultRef = useRef(mapResult);\n useEffect(() => {\n searchFnRef.current = searchFn;\n mapResultRef.current = mapResult;\n });\n\n const debouncedQuery = useDebounce(query, debounceMs);\n\n const clearResults = useCallback(() => {\n setResults(NO_RESULTS);\n setError(null);\n }, []);\n\n // Falling below the threshold clears the panel. That is a reset driven by a\n // value we already have during render, so it uses React's \"adjust state while\n // rendering\" pattern rather than an effect: the keystroke that shortened the\n // query has already scheduled this render, and clearing here means the stale\n // result list never reaches the screen. From an effect it would paint once\n // with the old results still showing.\n const searchable = Boolean(debouncedQuery) && debouncedQuery.length >= minQueryLength;\n const [wasSearchable, setWasSearchable] = useState(searchable);\n if (wasSearchable !== searchable) {\n setWasSearchable(searchable);\n if (!searchable) {\n setResults(NO_RESULTS);\n setIsLoading(false);\n setError(null);\n }\n }\n\n useEffect(() => {\n if (!searchable) return undefined;\n\n let cancelled = false;\n\n const run = async () => {\n setIsLoading(true);\n setError(null);\n\n try {\n const rawResults = await searchFnRef.current(debouncedQuery);\n\n if (!cancelled) {\n setResults(rawResults.map(mapResultRef.current));\n }\n } catch (err) {\n if (!cancelled) {\n setError(err instanceof Error ? err.message : 'Search failed');\n setResults(NO_RESULTS);\n }\n } finally {\n if (!cancelled) {\n setIsLoading(false);\n }\n }\n };\n\n // Never rejects — try/catch/finally, every write gated on `cancelled`.\n void run();\n\n return () => {\n cancelled = true;\n };\n }, [debouncedQuery, minQueryLength, searchable]);\n\n return { query, setQuery, results, isLoading, error, clearResults };\n}\n","'use client';\n\nimport { useMemo } from 'react';\nimport type { CursorPaginationProps } from '../../components/ui/cursor-pagination';\n\n/**\n * Client-side pagination configuration\n */\nexport interface ClientPaginationConfig {\n type: 'client';\n currentPage: number;\n totalPages: number;\n itemCount: number;\n itemName?: string;\n onNext: () => void;\n onPrevious: () => void;\n showInfo?: boolean;\n}\n\n/**\n * Server-side cursor pagination configuration\n */\nexport interface ServerPaginationConfig {\n type: 'server';\n hasNextPage: boolean;\n hasLoadedBeyondFirst: boolean;\n startCursor?: string | null;\n endCursor?: string | null;\n itemCount: number;\n itemName?: string;\n onNext: () => void | Promise<void>;\n onReset: () => void | Promise<void>;\n showInfo?: boolean;\n}\n\nexport type PaginationConfig = ClientPaginationConfig | ServerPaginationConfig;\n\n/**\n * Unified pagination hook that works for both client-side and server-side pagination\n *\n * @example Client-side pagination\n * ```typescript\n * const pagination = useTablePagination({\n * type: 'client',\n * currentPage: 1,\n * totalPages: 10,\n * itemCount: 20,\n * itemName: 'scripts',\n * onNext: () => setPage(p => p + 1),\n * onPrevious: () => setPage(p => p - 1),\n * onReset: () => setPage(1)\n * })\n * ```\n *\n * @example Server-side pagination\n * ```typescript\n * const pagination = useTablePagination({\n * type: 'server',\n * hasNextPage: pageInfo.hasNextPage,\n * hasLoadedBeyondFirst: true,\n * startCursor: pageInfo.startCursor,\n * endCursor: pageInfo.endCursor,\n * itemCount: devices.length,\n * itemName: 'devices',\n * onNext: fetchNextPage,\n * onReset: fetchFirstPage\n * })\n * ```\n */\nexport function useTablePagination(config: PaginationConfig | null): CursorPaginationProps | undefined {\n return useMemo(() => {\n if (!config) return undefined;\n\n if (config.type === 'client') {\n // Client-side pagination: show if more than 1 page\n if (config.totalPages <= 1) return undefined;\n\n return {\n hasNextPage: config.currentPage < config.totalPages,\n hasPreviousPage: config.currentPage > 1,\n isFirstPage: config.currentPage === 1,\n startCursor: (config.currentPage - 1).toString(),\n endCursor: config.currentPage.toString(),\n currentCount: config.itemCount,\n itemName: config.itemName || 'items',\n onNext: () => config.onNext(),\n onPrevious: () => config.onPrevious(),\n showInfo: config.showInfo ?? true,\n };\n } else {\n // Server-side pagination: show if pageInfo exists\n return {\n hasNextPage: config.hasNextPage,\n hasPreviousPage: config.hasLoadedBeyondFirst,\n isFirstPage: !config.hasLoadedBeyondFirst,\n startCursor: config.startCursor,\n endCursor: config.endCursor,\n currentCount: config.itemCount,\n itemName: config.itemName || 'items',\n onNext: config.onNext,\n onPrevious: config.onReset, // Previous button goes back to first page\n showInfo: config.showInfo ?? true,\n };\n }\n }, [config]);\n}\n","'use client';\n\nimport { useState, useEffect, useRef } from 'react';\n\n/**\n * Hook to throttle a value\n * @param value - Value to throttle\n * @param limit - Throttle limit in milliseconds\n * @returns Throttled value\n */\nexport function useThrottle<T>(value: T, limit = 200): T {\n const [throttledValue, setThrottledValue] = useState<T>(value);\n // The clock is read in a lazy `useState` initialiser, which runs exactly\n // once. `useRef(Date.now())` looks equivalent but is not: the argument is\n // evaluated on EVERY render even though only the first call uses it, so the\n // component reads the clock during render forever after.\n const [mountedAt] = useState(() => Date.now());\n const lastUpdated = useRef<number>(mountedAt);\n\n useEffect(() => {\n const now = Date.now();\n const elapsed = now - lastUpdated.current;\n\n // If enough time has elapsed, update the throttled value\n if (elapsed >= limit) {\n setThrottledValue(value);\n lastUpdated.current = now;\n return undefined;\n }\n\n // Otherwise, set up a timeout to update after the limit\n const timerId = setTimeout(() => {\n setThrottledValue(value);\n lastUpdated.current = Date.now();\n }, limit - elapsed);\n\n return () => {\n clearTimeout(timerId);\n };\n }, [value, limit]);\n\n return throttledValue;\n}\n","import { useLayoutEffect, useState } from 'react';\n\n/**\n * Hook to get window dimensions\n * @returns Window width and height\n */\nexport function useWindowSize() {\n const [windowSize, setWindowSize] = useState({\n width: 0,\n height: 0,\n });\n\n useLayoutEffect(() => {\n // The size lives on `window`, not in React — this is the initial read of an\n // external system plus a subscription to it. The identity bail-out matters:\n // `resize` fires for height-only changes (mobile URL bar) and for the same\n // dimensions on orientation lock, and without it every one of those would\n // re-render every consumer of this hook.\n const handleResize = () => {\n setWindowSize(prev =>\n prev.width === window.innerWidth && prev.height === window.innerHeight\n ? prev\n : { width: window.innerWidth, height: window.innerHeight },\n );\n };\n\n // Set initial size\n handleResize();\n\n // Add event listener\n window.addEventListener('resize', handleResize);\n\n // Remove event listener on cleanup\n return () => window.removeEventListener('resize', handleResize);\n }, []);\n\n return windowSize;\n}\n","// UI Hooks exports\nexport * from './use-auto-limit-tags';\nexport * from './use-debounce';\nexport * from './use-deferred-error';\nexport * from './use-drag-and-drop-enabled';\nexport * from './use-focus-trap';\nexport * from './use-header-height';\nexport * from './use-horizontal-scrollbar';\nexport * from './use-image-edge-color';\nexport * from './use-is-truncated';\nexport * from './use-isomorphic-layout-effect';\nexport * from './use-keyboard-collision-padding';\nexport * from './use-local-storage';\nexport * from './use-marquee-engine';\nexport * from './use-media-query';\nexport * from './use-memoized-callback';\nexport * from './use-notification-permission';\nexport * from './use-onboarding-state';\nexport * from './use-reset-on-page-hidden';\nexport * from './use-search';\nexport * from './use-suppress-clone-focus';\nexport * from './use-table-pagination';\nexport * from './use-throttle';\nexport * from './use-window-size';\n\n// Shared ref-counted, iOS-aware body scroll lock (react-aria). Re-exported so\n// hub/app consumers have one import path; lib internals may import the\n// package directly (npm dedupes to one module instance either way).\nexport { usePreventScroll } from '@react-aria/overlays';\n","import { useState, useEffect } from 'react';\nimport type { SelectableOption } from '../../components/features';\nimport type { PlatformConfig, PlatformOption } from '../../types/platform';\nimport { transformPlatformConfigsToOptions } from '../../utils/platform-config';\n\nexport interface UsePlatformConfigResult {\n platforms: PlatformConfig[];\n platformOptions: PlatformOption[];\n selectableOptions: SelectableOption[]; // Rich options with icons and colors\n isLoading: boolean;\n error: Error | null;\n}\n\n// Cache for platform configs to avoid repeated fetches\nlet platformCache: PlatformConfig[] | null = null;\nlet fetchPromise: Promise<PlatformConfig[]> | null = null;\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\n/** `Array.isArray` narrows `unknown` to `any[]`; this keeps the elements `unknown`. */\nfunction isUnknownArray(value: unknown): value is readonly unknown[] {\n return Array.isArray(value);\n}\n\n/**\n * Decode `/api/config/platforms`.\n *\n * `Response.json()` is typed `any`, so `data.platforms || data` used to be\n * cached verbatim — including when it was neither an array nor a list of rows\n * (`{ platforms: null }` fell through the `||` and cached the ENVELOPE).\n * That matters more here than at a normal fetch boundary because\n * `platformCache` is MODULE-level: a bad payload is committed once and then\n * served to every later mount for the rest of the session, and\n * `platforms.map(...)` in the hook body throws on each one. Returning null\n * instead routes the payload into the hook's existing `error` state and leaves\n * the cache empty, so a later mount can retry.\n *\n * Row decoding is deliberately tolerant: `value`/`name` and `label`/\n * `display_name` are documented as the same string, so either spelling\n * satisfies the pair, and the purely presentational fields fall back to '' —\n * a platform missing its description should still appear in the filter rather\n * than vanish from it or render `undefined`.\n */\nfunction toPlatformConfigs(payload: unknown): PlatformConfig[] | null {\n const rows = isRecord(payload) && 'platforms' in payload ? payload.platforms : payload;\n if (!isUnknownArray(rows)) return null;\n\n const configs: PlatformConfig[] = [];\n for (const row of rows) {\n if (!isRecord(row)) continue;\n const read = (key: string): string | undefined => {\n const value = row[key];\n return typeof value === 'string' && value ? value : undefined;\n };\n\n const value = read('value') ?? read('name');\n if (value === undefined) continue;\n const label = read('label') ?? read('display_name') ?? value;\n\n configs.push({\n id: read('id') ?? value,\n value,\n label,\n name: read('name') ?? value,\n display_name: read('display_name') ?? label,\n default_color: read('default_color') ?? '',\n default_icon: read('default_icon') ?? '',\n description: read('description') ?? '',\n });\n }\n return configs;\n}\n\n/**\n * Custom hook to fetch platform configurations from API\n * Provides both full platform configs and simplified options for dropdowns\n * Heavily cached to prevent excessive API calls - should only call once per session\n *\n * NOTE: This hook is designed to work without react-query dependency\n */\nexport function usePlatformConfig(): UsePlatformConfigResult {\n const [platforms, setPlatforms] = useState<PlatformConfig[]>(platformCache || []);\n const [isLoading, setIsLoading] = useState(!platformCache);\n const [error, setError] = useState<Error | null>(null);\n\n useEffect(() => {\n // `platformCache` / `fetchPromise` are module-level: an external store, as\n // far as React is concerned. Every path below hands its outcome to the same\n // pair of commit callbacks instead of repeating three near-identical\n // setState pairs.\n //\n // The identity bail-out is what keeps the cached path free: the useState\n // initialisers above already read `platformCache`, so re-committing the\n // very same array must not schedule a render. It still commits when the\n // cache was filled by another instance's fetch *between* this component's\n // first render and this effect — the only case where the branch does\n // anything at all.\n const commit = (data: PlatformConfig[]) => {\n setPlatforms(prev => (prev === data ? prev : data));\n setIsLoading(false);\n };\n const fail = (err: unknown) => {\n setError(err instanceof Error ? err : new Error(String(err)));\n setIsLoading(false);\n };\n\n // If we already have cached platforms, use them\n if (platformCache) {\n commit(platformCache);\n return;\n }\n\n // If a fetch is already in progress, wait for it\n if (fetchPromise) {\n fetchPromise.then(commit).catch(fail);\n return;\n }\n\n // Start a new fetch\n console.log('🔧 Fetching platform configurations from API (should only happen once)');\n\n fetchPromise = fetch('/api/config/platforms')\n .then((response): Promise<unknown> => {\n if (!response.ok) {\n throw new Error(`Failed to fetch platform config: ${response.statusText}`);\n }\n return response.json();\n })\n .then(payload => {\n const loadedPlatforms = toPlatformConfigs(payload);\n if (!loadedPlatforms) {\n // Deliberately thrown rather than cached: see `toPlatformConfigs`.\n throw new Error('Platform config response did not contain a platform list');\n }\n console.log('✅ Platform configurations loaded:', loadedPlatforms.length, 'platforms');\n platformCache = loadedPlatforms;\n fetchPromise = null;\n return loadedPlatforms;\n });\n\n fetchPromise.then(commit).catch((err: unknown) => {\n console.error('❌ Failed to fetch platform config:', err);\n fail(err);\n fetchPromise = null;\n });\n }, []);\n\n // Create options for dropdowns with \"All Platforms\" option\n const platformOptions: PlatformOption[] = [\n { value: 'all', label: 'All Platforms' },\n ...platforms.map((platform: PlatformConfig) => ({\n value: platform.value,\n label: platform.label,\n })),\n ];\n\n // Create rich selectable options with icons and colors\n const selectableOptions = transformPlatformConfigsToOptions(platforms);\n\n return {\n platforms,\n platformOptions,\n selectableOptions,\n isLoading,\n error,\n };\n}\n\n/**\n * Get platform configuration by value\n */\nexport function usePlatformByValue(value: string): PlatformConfig | undefined {\n const { platforms } = usePlatformConfig();\n return platforms.find(platform => platform.value === value);\n}\n\n/**\n * Check if a platform value is valid\n */\nexport function useValidatePlatform(value: string): boolean {\n const { platforms } = usePlatformConfig();\n return platforms.some(platform => platform.value === value);\n}\n","import { Globe } from 'lucide-react';\nimport type React from 'react';\nimport type { SelectableOption } from '../components/features';\nimport { OpenmspLogo, FlamingoLogo, OpenFrameLogo, MiamiCyberGangLogoFaceOnly, MlgLogo } from '../components/icons';\nimport type { PlatformConfig, PlatformName } from '../types/platform';\nimport { cn } from './cn';\nimport { getPlatformBrandClasses } from './platform-identity';\n\n/**\n * Per-platform ICON rendering. The colour decision lives in `platform-identity`\n * (`PLATFORM_BRAND` → `ODS_STEM_CLASSES`), never here: each mark is tinted by the\n * platform's accent CLASS and paints itself with `currentColor`. There is ONE\n * lookup, parameterized by `className`; the three former hand-written switches\n * (`getPlatformIcon`, `getSmallPlatformIcon`, `getPlatformIconComponent`) are\n * thin wrappers that only pick a size.\n *\n * Two marks cannot take an accent fill and are named exceptions:\n * - `universal` is a lucide `Globe`; lucide spreads `...rest` AFTER `fill: 'none'`,\n * so a `fill` prop would paint a solid disc. It takes the class only.\n * - `tmcg` is a two-colour mark (`MiamiCyberGangLogoFaceOnly`) with no fill prop.\n */\nfunction renderPlatformIcon(platformName: string, className: string): React.ReactNode {\n const tinted = cn(className, getPlatformBrandClasses(platformName).accentText);\n switch (platformName) {\n case 'openframe':\n // `lowerPathColor` is the accent; the upper path is the neutral greys white.\n return (\n <OpenFrameLogo\n className={tinted}\n lowerPathColor=\"currentColor\"\n upperPathColor=\"var(--ods-system-greys-white)\"\n />\n );\n case 'openmsp':\n // `OpenmspLogo`'s paths set their own fills, so a root `fill` is a no-op:\n // the accent is the BACK bubble. The other two are the neutral greys.\n return (\n <OpenmspLogo\n className={tinted}\n backBubbleColor=\"currentColor\"\n frontBubbleColor=\"var(--ods-system-greys-white)\"\n innerFrontBubbleColor=\"var(--ods-system-greys-black)\"\n />\n );\n case 'tmcg':\n return <MiamiCyberGangLogoFaceOnly className={className} />;\n case 'mlg':\n return <MlgLogo className={tinted} size={20} />;\n case 'universal':\n return <Globe className={tinted} />;\n default:\n return <FlamingoLogo className={tinted} fill=\"currentColor\" />;\n }\n}\n\n/** Platform icons mapping (5x5), tinted by each platform's accent class. */\nexport const platformIcons: Record<PlatformName, React.ReactNode> = {\n openframe: renderPlatformIcon('openframe', 'h-5 w-5'),\n openmsp: renderPlatformIcon('openmsp', 'h-5 w-5'),\n flamingo: renderPlatformIcon('flamingo', 'h-5 w-5'),\n 'flamingo-teaser': renderPlatformIcon('flamingo-teaser', 'h-5 w-5'),\n 'marketing-hub': renderPlatformIcon('marketing-hub', 'h-5 w-5'),\n 'product-hub': renderPlatformIcon('product-hub', 'h-5 w-5'),\n 'revenue-hub': renderPlatformIcon('revenue-hub', 'h-5 w-5'),\n 'people-hub': renderPlatformIcon('people-hub', 'h-5 w-5'),\n 'company-hub': renderPlatformIcon('company-hub', 'h-5 w-5'),\n tmcg: renderPlatformIcon('tmcg', 'h-5 w-5'),\n mlg: renderPlatformIcon('mlg', 'h-5 w-5'),\n universal: renderPlatformIcon('universal', 'h-5 w-5'),\n};\n\n// Identity + brand strings/colours live in ONE place; re-exported here so every\n// existing importer of `platform-config` keeps working unchanged.\nexport {\n platformDisplayNames,\n platformShortNames,\n platformDescriptions,\n platformSlogans,\n platformIconNames,\n platformHexColors,\n platformColors,\n getPlatformDisplayName,\n getPlatformShortName,\n getPlatformDescription,\n getPlatformSlogan,\n getPlatformColor,\n getDefaultColorForPlatform,\n getDefaultIconForPlatform,\n} from './platform-identity';\n\nexport function transformPlatformConfigsToOptions(platformConfigs: PlatformConfig[]): SelectableOption[] {\n return platformConfigs.map((platform: PlatformConfig) => ({\n id: platform.id, // Database UUID for matching\n name: platform.name, // Platform name enum\n displayName: platform.display_name, // Human-readable name\n description: platform.description,\n icon: getPlatformIcon(platform.name),\n color: getPlatformColorClass(platform.name),\n }));\n}\n\n/** Get platform icon by name (5x5). */\nexport function getPlatformIcon(platformName: string): React.ReactNode {\n return platformIcons[platformName as PlatformName] ?? platformIcons.universal;\n}\n\n/** Get small platform icon for filter buttons (4x4). */\nexport function getSmallPlatformIcon(platformName: string): React.ReactNode {\n return renderPlatformIcon(platformName, 'h-4 w-4 flex-shrink-0');\n}\n\n/** Get platform icon for admin/selector components (standard 6x6 size). */\nexport function getPlatformIconComponent(platformName: string, className: string = 'h-6 w-6'): React.ReactNode {\n return renderPlatformIcon(platformName, className);\n}\n\n// `getPlatformColor` is re-exported from the identity leaf above; this local alias\n// keeps `transformPlatformConfigsToOptions` readable without shadowing that export.\nfunction getPlatformColorClass(platformName: string): string {\n return getPlatformBrandClasses(platformName).accentBg;\n}\n","import { toast as sonnerToast } from 'sonner';\nimport { showToast, type ToastVariant } from '../components/ui/toaster';\n\nexport interface ToastOptions {\n title?: string;\n description?: string;\n variant?: 'default' | 'success' | 'destructive' | 'info' | 'warning' | 'error';\n duration?: number;\n dismissible?: boolean;\n}\n\nconst normalizeVariant = (variant: ToastOptions['variant']): ToastVariant => {\n if (variant === 'destructive') return 'error';\n if (!variant) return 'default';\n return variant;\n};\n\nexport const toast = (options: ToastOptions | string) => {\n if (typeof options === 'string') {\n return showToast({ title: options });\n }\n\n const { title, description, variant, duration, dismissible } = options;\n return showToast({\n title,\n description,\n variant: normalizeVariant(variant),\n duration,\n dismissible,\n });\n};\n\nexport const useToast = () => ({\n toast,\n dismiss: sonnerToast.dismiss,\n promise: sonnerToast.promise,\n});\n","'use client';\n\nimport { type ComponentProps, type ReactNode, useState } from 'react';\nimport { Toaster as SonnerToaster, toast as sonnerToast } from 'sonner';\nimport type { ExternalToast } from 'sonner';\nimport type { ToolType } from '../../types/tool.types';\nimport { cn } from '../../utils/cn';\nimport { Chevron02DownIcon } from '../icons-v2-generated/arrows/chevron-02-down-icon';\nimport { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon';\nimport { ToolIcon } from '../tool-icon';\n\nexport type ToastVariant = 'default' | 'success' | 'warning' | 'error' | 'info';\n\nexport const dotColorByVariant: Record<ToastVariant, string> = {\n default: 'bg-ods-text-secondary',\n success: 'bg-ods-success',\n warning: 'bg-ods-warning',\n error: 'bg-ods-error',\n info: 'bg-ods-text-secondary',\n};\n\nexport const progressColorByVariant: Record<ToastVariant, string> = {\n default: 'bg-ods-text-secondary',\n success: 'bg-ods-success',\n warning: 'bg-ods-warning',\n error: 'bg-ods-error',\n info: 'bg-ods-text-secondary',\n};\n\ninterface ToastHeaderProps {\n id: string | number;\n variant: ToastVariant;\n title?: ReactNode;\n description?: ReactNode;\n duration?: number;\n dismissible?: boolean;\n className?: string;\n showProgress?: boolean;\n}\n\nfunction ToastHeader({\n id,\n variant,\n title,\n description,\n duration = 4000,\n dismissible = true,\n className,\n showProgress = true,\n}: ToastHeaderProps) {\n return (\n <div className={cn('relative flex w-full items-start gap-2 overflow-hidden bg-ods-card p-3', className)}>\n <div className=\"flex size-6 shrink-0 items-center justify-center\">\n <span className={cn('size-[9px] rounded-full', dotColorByVariant[variant])} />\n </div>\n\n <div className=\"flex min-w-0 flex-1 flex-col justify-center\">\n {title ? (\n <p\n className=\"truncate pr-5 text-ods-text-primary text-h4\"\n title={typeof title === 'string' ? title : undefined}\n >\n {title}\n </p>\n ) : null}\n {description ? (\n <p\n className=\"line-clamp-3 text-ods-text-secondary text-h6\"\n title={typeof description === 'string' ? description : undefined}\n >\n {description}\n </p>\n ) : null}\n </div>\n\n {dismissible ? (\n <button\n type=\"button\"\n aria-label=\"Close\"\n onClick={() => sonnerToast.dismiss(id)}\n className=\"absolute right-[7px] top-[7px] flex size-4 items-center justify-center text-ods-text-secondary transition-colors hover:text-ods-text-primary\"\n >\n <XmarkIcon size={16} />\n </button>\n ) : null}\n\n {showProgress && duration !== Infinity && duration > 0 ? (\n <div\n className={cn('absolute inset-x-0 bottom-0 h-1 origin-left', progressColorByVariant[variant])}\n style={{\n animation: `toast-progress ${duration}ms linear forwards`,\n }}\n />\n ) : null}\n </div>\n );\n}\n\nexport interface ToastCardProps {\n id: string | number;\n variant?: ToastVariant;\n title?: ReactNode;\n description?: ReactNode;\n duration?: number;\n dismissible?: boolean;\n className?: string;\n}\n\nexport function ToastCard({\n id,\n variant = 'default',\n title,\n description,\n duration = 4000,\n dismissible = true,\n className,\n}: ToastCardProps) {\n return (\n <div\n role=\"status\"\n className={cn(\n 'w-[368px] max-w-[calc(100vw-32px)] overflow-hidden rounded-md border border-ods-border bg-ods-card shadow-lg',\n className,\n )}\n >\n <ToastHeader\n id={id}\n variant={variant}\n title={title}\n description={description}\n duration={duration}\n dismissible={dismissible}\n />\n </div>\n );\n}\n\nexport interface CommandApprovalToastProps {\n id: string | number;\n variant?: ToastVariant;\n title?: ReactNode;\n description?: ReactNode;\n command: string;\n toolType?: ToolType;\n approvalDescription?: ReactNode;\n approveLabel?: string;\n rejectLabel?: string;\n onApprove?: () => void;\n onReject?: () => void;\n duration?: number;\n dismissible?: boolean;\n defaultExpanded?: boolean;\n className?: string;\n}\n\nexport function CommandApprovalToast({\n id,\n variant = 'warning',\n title = 'Tech Required',\n description = 'Approval is required to execute the command.',\n command,\n toolType,\n approvalDescription,\n approveLabel = 'Approve',\n rejectLabel = 'Reject',\n onApprove,\n onReject,\n duration = Infinity,\n dismissible = true,\n defaultExpanded = false,\n className,\n}: CommandApprovalToastProps) {\n const [expanded, setExpanded] = useState(defaultExpanded);\n\n const handleApprove = () => {\n onApprove?.();\n sonnerToast.dismiss(id);\n };\n\n const handleReject = () => {\n onReject?.();\n sonnerToast.dismiss(id);\n };\n\n return (\n <div\n role=\"status\"\n className={cn(\n 'flex w-[368px] max-w-[calc(100vw-32px)] flex-col overflow-hidden rounded-md border border-ods-border bg-ods-bg shadow-lg',\n className,\n )}\n >\n <ToastHeader\n id={id}\n variant={variant}\n title={title}\n description={description}\n duration={duration}\n dismissible={dismissible}\n showProgress={!expanded}\n className=\"border-b border-ods-border\"\n />\n\n <div\n className=\"grid transition-[grid-template-rows] duration-300 ease-out\"\n style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}\n aria-hidden={!expanded}\n >\n <div className=\"overflow-hidden\">\n <div className=\"flex h-11 w-full items-center gap-2 border-b border-ods-border bg-ods-card px-3 py-2\">\n <p className=\"min-w-0 flex-1 truncate text-ods-text-primary text-h6\" title={command}>\n {command}\n </p>\n {toolType ? <ToolIcon toolType={toolType} size={16} /> : null}\n </div>\n\n <div className=\"flex flex-col gap-2 bg-ods-bg p-3\">\n {approvalDescription ? <p className=\"text-ods-text-secondary text-h6\">{approvalDescription}</p> : null}\n <div className=\"flex items-center gap-4\">\n <button\n type=\"button\"\n onClick={handleApprove}\n tabIndex={expanded ? 0 : -1}\n className=\"flex flex-1 items-center justify-center rounded-md bg-ods-accent px-2 py-2 text-ods-text-on-accent transition-colors text-h5 hover:bg-ods-accent-hover active:bg-ods-accent-active\"\n >\n {approveLabel}\n </button>\n <button\n type=\"button\"\n onClick={handleReject}\n tabIndex={expanded ? 0 : -1}\n className=\"flex flex-1 items-center justify-center rounded-md border border-ods-border bg-ods-card px-2 py-2 text-ods-text-primary transition-colors text-h5 hover:bg-ods-bg-hover\"\n >\n {rejectLabel}\n </button>\n </div>\n </div>\n </div>\n </div>\n\n {expanded ? null : (\n <button\n type=\"button\"\n onClick={() => setExpanded(true)}\n className=\"flex w-full items-center gap-2 bg-ods-card px-3 py-2 text-left text-ods-text-primary transition-colors text-h6 hover:bg-ods-bg-hover\"\n aria-expanded={false}\n >\n <span className=\"flex-1\">Show Command</span>\n <Chevron02DownIcon size={16} />\n </button>\n )}\n </div>\n );\n}\n\nexport type ToasterProps = ComponentProps<typeof SonnerToaster>;\n\nexport function Toaster({ position = 'bottom-right', offset = 24, gap = 8, toastOptions, ...rest }: ToasterProps = {}) {\n const { classNames: userClassNames, ...restToastOptions } = toastOptions ?? {};\n\n return (\n <>\n <style>{`\n @keyframes toast-progress {\n from { transform: scaleX(1); }\n to { transform: scaleX(0); }\n }\n `}</style>\n <SonnerToaster\n position={position}\n offset={offset}\n gap={gap}\n toastOptions={{\n unstyled: true,\n ...restToastOptions,\n classNames: {\n toast: 'w-full',\n ...userClassNames,\n },\n }}\n {...rest}\n />\n </>\n );\n}\n\nexport interface ShowToastOptions extends Omit<ExternalToast, 'description'> {\n title?: ReactNode;\n description?: ReactNode;\n variant?: ToastVariant;\n}\n\nexport function showToast(options: ShowToastOptions | string) {\n const opts: ShowToastOptions = typeof options === 'string' ? { title: options } : options;\n\n const { title, description, variant = 'default', duration = 4000, dismissible = true, ...rest } = opts;\n\n return sonnerToast.custom(\n id => (\n <ToastCard\n id={id}\n variant={variant}\n title={title}\n description={description}\n duration={duration}\n dismissible={dismissible}\n />\n ),\n { duration, dismissible, ...rest },\n );\n}\n\nexport interface ShowCommandApprovalToastOptions\n extends Omit<ExternalToast, 'description'>, Omit<CommandApprovalToastProps, 'id'> {}\n\nexport function showCommandApprovalToast(options: ShowCommandApprovalToastOptions) {\n const {\n variant,\n title,\n description,\n command,\n toolType,\n approvalDescription,\n approveLabel,\n rejectLabel,\n onApprove,\n onReject,\n defaultExpanded,\n duration = Infinity,\n dismissible = true,\n ...rest\n } = options;\n\n return sonnerToast.custom(\n id => (\n <CommandApprovalToast\n id={id}\n variant={variant}\n title={title}\n description={description}\n command={command}\n toolType={toolType}\n approvalDescription={approvalDescription}\n approveLabel={approveLabel}\n rejectLabel={rejectLabel}\n onApprove={onApprove}\n onReject={onReject}\n defaultExpanded={defaultExpanded}\n duration={duration}\n dismissible={dismissible}\n />\n ),\n { duration, dismissible, ...rest },\n );\n}\n","import { useState, useCallback, useEffect } from 'react';\nimport { useRequiredEndpointsRuntime } from '../contexts/endpoints-runtime-context';\nimport { useRouter } from '../embed-shims/next-navigation';\nimport { apiErrorMessage } from '../utils/common';\nimport { contentFetch } from '../utils/embed-content-fetch';\nimport { useToast } from './use-toast';\n\ninterface ContactSubmissionOptions {\n userId?: string;\n successRedirectUrl?: string;\n successToastMessage?: string;\n onSuccess?: () => void;\n}\n\n/**\n * Mirrors the API contract at POST /api/contact (see `ContactBaseSchema` in\n * the consuming app). Keep optional fields aligned with the Zod schema —\n * anything missing here OR there will be silently dropped on submission.\n *\n * Tracking fields (rdt_cid, utm_*, referrer_url) are forwarded as-is and\n * persisted by the API; they don't need to be declared on the form, but\n * the index signature below preserves any extra keys callers spread in.\n */\ninterface ContactFormData {\n name: string;\n email: string;\n helpCategory: string;\n message: string;\n linkedin_url?: string;\n companySize?: string;\n referralSource?: string;\n rdt_cid?: string;\n // Permissive index signature — tracking metadata, A/B variants, and\n // future per-form fields flow through unchanged. The API decides what\n // to keep via its Zod schema.\n [key: string]: unknown;\n}\n\n/**\n * useContactSubmission\n * --------------------\n * Provides a helper for submitting contact form data to `/api/contact`.\n * Handles loading state, success detection, toast notifications, and redirect.\n * Follows the same pattern as useWaitlistRegistration for consistency.\n */\nexport function useContactSubmission(options: ContactSubmissionOptions = {}) {\n const { userId, successRedirectUrl, successToastMessage, onSuccess } = options;\n const { toast } = useToast();\n const router = useRouter();\n // Endpoint URL injected via context — hub provides hub default, embedded\n // app provides its proxied path. Throws if no provider is mounted.\n const { contactUrl } = useRequiredEndpointsRuntime();\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [isSuccess, setIsSuccess] = useState(false);\n\n const submit = useCallback(\n async (formData: ContactFormData) => {\n if (isSubmitting) return;\n\n setIsSubmitting(true);\n\n try {\n const response = await contentFetch(contactUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n ...formData,\n user_id: userId,\n }),\n });\n\n // Read the body ONLY on failure — nothing here consumes the success\n // payload, and parsing it meant a 2xx with an empty (or non-JSON)\n // body threw and toasted a failure for a message that was delivered.\n if (!response.ok) {\n const failure: unknown = await response.json().catch(() => null);\n throw new Error(apiErrorMessage(failure, 'Failed to submit contact form'));\n }\n\n // Success\n setIsSuccess(true);\n const message = successToastMessage\n ? `Thank you! Your message has been sent. ${successToastMessage}`\n : 'Thank you! Your message has been sent successfully.';\n\n toast({\n title: 'Message sent!',\n description: message,\n variant: 'success',\n });\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Something went wrong. Please try again.';\n toast({\n title: 'Failed to send message',\n description: message,\n variant: 'destructive',\n });\n throw error; // allow caller to handle if needed\n } finally {\n setIsSubmitting(false);\n }\n },\n [isSubmitting, toast, userId, successToastMessage, contactUrl],\n );\n\n // Handle redirect after success\n useEffect(() => {\n if (!isSuccess || !successRedirectUrl) return undefined;\n console.log('🚀 Contact submission successful, redirecting to:', successRedirectUrl);\n const timer = setTimeout(() => {\n console.log('🎯 Performing redirect now to:', successRedirectUrl);\n if (successRedirectUrl.startsWith('http')) {\n window.location.href = successRedirectUrl;\n } else {\n router.push(successRedirectUrl);\n }\n }, 1500);\n\n return () => clearTimeout(timer);\n }, [isSuccess, successRedirectUrl, router]);\n\n // Call onSuccess callback if provided\n useEffect(() => {\n if (isSuccess && onSuccess) {\n onSuccess();\n }\n }, [isSuccess, onSuccess]);\n\n return { submit, isSubmitting, isSuccess, setIsSuccess } as const;\n}\n","'use client';\n\nimport { useCallback, useState } from 'react';\nimport { useToast } from './use-toast';\n\ninterface UseCopyToClipboardOptions {\n successTitle?: string;\n successDescription?: string;\n errorTitle?: string;\n errorDescription?: string;\n resetDelay?: number;\n}\n\nexport function useCopyToClipboard({\n successTitle = 'Copied',\n successDescription = 'Copied to clipboard',\n errorTitle = 'Copy failed',\n errorDescription = 'Could not copy to clipboard',\n resetDelay = 2000,\n}: UseCopyToClipboardOptions = {}) {\n const { toast } = useToast();\n const [copied, setCopied] = useState(false);\n\n const copy = useCallback(\n async (text: string) => {\n try {\n await navigator.clipboard.writeText(text);\n setCopied(true);\n toast({ title: successTitle, description: successDescription, variant: 'success' });\n setTimeout(() => setCopied(false), resetDelay);\n } catch {\n toast({ title: errorTitle, description: errorDescription, variant: 'destructive' });\n }\n },\n [successTitle, successDescription, errorTitle, errorDescription, resetDelay, toast],\n );\n\n return { copy, copied };\n}\n","import { useEffect, useMemo, useState, useRef } from 'react';\n\n/**\n * Configuration for batch image fetching\n */\nexport interface BatchImageFetchConfig {\n /** Base URL for tenant-specific API calls (e.g., 'https://tenant.openframe.dev' or '') */\n tenantHostUrl?: string;\n /** Enable dev mode with Bearer token from localStorage */\n enableDevMode?: boolean;\n /** localStorage key for access token (default: 'of_access_token') */\n accessTokenKey?: string;\n}\n\n/**\n * Global configuration for batch image fetching\n * Can be set once at app initialization\n */\nlet globalBatchImageConfig: BatchImageFetchConfig = {};\n\n/**\n * Configure global settings for batch image fetching\n * Call this once in your app initialization (e.g., _app.tsx or layout.tsx)\n *\n * @example\n * ```typescript\n * // In app initialization\n * configureBatchImageFetch({\n * tenantHostUrl: process.env.NEXT_PUBLIC_TENANT_HOST_URL || '',\n * enableDevMode: process.env.NEXT_PUBLIC_ENABLE_DEV_TICKET_OBSERVER === 'true'\n * })\n * ```\n */\nexport function configureBatchImageFetch(config: BatchImageFetchConfig): void {\n globalBatchImageConfig = { ...globalBatchImageConfig, ...config };\n}\n\n/**\n * Get current batch image fetch configuration\n */\nfunction getBatchImageConfig(): Required<BatchImageFetchConfig> {\n return {\n tenantHostUrl: globalBatchImageConfig.tenantHostUrl || '',\n enableDevMode: globalBatchImageConfig.enableDevMode ?? false,\n accessTokenKey: globalBatchImageConfig.accessTokenKey || 'of_access_token',\n };\n}\n\n/**\n * Fetch multiple images with authentication in batch\n * Returns a map of original imageUrl to fetched blob URL\n *\n * @param imageUrls - Array of image URLs to fetch\n * @param config - Optional configuration override for this batch\n * @returns Promise resolving to map of original URL → blob URL\n *\n * @example\n * ```typescript\n * const images = await batchFetchAuthenticatedImages([\n * '/api/organizations/123/image',\n * '/api/organizations/456/image'\n * ])\n * // { '/api/organizations/123/image': 'blob:...', '/api/organizations/456/image': 'blob:...' }\n * ```\n */\nexport async function batchFetchAuthenticatedImages(\n imageUrls: string[],\n config?: BatchImageFetchConfig,\n): Promise<Record<string, string | undefined>> {\n const results: Record<string, string | undefined> = {};\n\n if (imageUrls.length === 0) {\n return results;\n }\n\n const { tenantHostUrl, enableDevMode, accessTokenKey } = {\n ...getBatchImageConfig(),\n ...config,\n };\n\n const fetchPromises = imageUrls.map(async imageUrl => {\n try {\n // Construct full image URL\n let fullImageUrl: string;\n if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {\n fullImageUrl = imageUrl;\n } else if (imageUrl.startsWith('/api/')) {\n fullImageUrl = `${tenantHostUrl}${imageUrl}`;\n } else if (imageUrl.startsWith('/')) {\n fullImageUrl = `${tenantHostUrl}/api${imageUrl}`;\n } else {\n fullImageUrl = `${tenantHostUrl}/api/${imageUrl}`;\n }\n\n // Add cache buster\n const cacheBuster = `?t=${Date.now()}`;\n fullImageUrl = fullImageUrl + cacheBuster;\n\n // Prepare headers\n const headers: Record<string, string> = {\n Accept: 'image/*',\n 'Cache-Control': 'no-cache, no-store, must-revalidate',\n Pragma: 'no-cache',\n };\n\n // Add Bearer token in dev mode\n if (enableDevMode) {\n try {\n const accessToken = localStorage.getItem(accessTokenKey);\n if (accessToken) {\n headers.Authorization = `Bearer ${accessToken}`;\n }\n } catch {\n // Silently continue without token\n }\n }\n\n // Fetch image\n const response = await fetch(fullImageUrl, {\n method: 'GET',\n credentials: 'include', // Include cookies for authentication\n headers,\n });\n\n if (!response.ok) {\n throw new Error(`Failed to fetch image: ${response.status}`);\n }\n\n // Convert to blob URL\n const blob = await response.blob();\n const objectUrl = URL.createObjectURL(blob);\n\n return { imageUrl, fetchedUrl: objectUrl };\n } catch (error) {\n console.warn(`Failed to fetch image ${imageUrl}:`, error);\n return { imageUrl, fetchedUrl: undefined };\n }\n });\n\n const fetchResults = await Promise.all(fetchPromises);\n\n fetchResults.forEach(({ imageUrl, fetchedUrl }) => {\n results[imageUrl] = fetchedUrl;\n });\n\n return results;\n}\n\n/**\n * React hook to batch fetch images with authentication\n *\n * Features:\n * - Automatically deduplicates URLs\n * - Caches fetched results\n * - Only fetches new/unfetched URLs\n * - Cleans up blob URLs on unmount\n *\n * @param imageUrls - Array of image URLs (can include null/undefined)\n * @param config - Optional configuration override\n * @returns Map of original URL → fetched blob URL\n *\n * @example\n * ```typescript\n * // In a component\n * const imageUrls = useMemo(() =>\n * organizations.map(org => org.imageUrl).filter(Boolean),\n * [organizations]\n * )\n * const fetchedImages = useBatchImages(imageUrls)\n *\n * // Use in render\n * <img src={fetchedImages[org.imageUrl]} alt={org.name} />\n * ```\n */\nexport function useBatchImages(\n imageUrls: (string | null | undefined)[],\n config?: BatchImageFetchConfig,\n): Record<string, string | undefined> {\n const [fetchedImages, setFetchedImages] = useState<Record<string, string | undefined>>({});\n\n // Deduplicate and filter out null/undefined\n const uniqueUrls = useMemo(\n () => Array.from(new Set(imageUrls.filter((url): url is string => Boolean(url)))),\n [imageUrls],\n );\n\n // Track URLs we've already requested to avoid duplicate fetches\n const requestedUrls = useRef<Set<string>>(new Set());\n\n // Revoke on UNMOUNT only, reading the latest map through a ref.\n //\n // This used to be the cleanup of the fetch effect below, closing over\n // `fetchedImages` from the render the effect happened to run in. Two bugs\n // fell out of that: URLs fetched after that render were never revoked\n // (a leak), and a change to `imageUrls`/`config` revoked the whole captured\n // set while state still pointed at those blobs — every image on screen broke.\n const fetchedImagesRef = useRef(fetchedImages);\n useEffect(() => {\n fetchedImagesRef.current = fetchedImages;\n }, [fetchedImages]);\n useEffect(\n () => () => {\n Object.values(fetchedImagesRef.current).forEach(blobUrl => {\n if (blobUrl && blobUrl.startsWith('blob:')) {\n URL.revokeObjectURL(blobUrl);\n }\n });\n },\n [],\n );\n\n useEffect(() => {\n // An empty `imageUrls` used to wipe `fetchedImages` back to `{}` here. That\n // was wrong three ways, and removing it fixes all three:\n // * `requestedUrls` was NOT cleared alongside it, so once a url came back\n // it was filtered out as \"already requested\" and never re-fetched — the\n // image stayed permanently blank;\n // * the wiped blob URLs were dropped from the map the unmount cleanup\n // revokes from, so they leaked;\n // * it was a synchronous setState in an effect body, i.e. a second render\n // pass for a map nobody is indexing into (there are no urls to look up).\n // The map is a url-keyed cache: stale entries are unreachable, not stale\n // data, and it is bounded by the same url set `requestedUrls` already holds.\n\n // Find URLs we haven't fetched yet\n const urlsToFetch = uniqueUrls.filter(url => !requestedUrls.current.has(url));\n\n if (urlsToFetch.length === 0) {\n return; // All URLs already requested\n }\n\n // Mark these URLs as requested\n urlsToFetch.forEach(url => {\n requestedUrls.current.add(url);\n });\n\n batchFetchAuthenticatedImages(urlsToFetch, config)\n .then(newResults => {\n setFetchedImages(prev => ({ ...prev, ...newResults }));\n })\n .catch((error: unknown) => {\n console.error('Failed to batch fetch images:', error);\n });\n }, [uniqueUrls, config]);\n\n return fetchedImages;\n}\n","import { useEffect, useState, useRef } from 'react';\n\n/**\n * Configuration for single image fetching\n * Uses same config as batch image fetching for consistency\n */\nexport interface AuthenticatedImageConfig {\n /** Base URL for tenant-specific API calls (e.g., 'https://tenant.openframe.dev' or '') */\n tenantHostUrl?: string;\n /** Enable dev mode with Bearer token from localStorage */\n enableDevMode?: boolean;\n /** localStorage key for access token (default: 'of_access_token') */\n accessTokenKey?: string;\n}\n\n/**\n * Global configuration for authenticated image fetching\n * Shared with useBatchImages for consistency\n */\nlet globalImageConfig: AuthenticatedImageConfig = {};\n\n/**\n * Global cache for authenticated images\n * Stores blob URLs by cache key\n */\ninterface ImageCacheEntry {\n blobUrl: string;\n timestamp: number;\n refCount: number;\n}\n\nconst imageCache = new Map<string, ImageCacheEntry>();\nconst pendingRequests = new Map<string, Promise<string | undefined>>();\n\n/**\n * Cache cleanup interval (5 minutes)\n */\nconst CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000;\n\n/**\n * Cache entry max age (30 minutes)\n */\nconst CACHE_MAX_AGE = 30 * 60 * 1000;\n\n/**\n * Clean up expired cache entries\n */\nfunction cleanupImageCache() {\n const now = Date.now();\n for (const [key, entry] of imageCache.entries()) {\n if (entry.refCount === 0 && now - entry.timestamp > CACHE_MAX_AGE) {\n URL.revokeObjectURL(entry.blobUrl);\n imageCache.delete(key);\n }\n }\n}\n\n/**\n * Periodic cache cleanup\n */\nif (typeof window !== 'undefined') {\n setInterval(cleanupImageCache, CACHE_CLEANUP_INTERVAL);\n}\n\n/**\n * Configure global settings for authenticated image fetching\n * Call this once in your app initialization (e.g., _app.tsx or layout.tsx)\n *\n * Note: This uses the same configuration as useBatchImages. If you've already\n * called configureBatchImageFetch(), you don't need to call this separately.\n *\n * @example\n * ```typescript\n * // In app initialization\n * configureAuthenticatedImage({\n * tenantHostUrl: process.env.NEXT_PUBLIC_TENANT_HOST_URL || '',\n * enableDevMode: process.env.NEXT_PUBLIC_ENABLE_DEV_TICKET_OBSERVER === 'true'\n * })\n * ```\n */\nexport function configureAuthenticatedImage(config: AuthenticatedImageConfig): void {\n globalImageConfig = { ...globalImageConfig, ...config };\n}\n\n/**\n * Get current authenticated image configuration\n */\nfunction getImageConfig(): Required<AuthenticatedImageConfig> {\n return {\n tenantHostUrl: globalImageConfig.tenantHostUrl || '',\n enableDevMode: globalImageConfig.enableDevMode ?? false,\n accessTokenKey: globalImageConfig.accessTokenKey || 'of_access_token',\n };\n}\n\n/**\n * React hook to fetch a single image with authentication\n *\n * Features:\n * - Fetches image with cookie authentication\n * - Optional Bearer token in dev mode\n * - Converts to blob URL for img src\n * - Automatic cleanup of blob URLs\n * - Cache-busting with refreshKey\n * - Loading and error states\n * - **Global caching** - Prevents duplicate requests for identical URLs\n * - **Automatic deduplication** - Multiple components using same URL share cached result\n * - **Reference counting** - Cached blobs cleaned up when no longer used\n *\n * @param imageUrl - The image URL to fetch (null/undefined = no fetch)\n * @param refreshKey - Optional key to force re-fetch (e.g., version number, timestamp)\n * @param config - Optional configuration override\n * @returns Object with imageUrl (blob), isLoading, and error\n *\n * @example\n * ```typescript\n * // Basic usage\n * const { imageUrl, isLoading, error } = useAuthenticatedImage(\n * organization?.imageUrl\n * )\n *\n * // With refresh key (e.g., after upload)\n * const { imageUrl } = useAuthenticatedImage(\n * organization?.imageUrl,\n * organization?.imageVersion // Timestamp or version number\n * )\n *\n * // In render\n * {imageUrl && <img src={imageUrl} alt=\"Organization\" />}\n * ```\n */\nexport function useAuthenticatedImage(\n imageUrl?: string | null,\n refreshKey?: string | number,\n config?: AuthenticatedImageConfig,\n): {\n imageUrl: string | undefined;\n isLoading: boolean;\n error: string | null;\n} {\n // Result of the LAST completed attempt, tagged with the cache key it belongs\n // to. Tagging is what lets `isLoading` / `error` be derived below instead of\n // being written from the effect: \"still loading\" is simply \"nothing settled\n // for the key we are currently asked about\", which the render already knows.\n const [settled, setSettled] = useState<{ key: string; blobUrl?: string; error?: string } | null>(null);\n const currentCacheKeyRef = useRef<string | null>(null);\n\n const { tenantHostUrl, enableDevMode, accessTokenKey } = {\n ...getImageConfig(),\n ...config,\n };\n\n // Construct full image URL. Pure string work on the arguments — it belongs in\n // render so both the effect and the derived result below agree on one key.\n let fullImageUrl: string | null = null;\n if (imageUrl) {\n if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {\n fullImageUrl = imageUrl;\n } else if (imageUrl.startsWith('/api/')) {\n fullImageUrl = `${tenantHostUrl}${imageUrl}`;\n } else if (imageUrl.startsWith('/')) {\n fullImageUrl = `${tenantHostUrl}/api${imageUrl}`;\n } else {\n fullImageUrl = `${tenantHostUrl}/api/${imageUrl}`;\n }\n }\n\n // Create cache key (use refreshKey if provided, otherwise no cache buster for caching)\n const cacheKey = fullImageUrl === null ? null : refreshKey ? `${fullImageUrl}?v=${refreshKey}` : fullImageUrl;\n\n useEffect(() => {\n if (cacheKey === null) {\n if (currentCacheKeyRef.current) {\n const entry = imageCache.get(currentCacheKeyRef.current);\n if (entry) {\n entry.refCount--;\n }\n currentCacheKeyRef.current = null;\n }\n return;\n }\n\n if (currentCacheKeyRef.current && currentCacheKeyRef.current !== cacheKey) {\n const prevEntry = imageCache.get(currentCacheKeyRef.current);\n if (prevEntry) {\n prevEntry.refCount--;\n }\n }\n\n currentCacheKeyRef.current = cacheKey;\n\n const cachedEntry = imageCache.get(cacheKey);\n if (cachedEntry) {\n // Ref-counting only — the blob is already on screen, because the render\n // below reads the same cache. Publishing it from here instead would mean\n // a cache HIT still cost a spinner frame plus a second render pass.\n cachedEntry.refCount++;\n cachedEntry.timestamp = Date.now();\n return;\n }\n\n const pendingRequest = pendingRequests.get(cacheKey);\n if (pendingRequest) {\n pendingRequest\n .then(blobUrl => {\n const entry = blobUrl ? imageCache.get(cacheKey) : undefined;\n if (entry) entry.refCount++;\n // Settled either way: a request that resolves without a usable blob\n // still ends the spinner, exactly as the old `setIsLoading(false)` in\n // the non-`if` tail of this handler did.\n setSettled({ key: cacheKey, blobUrl: entry ? blobUrl : undefined });\n })\n .catch((err: unknown) => {\n setSettled({ key: cacheKey, error: err instanceof Error ? err.message : 'Failed to fetch image' });\n });\n return;\n }\n\n const requestUrl = refreshKey ? cacheKey : `${fullImageUrl}?t=${Date.now()}`;\n\n // Prepare headers\n const headers: Record<string, string> = {\n Accept: 'image/*',\n 'Cache-Control': 'no-cache, no-store, must-revalidate',\n Pragma: 'no-cache',\n };\n\n // Add Bearer token in dev mode\n if (enableDevMode) {\n try {\n const accessToken = localStorage.getItem(accessTokenKey);\n if (accessToken) {\n headers.Authorization = `Bearer ${accessToken}`;\n }\n } catch {\n // Silently continue without token\n }\n }\n\n const fetchPromise = fetch(requestUrl, {\n method: 'GET',\n credentials: 'include', // Include cookies for authentication\n headers,\n })\n .then(response => {\n if (!response.ok) {\n throw new Error(`Failed to fetch image: ${response.status}`);\n }\n return response.blob();\n })\n .then(blob => {\n const objectUrl = URL.createObjectURL(blob);\n\n imageCache.set(cacheKey, {\n blobUrl: objectUrl,\n timestamp: Date.now(),\n refCount: 1,\n });\n\n setSettled({ key: cacheKey, blobUrl: objectUrl });\n return objectUrl;\n })\n .catch((err: unknown) => {\n setSettled({ key: cacheKey, error: err instanceof Error ? err.message : 'Failed to fetch image' });\n throw err;\n })\n .finally(() => {\n pendingRequests.delete(cacheKey);\n });\n\n pendingRequests.set(cacheKey, fetchPromise);\n // Keyed on the derived `cacheKey` and the two PRIMITIVE config fields the\n // request itself needs — not on the `config` OBJECT. Callers pass `config`\n // inline, so a fresh identity every render used to re-run this effect on\n // every render, and each re-run incremented `refCount` again on a cache hit\n // (see above). Entries whose count never returned to 0 were never eligible\n // for `cleanupImageCache`, so their blob URLs leaked for the whole session.\n }, [cacheKey, fullImageUrl, refreshKey, enableDevMode, accessTokenKey]);\n\n useEffect(() => {\n return () => {\n if (currentCacheKeyRef.current) {\n const entry = imageCache.get(currentCacheKeyRef.current);\n if (entry) {\n entry.refCount--;\n }\n }\n };\n }, []);\n\n // Everything below is derived, not stored.\n //\n // `active` is the settled result for the key we are being asked about right\n // now; a stale one (the caller bumped `refreshKey`) does not count as an\n // answer. The cache is consulted directly so a HIT renders the blob on the\n // very first pass. The final `settled?.blobUrl` fallback keeps the previously\n // resolved image on screen while a new `refreshKey` reloads it — the old code\n // got that for free by never clearing `fetchedImageUrl` on a key change.\n const active = settled && settled.key === cacheKey ? settled : null;\n const cachedBlobUrl = cacheKey === null ? undefined : imageCache.get(cacheKey)?.blobUrl;\n const resolvedBlobUrl = active?.blobUrl ?? cachedBlobUrl ?? settled?.blobUrl;\n\n return {\n imageUrl: cacheKey === null ? undefined : resolvedBlobUrl,\n isLoading: cacheKey !== null && active === null && cachedBlobUrl === undefined,\n error: (cacheKey === null ? undefined : active?.error) ?? null,\n };\n}\n","/**\n * useQueryParams Hook - GraphQL Integration for URL State Management\n *\n * Automatically generates URL state management from GraphQL queries.\n * Parses query AST at runtime, flattens nested input types, and syncs with URL.\n *\n * @example\n * const LOGS_QUERY = gql`\n * query GetLogs($search: String, $filter: LogFilterInput) { ... }\n * `\n *\n * const { variables, setParam } = useQueryParams(LOGS_QUERY)\n * const { data } = useQuery(LOGS_QUERY, { variables })\n *\n * // URL: /logs?search=error&severity=critical\n * // variables: { search: 'error', filter: { severity: ['critical'] } }\n */\n\n'use client';\n\nimport type { DocumentNode } from 'graphql';\nimport { useEffect, useState, useMemo, useCallback, useRef } from 'react';\nimport { useRouter, useSearchParams } from '../../embed-shims/next-navigation';\n\nimport { flattenQueryVariables, mergeDefaults, validateSchema, type FlattenedParam } from './flatten-schema';\nimport { extractVariablesFromQuery } from './graphql-parser';\nimport { introspector } from './introspection';\nimport {\n urlParamsToVariables,\n variablesToUrlParams,\n mergeVariables,\n clearParams,\n type GraphQLVariables,\n} from './url-converter';\n\n/**\n * Options for useQueryParams hook\n */\nexport interface UseQueryParamsOptions {\n /** Default values for parameters */\n defaultValues?: GraphQLVariables;\n\n /** GraphQL endpoint for introspection (defaults to process.env.NEXT_PUBLIC_API_URL/graphql) */\n introspectionEndpoint?: string;\n\n /** HTTP headers for introspection (e.g., authentication) */\n introspectionHeaders?: Record<string, string>;\n\n /** Skip introspection (use only AST parsing, no nested type flattening) */\n skipIntrospection?: boolean;\n\n /** Custom parameter name mapping (override auto-generated names) */\n paramMapping?: Record<string, string>;\n\n /** Enable debug logging */\n debug?: boolean;\n}\n\n/**\n * Return type for useQueryParams hook\n */\nexport interface UseQueryParamsReturn<TVariables = GraphQLVariables> {\n /** GraphQL variables ready for Apollo Client */\n variables: TVariables;\n\n /** Raw URL parameters (before conversion to variables). Repeated keys\n * collapse into an array, in URL order. */\n params: Record<string, string | string[]>;\n\n /** Flattened parameter schema */\n schema: Record<string, FlattenedParam>;\n\n /** Set a single parameter */\n setParam: (key: string, value: unknown) => void;\n\n /** Set multiple parameters at once */\n setParams: (params: GraphQLVariables) => void;\n\n /** Clear specific parameters */\n clearParams: (keys: string[]) => void;\n\n /** Reset all parameters (clear URL) */\n resetParams: () => void;\n\n /** Whether schema is ready (introspection complete) */\n isReady: boolean;\n\n /** Loading state during initialization */\n isLoading: boolean;\n\n /** Error during initialization */\n error: Error | null;\n}\n\n/**\n * useQueryParams - Auto-generate URL state from GraphQL query\n *\n * This hook:\n * 1. Parses GraphQL query AST to extract variable definitions\n * 2. Fetches GraphQL schema via introspection (optional, cached)\n * 3. Flattens nested input types to simple URL parameters\n * 4. Syncs URL ↔ GraphQL variables bidirectionally\n * 5. Provides type-safe parameter updates\n *\n * @param query - GraphQL DocumentNode (from gql`` template tag)\n * @param options - Configuration options\n * @returns Hook API for managing URL state\n */\nexport function useQueryParams<TVariables = GraphQLVariables>(\n query: DocumentNode,\n options: UseQueryParamsOptions = {},\n): UseQueryParamsReturn<TVariables> {\n const router = useRouter();\n const searchParams = useSearchParams();\n\n const [isLoading, setIsLoading] = useState(true);\n const [isReady, setIsReady] = useState(false);\n const [error, setError] = useState<Error | null>(null);\n const [schema, setSchema] = useState<Record<string, FlattenedParam>>({});\n\n // Extract default values. Memoised: `|| {}` minted a new object on every\n // render, so every memo and effect keyed on it re-ran every time.\n const defaultValues = useMemo(() => options.defaultValues || {}, [options.defaultValues]);\n const skipIntrospection = options.skipIntrospection || false;\n const debug = options.debug || false;\n\n // Latest option bag for the one-time initialiser below. These cannot be\n // dependencies: `introspectionHeaders`, `paramMapping` and `defaultValues`\n // are almost always object literals, so as deps they would re-run schema\n // introspection on every render of the calling component.\n const initSourceRef = useRef({\n introspectionHeaders: options.introspectionHeaders,\n paramMapping: options.paramMapping,\n defaultValues,\n });\n useEffect(() => {\n initSourceRef.current = {\n introspectionHeaders: options.introspectionHeaders,\n paramMapping: options.paramMapping,\n defaultValues,\n };\n });\n\n // Initialize: Parse query + fetch schema (once)\n useEffect(() => {\n // `initialize` awaits introspection and schema flattening, so a `query`\n // change can start a second run while the first is still in flight. Without\n // this guard the SUPERSEDED run's `setSchema`/`setIsReady` could land last\n // and leave the hook parsing the URL against the PREVIOUS query's schema —\n // params for the new query then silently drop or coerce to the wrong type.\n let cancelled = false;\n async function initialize() {\n try {\n if (debug) console.log('[useQueryParams] Initializing...');\n\n // 1. Extract variables from query AST\n const queryVariables = extractVariablesFromQuery(query);\n\n if (debug) {\n console.log('[useQueryParams] Extracted variables:', queryVariables);\n }\n\n // 2. Fetch GraphQL schema via introspection (if needed and not skipped)\n if (!skipIntrospection && !introspector.isLoaded()) {\n const endpoint =\n options.introspectionEndpoint ||\n (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_API_URL\n ? `${process.env.NEXT_PUBLIC_API_URL}/graphql`\n : '');\n\n if (endpoint) {\n try {\n await introspector.fetchSchema(endpoint, initSourceRef.current.introspectionHeaders);\n if (debug) console.log('[useQueryParams] Introspection complete');\n } catch (err) {\n console.warn('[useQueryParams] Introspection failed, continuing without it:', err);\n // Continue without introspection - nested types won't be flattened\n }\n }\n }\n\n // 3. Flatten schema (with or without introspection). Synchronous — the\n // schema fetch it depends on was already awaited in step 2 above.\n let flattenedSchema = flattenQueryVariables(queryVariables, introspector);\n\n // Apply custom param mapping if provided\n const { paramMapping, defaultValues: initialDefaults } = initSourceRef.current;\n if (paramMapping) {\n flattenedSchema = applyParamMapping(flattenedSchema, paramMapping);\n }\n\n // Merge default values\n flattenedSchema = mergeDefaults(flattenedSchema, initialDefaults);\n\n // Validate schema\n validateSchema(flattenedSchema);\n\n if (debug) {\n console.log('[useQueryParams] Flattened schema:', flattenedSchema);\n }\n\n if (cancelled) return;\n setSchema(flattenedSchema);\n setIsReady(true);\n } catch (err) {\n if (cancelled) return;\n const initError = err instanceof Error ? err : new Error(String(err));\n console.error('[useQueryParams] Initialization failed:', initError);\n setError(initError);\n } finally {\n if (!cancelled) setIsLoading(false);\n }\n }\n\n // Never rejects — settles every failure into `error` state.\n void initialize();\n return () => {\n cancelled = true;\n };\n }, [query, options.introspectionEndpoint, skipIntrospection, debug]);\n\n // Convert URL params to GraphQL variables. Kept as the plain record shape\n // internally (that is what the converters take); the caller-chosen\n // `TVariables` view is applied once, at the returned value.\n const variables = useMemo<GraphQLVariables>(() => {\n if (!isReady) {\n return defaultValues;\n }\n\n try {\n const varsFromUrl = urlParamsToVariables(searchParams, schema);\n const merged = { ...defaultValues, ...varsFromUrl };\n\n if (debug) {\n console.log('[useQueryParams] Variables from URL:', merged);\n }\n\n return merged;\n } catch (err) {\n console.error('[useQueryParams] Failed to convert URL to variables:', err);\n return defaultValues;\n }\n }, [searchParams, schema, isReady, defaultValues, debug]);\n\n // Raw URL params (before conversion)\n const params = useMemo(() => {\n const result: Record<string, string | string[]> = {};\n searchParams.forEach((value, key) => {\n const existing = result[key];\n if (existing === undefined) {\n result[key] = value;\n } else if (Array.isArray(existing)) {\n // Multiple values - append to the array\n existing.push(value);\n } else {\n result[key] = [existing, value];\n }\n });\n return result;\n }, [searchParams]);\n\n // Update URL with new parameters\n const updateUrl = useCallback(\n (newParams: URLSearchParams) => {\n const url = newParams.toString() ? `?${newParams.toString()}` : window.location.pathname;\n\n if (debug) {\n console.log('[useQueryParams] Updating URL:', url);\n }\n\n // Use replace for shallow routing (no page reload, no history spam)\n router.replace(url, { scroll: false });\n },\n [router, debug],\n );\n\n // Set a single parameter\n const setParam = useCallback(\n (key: string, value: unknown) => {\n if (!isReady) {\n console.warn('[useQueryParams] Schema not ready, cannot set param');\n return;\n }\n\n try {\n const updated = mergeVariables(variables, { [key]: value }, schema);\n const newParams = variablesToUrlParams(updated, schema);\n updateUrl(newParams);\n } catch (err) {\n console.error('[useQueryParams] Failed to set param:', err);\n }\n },\n [variables, schema, isReady, updateUrl],\n );\n\n // Set multiple parameters\n const setParams = useCallback(\n (updates: GraphQLVariables) => {\n if (!isReady) {\n console.warn('[useQueryParams] Schema not ready, cannot set params');\n return;\n }\n\n try {\n const updated = mergeVariables(variables, updates, schema);\n const newParams = variablesToUrlParams(updated, schema);\n updateUrl(newParams);\n } catch (err) {\n console.error('[useQueryParams] Failed to set params:', err);\n }\n },\n [variables, schema, isReady, updateUrl],\n );\n\n // Clear specific parameters\n const clearParamsHandler = useCallback(\n (keys: string[]) => {\n if (!isReady) {\n console.warn('[useQueryParams] Schema not ready, cannot clear params');\n return;\n }\n\n try {\n const updated = clearParams(variables, keys, schema);\n const newParams = variablesToUrlParams(updated, schema);\n updateUrl(newParams);\n } catch (err) {\n console.error('[useQueryParams] Failed to clear params:', err);\n }\n },\n [variables, schema, isReady, updateUrl],\n );\n\n // Reset all parameters\n const resetParams = useCallback(() => {\n if (debug) {\n console.log('[useQueryParams] Resetting params');\n }\n\n router.replace(window.location.pathname, { scroll: false });\n }, [router, debug]);\n\n return {\n // The only place the caller's `TVariables` view is applied — this hook\n // builds the object from the URL, so its exact shape is the caller's\n // claim about their own query, not something we can verify here.\n variables: variables as TVariables,\n params,\n schema,\n setParam,\n setParams,\n clearParams: clearParamsHandler,\n resetParams,\n isReady,\n isLoading,\n error,\n };\n}\n\n/**\n * Apply custom parameter name mapping to schema\n */\nfunction applyParamMapping(\n schema: Record<string, FlattenedParam>,\n mapping: Record<string, string>,\n): Record<string, FlattenedParam> {\n const mapped: Record<string, FlattenedParam> = {};\n\n for (const [key, param] of Object.entries(schema)) {\n const newKey = mapping[key] || key;\n mapped[newKey] = {\n ...param,\n urlParamName: mapping[key] || param.urlParamName,\n };\n }\n\n return mapped;\n}\n","/**\n * Schema Flattening Utilities for URL State Management\n *\n * Converts nested GraphQL input types to flat URL parameter mappings.\n * Example: { filter: { severity: [...] } } → URL params: ?severity=...\n */\n\nimport type { JSType } from '../../utils/search-params';\nimport type { VariableDefinition } from './graphql-parser';\nimport type { GraphQLIntrospector } from './introspection';\n\n/**\n * Flattened URL parameter configuration\n *\n * Maps URL parameter names to their GraphQL variable paths\n */\nexport interface FlattenedParam {\n /** URL parameter name (e.g., \"severity\") */\n urlParamName: string;\n /** Path in GraphQL variables (e.g., \"filter.severity\") */\n graphqlPath: string;\n /** JavaScript type for URL handling */\n type: JSType;\n /** Default value for this parameter */\n defaultValue?: unknown;\n /** Whether this parameter is required */\n required?: boolean;\n /** Whether this parameter is an array */\n isArray?: boolean;\n}\n\n/**\n * Flatten GraphQL query variables to URL parameter schema\n *\n * Takes top-level query variables and flattens nested input objects\n * to create a simple URL parameter mapping.\n *\n * @param queryVariables - Variables extracted from GraphQL query\n * @param introspector - Introspector instance with loaded schema\n * @returns Flattened parameter schema for URL handling\n *\n * @example\n * Input variables:\n * {\n * search: { type: 'string', ... },\n * filter: { type: 'object', graphqlTypeName: 'LogFilterInput' },\n * cursor: { type: 'string', ... }\n * }\n *\n * Output schema:\n * {\n * search: { urlParamName: 'search', graphqlPath: 'search', type: 'string' },\n * severity: { urlParamName: 'severity', graphqlPath: 'filter.severity', type: 'array' },\n * toolType: { urlParamName: 'toolType', graphqlPath: 'filter.toolType', type: 'array' },\n * cursor: { urlParamName: 'cursor', graphqlPath: 'cursor', type: 'string' }\n * }\n *\n * URL: ?search=error&severity=critical&toolType=rmm&cursor=abc\n */\nexport function flattenQueryVariables(\n queryVariables: Record<string, VariableDefinition>,\n introspector: GraphQLIntrospector,\n): Record<string, FlattenedParam> {\n const flattened: Record<string, FlattenedParam> = {};\n\n for (const [varName, varDef] of Object.entries(queryVariables)) {\n // Primitive types or arrays - keep at top level\n if (varDef.type !== 'object') {\n flattened[varName] = {\n urlParamName: varName,\n graphqlPath: varName,\n type: varDef.isArray ? 'array' : varDef.type,\n required: varDef.required,\n isArray: varDef.isArray,\n };\n continue;\n }\n\n // Input object types - flatten fields to top level\n // This requires introspection to know the input type's fields\n if (introspector.isLoaded() && introspector.hasType(varDef.graphqlTypeName)) {\n const fields = introspector.getInputTypeFields(varDef.graphqlTypeName);\n\n for (const [fieldName, fieldDef] of Object.entries(fields)) {\n flattened[fieldName] = {\n urlParamName: fieldName,\n graphqlPath: `${varName}.${fieldName}`,\n type: fieldDef.isArray ? 'array' : fieldDef.type,\n required: fieldDef.required,\n isArray: fieldDef.isArray,\n };\n }\n } else {\n // Introspection not available or type not found\n // Keep as top-level object (will need manual handling)\n flattened[varName] = {\n urlParamName: varName,\n graphqlPath: varName,\n type: 'object',\n required: varDef.required,\n isArray: false,\n };\n }\n }\n\n return flattened;\n}\n\n/**\n * Merge default values into flattened schema\n *\n * @param schema - Flattened parameter schema\n * @param defaults - Default values keyed by URL param name\n * @returns Updated schema with default values\n */\nexport function mergeDefaults(\n schema: Record<string, FlattenedParam>,\n defaults: Record<string, unknown>,\n): Record<string, FlattenedParam> {\n const merged: Record<string, FlattenedParam> = {};\n\n for (const [key, param] of Object.entries(schema)) {\n merged[key] = {\n ...param,\n defaultValue: defaults[key] !== undefined ? defaults[key] : param.defaultValue,\n };\n }\n\n return merged;\n}\n\n/**\n * Validate that flattened schema has no conflicts\n *\n * Ensures that no two parameters map to the same URL param name\n *\n * @param schema - Flattened parameter schema\n * @throws Error if conflicts are detected\n */\nexport function validateSchema(schema: Record<string, FlattenedParam>): void {\n const urlParamNames = new Set<string>();\n\n for (const param of Object.values(schema)) {\n if (urlParamNames.has(param.urlParamName)) {\n throw new Error(`[FlattenSchema] Conflict: Multiple parameters map to URL param \"${param.urlParamName}\"`);\n }\n urlParamNames.add(param.urlParamName);\n }\n}\n\n/**\n * Get all array-type parameters from schema\n *\n * Useful for knowing which URL params should use repeated values\n * (e.g., ?severity=error&severity=warning)\n *\n * @param schema - Flattened parameter schema\n * @returns Array parameter names\n */\nexport function getArrayParams(schema: Record<string, FlattenedParam>): string[] {\n return Object.entries(schema)\n .filter(([_, param]) => param.type === 'array' || param.isArray)\n .map(([key]) => key);\n}\n\n/**\n * Get required parameters from schema\n *\n * @param schema - Flattened parameter schema\n * @returns Required parameter names\n */\nexport function getRequiredParams(schema: Record<string, FlattenedParam>): string[] {\n return Object.entries(schema)\n .filter(([_, param]) => param.required)\n .map(([key]) => key);\n}\n\n// THE omission rule lives in the search-params leaf (the server serializer and\n// this flattened-schema path must agree); re-exported for existing importers.\nexport { shouldIncludeInUrl } from '../../utils/search-params';\n","/**\n * GraphQL AST Parser for URL State Management\n *\n * Extracts variable definitions from GraphQL DocumentNode at runtime\n * to automatically generate URL parameter handling.\n */\n\nimport { type DocumentNode, type VariableDefinitionNode, type TypeNode, visit } from 'graphql';\n\n// `JSType` is OWNED by the search-params leaf (server and client share it).\n// Imported, not re-exported: a second export site is a second place to look\n// when you want to know where the type lives.\nimport type { JSType } from '../../utils/search-params';\n\n/**\n * Variable definition extracted from GraphQL query\n */\nexport interface VariableDefinition {\n /** Variable name (e.g., \"search\", \"filter\") */\n name: string;\n /** JavaScript type for URL parameter handling */\n type: JSType;\n /** Whether the variable is required (non-null in GraphQL) */\n required: boolean;\n /** Whether the variable is an array/list */\n isArray: boolean;\n /** Original GraphQL type name (e.g., \"String\", \"LogFilterInput\") */\n graphqlTypeName: string;\n}\n\n/**\n * Parsed type information from GraphQL TypeNode\n */\ninterface ParsedType {\n typeName: string;\n isNonNull: boolean;\n isList: boolean;\n}\n\n/**\n * Extract all variable definitions from a GraphQL query\n *\n * @param query - GraphQL DocumentNode (from gql template tag)\n * @returns Record of variable definitions keyed by variable name\n *\n * @example\n * const LOGS_QUERY = gql`\n * query GetLogs($search: String, $filter: LogFilterInput) { ... }\n * `\n *\n * const variables = extractVariablesFromQuery(LOGS_QUERY)\n * // {\n * // search: { name: 'search', type: 'string', ... },\n * // filter: { name: 'filter', type: 'object', graphqlTypeName: 'LogFilterInput' }\n * // }\n */\nexport function extractVariablesFromQuery(query: DocumentNode): Record<string, VariableDefinition> {\n const variables: Record<string, VariableDefinition> = {};\n\n visit(query, {\n VariableDefinition(node: VariableDefinitionNode) {\n const name = node.variable.name.value;\n const typeInfo = parseGraphQLType(node.type);\n\n variables[name] = {\n name,\n type: mapGraphQLTypeToJS(typeInfo.typeName),\n required: typeInfo.isNonNull,\n isArray: typeInfo.isList,\n graphqlTypeName: typeInfo.typeName,\n };\n },\n });\n\n return variables;\n}\n\n/**\n * Parse GraphQL TypeNode to extract type information\n * Handles NonNullType, ListType, and NamedType recursively\n *\n * @param typeNode - GraphQL type node from AST\n * @returns Parsed type information\n */\nfunction parseGraphQLType(typeNode: TypeNode): ParsedType {\n let typeName = '';\n let isNonNull = false;\n let isList = false;\n let current: TypeNode = typeNode;\n\n // Unwrap NonNullType wrapper\n if (current.kind === 'NonNullType') {\n isNonNull = true;\n current = current.type;\n }\n\n // Handle ListType\n if (current.kind === 'ListType') {\n isList = true;\n current = current.type;\n\n // ListType can also be wrapped in NonNullType\n if (current.kind === 'NonNullType') {\n current = current.type;\n }\n }\n\n // Extract the base type name\n if (current.kind === 'NamedType') {\n typeName = current.name.value;\n }\n\n return {\n typeName,\n isNonNull,\n isList,\n };\n}\n\n/**\n * Map GraphQL scalar/input types to JavaScript types\n *\n * @param graphqlType - GraphQL type name (e.g., \"String\", \"Int\", \"LogFilterInput\")\n * @returns JavaScript type for URL parameter handling\n */\nfunction mapGraphQLTypeToJS(graphqlType: string): JSType {\n // GraphQL scalar types\n const scalarTypeMap: Record<string, JSType> = {\n String: 'string',\n Int: 'number',\n Float: 'number',\n Boolean: 'boolean',\n ID: 'string',\n };\n\n // Check if it's a known scalar\n if (scalarTypeMap[graphqlType]) {\n return scalarTypeMap[graphqlType];\n }\n\n // Unknown types are assumed to be input objects\n // These will be flattened using introspection\n return 'object';\n}\n\n/**\n * Check if a GraphQL type is a scalar type\n */\nexport function isScalarType(graphqlTypeName: string): boolean {\n const scalars = ['String', 'Int', 'Float', 'Boolean', 'ID'];\n return scalars.includes(graphqlTypeName);\n}\n\n/**\n * Check if a GraphQL type is an input object type\n */\nexport function isInputObjectType(graphqlTypeName: string): boolean {\n return !isScalarType(graphqlTypeName);\n}\n","/**\n * GraphQL Schema Introspection for URL State Management\n *\n * Fetches GraphQL schema via introspection query at runtime (works behind auth).\n * Caches schema in localStorage + memory for performance.\n */\n\nimport {\n getIntrospectionQuery,\n buildClientSchema,\n type IntrospectionQuery,\n type GraphQLSchema,\n type GraphQLInputType,\n isInputObjectType,\n} from 'graphql';\nimport type { JSType } from '../../utils/search-params';\nimport type { VariableDefinition } from './graphql-parser';\n\n/**\n * Schema cache structure stored in localStorage\n */\ninterface SchemaCache {\n schema: IntrospectionQuery;\n timestamp: number;\n version: string;\n}\n\n/**\n * GraphQL Introspection Manager\n *\n * Singleton class that handles schema introspection with caching.\n * Fetches schema once per session and caches for 24 hours.\n */\nexport class GraphQLIntrospector {\n private schema: GraphQLSchema | null = null;\n private cacheKey = 'graphql-schema-cache-v1';\n private cacheDuration = 24 * 60 * 60 * 1000; // 24 hours\n private schemaVersion = '1.0.0';\n\n /**\n * Fetch GraphQL schema via introspection query\n *\n * @param endpoint - GraphQL endpoint URL\n * @param headers - HTTP headers (e.g., authentication)\n * @param skipCache - Force fresh fetch, ignore cache\n *\n * @example\n * await introspector.fetchSchema(\n * 'http://localhost/api/graphql',\n * { 'Authorization': 'Bearer token123' }\n * )\n */\n async fetchSchema(endpoint: string, headers: Record<string, string> = {}, skipCache = false): Promise<void> {\n // Check cache first (unless skipped)\n if (!skipCache) {\n const cached = this.loadFromCache();\n if (cached && Date.now() - cached.timestamp < this.cacheDuration) {\n try {\n this.schema = buildClientSchema(cached.schema);\n return;\n } catch (error) {\n console.warn('[Introspector] Failed to build schema from cache:', error);\n // Continue to fetch fresh schema\n }\n }\n }\n\n // Fetch schema via introspection query\n try {\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n ...headers,\n },\n body: JSON.stringify({\n query: getIntrospectionQuery(),\n }),\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n // Standard GraphQL execution result. `data` carries the introspection\n // payload `buildClientSchema` expects; `errors`, when present, is only\n // ever stringified for the thrown message.\n const { data, errors } = (await response.json()) as {\n data?: IntrospectionQuery;\n errors?: readonly unknown[];\n };\n\n if (errors) {\n throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`);\n }\n\n if (!data) {\n throw new Error('No data returned from introspection query');\n }\n\n // Build and cache schema\n this.schema = buildClientSchema(data);\n this.saveToCache(data);\n } catch (error) {\n console.error('[Introspector] Failed to fetch schema:', error);\n throw error;\n }\n }\n\n /**\n * Get fields from a GraphQL input object type\n *\n * @param typeName - Name of the input type (e.g., \"LogFilterInput\")\n * @returns Record of field definitions\n *\n * @example\n * const fields = introspector.getInputTypeFields('LogFilterInput')\n * // {\n * // severity: { name: 'severity', type: 'array', ... },\n * // toolType: { name: 'toolType', type: 'array', ... }\n * // }\n */\n getInputTypeFields(typeName: string): Record<string, VariableDefinition> {\n if (!this.schema) {\n return {};\n }\n\n const type = this.schema.getType(typeName);\n\n if (!type || !isInputObjectType(type)) {\n return {};\n }\n\n const fields: Record<string, VariableDefinition> = {};\n const typeFields = type.getFields();\n\n for (const [fieldName, field] of Object.entries(typeFields)) {\n const fieldType = this.unwrapType(field.type);\n\n fields[fieldName] = {\n name: fieldName,\n type: this.mapGraphQLTypeToJS(fieldType.typeName),\n required: fieldType.isNonNull,\n isArray: fieldType.isList,\n graphqlTypeName: fieldType.typeName,\n };\n }\n\n return fields;\n }\n\n /**\n * Check if a type exists in the schema\n */\n hasType(typeName: string): boolean {\n if (!this.schema) return false;\n return this.schema.getType(typeName) !== undefined;\n }\n\n /**\n * Get the schema (if loaded)\n */\n getSchema(): GraphQLSchema | null {\n return this.schema;\n }\n\n /**\n * Check if schema is loaded\n */\n isLoaded(): boolean {\n return this.schema !== null;\n }\n\n /**\n * Clear cached schema\n */\n clearCache(): void {\n try {\n localStorage.removeItem(this.cacheKey);\n this.schema = null;\n } catch {\n // Ignore storage errors\n }\n }\n\n /**\n * Unwrap GraphQL type to get base type info\n * Handles NonNull and List wrappers\n */\n private unwrapType(type: GraphQLInputType): {\n typeName: string;\n isNonNull: boolean;\n isList: boolean;\n } {\n let typeName = '';\n let isNonNull = false;\n let isList = false;\n let current: GraphQLInputType = type;\n\n // Unwrap NonNull wrapper\n if (current.toString().endsWith('!')) {\n isNonNull = true;\n current = 'ofType' in current ? current.ofType : current;\n }\n\n // Check for List wrapper\n if (current.toString().startsWith('[')) {\n isList = true;\n current = 'ofType' in current ? current.ofType : current;\n }\n\n // Handle inner NonNull (e.g., [String!])\n if (current.toString().endsWith('!')) {\n current = 'ofType' in current ? current.ofType : current;\n }\n\n // Get base type name\n // Only NAMED types carry `name`; list/non-null wrappers fall back to\n // stripping the wrapper punctuation off their printed form.\n const named = 'name' in current ? current.name : undefined;\n typeName = named || current.toString().replace(/[[\\]!]/g, '');\n\n return { typeName, isNonNull, isList };\n }\n\n /**\n * Map GraphQL types to JavaScript types for URL handling\n */\n private mapGraphQLTypeToJS(graphqlType: string): JSType {\n const typeMap: Record<string, JSType> = {\n String: 'string',\n Int: 'number',\n Float: 'number',\n Boolean: 'boolean',\n ID: 'string',\n };\n\n return typeMap[graphqlType] || 'object';\n }\n\n /**\n * Load schema from localStorage cache\n */\n private loadFromCache(): SchemaCache | null {\n try {\n const cached = localStorage.getItem(this.cacheKey);\n if (!cached) return null;\n\n const parsed = JSON.parse(cached) as SchemaCache;\n\n // Validate cache version\n if (parsed.version !== this.schemaVersion) {\n return null;\n }\n\n return parsed;\n } catch (error) {\n console.warn('[Introspector] Failed to load cache:', error);\n return null;\n }\n }\n\n /**\n * Save schema to localStorage cache\n */\n private saveToCache(schema: IntrospectionQuery): void {\n try {\n const cache: SchemaCache = {\n schema,\n timestamp: Date.now(),\n version: this.schemaVersion,\n };\n\n localStorage.setItem(this.cacheKey, JSON.stringify(cache));\n } catch (error) {\n console.warn('[Introspector] Failed to save cache:', error);\n // Ignore storage errors (e.g., quota exceeded)\n }\n }\n}\n\n/**\n * Singleton introspector instance\n *\n * Use this instance throughout the application to share schema cache\n *\n * @example\n * import { introspector } from '@flamingo/ui-kit/hooks/state'\n *\n * // Initialize after auth\n * await introspector.fetchSchema(endpoint, { Authorization: token })\n *\n * // Get input type fields\n * const fields = introspector.getInputTypeFields('LogFilterInput')\n */\nexport const introspector = new GraphQLIntrospector();\n","/**\n * URL ↔ Variables Converter for URL State Management\n *\n * Bidirectional conversion between URL parameters and GraphQL variables.\n * Handles type coercion, nested paths, and array parameters.\n */\n\nimport { positiveInt } from '../../utils/search-params';\nimport type { JSType } from '../../utils/search-params';\nimport { type FlattenedParam, shouldIncludeInUrl } from './flatten-schema';\n\n/** A single URL-decodable value. */\nexport type UrlScalar = string | number | boolean | null;\n/** What `coerceValue` can produce for one URL parameter. */\nexport type UrlParamValue = UrlScalar | UrlScalar[];\n\n/** GraphQL variables object — nested, values are opaque to this module. */\nexport type GraphQLVariables = Record<string, unknown>;\n\n/** Narrow an unknown to something indexable by string key. Arrays qualify\n * (path segments may address array indices), `null` does not. */\nfunction isIndexable(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\n/**\n * Convert URL search params to GraphQL variables\n *\n * Reads URL parameters and reconstructs the nested GraphQL variables object\n * based on the flattened schema mapping.\n *\n * @param searchParams - URLSearchParams from window.location or Next.js\n * @param schema - Flattened parameter schema\n * @returns GraphQL variables object ready for Apollo Client\n *\n * @example\n * URL: ?search=error&severity=critical&severity=error&cursor=abc\n * Schema: {\n * search: { graphqlPath: 'search', type: 'string' },\n * severity: { graphqlPath: 'filter.severity', type: 'array' },\n * cursor: { graphqlPath: 'cursor', type: 'string' }\n * }\n * Result: {\n * search: 'error',\n * filter: { severity: ['critical', 'error'] },\n * cursor: 'abc'\n * }\n */\nexport function urlParamsToVariables(\n searchParams: URLSearchParams,\n schema: Record<string, FlattenedParam>,\n): GraphQLVariables {\n const variables: GraphQLVariables = {};\n\n for (const [paramName, paramConfig] of Object.entries(schema)) {\n // Read value from URL\n const rawValue =\n paramConfig.type === 'array' || paramConfig.isArray\n ? searchParams.getAll(paramName)\n : searchParams.get(paramName);\n\n // Skip if no value in URL\n if (!rawValue || (Array.isArray(rawValue) && rawValue.length === 0)) {\n // Use default value if available\n if (paramConfig.defaultValue !== undefined) {\n setNestedValue(variables, paramConfig.graphqlPath, paramConfig.defaultValue);\n }\n continue;\n }\n\n // Coerce value to correct type\n const value = coerceValue(rawValue, paramConfig.type);\n\n // Set value at nested path\n setNestedValue(variables, paramConfig.graphqlPath, value);\n }\n\n return variables;\n}\n\n/**\n * Convert GraphQL variables to URL search params\n *\n * Flattens nested GraphQL variables to URL parameters based on schema mapping.\n * Excludes null/undefined/default values to keep URLs clean.\n *\n * @param variables - GraphQL variables object\n * @param schema - Flattened parameter schema\n * @returns URLSearchParams ready for router.push()\n *\n * @example\n * Variables: {\n * search: 'error',\n * filter: { severity: ['critical'], toolType: [] },\n * cursor: null\n * }\n * Result URL: ?search=error&severity=critical\n * (empty arrays and null values excluded)\n */\nexport function variablesToUrlParams(\n variables: GraphQLVariables,\n schema: Record<string, FlattenedParam>,\n): URLSearchParams {\n const params = new URLSearchParams();\n\n for (const [paramName, paramConfig] of Object.entries(schema)) {\n // Get value from nested path\n const value = getNestedValue(variables, paramConfig.graphqlPath);\n\n // Skip if should not include in URL\n if (!shouldIncludeInUrl(value, paramConfig)) {\n continue;\n }\n\n // Add to URL params\n if (Array.isArray(value)) {\n // Array: Use repeated params (e.g., ?tag=foo&tag=bar)\n value.forEach(v => {\n if (v !== null && v !== undefined && v !== '') {\n params.append(paramName, String(v));\n }\n });\n } else {\n // Single value: Use set\n params.set(paramName, String(value));\n }\n }\n\n return params;\n}\n\n/**\n * Coerce URL parameter value to correct JavaScript type\n *\n * @param value - Raw value from URLSearchParams (string or string[])\n * @param type - Target JavaScript type\n * @returns Coerced value\n */\nexport function coerceValue(value: string | string[], type: JSType): UrlParamValue {\n // Array handling — each element is coerced as a scalar ('array' degrades\n // to 'string', same as before).\n if (Array.isArray(value)) {\n return value.map(v => coerceScalar(v, type === 'array' ? 'string' : type));\n }\n\n // Single value under an array-typed param becomes a one-element array.\n if (type === 'array') return [value];\n\n return coerceScalar(value, type);\n}\n\n/** Scalar half of {@link coerceValue}. `'array'` never reaches here. */\nfunction coerceScalar(value: string, type: JSType): UrlScalar {\n switch (type) {\n case 'number': {\n const num = parseFloat(value);\n return isNaN(num) ? null : num;\n }\n\n case 'int':\n // THE integer grammar has ONE owner. This used to inline its own copy,\n // which then drifted (the shared one honours a configured `max`).\n // `positiveInt` returns the fallback for anything unparseable, and this\n // path has no schema config to read a default from, so `null` it is.\n return positiveInt<null>(value, null);\n\n case 'boolean':\n return value === 'true' || value === '1';\n\n case 'string':\n default:\n return value;\n }\n}\n\n/**\n * Set value at nested path in object\n *\n * Creates intermediate objects as needed.\n *\n * @param obj - Target object to modify\n * @param path - Dot-separated path (e.g., \"filter.severity\")\n * @param value - Value to set\n *\n * @example\n * setNestedValue({}, 'filter.severity', ['error'])\n * // Result: { filter: { severity: ['error'] } }\n */\nexport function setNestedValue(obj: GraphQLVariables, path: string, value: unknown): void {\n const parts = path.split('.');\n let current = obj;\n\n // Navigate/create intermediate objects\n for (let i = 0; i < parts.length - 1; i++) {\n const part = parts[i];\n const existing = current[part];\n // `null` counts as \"not an object\" here — the old `typeof x === 'object'`\n // check let a null segment through and then dereferenced it.\n const next = isIndexable(existing) ? existing : {};\n if (next !== existing) current[part] = next;\n current = next;\n }\n\n // Set final value\n const lastPart = parts[parts.length - 1];\n current[lastPart] = value;\n}\n\n/**\n * Get value from nested path in object\n *\n * Returns undefined if path doesn't exist.\n *\n * @param obj - Source object\n * @param path - Dot-separated path (e.g., \"filter.severity\")\n * @returns Value at path or undefined\n *\n * @example\n * getNestedValue({ filter: { severity: ['error'] } }, 'filter.severity')\n * // Result: ['error']\n */\nexport function getNestedValue(obj: unknown, path: string): unknown {\n return path.split('.').reduce<unknown>((current, key) => {\n return isIndexable(current) ? current[key] : undefined;\n }, obj);\n}\n\n/**\n * Merge URL parameters into existing variables\n *\n * Useful for updating specific parameters without losing others.\n *\n * @param currentVariables - Current GraphQL variables\n * @param updates - Parameter updates (flat object)\n * @param schema - Flattened parameter schema\n * @returns Updated variables object\n */\nexport function mergeVariables(\n currentVariables: GraphQLVariables,\n updates: Record<string, unknown>,\n schema: Record<string, FlattenedParam>,\n): GraphQLVariables {\n const merged = { ...currentVariables };\n\n for (const [paramName, value] of Object.entries(updates)) {\n const paramConfig = schema[paramName];\n if (!paramConfig) continue;\n\n setNestedValue(merged, paramConfig.graphqlPath, value);\n }\n\n return merged;\n}\n\n/**\n * Clear specific parameters from variables\n *\n * @param variables - Current GraphQL variables\n * @param paramNames - Parameter names to clear\n * @param schema - Flattened parameter schema\n * @returns Variables with specified params removed\n */\nexport function clearParams(\n variables: GraphQLVariables,\n paramNames: string[],\n schema: Record<string, FlattenedParam>,\n): GraphQLVariables {\n const cleared = { ...variables };\n\n for (const paramName of paramNames) {\n const paramConfig = schema[paramName];\n if (!paramConfig) continue;\n\n // Set to undefined (will be excluded from URL)\n setNestedValue(cleared, paramConfig.graphqlPath, undefined);\n }\n\n return cleared;\n}\n\n/**\n * Validate GraphQL variables against schema\n *\n * Checks for required parameters and type consistency.\n *\n * @param variables - GraphQL variables to validate\n * @param schema - Flattened parameter schema\n * @returns Validation errors (empty array if valid)\n */\nexport function validateVariables(variables: GraphQLVariables, schema: Record<string, FlattenedParam>): string[] {\n const errors: string[] = [];\n\n for (const [paramName, paramConfig] of Object.entries(schema)) {\n const value = getNestedValue(variables, paramConfig.graphqlPath);\n\n // Check required parameters\n if (paramConfig.required && (value === null || value === undefined)) {\n errors.push(`Required parameter \"${paramName}\" is missing`);\n }\n\n // Check type consistency\n if (value !== null && value !== undefined) {\n const actualType = Array.isArray(value) ? 'array' : typeof value;\n const expectedType = paramConfig.type === 'array' ? 'array' : paramConfig.type;\n\n if (actualType !== expectedType && actualType !== 'object') {\n errors.push(`Parameter \"${paramName}\" has wrong type: expected ${expectedType}, got ${actualType}`);\n }\n }\n }\n\n return errors;\n}\n","/**\n * useApiParams Hook - REST API Integration for URL State Management\n *\n * Manual schema definition for REST APIs. Provides same URL sync functionality\n * as useQueryParams but without GraphQL dependency.\n *\n * @example\n * const { params, setParam } = useApiParams({\n * search: { type: 'string', default: '' },\n * page: { type: 'number', default: 1 },\n * tags: { type: 'array', default: [] }\n * })\n *\n * fetch(`/api/items?${new URLSearchParams(params)}`)\n *\n * // URL: /items?search=laptop&page=2&tags=electronics&tags=sale\n * // params: { search: 'laptop', page: 2, tags: ['electronics', 'sale'] }\n */\n\n'use client';\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useRouter, useSearchParams } from '../../embed-shims/next-navigation';\nimport { createSearchParams, parseSchemaParams, type ParamSchema, type JSType } from '../../utils/search-params';\nimport { type FlattenedParam, shouldIncludeInUrl } from './flatten-schema';\n\n/**\n * Returns the previous reference if the JSON-serialized content of `value`\n * hasn't changed across renders. Internal helper used to shield consumers from\n * ref churn caused by:\n * - `useSearchParams()` returning a new `ReadonlyURLSearchParams` instance\n * on every render even when the URL is unchanged (Next.js behavior).\n * - Consumers passing the schema as a fresh object literal on every render.\n */\nfunction useContentStable<T>(value: T, key: string): T {\n // React's \"adjust state while rendering\" pattern rather than a ref written\n // during render: the cache must not be updated by a render attempt React\n // discards (concurrent interruption, error retry), or a value that never\n // committed would be handed to every later render as the stable one. The\n // extra render pass only happens when the content key actually changes,\n // which for the one caller (the param schema) is essentially never after\n // mount.\n const [cached, setCached] = useState<{ value: T; key: string }>({ value, key });\n if (cached.key !== key) setCached({ value, key });\n // Same object either way: the pass that stores `value` also returns it.\n return cached.key === key ? cached.value : value;\n}\n\n/**\n * Reuses a previous array reference if its content (shallow string equality)\n * matches the freshly parsed array. Lets `params.tier` etc. stay\n * reference-stable across renders that don't actually change those values.\n */\nfunction reuseIfShallowEqual<T extends string | number | boolean>(prev: unknown, next: T[]): T[] {\n if (!Array.isArray(prev) || prev.length !== next.length) return next;\n for (let i = 0; i < next.length; i++) {\n if (prev[i] !== next[i]) return next;\n }\n return prev as T[];\n}\n\n/** Cached params snapshot plus the raw parse it was derived from. */\ninterface ParamsCache {\n src: Record<string, unknown>;\n value: Record<string, unknown>;\n}\n\n/**\n * Copy of `next` in which every array field whose content matches `prev`'s\n * keeps `prev`'s instance. Returns `next` untouched when nothing was reusable,\n * so an unrelated change costs no extra allocation.\n */\nfunction reuseArrayRefs(prev: Record<string, unknown>, next: Record<string, unknown>): Record<string, unknown> {\n let merged: Record<string, unknown> | undefined;\n for (const [key, value] of Object.entries(next)) {\n if (!Array.isArray(value)) continue;\n const reused = reuseIfShallowEqual(prev[key], value as (string | number | boolean)[]);\n if (reused === value) continue;\n merged ??= { ...next };\n merged[key] = reused;\n }\n return merged ?? next;\n}\n\n/**\n * Type mapping from JSType to TypeScript types for OUTPUT (reading params)\n */\ntype OutputTypeMap = {\n string: string;\n number: number;\n boolean: boolean;\n array: string[];\n object: Record<string, unknown>;\n int: number;\n};\n\n/**\n * Type mapping from JSType to TypeScript types for INPUT (setting params)\n * More permissive to allow null/undefined in arrays which get filtered\n */\ntype InputTypeMap = {\n string: string | null | undefined;\n number: number | null | undefined;\n boolean: boolean | null | undefined;\n array: (string | null | undefined)[];\n object: Record<string, unknown> | null | undefined;\n int: number | null | undefined;\n};\n\n/**\n * Get the TypeScript type for OUTPUT (reading from params)\n */\ntype OutputTypeForJSType<T extends JSType> = OutputTypeMap[T];\n\n/**\n * Get the TypeScript type for INPUT (setting params)\n */\ntype InputTypeForJSType<T extends JSType> = InputTypeMap[T];\n\n// Schema types + the schema helper live in the search-params leaf, so the hook's\n// `params` ARE the same parsed contract a server route produces for that URL.\nexport type { ParamConfig, ParamSchema } from '../../utils/search-params';\nexport { defineParamSchema } from '../../utils/search-params';\n\n/**\n * Options for useApiParams hook\n */\nexport interface UseApiParamsOptions {\n /** Enable debug logging */\n debug?: boolean;\n /**\n * What an ABSENT scalar with no declared `default` reads as. Passed straight\n * through to `parseSchemaParams`, so the hook's `params` ARE the same parsed\n * contract the server produces for the same URL.\n */\n absent?: 'undefined' | 'null';\n}\n\n/**\n * Infer the OUTPUT params type from a ParamSchema (for reading)\n * Maps each key in the schema to its corresponding TypeScript type\n *\n * @example\n * const schema = defineParamSchema({\n * search: { type: 'string', default: '' },\n * page: { type: 'number', default: 1 },\n * tags: { type: 'array', default: [] }\n * })\n * type Params = InferParamsFromSchema<typeof schema>\n * // { search: string; page: number; tags: string[] }\n */\nexport type InferParamsFromSchema<TSchema extends ParamSchema> = {\n [K in keyof TSchema]: TSchema[K]['type'] extends infer T\n ? T extends JSType\n ? OutputTypeForJSType<T>\n : never\n : never;\n};\n\n/**\n * Infer the INPUT params type from a ParamSchema (for setting)\n * More permissive to allow null/undefined values\n */\nexport type InferInputParamsFromSchema<TSchema extends ParamSchema> = {\n [K in keyof TSchema]: TSchema[K]['type'] extends infer T ? (T extends JSType ? InputTypeForJSType<T> : never) : never;\n};\n\n/**\n * Type for parameter values that can be set\n * Allows setting values that match the schema types or can be coerced to them\n */\nexport type ParamValue =\n string | number | boolean | string[] | (string | null | undefined)[] | Record<string, unknown> | null | undefined;\n\n/**\n * Return type for useApiParams hook with strict typing\n */\nexport interface UseApiParamsReturn<TSchema extends ParamSchema, TParams = InferParamsFromSchema<TSchema>> {\n /** Parsed parameters object with strict typing */\n params: TParams;\n\n /**\n * `params` of the last write this hook issued, or `params` itself when no\n * write is in flight. The URL is authoritative; this is the latest INTENT.\n */\n pendingParams: TParams;\n\n /** URLSearchParams for fetch/axios */\n urlSearchParams: URLSearchParams;\n\n /** Set a single parameter with type-safe key and value */\n setParam: <K extends keyof TSchema & string>(key: K, value: InferInputParamsFromSchema<Pick<TSchema, K>>[K]) => void;\n\n /** Set multiple parameters at once */\n setParams: (updates: Partial<InferInputParamsFromSchema<TSchema>>) => void;\n\n /** Clear specific parameters */\n clearParams: (keys: (keyof TSchema & string)[]) => void;\n\n /** Reset all parameters (clear URL) */\n resetParams: () => void;\n}\n\n/**\n * useApiParams - Manual URL state for REST APIs\n *\n * This hook:\n * 1. Reads URL search parameters\n * 2. Coerces to correct types based on schema\n * 3. Provides type-safe parameter updates\n * 4. Syncs changes to URL automatically\n *\n * @param schema - Parameter schema definition\n * @param options - Configuration options\n * @returns Hook API for managing URL state\n */\nexport function useApiParams<TSchema extends ParamSchema>(\n schema: TSchema,\n options: UseApiParamsOptions = {},\n): UseApiParamsReturn<TSchema> {\n const router = useRouter();\n const searchParamsLive = useSearchParams();\n const debug = options.debug || false;\n\n // ───── Reference-stability layer ──────────────────────────────────────\n //\n // Goal: `params`, `params.<arrayField>`, and the setter callbacks must keep\n // the SAME reference across renders unless the URL or schema content\n // actually changes. Otherwise consumer `useMemo`/`useEffect` deps that\n // include `params.foo` invalidate on every parent re-render.\n //\n // Without this, every call site has to defensively `JSON.stringify` filter\n // arrays into a content-key — a known footgun. The stability is provided\n // here, once, instead of in 17 consumers.\n\n // 1. URL string is the canonical, value-stable representation of search params.\n const searchString = searchParamsLive.toString();\n\n // Serialized query strings this hook has written and not yet seen committed.\n const pendingRef = useRef<string[]>([]);\n // The queue's TAIL, mirrored into state: `pendingParams` is render output, and\n // reading a ref during render is both a lint error and a correctness hazard\n // (the value would not re-render the consumer). The ref stays the mutable\n // queue the commit effect drains; this is the one part render may see.\n const [pendingTail, setPendingTail] = useState<string | undefined>(undefined);\n const syncPendingTail = useCallback(() => {\n setPendingTail(pendingRef.current[pendingRef.current.length - 1]);\n }, []);\n\n // 2. Schema reference stabilized by content. Consumers commonly pass an\n // object literal each render, which would otherwise invalidate every memo.\n const schemaKey = useMemo(() => JSON.stringify(schema), [schema]);\n const stableSchema = useContentStable(schema, schemaKey);\n\n // ──────────────────────────────────────────────────────────────────────\n\n // Convert schema to flattened format for reuse\n const flattenedSchema = useMemo((): Record<string, FlattenedParam> => {\n const flattened: Record<string, FlattenedParam> = {};\n\n for (const [key, config] of Object.entries(stableSchema)) {\n flattened[key] = {\n urlParamName: key,\n graphqlPath: key,\n type: config.type,\n defaultValue: config.default,\n required: config.required,\n isArray: config.type === 'array',\n };\n }\n\n return flattened;\n }, [stableSchema]);\n\n // Parse URL parameters. PURE — it reads nothing but its own dependencies, so\n // a render attempt React discards leaves no trace.\n //\n // THE reader, and the SAME one `pendingParams` uses. This used to walk the\n // schema itself through `coerceValue`, which knows only a param's TYPE — so\n // `min`/`max`/`default` were applied to the pending params and not to the\n // committed ones. `?pageSize=9999` then read as 9999 here and 100 there, and\n // `?page=abc` as `null` here and `1` there, which is enough to desynchronize\n // any adapter that compares the two.\n const rawParams = useMemo((): Record<string, unknown> => {\n const result = parseSchemaParams(stableSchema, new URLSearchParams(searchString), {\n absent: options.absent === 'null' ? 'null' : 'undefined',\n }) as Record<string, unknown>;\n\n if (debug) {\n console.log('[useApiParams] Parsed params:', result);\n }\n\n return result;\n }, [searchString, debug, stableSchema, options.absent]);\n\n // Carry the previously COMMITTED array instances forward when their content\n // is unchanged, so `params.<arrayField>` stays reference-stable across a URL\n // change that touched some OTHER param. Held in state and updated with\n // React's \"adjust state while rendering\" pattern rather than in a ref: the\n // carry-forward source must be a params object that actually committed, or a\n // discarded render attempt would seed it with array instances no consumer\n // ever saw. The extra render pass only runs when `rawParams` is recomputed,\n // i.e. when the URL or the schema really changed.\n const [paramsCache, setParamsCache] = useState<ParamsCache>(() => ({ src: rawParams, value: rawParams }));\n let stableParams = paramsCache.value;\n if (paramsCache.src !== rawParams) {\n stableParams = reuseArrayRefs(paramsCache.value, rawParams);\n setParamsCache({ src: rawParams, value: stableParams });\n }\n const params = stableParams as InferParamsFromSchema<TSchema>;\n\n // Helper: Add parameter value to URLSearchParams.\n //\n // Delegates to `createSearchParams`, which owns the ENCODING rule (skip\n // null/undefined/'', repeat the key per array element, JSON objects,\n // `String()` scalars). The rule used to be spelled out here as well; the two\n // sat side by side in one file, and hoisting the shared one into the leaf\n // put them in different modules where drift is much easier.\n const addParamToSearchParams = useCallback((searchParams: URLSearchParams, key: string, value: ParamValue): void => {\n for (const [k, v] of createSearchParams({ [key]: value })) searchParams.append(k, v);\n }, []);\n\n // Get URLSearchParams for fetch/axios. Iterates `stableSchema` (not raw\n // `schema`) so consumers passing an inline schema literal don't invalidate\n // this memo on every render.\n const urlSearchParams = useMemo((): URLSearchParams => {\n const newParams = new URLSearchParams();\n\n for (const key of Object.keys(stableSchema)) {\n const value = (params as Record<string, unknown>)[key];\n const paramConfig = flattenedSchema[key];\n\n // Skip if should not include\n if (!shouldIncludeInUrl(value, paramConfig)) {\n continue;\n }\n\n addParamToSearchParams(newParams, key, value as ParamValue);\n }\n\n return newParams;\n }, [params, flattenedSchema, addParamToSearchParams, stableSchema]);\n\n // Update URL with new parameters (preserve other params not managed by this\n // hook). Depends only on value-stable inputs (`searchString`, `schemaKey`),\n // so the callback ref itself is stable across renders that don't change URL\n // or schema — important for consumers that put `setParam`/`setParams` in\n // `useEffect` deps.\n const updateUrl = useCallback(\n (newParams: URLSearchParams, keysToRemove: string[] = []) => {\n // Base the write on the LAST WRITE WE ISSUED, not on the committed URL.\n // Next commits each `router.replace` as its own navigation, so two writes\n // fired before the first commits would both rebase on the pre-first URL\n // and the first one's keys would be lost.\n const base = pendingRef.current[pendingRef.current.length - 1] ?? searchString;\n const finalParams = new URLSearchParams(base);\n\n // Remove keys that are explicitly marked for removal\n keysToRemove.forEach(key => {\n if (key in stableSchema) {\n finalParams.delete(key);\n }\n });\n\n // Remove keys that are being updated (from newParams)\n // This preserves other schema parameters that aren't being changed\n newParams.forEach((_, key) => {\n // Only remove keys that are in our schema\n if (key in stableSchema) {\n finalParams.delete(key);\n }\n });\n\n // Add all new values (including multiple values for array params)\n // Only add parameters that are in our schema to avoid duplicating external params\n newParams.forEach((value, key) => {\n // Only process keys that are in our schema\n if (key in stableSchema) {\n if (finalParams.has(key)) {\n // Key already exists (from array params), append\n finalParams.append(key, value);\n } else {\n // First value for this key, use set\n finalParams.set(key, value);\n }\n }\n });\n\n const merged = finalParams.toString();\n\n // A NO-OP write (re-selecting the current option, clearing an already\n // empty picker) must not enter the queue: `router.replace` with an\n // identical URL produces no commit, so the entry would never drain and\n // every later write would rebase on a phantom.\n if (merged === base) return;\n\n pendingRef.current = [...pendingRef.current, merged];\n syncPendingTail();\n\n const url = merged ? `?${merged}` : window.location.pathname;\n\n if (debug) {\n console.log('[useApiParams] Updating URL:', url);\n }\n\n // Use replace for shallow routing (no page reload, no history spam)\n router.replace(url, { scroll: false });\n },\n [router, debug, searchString, stableSchema, syncPendingTail],\n );\n\n // Drain the queue as commits arrive. A commit EQUAL to a queued write drops\n // that entry and everything before it (so `[A, B, A']` survives A's commit\n // with `[B, A']` intact); a commit matching NOTHING is a FOREIGN navigation\n // (Back/Forward, another hook's replace) and invalidates the whole base.\n useEffect(() => {\n const queue = pendingRef.current;\n if (queue.length === 0) return;\n const hit = queue.indexOf(searchString);\n pendingRef.current = hit === -1 ? [] : queue.slice(hit + 1);\n syncPendingTail();\n }, [searchString, syncPendingTail]);\n\n // Set a single parameter\n const setParam = useCallback(\n <K extends keyof TSchema & string>(key: K, value: InferInputParamsFromSchema<Pick<TSchema, K>>[K]) => {\n const config = stableSchema[key];\n\n if (!config) {\n console.warn(`[useApiParams] Unknown parameter: ${key}`);\n return;\n }\n\n const newParams = new URLSearchParams();\n\n // THE omission rule, the same one `setParams` uses. This branch moved\n // `setParams` onto `shouldIncludeInUrl` (so a value equal to its schema\n // default is dropped) and left `setParam` on a local emptiness check\n // that knew nothing about defaults — so `setParam('page', 1)` kept\n // `?page=1` on a URL `setParams({ page: 1 })` left clean.\n if (shouldIncludeInUrl(value, config)) {\n addParamToSearchParams(newParams, key, value);\n updateUrl(newParams);\n } else {\n updateUrl(newParams, [key]);\n }\n },\n [updateUrl, addParamToSearchParams, stableSchema],\n );\n\n // Set multiple parameters\n const setParams = useCallback(\n (updates: Partial<InferInputParamsFromSchema<TSchema>>) => {\n const newParams = new URLSearchParams();\n const keysToRemove: string[] = [];\n\n for (const [key, value] of Object.entries(updates)) {\n const config = stableSchema[key];\n\n if (!config) {\n console.warn(`[useApiParams] Unknown parameter: ${key}`);\n continue;\n }\n\n // The SAME omission rule the reader uses (`urlSearchParams`): empty\n // values AND values equal to the schema default drop out. Writing a\n // default back into the URL left `?page=1&pageSize=15` on a pristine\n // board and made the canonical URL un-shareable.\n if (shouldIncludeInUrl(value, flattenedSchema[key])) {\n addParamToSearchParams(newParams, key, value);\n } else {\n keysToRemove.push(key);\n }\n }\n\n updateUrl(newParams, keysToRemove);\n },\n [updateUrl, addParamToSearchParams, stableSchema, flattenedSchema],\n );\n\n // Clear specific parameters\n const clearParams = useCallback(\n (keys: (keyof TSchema & string)[]) => {\n const newParams = new URLSearchParams();\n updateUrl(newParams, keys);\n },\n [updateUrl],\n );\n\n // Reset all parameters\n const resetParams = useCallback(() => {\n if (debug) {\n console.log('[useApiParams] Resetting params');\n }\n\n router.replace(window.location.pathname, { scroll: false });\n }, [router, debug]);\n\n // The params of the LAST write we issued, or `params` when nothing is in\n // flight. Adapters read this as \"the user's latest intent\" so a decision made\n // while a `router.replace` is uncommitted compares against the click, not the\n // stale URL.\n const pendingParams = useMemo((): InferParamsFromSchema<TSchema> => {\n if (pendingTail === undefined || pendingTail === searchString) return params;\n return parseSchemaParams(stableSchema, new URLSearchParams(pendingTail), {\n absent: options.absent === 'null' ? 'null' : 'undefined',\n }) as InferParamsFromSchema<TSchema>;\n }, [pendingTail, searchString, params, stableSchema, options.absent]);\n\n return {\n params,\n pendingParams,\n urlSearchParams,\n setParam,\n setParams,\n clearParams,\n resetParams,\n };\n}\n\n// THE serializer lives in the search-params leaf (one array/object/omission\n// rule for the hook and for server code); re-exported for existing importers.\nexport { createSearchParams } from '../../utils/search-params';\n","/**\n * Unified Cursor Pagination State Hook\n *\n * Manages all common cursor-based pagination state logic:\n * - URL state management with useApiParams\n * - Debounced search input\n * - hasLoadedBeyondFirst tracking\n * - Initial load detection\n * - Search change detection\n * - Pagination handlers (next, reset)\n *\n * This eliminates ~60-80 lines of boilerplate from each paginated component.\n *\n * @example\n * const {\n * searchInput, setSearchInput,\n * hasLoadedBeyondFirst,\n * handleNextPage, handleResetToFirstPage\n * } = useCursorPaginationState({\n * onInitialLoad: (search, cursor) => fetchDialogs(false, search, true, cursor),\n * onSearchChange: (search) => fetchDialogs(false, search)\n * })\n */\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { useApiParams, type UseApiParamsReturn } from './use-api-params';\n\nexport interface UseCursorPaginationStateOptions {\n /**\n * Debounce delay for search input (default: 300ms)\n */\n debounceMs?: number;\n\n /**\n * Callback for initial page load\n * Called once on mount with current search and cursor from URL\n */\n onInitialLoad: (search: string, cursor: string | null) => void | Promise<unknown>;\n\n /**\n * Callback when search term changes (after debounce)\n * Called after URL is updated, cursor is already reset\n */\n onSearchChange: (search: string) => void | Promise<unknown>;\n}\n\nconst urlSchema = {\n search: { type: 'string' as const, default: '' },\n cursor: { type: 'string' as const, default: '' },\n};\n\ntype ApiParamsReturn = UseApiParamsReturn<typeof urlSchema>;\n\n/** Pagination params managed by this hook */\nexport type PaginationParams = ApiParamsReturn['params'];\n\nexport interface CursorPaginationStateReturn {\n // Search\n searchInput: string;\n setSearchInput: (value: string) => void;\n\n // Pagination tracking\n hasLoadedBeyondFirst: boolean;\n setHasLoadedBeyondFirst: (value: boolean) => void;\n\n // Handlers for useTablePagination\n handleNextPage: (endCursor: string, fetchFn: () => Promise<unknown>) => Promise<void>;\n handleResetToFirstPage: (fetchFn: () => Promise<unknown>) => Promise<void>;\n\n // URL params access (for advanced use cases)\n params: PaginationParams;\n setParam: ApiParamsReturn['setParam'];\n setParams: ApiParamsReturn['setParams'];\n}\n\nexport function useCursorPaginationState(options: UseCursorPaginationStateOptions): CursorPaginationStateReturn {\n const { onInitialLoad, onSearchChange } = options;\n\n const { params, setParam, setParams } = useApiParams(urlSchema);\n\n // Local search input with debounce\n const [searchInput, setSearchInput] = useState(params.search || '');\n\n // Pagination tracking\n const [hasLoadedBeyondFirst, setHasLoadedBeyondFirst] = useState(false);\n // Use a counter instead of boolean to ensure effects see the latest state\n const [initialLoadCount, setInitialLoadCount] = useState(0);\n // Initialize to null to distinguish \"never set\" from \"set to empty string\"\n const lastSearchRef = useRef<string | null>(null);\n // Track if we're syncing from URL to prevent loops\n const isSyncingFromUrl = useRef(false);\n // Track if initial load is in progress to block ALL other effects\n const isInitialLoadInProgress = useRef(true);\n\n // Latest values for the effects below, none of which may take them as\n // dependencies: the URL->input sync would revert the user's keystrokes the\n // moment they typed, and the mount-once initial load would re-fire on every\n // param change. Declared first so the sync runs before either of them.\n const searchInputRef = useRef(searchInput);\n const onInitialLoadRef = useRef(onInitialLoad);\n const paramsRef = useRef(params);\n useEffect(() => {\n searchInputRef.current = searchInput;\n onInitialLoadRef.current = onInitialLoad;\n paramsRef.current = params;\n });\n\n // Sync local input with URL param (for tab switches that clear params)\n // Only sync if the URL changed externally (not from our own debounce update)\n useEffect(() => {\n // Block during initial load\n if (isInitialLoadInProgress.current) return;\n\n const urlSearch = params.search || '';\n // Only sync if URL differs from current input\n if (urlSearch !== searchInputRef.current) {\n isSyncingFromUrl.current = true;\n setSearchInput(urlSearch);\n // Reset the flag after a tick to allow normal operation\n setTimeout(() => {\n isSyncingFromUrl.current = false;\n }, 0);\n }\n }, [params.search, initialLoadCount]); // Add initialLoadCount to re-run after initial load\n\n // Sync debounced search to URL, reset cursor when search changes\n useEffect(() => {\n // Block during initial load\n if (isInitialLoadInProgress.current) return;\n // Skip if we're syncing from URL to prevent loops\n if (isSyncingFromUrl.current) return;\n\n if (searchInput !== params.search) {\n setParams({\n search: searchInput,\n cursor: '', // Reset cursor when search changes\n });\n }\n }, [searchInput, params.search, setParams, initialLoadCount]);\n\n // Initial load effect - runs once and blocks all other effects until complete\n useEffect(() => {\n if (initialLoadCount === 0) {\n const cursor = paramsRef.current.cursor || null;\n const search = paramsRef.current.search || '';\n\n // Set all refs BEFORE calling onInitialLoad\n lastSearchRef.current = search;\n\n // If we have a cursor in URL (page refresh), we're beyond first page\n if (cursor) {\n setHasLoadedBeyondFirst(true);\n }\n\n // Call the initial load and wait for it to complete before\n // marking initial load as done (handles async onInitialLoad)\n // `.catch` BEFORE `.finally` so a rejecting `onInitialLoad` is reported\n // instead of becoming an unhandled rejection, while the unblock below\n // still runs on both paths (it must: otherwise every other effect in\n // this hook stays permanently gated behind `isInitialLoadInProgress`).\n void Promise.resolve(onInitialLoadRef.current(search, cursor))\n .catch((err: unknown) => {\n console.error('[useCursorPaginationState] onInitialLoad failed:', err);\n })\n .finally(() => {\n isInitialLoadInProgress.current = false;\n setInitialLoadCount(1);\n });\n }\n }, [initialLoadCount]);\n\n // Search change detection - only after initial load is fully complete\n useEffect(() => {\n // Block during initial load\n if (isInitialLoadInProgress.current) return;\n if (initialLoadCount === 0) return;\n\n const currentSearch = params.search || '';\n // Only trigger search change if:\n // 1. lastSearchRef has been set (not null - means initial load happened)\n // 2. The search actually changed\n if (lastSearchRef.current !== null && currentSearch !== lastSearchRef.current) {\n lastSearchRef.current = currentSearch;\n setHasLoadedBeyondFirst(false);\n // `onSearchChange` is declared `void | Promise<unknown>`; normalise so a\n // rejecting async implementation is logged rather than left unhandled.\n void Promise.resolve(onSearchChange(currentSearch)).catch((err: unknown) => {\n console.error('[useCursorPaginationState] onSearchChange failed:', err);\n });\n }\n }, [params.search, onSearchChange, initialLoadCount]);\n\n // Pagination handlers\n const handleNextPage = useCallback(\n async (endCursor: string, fetchFn: () => Promise<unknown>) => {\n setParam('cursor', endCursor);\n await fetchFn();\n setHasLoadedBeyondFirst(true);\n },\n [setParam],\n );\n\n const handleResetToFirstPage = useCallback(\n async (fetchFn: () => Promise<unknown>) => {\n setParam('cursor', '');\n await fetchFn();\n setHasLoadedBeyondFirst(false);\n },\n [setParam],\n );\n\n return {\n searchInput,\n setSearchInput,\n hasLoadedBeyondFirst,\n setHasLoadedBeyondFirst,\n handleNextPage,\n handleResetToFirstPage,\n params,\n setParam,\n setParams,\n };\n}\n","import { useEffect, useMemo, useState } from 'react';\n\nimport type { NatsClient, NatsClientOptions, NatsStatus } from '../../nats';\nimport { createNatsClient } from '../../nats';\n\nexport interface UseNatsClientOptions {\n /**\n * When true, connect on mount and close on unmount.\n */\n autoConnect?: boolean;\n}\n\nexport interface UseNatsClientResult {\n client: NatsClient | null;\n status: NatsStatus;\n isConnected: boolean;\n lastError: unknown;\n}\n\n/**\n * React hook for managing a shared NATS WebSocket connection lifecycle.\n *\n * Important: pass a memoized `clientOptions` (e.g. via `useMemo`) to avoid\n * reconnecting on every render.\n */\nexport function useNatsClient(\n clientOptions: NatsClientOptions | null,\n options: UseNatsClientOptions = {},\n): UseNatsClientResult {\n const { autoConnect = true } = options;\n\n const client = useMemo(() => {\n if (!clientOptions) return null;\n return createNatsClient(clientOptions);\n }, [clientOptions]);\n\n const [status, setStatus] = useState<NatsStatus>('disconnected');\n const [lastError, setLastError] = useState<unknown>(null);\n const [statusClient, setStatusClient] = useState(client);\n\n // Reset while rendering (React's documented prop-sync pattern) rather than\n // from the effect below. `client.onStatus` does NOT replay the current status\n // to a new listener, so a swapped-in client emits nothing until it actually\n // changes state — leaving `status`/`lastError` describing the PREVIOUS\n // client. A fresh client from `createNatsClient` is genuinely disconnected\n // and error-free at this point, so that is what we report until it says\n // otherwise.\n if (statusClient !== client) {\n setStatusClient(client);\n setStatus('disconnected');\n setLastError(null);\n }\n\n useEffect(() => {\n if (!client) return undefined;\n\n const off = client.onStatus(event => {\n setStatus(event.status);\n if (event.status === 'error') setLastError(event.data);\n });\n\n if (autoConnect) {\n client.connect().catch((e: unknown) => {\n setLastError(e);\n setStatus('error');\n });\n }\n\n return () => {\n off();\n client.close().catch(() => {\n // ignore\n });\n };\n }, [client, autoConnect]);\n\n return {\n client,\n status,\n isConnected: status === 'connected',\n lastError,\n };\n}\n","'use client';\n\n/**\n * Access Code Integration Hook\n *\n * React-side wrapper around the pure `access-code-client` utilities.\n * Lives in `hooks/` (client bundle) so the `createContext()` call in\n * `endpoints-runtime-context` doesn't get pulled into the server-safe\n * `utils/index` bundle.\n *\n * The pure standalone functions (`validateAccessCode`,\n * `consumeAccessCode`, `validateAndConsumeAccessCode`) remain importable\n * from `@flamingo-stack/openframe-frontend-core/utils` — they take the\n * endpoints object as an argument. This hook binds those endpoints from\n * `EndpointsRuntimeContext` so React callers don't have to plumb URLs.\n */\n\nimport React from 'react';\n\nimport { useRequiredEndpointsRuntime } from '../contexts/endpoints-runtime-context';\nimport { validateAccessCode, consumeAccessCode, validateAndConsumeAccessCode } from '../utils/access-code-client';\n\n/**\n * Resolves access-code endpoints from `EndpointsRuntimeContext` (throws\n * if no provider is mounted) and exposes loading-state-aware wrappers\n * around the standalone helpers in `utils/access-code-client`.\n *\n * @returns the following fields. The `validate` / `consume` /\n * `validateAndConsume` functions and the returned object identity are\n * NOT memoized — they're re-created each render. Wrap with\n * `useCallback` / `useMemo` at the call site if downstream effect\n * dep arrays depend on stable identities.\n * - `validate(email, code)`: validates only.\n * - `consume(email, code)`: consumes only.\n * - `validateAndConsume(email, code)`: one-step validate-then-consume.\n * - `isValidating: boolean`: a validate call is in flight.\n * - `isConsuming: boolean`: a consume call is in flight.\n * - `isProcessing: boolean`: convenience — `isValidating || isConsuming`.\n * Use this for a single \"in-flight\" indicator on UI affordances that\n * should disable during both phases.\n *\n * @example\n * const { validate, consume, isProcessing } = useAccessCodeIntegration();\n *\n * const handleRegistration = async (formData) => {\n * const validation = await validate(formData.email, formData.accessCode);\n * if (!validation.valid) {\n * setError(validation.message);\n * return;\n * }\n *\n * // Process registration...\n * const registrationResult = await registerUser(formData);\n *\n * if (registrationResult.success) {\n * await consume(formData.email, formData.accessCode);\n * }\n * };\n */\nexport function useAccessCodeIntegration() {\n const runtime = useRequiredEndpointsRuntime();\n const endpoints = runtime.accessCode;\n const [isValidating, setIsValidating] = React.useState(false);\n const [isConsuming, setIsConsuming] = React.useState(false);\n\n const validate = async (email: string, code: string) => {\n setIsValidating(true);\n try {\n return await validateAccessCode(email, code, endpoints);\n } finally {\n setIsValidating(false);\n }\n };\n\n const consume = async (email: string, code: string) => {\n setIsConsuming(true);\n try {\n return await consumeAccessCode(email, code, endpoints);\n } finally {\n setIsConsuming(false);\n }\n };\n\n const validateAndConsume = async (email: string, code: string) => {\n setIsValidating(true);\n setIsConsuming(true);\n try {\n return await validateAndConsumeAccessCode(email, code, endpoints);\n } finally {\n setIsValidating(false);\n setIsConsuming(false);\n }\n };\n\n return {\n validate,\n consume,\n validateAndConsume,\n isValidating,\n isConsuming,\n isProcessing: isValidating || isConsuming,\n };\n}\n","/**\n * Access Code Client Utilities — pure standalone functions.\n *\n * Endpoint paths are NOT hardcoded — every function takes an\n * `endpoints` argument. The React-side wrapper that binds them from\n * `EndpointsRuntimeContext` lives separately at\n * `hooks/use-access-code-integration.ts` (`useAccessCodeIntegration`).\n *\n * Keep this file **free of React imports** — it lives in the\n * server-safe `utils/index` tsup bundle. Any module-top-level call\n * into `createContext()` (which the runtime context file does) would\n * be pulled into the server bundle and crash SSR with\n * `createContext is not a function`.\n */\n\nimport type { AccessCodeValidationResponse, AccessCodeConsumptionResponse } from '../types/access-code-cohorts';\nimport { apiErrorMessage } from './common';\n\n/** Endpoints required by the standalone client utilities. The\n * `useAccessCodeIntegration` hook (in `hooks/`) resolves these from\n * `EndpointsRuntimeContext.accessCode` automatically. */\nexport interface AccessCodeEndpoints {\n validateUrl: string;\n consumeUrl: string;\n}\n\n/**\n * Validate an access code for a given email\n *\n * @param email - User's email address\n * @param code - Access code to validate\n * @returns Promise with validation result\n *\n * @example\n * const result = await validateAccessCode('user@example.com', 'ABC123XY');\n * if (result.valid) {\n * // Allow user to proceed with registration\n * console.log(`Welcome to ${result.cohort_name}!`);\n * } else {\n * // Show error message\n * console.error(result.message);\n * }\n */\nexport async function validateAccessCode(\n email: string,\n code: string,\n endpoints: AccessCodeEndpoints,\n): Promise<AccessCodeValidationResponse> {\n try {\n const response = await fetch(endpoints.validateUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ email, code }),\n });\n\n if (!response.ok) {\n const failure: unknown = await response.json().catch(() => null);\n throw new Error(apiErrorMessage(failure, 'Validation request failed'));\n }\n\n return (await response.json()) as AccessCodeValidationResponse;\n } catch (error) {\n return {\n valid: false,\n message: error instanceof Error ? error.message : 'Validation failed',\n };\n }\n}\n\n/**\n * Consume an access code after successful registration\n *\n * Call this ONLY after the user has successfully completed registration.\n * This marks the code as used and prevents further usage.\n *\n * @param email - User's email address\n * @param code - Access code to consume\n * @returns Promise with consumption result\n *\n * @example\n * // After successful registration\n * const result = await consumeAccessCode('user@example.com', 'ABC123XY');\n * if (result.consumed) {\n * console.log('Access code consumed successfully');\n * } else {\n * console.warn('Failed to consume access code:', result.message);\n * }\n */\nexport async function consumeAccessCode(\n email: string,\n code: string,\n endpoints: AccessCodeEndpoints,\n): Promise<AccessCodeConsumptionResponse> {\n try {\n const response = await fetch(endpoints.consumeUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ email, code }),\n });\n\n if (!response.ok) {\n const failure: unknown = await response.json().catch(() => null);\n throw new Error(apiErrorMessage(failure, 'Consumption request failed'));\n }\n\n return (await response.json()) as AccessCodeConsumptionResponse;\n } catch (error) {\n return {\n success: false,\n consumed: false,\n message: error instanceof Error ? error.message : 'Consumption failed',\n };\n }\n}\n\n/**\n * Complete access code flow: validate then consume\n *\n * This is a convenience function that validates an access code and,\n * if valid, immediately consumes it. Use this when you want to\n * validate and consume in one step during registration.\n *\n * @param email - User's email address\n * @param code - Access code to validate and consume\n * @returns Promise with validation and consumption results\n *\n * @example\n * const result = await validateAndConsumeAccessCode('user@example.com', 'ABC123XY');\n * if (result.valid && result.consumed) {\n * // Registration successful\n * console.log(`Welcome to ${result.cohort_name}!`);\n * } else {\n * console.error(result.message);\n * }\n */\nexport async function validateAndConsumeAccessCode(\n email: string,\n code: string,\n endpoints: AccessCodeEndpoints,\n): Promise<AccessCodeValidationResponse & { consumed?: boolean }> {\n // First validate\n const validation = await validateAccessCode(email, code, endpoints);\n\n if (!validation.valid) {\n return validation;\n }\n\n // If valid, consume the code\n const consumption = await consumeAccessCode(email, code, endpoints);\n\n return {\n ...validation,\n consumed: consumption.consumed,\n message: consumption.consumed\n ? `Access granted for ${validation.cohort_name}`\n : consumption.message || validation.message,\n };\n}\n\n// `useAccessCodeIntegration` (the React-side wrapper) lives in\n// `hooks/use-access-code-integration.ts`. It binds the endpoints from\n// `EndpointsRuntimeContext` so React callers don't have to plumb URLs.\n","'use client';\n\nimport { useEffect, useState } from 'react';\nimport { useEndpointsRuntime } from '../contexts/endpoints-runtime-context';\nimport { toClaudeMirrorPath } from '../utils/embed-url-converters';\n\nexport type ClaudeMirrorStatus =\n /** No mirror candidate at all (no proxy configured, or a non-mirrorable url). */\n | 'absent'\n /** Probing the host for a self-hosted mirror (or minting one on first view). */\n | 'probing'\n /** A mirror exists and `src` points at it. */\n | 'found';\n\nexport interface ClaudeMirrorState {\n /** The mirror src when found (with a cache-bust once a re-publish is\n * detected), else `null`. */\n src: string | null;\n status: ClaudeMirrorStatus;\n}\n\n/**\n * Ask the host's storage-view proxy whether the cached mirror is still the\n * artifact's CURRENT version, re-minting it silently if a re-publish moved\n * it. Returns a cache-bust token (the new version) when the mirror changed,\n * else null. Best-effort: any failure just leaves the shown mirror in place.\n */\nasync function revalidateMirror(mirrorPath: string): Promise<string | null> {\n try {\n const res = await fetch(`${mirrorPath}?revalidate=1`);\n if (!res.ok) return null;\n const body = (await res.json()) as { updated?: boolean; version?: string | null };\n return body?.updated ? String(body.version ?? Date.now()) : null;\n } catch {\n return null;\n }\n}\n\n/**\n * The host's SELF-HOSTED MIRROR src for a claude artifact url, plus a\n * `status` so the viewer can show a loading state while it resolves — THE\n * hook behind `ClaudeEmbed`'s transparent mirror leg, extracted so the\n * component stays presentational.\n *\n * Fully under the hood, three moves:\n * 1. Derive the candidate mirror path from the url's artifact id under the\n * host's configured storage-view proxy base\n * (`EndpointsRuntime.storageViewBaseUrl`) and probe it with a 1-byte\n * ranged GET. On a cold first view that probe IS the server-side\n * self-heal (~1-2s), which is exactly why `status: 'probing'` exists —\n * the viewer shows a spinner instead of the empty \"no embeddable view\"\n * flash. Exists → `found`; missing / no proxy / blip → `absent`, and the\n * caller's claude.ai fallback stands (the pre-mirror behavior).\n * 2. Once found, fire ONE background revalidation. If the artifact was\n * re-published since the mirror was cached, the server re-mints it and\n * reports the new version; the hook bumps a cache-bust on `src` so the\n * iframe silently reloads the fresh bytes. Unchanged → nothing happens.\n *\n * A host that mounts no EndpointsRuntime provider (or omits the entry) skips\n * the mirror leg entirely (`absent`).\n */\nexport function useClaudeMirrorSrc(url: string): ClaudeMirrorState {\n const endpoints = useEndpointsRuntime();\n const mirrorPath = toClaudeMirrorPath(url, endpoints?.storageViewBaseUrl);\n\n // Keyed on `mirrorPath` and DERIVED rather than seeded by the effect: when\n // the url changes, the fallback below is what a reader sees, so a previous\n // artifact's `found` src can never be shown for a new one. Doing that with a\n // synchronous `setState` in the effect body is the cascading-render pattern\n // `react-hooks/set-state-in-effect` rules out; the effect now only settles\n // state from its async callbacks.\n const [resolved, setResolved] = useState<{ key: string; value: ClaudeMirrorState } | null>(null);\n const state: ClaudeMirrorState =\n resolved && resolved.key === mirrorPath ? resolved.value : { src: null, status: mirrorPath ? 'probing' : 'absent' };\n\n useEffect(() => {\n if (!mirrorPath) return undefined;\n let cancelled = false;\n const settle = (value: ClaudeMirrorState) => {\n if (!cancelled) setResolved({ key: mirrorPath, value });\n };\n fetch(mirrorPath, { headers: { Range: 'bytes=0-0' } })\n .then(res => {\n if (cancelled) return;\n if (!(res.ok || res.status === 206)) {\n settle({ src: null, status: 'absent' });\n return;\n }\n settle({ src: mirrorPath, status: 'found' });\n // Background freshness check — reload in place only if it changed.\n void revalidateMirror(mirrorPath).then(bust => {\n if (bust) settle({ src: `${mirrorPath}?v=${encodeURIComponent(bust)}`, status: 'found' });\n });\n })\n .catch(() => {\n // No proxy on this host / network blip — the caller's claude.ai\n // fallback stands, same as before mirrors existed.\n settle({ src: null, status: 'absent' });\n });\n return () => {\n cancelled = true;\n };\n }, [mirrorPath]);\n\n return state;\n}\n","/**\n * Shared utilities for converting external service URLs to embeddable formats.\n * Used by lib's embed components and the hub's admin document editor.\n */\n\nexport function toGoogleSheetsEmbedUrl(url: string): string {\n if (url.includes('/htmlembed')) return url;\n\n const match = url.match(/\\/spreadsheets\\/d\\/([a-zA-Z0-9-_]+)/);\n if (!match) return url;\n\n const gidMatch = url.match(/[#?&]gid=(\\d+)/);\n const gid = gidMatch ? gidMatch[1] : '0';\n\n return `https://docs.google.com/spreadsheets/d/${match[1]}/htmlembed?widget=true&chrome=false&headers=false&gid=${gid}`;\n}\n\nexport function toGoogleSheetsOriginalUrl(url: string): string {\n const match = url.match(/\\/spreadsheets\\/d\\/([a-zA-Z0-9-_]+)/);\n if (!match) return url;\n\n const gidMatch = url.match(/[#?&]gid=(\\d+)/);\n const gid = gidMatch ? gidMatch[1] : '0';\n\n return `https://docs.google.com/spreadsheets/d/${match[1]}/edit#gid=${gid}`;\n}\n\n/**\n * Convert a Figma URL to an embeddable URL.\n * Slides/deck URLs map to `deck` (present) by default; `slidesView: 'browse'` switches to `slides` (browse).\n */\nexport function toFigmaEmbedUrl(url: string, opts?: { slidesView?: 'present' | 'browse' }): string {\n if (url.includes('embed.figma.com')) return url;\n if (url.includes('figma.com/embed')) return url;\n\n const match = url.match(/figma\\.com\\/(design|file|proto|board|slides|deck)\\/([a-zA-Z0-9]+)(?:\\/([^?]*))?(\\?.*)?$/);\n\n if (match) {\n const [, urlType, fileKey, titleSlug, queryString] = match;\n const isSlides = urlType === 'slides' || urlType === 'deck';\n const embedType =\n urlType === 'proto' ? 'proto' : isSlides ? (opts?.slidesView === 'browse' ? 'slides' : 'deck') : 'design';\n const pathSuffix = titleSlug ? `/${titleSlug}` : '';\n\n const params = new URLSearchParams(queryString?.replace(/^\\?/, '') || '');\n if (!params.has('embed-host')) {\n params.set('embed-host', 'flamingo');\n }\n const clientId = process.env.NEXT_PUBLIC_FIGMA_CLIENT_ID;\n if (clientId && !params.has('client-id')) {\n params.set('client-id', clientId);\n }\n\n return `https://embed.figma.com/${embedType}/${fileKey}${pathSuffix}?${params.toString()}`;\n }\n\n return `https://www.figma.com/embed?embed-host=flamingo&url=${encodeURIComponent(url)}`;\n}\n\nexport function isFigmaSlidesUrl(url: string): boolean {\n if (!url) return false;\n return /(?:www\\.|embed\\.)?figma\\.com\\/(?:slides|deck)\\/[a-zA-Z0-9]+/.test(url);\n}\n\nexport function toFigmaOriginalUrl(url: string): string {\n if (url.includes('embed.figma.com')) {\n return url.replace('embed.figma.com', 'www.figma.com').replace(/\\?.*$/, '');\n }\n return url.replace(/\\?.*$/, '');\n}\n\n/**\n * The self-hosted MIRROR url for a claude artifact url — the whole\n * mirror mechanism is UNDER THE HOOD: the viewer detects a claude url,\n * DERIVES the mirror location from the artifact's own id under the\n * host's configured storage-view proxy base, and frames the downloaded\n * copy from there. No per-link data, no props, no shortcode changes —\n * 100% transparent to every consumer.\n *\n * `storageViewBaseUrl` comes from `EndpointsRuntime` — the SAME\n * host-overridable proxy-path mechanism every other lib→API call uses\n * (a reverse-proxied embedder remaps it; a host without the proxy omits\n * it and gets `null` here, i.e. no mirror leg at all).\n *\n * `null` when the base is absent or the url carries no artifact id. The\n * derived url is the configured base + a validated uuid — safe as a\n * frame source without further checks. KEEP THE OBJECT NAMING IN SYNC\n * with the hub's `lib/data/design-brief-mirror.cjs` (bucket +\n * `<uuid>.html`) — that module owns the ingest/audit half.\n */\nexport function toClaudeMirrorPath(\n url: string | null | undefined,\n storageViewBaseUrl: string | null | undefined,\n): string | null {\n if (!url || !storageViewBaseUrl) return null;\n try {\n const u = new URL(url);\n const host = u.hostname.toLowerCase();\n const id =\n (host === 'claude.ai' &&\n /^\\/(?:code\\/artifact|public\\/artifacts)\\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[/?#]|$)/i.exec(\n u.pathname,\n )?.[1]) ||\n (host === 'claude.site' &&\n /^\\/artifacts\\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[/?#]|$)/i.exec(\n u.pathname,\n )?.[1]) ||\n null;\n return id ? `${storageViewBaseUrl.replace(/\\/$/, '')}/design-briefs/${id.toLowerCase()}.html` : null;\n } catch {\n return null;\n }\n}\n\n/**\n * The EMBED url for a published Claude artifact, or `null` when the link is not\n * one we can frame — the Claude counterpart of `toFigmaEmbedUrl`.\n *\n * claude.ai serves `frame-ancestors 'self'` on the artifact page itself, but a\n * PUBLISHED artifact also has an `/embed` route whose `frame-ancestors` carries\n * the domains its author allow-listed under \"Get embed code → Allowed domains\".\n * That is Anthropic's supported way to put an artifact on another site, so that\n * is what the iframe points at.\n *\n * Returns `null` for everything else, including a Claude CODE url\n * (`claude.ai/code/artifact/…`), whose Share dialog exposes no Embed settings\n * and whose `/embed` path answers `frame-ancestors 'self'` (verified against\n * real artifacts, 2026-08). A trailing `/embed` is accepted because that is the\n * url \"Get embed code\" hands the author.\n */\nexport function toClaudeEmbedUrl(url: string | null | undefined): string | null {\n if (!url) return null;\n try {\n const u = new URL(url);\n const host = u.hostname.toLowerCase();\n // `claude.site/artifacts/<id>` 308-redirects to the claude.ai public url.\n const publicId =\n (host === 'claude.ai' && /^\\/public\\/artifacts\\/([\\w-]+)(?:\\/embed)?\\/?$/.exec(u.pathname)?.[1]) ||\n (host === 'claude.site' && /^\\/artifacts\\/([\\w-]+)(?:\\/embed)?\\/?$/.exec(u.pathname)?.[1]) ||\n null;\n return publicId ? `https://claude.ai/public/artifacts/${publicId}/embed` : null;\n } catch {\n return null;\n }\n}\n","'use client';\n\nimport { useEffect } from 'react';\nimport { getHashTargetElement, normalizeHashFragment } from '../utils/same-page-hash-nav';\nimport { scrollElementIntoView } from '../utils/scroll-into-view';\n\n/** ~1s at 60fps — long enough to outlast Radix accordion expand + SWR\n * mount, short enough that a missed anchor doesn't hang the page. */\nconst MAX_POLL_FRAMES = 60;\n\nexport interface UseScrollToHashOptions {\n /** Pixels to subtract for sticky chrome. */\n headerOffset?: number;\n}\n\n/**\n * Scroll the page to `window.location.hash` once `readyDep` resolves\n * to a truthy value. Polls via rAF for ~1s so lazy-mounted rows (Radix\n * accordion, SWR fetch) have time to render. Re-runs on `readyDep`\n * reference change AND on `hashchange` (browser back/forward + the\n * synthetic event `navigateSamePageHash` dispatches).\n *\n * Skipped when `readyDep == null || readyDep === false`. Default\n * `true` makes the hook run on mount for pages whose target is in the\n * initial SSR render.\n */\nexport function useScrollToHash(readyDep: unknown = true, options?: UseScrollToHashOptions): void {\n const headerOffset = options?.headerOffset ?? 0;\n useEffect(() => {\n if (typeof window === 'undefined') return undefined;\n if (readyDep === null || readyDep === false) return undefined;\n let rafId: number | null = null;\n const cancelPoll = () => {\n if (rafId !== null) {\n cancelAnimationFrame(rafId);\n rafId = null;\n }\n };\n const tryScrollToHash = () => {\n // `normalizeHashFragment` heals a malformed multi-fragment hash\n // so `getElementById` resolves on deep-link entries that bypass\n // `navigateSamePageHash`'s own normalize.\n const hash = normalizeHashFragment(window.location.hash).slice(1);\n if (!hash) return;\n // Cancel any in-flight poll from a prior invocation so two\n // concurrent ticks can't both call scrollElementIntoView.\n cancelPoll();\n let frames = 0;\n const tick = () => {\n // Raw-then-decoded lookup — encoded fragments from href\n // composers must match the unencoded ids rows render.\n const el = getHashTargetElement(hash);\n if (el) {\n rafId = null;\n scrollElementIntoView(el, { headerOffset });\n return;\n }\n if (frames++ < MAX_POLL_FRAMES) {\n rafId = requestAnimationFrame(tick);\n } else {\n rafId = null;\n }\n };\n tick();\n };\n tryScrollToHash();\n window.addEventListener('hashchange', tryScrollToHash);\n return () => {\n window.removeEventListener('hashchange', tryScrollToHash);\n cancelPoll();\n };\n }, [readyDep, headerOffset]);\n}\n","'use client';\n\nimport { useCallback, useRef, useState } from 'react';\nimport { HONEYPOT_FIELD, ELAPSED_MS_FIELD, type HumanitySignals } from '../utils/humanity-signals';\n\n/**\n * useHumanitySignals — client primitive backing the invisible bot-protection\n * layer. Owns the honeypot <input> ref + a mount timestamp, and exposes the\n * props to render the field plus a getter that produces the wire object to\n * merge into a form's POST body. Reused by every public form (contact,\n * waitlist, ticket, review) so the signal shape lives in exactly one place.\n *\n * Usage:\n * const { honeypotInputProps, getSignals, resetSignals } = useHumanitySignals()\n * // render: <HoneypotField {...honeypotInputProps} />\n * // submit: fetch(url, { body: JSON.stringify({ ...formData, ...getSignals() }) })\n * // after a successful submit: resetSignals()\n *\n * The two signals are origin-independent (they ride in the body), so they keep\n * working when the form is embedded behind a reverse-proxy / prefixed URL.\n */\nexport function useHumanitySignals() {\n const ref = useRef<HTMLInputElement>(null);\n // performance.now() is monotonic (immune to wall-clock skew). SSR-guarded\n // even though the hook only executes on the client.\n //\n // Sampled in a lazy `useState` initialiser, then copied into a ref. Not\n // `useRef(performance.now())`: that argument is re-evaluated on EVERY render\n // even though only the first call uses it, so the component would read the\n // clock during render forever. And not state alone: `resetSignals` restarts\n // the window after a successful submit, which through state would re-render\n // the whole form for a value nothing renders.\n const [mountedAt] = useState(() => (typeof performance !== 'undefined' ? performance.now() : 0));\n const startedAtRef = useRef<number>(mountedAt);\n\n const getSignals = useCallback(\n (): HumanitySignals => ({\n [HONEYPOT_FIELD]: ref.current?.value ?? '',\n [ELAPSED_MS_FIELD]: typeof performance !== 'undefined' ? Math.round(performance.now() - startedAtRef.current) : 0,\n }),\n [],\n );\n\n const resetSignals = useCallback(() => {\n if (ref.current) ref.current.value = '';\n if (typeof performance !== 'undefined') startedAtRef.current = performance.now();\n }, []);\n\n return { honeypotInputProps: { ref, name: HONEYPOT_FIELD }, getSignals, resetSignals } as const;\n}\n","/**\n * Humanity signals — invisible bot-protection primitives shared by the lib's\n * public forms (client) and the hub's per-route `verifyHuman` gate (server).\n *\n * PURE + React-free on purpose: this module is a tsup SERVER entry (no\n * \"use client\" banner) so the hub can import it server-side without pulling a\n * client-reference boundary — same pattern as `schemas/contact-schema` and\n * `components/features/mux-origins`.\n *\n * Two origin-independent signals travel in the POST body: a honeypot (a hidden\n * field real users never fill) and timing (ms from form mount to submit).\n * `evaluateHumanitySignals` is the SINGLE source of truth for the block/allow\n * decision — the hub imports + calls it rather than re-implementing the rules.\n *\n * FALSE-POSITIVE HISTORY (2026-08-27): the honeypot was named\n * `contact_url_confirm`, and browser/password-manager autofill (which ignores\n * `autocomplete=\"off\"` and matches \"url\"/\"confirm\" name heuristics) filled it\n * for REAL users — every production `BOT_DETECTED` in the log window was a\n * legitimate Chrome user whose autofill tripped the decoy. Three layers now\n * prevent a recurrence; keep all three when touching this system:\n * 1. The field name avoids every autofill-heuristic token (name/email/\n * phone/url/website/confirm/company/address/code/…).\n * 2. `HoneypotField` renders `readOnly`-until-focus + password-manager\n * ignore attributes — autofill skips read-only inputs.\n * 3. `evaluateHumanitySignals` forgives a filled decoy whose value was\n * COPIED from another field in the same body (the autofill signature —\n * a human, not a bot). See `findHoneypotCopySource` for the match rules.\n *\n * ACCEPTED TRADEOFF of layer 3 (do not \"fix\" by removing the forgiveness): a\n * bot that fills EVERY field with one identical value now passes the honeypot\n * check. That bot class always had a strictly easier evasion — send the decoy\n * empty — so no new attacker capability is admitted; it remains covered by the\n * timing check, per-IP rate limits, route Zod validation, and first-party\n * BotID. Every forgiven allow is warn-logged by the hub gate for monitoring.\n */\n\n/** Hidden honeypot field name. Deliberately free of autofill-heuristic tokens (see module doc). */\nexport const HONEYPOT_FIELD = 'form_extra_note';\n/** Client-measured ms between form mount and submit. */\nexport const ELAPSED_MS_FIELD = 'form_elapsed_ms';\n/** Default minimum fill time (ms). A submit faster than this is treated as a bot. */\nexport const DEFAULT_MIN_FILL_MS = 700;\n\n/**\n * Every humanity-signal key that rides in a public form's POST body.\n * Server-side handlers that forward form payloads upstream (HubSpot booking,\n * CRM pushes, …) MUST strip by THIS array — never hand-typed strings — so a\n * field rename here propagates everywhere and the honeypot value can never\n * silently leak into an upstream record.\n */\nexport const HUMANITY_SIGNAL_KEYS = [HONEYPOT_FIELD, ELAPSED_MS_FIELD] as const;\n\n/** Is this body key one of the humanity-signal wire fields? */\nexport const isHumanitySignalKey = (key: string): boolean => (HUMANITY_SIGNAL_KEYS as readonly string[]).includes(key);\n\n/** Keyed wire object produced by `useHumanitySignals().getSignals()` and spread into the POST body. */\nexport type HumanitySignals = Record<string, string | number>;\n\n/**\n * Diagnostics every verdict carries so callers LOG what this module already\n * computed instead of re-deriving it (a re-derived predicate silently diverges\n * the day the rules here change):\n * - `honeypotLength`: decoy length — never the typed value (log-safe by\n * construction).\n * - `timingAffirmed`: the submission POSITIVELY proved human timing (a PRESENT\n * elapsed-ms at/above the floor — merely-missing timing does not affirm).\n * The hub gate keys its BotID form-downgrade on this.\n */\nexport type HumanityVerdictDiagnostics = {\n honeypotLength: number;\n timingAffirmed: boolean;\n};\n\n/** Result of {@link evaluateHumanitySignals}. */\nexport type HumanityVerdict = HumanityVerdictDiagnostics &\n (\n | {\n ok: true;\n /** Present when a filled decoy was forgiven as autofill; `sourceField` names the body field it was copied from. */\n note?: 'honeypot_autofill';\n sourceField?: string;\n }\n | { ok: false; reason: 'honeypot' | 'too_fast' }\n );\n\n/** Tolerant reader — never throws; missing/garbage timing → null. */\nexport function extractHumanitySignals(body: unknown): { honeypot: string; elapsedMs: number | null } {\n const b = (body ?? {}) as Record<string, unknown>;\n const rawHp = b[HONEYPOT_FIELD];\n // A legit client always sends a STRING here (getSignals → ref.value ?? ''),\n // so ANY present non-string value is a bot dodging the empty-check — coerce\n // to a NON-EMPTY string so it still trips (JSON.stringify keeps `[]`/`{}`\n // non-empty where String() would collapse them to '').\n // null/undefined → '' = the correct \"field absent / unfilled\" allow case.\n const honeypot = rawHp == null ? '' : typeof rawHp === 'string' ? rawHp : (JSON.stringify(rawHp) ?? String(rawHp));\n const rawMs = b[ELAPSED_MS_FIELD];\n const elapsedMs = typeof rawMs === 'number' && Number.isFinite(rawMs) ? rawMs : null;\n return { honeypot, elapsedMs };\n}\n\n/**\n * Comparison normalization for the copy-match: autofill may fill the decoy\n * with a differently-FORMATTED rendition of the value the client posts (the\n * waitlist normalizes phones to E.164 before POST while a manager fills the\n * stored \"(555) 123-4567\"), so exact equality misses real humans. Normalized\n * equality still requires the decoy to mirror a real field's CONTENT, which a\n * bot gains nothing from — it could always send the decoy empty instead.\n */\nconst normalizeForCopyMatch = (s: string): string => s.normalize('NFKC').trim().toLowerCase();\nconst digitsOf = (s: string): string => s.replace(/\\D/g, '');\n\n/** Minimum normalized length for a copy-match — a 1-char echo is coincidence, not autofill. */\nconst MIN_COPY_MATCH_LENGTH = 2;\n/** Digits-only phone matching needs a real phone-sized run to be meaningful. */\nconst MIN_PHONE_DIGITS = 7;\n\n/**\n * Phone-sized digit runs match when one ENDS WITH the other: a manager fills\n * the stored national format (\"(555) 123-4567\") while the client posts E.164\n * (\"+15551234567\") — same phone, differing only by the country-code prefix.\n */\nconst phoneDigitsMatch = (a: string, b: string): boolean =>\n a.length >= MIN_PHONE_DIGITS && b.length >= MIN_PHONE_DIGITS && (a.endsWith(b) || b.endsWith(a));\n\n/**\n * Find the body field the decoy value was COPIED from — the autofill\n * signature: browsers and password-manager extensions fill the hidden input\n * with the same datum they put in a visible field (email, phone, …) — a human\n * with autofill, not a bot. Scans top-level string values, string arrays, and\n * one nested level (the booking form's custom `formFields` object). Matches\n * normalized equality, plus digits-only equality for phone-sized values.\n *\n * Returns the matched field's path (`email`, `formFields.phone`, `tags[]`) —\n * the SSOT for both the verdict and the hub gate's `sameAs` log diagnostic —\n * or `null` when nothing matches.\n */\nexport function findHoneypotCopySource(body: unknown, honeypot: string): string | null {\n const hpNorm = normalizeForCopyMatch(honeypot);\n const hpDigits = digitsOf(honeypot);\n const phoneCandidate = hpDigits.length >= MIN_PHONE_DIGITS;\n if (hpNorm.length < MIN_COPY_MATCH_LENGTH && !phoneCandidate) return null;\n\n const matches = (value: unknown): boolean => {\n if (typeof value !== 'string' || value.trim() === '') return false;\n // No length re-check: the top guard already rejected sub-minimum values\n // (phoneCandidate implies ≥7 chars survive normalization — digits do).\n if (normalizeForCopyMatch(value) === hpNorm) return true;\n return phoneCandidate && phoneDigitsMatch(digitsOf(value), hpDigits);\n };\n const matchInArray = (value: unknown): boolean => Array.isArray(value) && value.some(matches);\n\n const b = (body ?? {}) as Record<string, unknown>;\n for (const [key, value] of Object.entries(b)) {\n if (isHumanitySignalKey(key)) continue;\n if (matches(value)) return key;\n if (matchInArray(value)) return `${key}[]`;\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n for (const [nestedKey, nested] of Object.entries(value as Record<string, unknown>)) {\n if (matches(nested)) return `${key}.${nestedKey}`;\n if (matchInArray(nested)) return `${key}.${nestedKey}[]`;\n }\n }\n }\n return null;\n}\n\n/**\n * SINGLE decision fn for honeypot + timing (the hub's `verifyHuman` imports + calls this):\n * - honeypot non-empty → bot (real users never fill the off-screen field) — UNLESS the\n * value was copied from another field in the body (autofill reached the decoy → human;\n * the verdict carries `note: 'honeypot_autofill'` + the `sourceField` so callers log it)\n * - elapsed below `minFillMs` → bot (humans take time; a MISSING timing value never\n * blocks — and the too-fast check still applies to autofill-forgiven submissions)\n */\nexport function evaluateHumanitySignals(body: unknown, opts: { minFillMs: number }): HumanityVerdict {\n const { honeypot, elapsedMs } = extractHumanitySignals(body);\n const diagnostics: HumanityVerdictDiagnostics = {\n honeypotLength: honeypot.length,\n timingAffirmed: elapsedMs !== null && elapsedMs >= opts.minFillMs,\n };\n const filled = honeypot.trim() !== '';\n const sourceField = filled ? findHoneypotCopySource(body, honeypot) : null;\n if (filled && sourceField === null) return { ...diagnostics, ok: false, reason: 'honeypot' };\n if (elapsedMs !== null && elapsedMs < opts.minFillMs) return { ...diagnostics, ok: false, reason: 'too_fast' };\n return sourceField !== null\n ? { ...diagnostics, ok: true, note: 'honeypot_autofill', sourceField }\n : { ...diagnostics, ok: true };\n}\n\n/** Parse a comma-separated env string → trimmed, non-empty entries (undefined → []). */\nexport const splitCsvEnv = (s?: string): string[] =>\n s\n ?.split(',')\n .map(t => t.trim())\n .filter(Boolean) ?? [];\n","'use client';\n\nimport { useCallback, useEffect, useMemo, useRef } from 'react';\nimport { useEndpointsRuntime } from '../contexts/endpoints-runtime-context';\nimport { contentFetch } from '../utils/embed-content-fetch';\nimport {\n captureFormRescueEvent,\n FORM_RESCUE_ATTEMPT_FIELD,\n FORM_RESCUE_DEBOUNCE_MS,\n FORM_RESCUE_EVENTS,\n FORM_RESCUE_RESUME_FIELD,\n FORM_RESCUE_RESUME_PARAM,\n filledRescueFields,\n isFormAttemptId,\n isResumeToken,\n isRescueEmail,\n rescueCompletionPct,\n sanitizeRescueFieldName,\n sanitizeRescueValues,\n type FormDraftProgress,\n type FormDraftResumeResponse,\n type FormDraftSaveRequest,\n type FormRescueDefinition,\n} from '../utils/form-rescue';\n\n/** A local draft older than this is ignored and replaced. */\nconst LOCAL_DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1000;\nconst STORAGE_PREFIX = 'form-rescue:v1:';\nconst UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term'] as const;\n\ninterface LocalDraft {\n attemptId: string;\n values: Record<string, string>;\n savedAt: number;\n}\n\nexport interface UseFormRescueOptions {\n /** Which form this is (`defineRescueForm`); `null` turns rescue off. */\n form: FormRescueDefinition | null;\n /** Every field the visitor can see. Hidden fields are never listed, so never sent. */\n fieldNames: readonly string[];\n /** Called once after mount with values from a resume link, else from this device's local draft. */\n onRestore?: (values: Record<string, string>) => void;\n /** The form's humanity signals, so the draft endpoint can run the same bot gate as the submit. */\n getSignals?: () => Record<string, string | number>;\n}\n\nexport interface FormRescueHandle {\n /** Report the form's current values after a change (`lastField` = the field just edited). */\n track: (values: Record<string, unknown>, lastField?: string | null) => void;\n /** Keys to spread into the SUBMIT body so the host closes this attempt's draft. */\n submitFields: () => Record<string, string>;\n /** Call after a successful submit: clears the local draft and starts a fresh attempt. */\n complete: () => void;\n}\n\nfunction newAttemptId(): string {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();\n // RFC 4122 v4 from getRandomValues for older browsers.\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n bytes[6] = (bytes[6] & 0x0f) | 0x40;\n bytes[8] = (bytes[8] & 0x3f) | 0x80;\n const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n\nfunction readLocalDraft(formId: string): LocalDraft | null {\n try {\n const raw = window.localStorage.getItem(STORAGE_PREFIX + formId);\n if (!raw) return null;\n const parsed = JSON.parse(raw) as Partial<LocalDraft>;\n if (!isFormAttemptId(parsed.attemptId) || typeof parsed.savedAt !== 'number') return null;\n if (Date.now() - parsed.savedAt > LOCAL_DRAFT_TTL_MS) return null;\n return { attemptId: parsed.attemptId, values: sanitizeRescueValues(parsed.values), savedAt: parsed.savedAt };\n } catch {\n return null;\n }\n}\n\nfunction writeLocalDraft(formId: string, draft: LocalDraft): void {\n try {\n window.localStorage.setItem(STORAGE_PREFIX + formId, JSON.stringify(draft));\n } catch {\n // Storage full or blocked: the server draft still works.\n }\n}\n\nfunction clearLocalDraft(formId: string): void {\n try {\n window.localStorage.removeItem(STORAGE_PREFIX + formId);\n } catch {\n // Nothing to clear.\n }\n}\n\n/**\n * Close a rescue attempt after a successful submit, for a form that has already\n * unmounted by then (the meeting scheduler's details-first flow): clears this\n * device's draft and reports the submit. The hook's `complete` calls it too.\n */\nexport function completeFormRescue(form: FormRescueDefinition | null, attemptId?: unknown): void {\n if (!form || typeof window === 'undefined') return;\n const local = readLocalDraft(form.id);\n captureFormRescueEvent(FORM_RESCUE_EVENTS.submitted, {\n form_id: form.id,\n attempt_id: isFormAttemptId(attemptId) ? attemptId : (local?.attemptId ?? null),\n });\n clearLocalDraft(form.id);\n}\n\nfunction readUtm(): FormDraftSaveRequest['utm'] {\n try {\n const params = new URLSearchParams(window.location.search);\n const utm: NonNullable<FormDraftSaveRequest['utm']> = {};\n for (const key of UTM_KEYS) {\n const value = params.get(`utm_${key}`);\n if (value) utm[key] = value.slice(0, 200);\n }\n return Object.keys(utm).length ? utm : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * useFormRescue — saves a public form's progress so a visitor who leaves\n * before submitting can be followed up with, and restores it when they return.\n *\n * Every public lead form calls it the same way (next to `useHumanitySignals`):\n *\n * const rescue = useFormRescue({ form: RESCUE_FORMS.waitlist, fieldNames, onRestore, getSignals })\n * // on change: rescue.track(values, changedField)\n * // submit: body = { ...data, ...getSignals(), ...rescue.submitFields() }\n * // success: rescue.complete()\n *\n * Saves go to the host's `EndpointsRuntime.formDraftsUrl` (debounced, plus one\n * final keepalive save when the tab is hidden). A host that does not provide\n * the url gets the local draft only. Nothing here can block or delay a submit:\n * every network and storage call is fire-and-forget and swallows its errors.\n */\nexport function useFormRescue({ form, fieldNames, onRestore, getSignals }: UseFormRescueOptions): FormRescueHandle {\n const draftsUrl = useEndpointsRuntime()?.formDraftsUrl;\n const formId = form?.id ?? null;\n\n const attemptIdRef = useRef<string | null>(null);\n const resumeTokenRef = useRef<string | null>(null);\n const latestRef = useRef<FormDraftProgress | null>(null);\n const dirtyRef = useRef(false);\n const startedRef = useRef(false);\n const leadCapturedRef = useRef(false);\n const completedRef = useRef(false);\n const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n // Callers pass inline arrays and callbacks; read them through refs so the\n // returned handle stays stable and effects run once. Written in an effect\n // (declared first, so it runs before the restore below), never during render.\n const fieldNamesRef = useRef(fieldNames);\n const onRestoreRef = useRef(onRestore);\n const getSignalsRef = useRef(getSignals);\n useEffect(() => {\n fieldNamesRef.current = fieldNames;\n onRestoreRef.current = onRestore;\n getSignalsRef.current = getSignals;\n });\n\n const attemptId = useCallback((): string => {\n if (!attemptIdRef.current) attemptIdRef.current = newAttemptId();\n return attemptIdRef.current;\n }, []);\n\n const send = useCallback(\n (keepalive: boolean) => {\n if (!formId || !draftsUrl || !dirtyRef.current || completedRef.current || !latestRef.current) return;\n dirtyRef.current = false;\n const body: FormDraftSaveRequest = {\n attempt_id: attemptId(),\n form_id: formId,\n ...latestRef.current,\n source_path: typeof window !== 'undefined' ? window.location.pathname : '/',\n utm: readUtm(),\n ...(resumeTokenRef.current ? { resume_token: resumeTokenRef.current } : {}),\n };\n try {\n void contentFetch(draftsUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ ...body, ...(getSignalsRef.current?.() ?? {}) }),\n keepalive,\n }).catch(() => undefined);\n } catch {\n // A failed save is retried by the next change.\n }\n captureFormRescueEvent(FORM_RESCUE_EVENTS.progress, {\n form_id: formId,\n attempt_id: attemptId(),\n fields_filled: body.fields_filled,\n last_field: body.last_field,\n completion_pct: body.completion_pct,\n });\n },\n [attemptId, draftsUrl, formId],\n );\n\n // Restore once on mount: a resume link wins over this device's local draft.\n useEffect(() => {\n if (!formId || typeof window === 'undefined') return undefined;\n let cancelled = false;\n const token = new URLSearchParams(window.location.search).get(FORM_RESCUE_RESUME_PARAM);\n\n const restoreLocal = () => {\n const local = readLocalDraft(formId);\n if (!local) return;\n attemptIdRef.current = local.attemptId;\n if (Object.keys(local.values).length) onRestoreRef.current?.(local.values);\n };\n\n if (isResumeToken(token) && draftsUrl) {\n contentFetch(`${draftsUrl}/resume/${encodeURIComponent(token)}`)\n .then(async res => (res.ok ? ((await res.json()) as FormDraftResumeResponse) : null))\n .then(data => {\n if (cancelled) return;\n if (!data || data.form_id !== formId) {\n restoreLocal();\n return;\n }\n resumeTokenRef.current = token;\n onRestoreRef.current?.(sanitizeRescueValues(data.values));\n captureFormRescueEvent(FORM_RESCUE_EVENTS.resumed, { form_id: formId, attempt_id: attemptId() });\n })\n .catch(() => {\n if (!cancelled) restoreLocal();\n });\n } else {\n restoreLocal();\n }\n return () => {\n cancelled = true;\n };\n }, [attemptId, draftsUrl, formId]);\n\n // Final save when the visitor hides or leaves the tab.\n useEffect(() => {\n if (!formId || typeof document === 'undefined') return undefined;\n const onHide = () => {\n if (completedRef.current || !startedRef.current) return;\n if (timerRef.current) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n send(true);\n captureFormRescueEvent(FORM_RESCUE_EVENTS.abandoned, {\n form_id: formId,\n attempt_id: attemptId(),\n last_field: latestRef.current?.last_field ?? null,\n completion_pct: latestRef.current?.completion_pct ?? 0,\n });\n };\n const onVisibility = () => {\n if (document.visibilityState === 'hidden') onHide();\n };\n document.addEventListener('visibilitychange', onVisibility);\n window.addEventListener('pagehide', onHide);\n return () => {\n document.removeEventListener('visibilitychange', onVisibility);\n window.removeEventListener('pagehide', onHide);\n if (timerRef.current) clearTimeout(timerRef.current);\n };\n }, [attemptId, formId, send]);\n\n const track = useCallback(\n (values: Record<string, unknown>, lastField?: string | null) => {\n if (!formId || completedRef.current) return;\n const names = fieldNamesRef.current;\n const visible: Record<string, unknown> = {};\n for (const name of names) visible[name] = values[name];\n const filled = filledRescueFields(visible, names);\n if (!filled.length) return;\n\n if (!startedRef.current) {\n startedRef.current = true;\n captureFormRescueEvent(FORM_RESCUE_EVENTS.started, { form_id: formId, attempt_id: attemptId() });\n }\n const clean = sanitizeRescueValues(visible);\n if (!leadCapturedRef.current && isRescueEmail(clean.email)) {\n leadCapturedRef.current = true;\n captureFormRescueEvent(FORM_RESCUE_EVENTS.leadCaptured, { form_id: formId, attempt_id: attemptId() });\n }\n latestRef.current = {\n values: clean,\n fields_filled: filled,\n last_field: sanitizeRescueFieldName(lastField),\n completion_pct: rescueCompletionPct(filled.length, names.length),\n };\n dirtyRef.current = true;\n writeLocalDraft(formId, { attemptId: attemptId(), values: clean, savedAt: Date.now() });\n\n if (timerRef.current) clearTimeout(timerRef.current);\n timerRef.current = setTimeout(() => {\n timerRef.current = null;\n send(false);\n }, FORM_RESCUE_DEBOUNCE_MS);\n },\n [attemptId, formId, send],\n );\n\n const submitFields = useCallback((): Record<string, string> => {\n if (!formId) return {};\n return {\n [FORM_RESCUE_ATTEMPT_FIELD]: attemptId(),\n ...(resumeTokenRef.current ? { [FORM_RESCUE_RESUME_FIELD]: resumeTokenRef.current } : {}),\n };\n }, [attemptId, formId]);\n\n const complete = useCallback(() => {\n if (!formId) return;\n if (timerRef.current) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n completeFormRescue(form, attemptId());\n // A fresh attempt for a second submission from the same page.\n attemptIdRef.current = null;\n resumeTokenRef.current = null;\n latestRef.current = null;\n dirtyRef.current = false;\n startedRef.current = false;\n leadCapturedRef.current = false;\n }, [attemptId, form, formId]);\n\n return useMemo(() => ({ track, submitFields, complete }), [track, submitFields, complete]);\n}\n","/**\n * Form rescue — the shared rules for saving a half-filled public form so the\n * team can follow up with a visitor who left before submitting.\n *\n * Pure and server-safe: the browser hook (`hooks/use-form-rescue`) and the\n * host's draft endpoint import the SAME allowlist, exclusions, caps and\n * completion math, so the server re-applies exactly the filter the client ran\n * and a rename here reaches both sides at once. Also exported through the\n * granular subpath `./utils/form-rescue` for server-only consumers.\n *\n * What a draft may carry:\n * - values of ALLOWLISTED contact fields only (`FORM_RESCUE_VALUE_FIELDS`),\n * trimmed and capped at `FORM_RESCUE_MAX_VALUE_CHARS`;\n * - for every other field, only whether it is filled (never its value);\n * - nothing whose name looks like a credential or payment field\n * (`isExcludedRescueField`), whatever the allowlist says.\n *\n * Field values go to the host only, never to analytics: the PostHog events\n * (`captureFormRescueEvent`) carry ids, field names and percentages.\n */\n\n/**\n * A rescuable form, defined ONCE beside the form that uses it (the strategy a\n * form plugs in with). The browser sends `id`; the host validates it with\n * `getRescueForm` and shows `label` to the team. A new form is one\n * `defineRescueForm` call: no central list, no host change.\n */\nexport interface FormRescueDefinition {\n /** Stable wire id: lower snake case, stored on every draft of this form. */\n readonly id: string;\n /** What the team reads on the alert (\"Contact form\"). */\n readonly label: string;\n}\n\nconst FORM_ID_PATTERN = /^[a-z][a-z0-9_]{1,47}$/;\nconst definitions = new Map<string, FormRescueDefinition>();\n\n/**\n * Register a rescuable form. Idempotent for the same id and label (a module\n * evaluated twice registers once); a second, different definition of one id\n * throws, so two forms can never share a draft vocabulary.\n */\nexport function defineRescueForm(definition: FormRescueDefinition): FormRescueDefinition {\n if (!FORM_ID_PATTERN.test(definition.id)) throw new Error(`[form-rescue] invalid form id \"${definition.id}\"`);\n const existing = definitions.get(definition.id);\n if (existing) {\n if (existing.label !== definition.label) {\n throw new Error(`[form-rescue] form id \"${definition.id}\" is already defined as \"${existing.label}\"`);\n }\n return existing;\n }\n const frozen = Object.freeze({ id: definition.id, label: definition.label });\n definitions.set(frozen.id, frozen);\n return frozen;\n}\n\n/** The registered definition for a wire id, or null (unknown ids are refused by the host). */\nexport function getRescueForm(id: unknown): FormRescueDefinition | null {\n return typeof id === 'string' ? (definitions.get(id) ?? null) : null;\n}\n\n/** Every registered form, for a host listing them. */\nexport function listRescueForms(): FormRescueDefinition[] {\n return [...definitions.values()];\n}\n\n/** The lib's own public forms. A host defines its own forms the same way, beside them. */\nexport const RESCUE_FORMS = {\n contact: defineRescueForm({ id: 'contact', label: 'Contact form' }),\n caseStudyPitch: defineRescueForm({ id: 'case_study_pitch', label: 'Case study pitch' }),\n dataRoomRequest: defineRescueForm({ id: 'data_room_request', label: 'Data room request' }),\n trustCenterRequest: defineRescueForm({ id: 'trust_center_request', label: 'Trust Center document request' }),\n meetingBooking: defineRescueForm({ id: 'meeting_booking', label: 'Meeting booking' }),\n waitlist: defineRescueForm({ id: 'waitlist', label: 'Waitlist' }),\n} as const;\n\n/**\n * Fields whose VALUES a draft keeps: who the visitor is and where they work.\n * Covers both naming schemes in use (the contact form's `name` / `companySize`,\n * the meeting booking's HubSpot `firstName` / `company` / `jobtitle`).\n * Free text (`message`) and phone numbers are reported as filled only.\n */\nexport const FORM_RESCUE_VALUE_FIELDS = [\n 'email',\n 'name',\n 'firstName',\n 'lastName',\n 'company',\n 'jobtitle',\n 'companySize',\n 'linkedin_url',\n] as const;\n\n/** Never send or store these, whatever the allowlist says. */\nconst EXCLUDED_FIELD_PATTERN =\n /pass(word|code)?|card|cvv|cvc|ssn|social.?security|iban|bank|routing|token|secret|otp|pin$/i;\n\nexport function isExcludedRescueField(name: string): boolean {\n return EXCLUDED_FIELD_PATTERN.test(name);\n}\n\n/** Longest value a draft keeps per field. */\nexport const FORM_RESCUE_MAX_VALUE_CHARS = 200;\n/** Save this long after the visitor stops typing. */\nexport const FORM_RESCUE_DEBOUNCE_MS = 1500;\n/** Most fields a draft reports (filled names), a bound on the payload. */\nexport const FORM_RESCUE_MAX_FIELDS = 40;\n\n/** Body keys a form's SUBMIT carries so the host can close the matching draft. */\nexport const FORM_RESCUE_ATTEMPT_FIELD = 'form_attempt_id';\nexport const FORM_RESCUE_RESUME_FIELD = 'form_resume_token';\n/** Every rescue key that rides in a submit body. Hosts forwarding a payload upstream strip by THIS array. */\nexport const FORM_RESCUE_SUBMIT_KEYS = [FORM_RESCUE_ATTEMPT_FIELD, FORM_RESCUE_RESUME_FIELD] as const;\n\n/** Query parameter a resume link carries. */\nexport const FORM_RESCUE_RESUME_PARAM = 'resume';\n\n/** A resume token's shape (base64url, as the host mints it). THE check for every reader of one. */\nconst RESUME_TOKEN_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;\n\nexport function isResumeToken(value: unknown): value is string {\n return typeof value === 'string' && RESUME_TOKEN_PATTERN.test(value);\n}\n\n/** Draft lifecycle. `submitted` is final; `rescued` can still become `submitted`. */\nexport const FORM_DRAFT_STATUSES = ['started', 'rescuable', 'rescued', 'submitted'] as const;\nexport type FormDraftStatus = (typeof FORM_DRAFT_STATUSES)[number];\n\n/** Why a due draft was settled WITHOUT alerting the team (stored on the draft). */\nexport const FORM_RESCUE_SKIP_REASONS = {\n finishedElsewhere: 'finished_elsewhere',\n alreadyRescued: 'already_rescued',\n internal: 'internal_email',\n} as const;\nexport type FormRescueSkipReason = (typeof FORM_RESCUE_SKIP_REASONS)[keyof typeof FORM_RESCUE_SKIP_REASONS];\n\n/** Same shape the host's `contact_submissions.email` check accepts. */\nconst EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$/;\n\nexport function isRescueEmail(value: unknown): value is string {\n return typeof value === 'string' && value.length <= FORM_RESCUE_MAX_VALUE_CHARS && EMAIL_PATTERN.test(value.trim());\n}\n\nconst UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nexport function isFormAttemptId(value: unknown): value is string {\n return typeof value === 'string' && UUID_PATTERN.test(value);\n}\n\nfunction isFilled(value: unknown): boolean {\n if (value == null) return false;\n if (typeof value === 'string') return value.trim().length > 0;\n if (typeof value === 'boolean') return value;\n if (Array.isArray(value)) return value.length > 0;\n if (typeof value === 'object') return Object.values(value as Record<string, unknown>).some(isFilled);\n return true;\n}\n\n/**\n * Keep only allowlisted, non-excluded string values, trimmed and capped.\n * The ONE filter, run by the browser before sending and by the host before storing.\n */\nexport function sanitizeRescueValues(values: Record<string, unknown> | null | undefined): Record<string, string> {\n const out: Record<string, string> = {};\n if (!values || typeof values !== 'object') return out;\n for (const key of FORM_RESCUE_VALUE_FIELDS) {\n if (isExcludedRescueField(key)) continue;\n const raw = values[key];\n if (typeof raw !== 'string') continue;\n const trimmed = raw.trim().slice(0, FORM_RESCUE_MAX_VALUE_CHARS);\n if (trimmed) out[key] = trimmed;\n }\n return out;\n}\n\n/** Names of the given fields that hold something, credentials never included. */\nexport function filledRescueFields(values: Record<string, unknown>, fieldNames: readonly string[]): string[] {\n return fieldNames\n .filter(name => !isExcludedRescueField(name) && isFilled(values[name]))\n .slice(0, FORM_RESCUE_MAX_FIELDS);\n}\n\nexport function rescueCompletionPct(filledCount: number, totalCount: number): number {\n if (totalCount <= 0) return 0;\n return Math.max(0, Math.min(100, Math.round((filledCount / totalCount) * 100)));\n}\n\n/** A field NAME the host stores (`last_field`, `fields_filled`); bounded, printable. */\nexport function sanitizeRescueFieldName(value: unknown): string | null {\n if (typeof value !== 'string') return null;\n const trimmed = value.trim();\n return /^[A-Za-z0-9_.-]{1,64}$/.test(trimmed) && !isExcludedRescueField(trimmed) ? trimmed : null;\n}\n\n/** A form's progress at one moment: allowlisted values, filled field names, position. */\nexport interface FormDraftProgress {\n values: Record<string, string>;\n fields_filled: string[];\n last_field: string | null;\n completion_pct: number;\n}\n\n/** What the browser sends on every save (`POST <formDraftsUrl>`), beside the humanity signals. */\nexport interface FormDraftSaveRequest extends FormDraftProgress {\n /** The attempt's idempotency key: every save of one attempt updates one draft. */\n attempt_id: string;\n /** A `FormRescueDefinition.id`. */\n form_id: string;\n source_path: string;\n utm?: Partial<Record<'source' | 'medium' | 'campaign' | 'content' | 'term', string>>;\n /** Set when the visitor came back through a resume link: the host saves onto\n * THAT draft, so one person's return never starts a second draft. */\n resume_token?: string;\n}\n\nconst UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term'] as const;\n\n/** Longest source path a draft keeps. */\nconst MAX_SOURCE_PATH_CHARS = 500;\n\n/**\n * THE server-side read of a draft save: turns an untrusted body into a\n * `FormDraftSaveRequest` with every rule re-applied (known form, uuid attempt,\n * allowlisted and capped values, bounded field names, a same-site path, capped\n * UTM), or null when the form or attempt is unknown. `dropped` names the value\n * keys the allowlist removed, for a log line that never carries a value.\n */\nexport function parseFormDraftSaveRequest(\n body: Record<string, unknown>,\n): { request: FormDraftSaveRequest; dropped: string[] } | null {\n const form = getRescueForm(body.form_id);\n if (!isFormAttemptId(body.attempt_id) || !form) return null;\n\n const rawValues = body.values && typeof body.values === 'object' ? (body.values as Record<string, unknown>) : {};\n const values = sanitizeRescueValues(rawValues);\n const fieldsFilled = (Array.isArray(body.fields_filled) ? body.fields_filled : [])\n .map(sanitizeRescueFieldName)\n .filter((name): name is string => name !== null)\n .slice(0, FORM_RESCUE_MAX_FIELDS);\n const path = typeof body.source_path === 'string' ? body.source_path : '';\n const utmIn = body.utm && typeof body.utm === 'object' ? (body.utm as Record<string, unknown>) : {};\n const utm: NonNullable<FormDraftSaveRequest['utm']> = {};\n for (const key of UTM_KEYS) {\n const value = utmIn[key];\n if (typeof value === 'string' && value.trim()) utm[key] = value.trim().slice(0, FORM_RESCUE_MAX_VALUE_CHARS);\n }\n\n return {\n request: {\n attempt_id: body.attempt_id,\n form_id: form.id,\n values,\n fields_filled: fieldsFilled,\n last_field: sanitizeRescueFieldName(body.last_field),\n completion_pct: typeof body.completion_pct === 'number' ? rescueCompletionPct(body.completion_pct, 100) : 0,\n // Same-site only: a path, never a scheme-relative or absolute URL.\n source_path: path.startsWith('/') && !path.startsWith('//') ? path.slice(0, MAX_SOURCE_PATH_CHARS) : '',\n ...(Object.keys(utm).length ? { utm } : {}),\n ...(isResumeToken(body.resume_token) ? { resume_token: body.resume_token } : {}),\n },\n dropped: Object.keys(rawValues).filter(key => !(key in values)),\n };\n}\n\n/** What the host answers a resume link with: the allowlisted values, nothing internal. */\nexport interface FormDraftResumeResponse {\n form_id: string;\n values: Record<string, string>;\n}\n\n/** The PostHog events this feature emits. Properties are ids, names and numbers, never values. */\nexport const FORM_RESCUE_EVENTS = {\n started: 'form_started',\n progress: 'form_progress',\n leadCaptured: 'form_lead_captured',\n abandoned: 'form_abandoned',\n submitted: 'form_submitted',\n rescued: 'form_rescued',\n resumed: 'form_resumed',\n} as const;\n\ntype AnalyticsValue = string | number | boolean | string[] | null;\n\n/**\n * Send one form event to PostHog when the page loaded it (GTM exposes\n * `window.posthog`); a no-op otherwise. Never throws.\n */\nexport function captureFormRescueEvent(event: string, properties: Record<string, AnalyticsValue>): void {\n try {\n if (typeof window === 'undefined') return;\n const ph = (window as unknown as { posthog?: { capture?: (e: string, p: object) => void } }).posthog;\n if (typeof ph?.capture === 'function') ph.capture(event, properties);\n } catch {\n // Analytics never breaks a form.\n }\n}\n","'use client';\n\nimport { useEffect } from 'react';\nimport type { FieldValues, Path, PathValue, UseFormReturn } from 'react-hook-form';\nimport type { FormRescueDefinition } from '../utils/form-rescue';\nimport { useFormRescue, type FormRescueHandle } from './use-form-rescue';\n\nexport interface UseRescuedFormOptions<T extends FieldValues> {\n /** The form's definition (`defineRescueForm` / `RESCUE_FORMS`); `null` turns rescue off. */\n rescue: FormRescueDefinition | null;\n /** The fields the visitor can see, by the name a draft stores them under. Hidden fields are never listed. */\n fields: readonly string[];\n /** The form's humanity signals, so draft saves pass the same bot gate as the submit. */\n getSignals?: () => Record<string, string | number>;\n /** Where a stored field lives in the form, when it is nested (`company` → `formFields.company`). Default: the same name. */\n fieldPath?: (name: string) => Path<T>;\n}\n\n/** One level of nesting flattened (`{ formFields: { company } }` → `{ company }`), so nested answers read like top-level ones. */\nfunction flattenOneLevel(values: Record<string, unknown>): Record<string, unknown> {\n const flat: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(values)) {\n if (value && typeof value === 'object' && !Array.isArray(value)) Object.assign(flat, value);\n else flat[key] = value;\n }\n return flat;\n}\n\n/**\n * useRescuedForm — form rescue for ANY react-hook-form form, in one call.\n *\n * Wires what every rescued form needs so no form hand-rolls it: every edit is\n * tracked (`watch`), a resume link or this device's draft fills the empty\n * fields (`setValue`), and the handle's `submitFields()` / `complete()` close\n * the attempt on submit. A form that is not react-hook-form uses\n * `useFormRescue` directly.\n *\n * const rescue = useRescuedForm(form, { rescue: RESCUE_FORMS.contact, fields, getSignals })\n * // submit: body = { ...data, ...getSignals(), ...rescue.submitFields() }\n * // success: rescue.complete()\n */\nexport function useRescuedForm<T extends FieldValues>(\n form: Pick<UseFormReturn<T>, 'watch' | 'getValues' | 'setValue'>,\n { rescue, fields, getSignals, fieldPath }: UseRescuedFormOptions<T>,\n): FormRescueHandle {\n const { watch, getValues, setValue } = form;\n\n const handle = useFormRescue({\n form: rescue,\n fieldNames: fields,\n getSignals,\n onRestore: values => {\n for (const name of fields) {\n const value = values[name];\n if (!value) continue;\n const path = fieldPath ? fieldPath(name) : (name as Path<T>);\n // Only an empty field is filled: never overwrite what the visitor typed.\n if (!getValues(path)) setValue(path, value as PathValue<T, Path<T>>, { shouldDirty: true });\n }\n },\n });\n\n useEffect(() => {\n const subscription = watch((values, { name }) => {\n handle.track(flattenOneLevel(values), name ? (name.split('.').pop() ?? null) : null);\n });\n return () => subscription.unsubscribe();\n }, [watch, handle]);\n\n return handle;\n}\n"]}
1
+ {"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-XF5TJ4P2.cjs","../src/hooks/ui/use-auto-limit-tags.ts","../src/hooks/ui/use-deferred-error.ts","../src/hooks/ui/use-drag-and-drop-enabled.ts","../src/hooks/ui/use-focus-trap.ts","../src/hooks/ui/use-horizontal-scrollbar.ts","../src/hooks/ui/use-keyboard-collision-padding.ts","../src/hooks/ui/use-local-storage.ts","../src/utils/storage-event.ts","../src/hooks/ui/use-memoized-callback.ts","../src/hooks/ui/use-notification-permission.ts","../src/hooks/ui/use-onboarding-state.ts","../src/utils/onboarding-storage.ts","../src/hooks/ui/use-search.ts","../src/hooks/ui/use-table-pagination.ts","../src/hooks/ui/use-throttle.ts","../src/hooks/ui/use-window-size.ts","../src/hooks/ui/index.ts","../src/hooks/platform/use-platform-config.ts","../src/utils/platform-config.tsx","../src/hooks/use-toast.ts","../src/components/ui/toaster.tsx","../src/hooks/use-contact-submission.ts","../src/hooks/use-copy-to-clipboard.ts","../src/hooks/use-batch-images.ts","../src/hooks/use-authenticated-image.ts","../src/hooks/state/use-query-params.ts","../src/hooks/state/flatten-schema.ts","../src/hooks/state/graphql-parser.ts","../src/hooks/state/introspection.ts","../src/hooks/state/url-converter.ts","../src/hooks/state/use-api-params.ts","../src/hooks/state/use-cursor-pagination-state.ts","../src/hooks/nats/use-nats-client.ts","../src/hooks/use-access-code-integration.ts","../src/utils/access-code-client.ts","../src/hooks/use-claude-mirror-src.ts","../src/utils/embed-url-converters.ts","../src/hooks/use-scroll-to-hash.ts","../src/hooks/use-humanity-signals.ts","../src/utils/humanity-signals.ts","../src/hooks/use-form-rescue.ts","../src/utils/form-rescue.ts","../src/hooks/use-rescued-form.ts"],"names":["measure","useState","useEffect","useCallback","dismissOnboarding","markMultipleComplete","jsx","useMemo","isInputObjectType","clearParams"],"mappings":"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ACM,IAAA;AACZ,IAAA;AACqB,IAAA;AACQ,MAAA;AACA,QAAA;AACb,QAAA;AACgC,QAAA;AAE8C,QAAA;AAC5F,MAAA;AACF,IAAA;AACD,EAAA;AAEe,EAAA;AACmC,IAAA;AACoC,MAAA;AACpF,IAAA;AACqC,IAAA;AACtB,EAAA;AAEX,EAAA;AACT;A3Cq1GyF;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-XF5TJ4P2.cjs","sourcesContent":[null,"'use client';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport type { RefObject } from 'react';\n\nexport interface UseAutoLimitTagsOptions {\n /** Total number of tags */\n count: number;\n /** Fixed limit or \"auto\" for DOM-based measurement. Default \"auto\" */\n limitTags?: number | 'auto';\n /** Placeholder text used to reserve input width (only used in \"auto\" mode) */\n placeholder?: string;\n /**\n * Reserve trailing space for a text input (the autocomplete combobox). Set\n * `false` for a pure chip row that has no input — then only the chips and the\n * \"+N\" / \"⋯\" overflow badge compete for the available width. Default `true`.\n */\n reserveInputWidth?: boolean;\n}\n\nexport interface UseAutoLimitTagsReturn {\n /** How many tags to show */\n visibleCount: number;\n /** Ref for the zone that contains tags + input (must have overflow-hidden, gap, padding) */\n middleRef: RefObject<HTMLDivElement | null>;\n /** Ref for the off-screen container that holds measurement copies of ALL tags */\n measureRef: RefObject<HTMLDivElement | null>;\n /** Ref for the hidden span that measures placeholder text width */\n textMeasureRef: RefObject<HTMLSpanElement | null>;\n /** Ref for the \"+N\" badge element (used to measure its width) */\n badgeRef: RefObject<HTMLButtonElement | null>;\n /** Ref for the input element (used to read its min-width) */\n inputRef: RefObject<HTMLInputElement | null>;\n}\n\n/**\n * Calculates how many tags fit in a single-line container.\n *\n * Requires three off-screen measurement elements rendered by the consumer:\n * 1. A `<div ref={measureRef}>` containing Tag copies for every item (to measure widths)\n * 2. A `<span ref={textMeasureRef}>` containing the placeholder text (to reserve input width)\n * 3. The `<button ref={badgeRef}>` for the \"+N\" badge (to measure badge width)\n *\n * The hook reads real CSS values (padding, gap) from the middleRef container,\n * so it works regardless of responsive breakpoints or custom styling.\n */\nexport function useAutoLimitTags({\n count,\n limitTags = 'auto',\n placeholder = '',\n reserveInputWidth = true,\n}: UseAutoLimitTagsOptions): UseAutoLimitTagsReturn {\n const middleRef = useRef<HTMLDivElement>(null);\n const measureRef = useRef<HTMLDivElement>(null);\n const textMeasureRef = useRef<HTMLSpanElement>(null);\n const badgeRef = useRef<HTMLButtonElement>(null);\n const inputRef = useRef<HTMLInputElement>(null);\n // `null` = never measured (no container rendered, or a fixed `limitTags` that\n // needs no measurement). Kept distinct from `0` so the \"show everything\"\n // fallback below stays derivable during render instead of having to be\n // written into state from an effect.\n const [measuredCount, setMeasuredCount] = useState<number | null>(null);\n\n const recalculate = useCallback(() => {\n const middle = middleRef.current;\n const measure = measureRef.current;\n if (!middle || !measure) {\n setMeasuredCount(null);\n return;\n }\n if (count === 0) {\n setMeasuredCount(0);\n return;\n }\n\n // Read real CSS metrics from the middle zone\n const cs = getComputedStyle(middle);\n const padL = parseFloat(cs.paddingLeft) || 0;\n const padR = parseFloat(cs.paddingRight) || 0;\n const gap = parseFloat(cs.gap) || 0;\n const middleW = middle.clientWidth;\n\n // Reserve trailing space for the input (autocomplete only). A pure chip row\n // (`reserveInputWidth: false`) has no input, so nothing — and no gap before\n // it — is reserved.\n let inputReservedW = 0;\n let trailingGap = 0;\n if (reserveInputWidth) {\n const textW = textMeasureRef.current?.offsetWidth ?? 60;\n const inputMinW = inputRef.current ? parseFloat(getComputedStyle(inputRef.current).minWidth) || 60 : 60;\n inputReservedW = Math.max(textW + 8, inputMinW);\n trailingGap = gap;\n }\n\n // Available = middle zone − padding − input reserved − gap before input\n const available = middleW - padL - padR - inputReservedW - trailingGap;\n\n // Measure every tag from the off-screen container\n const tagEls = Array.from(measure.children) as HTMLElement[];\n const widths = tagEls.map(el => el.offsetWidth);\n\n // Fast check: do ALL tags fit?\n let total = 0;\n for (let i = 0; i < widths.length; i++) {\n total += widths[i] + (i > 0 ? gap : 0);\n }\n if (total <= available) {\n setMeasuredCount(count);\n return;\n }\n\n // Not all fit → reserve space for the \"+N\" badge\n const badgeW = badgeRef.current?.offsetWidth ?? 40;\n const spaceWithBadge = available - badgeW - gap;\n\n let used = 0;\n let fitCount = 0;\n for (let i = 0; i < widths.length; i++) {\n const need = widths[i] + (i > 0 ? gap : 0);\n if (used + need > spaceWithBadge) break;\n used += need;\n fitCount++;\n }\n\n setMeasuredCount(Math.max(0, fitCount));\n }, [count, placeholder, reserveInputWidth]);\n\n // ONE effect, not two. `ResizeObserver` delivers an initial notification as\n // soon as it starts observing, so `observe()` IS the mount-time measurement —\n // the separate `recalculate()` call this replaces was a synchronous setState\n // in an effect body, i.e. a guaranteed second render pass on every mount and\n // on every change of `count`. `recalculate`'s identity changes with `count` /\n // `placeholder` / `reserveInputWidth`, so a re-observe (and therefore a fresh\n // measurement) still happens for input changes that do not resize the box.\n useEffect(() => {\n if (limitTags !== 'auto') return undefined;\n const el = middleRef.current;\n if (!el) return undefined;\n const ro = new ResizeObserver(recalculate);\n ro.observe(el);\n return () => ro.disconnect();\n }, [recalculate, limitTags]);\n\n // A fixed limit is pure arithmetic on props and never needed the DOM at all.\n // The `Math.min(…, count)` clamp matters because the measurement lags a\n // shrinking `count` by one commit: unclamped, callers computing\n // `count - visibleCount` for the \"+N\" badge briefly get a NEGATIVE number.\n const visibleCount =\n limitTags !== 'auto' ? Math.min(limitTags, count) : measuredCount === null ? count : Math.min(measuredCount, count);\n\n return { visibleCount, middleRef, measureRef, textMeasureRef, badgeRef, inputRef };\n}\n","'use client';\n\nimport { useState, useEffect, useCallback } from 'react';\n\nexport interface UseDeferredErrorResult {\n /** The message to render (undefined while the reveal is deferred or the field is valid). */\n error: string | undefined;\n /** Attach to the field's onBlur — reveals validation immediately and keeps it live from then on. */\n onBlur: () => void;\n}\n\n/**\n * Defers showing a field validation message while the user is still typing.\n *\n * The message is revealed when the field loses focus (touched) or when the\n * value hasn't changed for `delay` ms; every keystroke restarts the timer.\n * Once touched, validation stays live. A message that clears (the value became\n * valid) disappears immediately.\n */\nexport function useDeferredError(error: string | undefined, value: string, delay = 1500): UseDeferredErrorResult {\n const [touched, setTouched] = useState(false);\n const [paused, setPaused] = useState(false);\n const [pausedFor, setPausedFor] = useState(value);\n\n // Typing hides the message: React's \"adjust state while rendering\" reset, not\n // an effect. The keystroke has already scheduled this render, so clearing\n // `paused` here costs nothing, whereas doing it from the effect below commits\n // a render with the stale message still on screen and then immediately\n // re-renders to remove it.\n if (pausedFor !== value) {\n setPausedFor(value);\n setPaused(false);\n }\n\n // …and restarts the pause timer.\n useEffect(() => {\n if (touched) return undefined;\n const timer = setTimeout(() => setPaused(true), delay);\n return () => clearTimeout(timer);\n }, [value, delay, touched]);\n\n const onBlur = useCallback(() => setTouched(true), []);\n\n return {\n error: error && (touched || paused) ? error : undefined,\n onBlur,\n };\n}\n","'use client';\n\nimport { breakpoints, useMediaQuery } from './use-media-query';\n\n/**\n * The one rule for whether drag-and-drop is initialized at all.\n *\n * Pointer type AND breakpoint, never either alone:\n * - A breakpoint alone fails on tablets: an iPad in landscape is 1024-1366px\n * wide and would pass any sane `min-width` — exactly the case being excluded,\n * because the HTML5 drag-and-drop API Pragmatic builds on does not fire under\n * touch.\n * - `pointer: coarse` alone fails the other way: a touchscreen laptop with a\n * trackpad reports `pointer: fine` and should keep DnD, but in a narrow\n * window its lists collapse to the mobile layout where a drag handle is out\n * of place.\n * - No `'ontouchstart' in window`, no UA sniffing: the first is true on touch\n * laptops, the second is neither accurate nor reactive. A media query\n * re-evaluates live when a mouse is attached or the window resizes.\n *\n * `breakpoints.md` (800px) is the existing tablet step — no new breakpoint.\n */\nexport const DRAG_AND_DROP_MEDIA_QUERY = `(pointer: fine) and (hover: hover) and ${breakpoints.md}`;\n\n/**\n * Whether drag-and-drop should be available on this device/viewport.\n *\n * `false` on touch/narrow screens — there the reordering surfaces render\n * explicit controls (`SortableMoveButtons`) instead of a drag handle, and no\n * DnD is initialized at all. Also `false` during SSR and the first client\n * render (`useMediaQuery` is still `undefined`): defaulting to \"off\" renders\n * no dead drag handle, and the underlying layout effect settles the real\n * answer before first paint, so a desktop user never sees the fallback flash.\n */\nexport function useDragAndDropEnabled(): boolean {\n return useMediaQuery(DRAG_AND_DROP_MEDIA_QUERY) ?? false;\n}\n","'use client';\n\nimport { useEffect, useRef, type RefObject } from 'react';\n\nconst FOCUSABLE_SELECTOR = [\n 'a[href]',\n 'button:not([disabled])',\n 'input:not([disabled])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(', ');\n\nexport interface UseFocusTrapOptions {\n /** Called when Escape is pressed while focus is inside the container.\n * When omitted, Escape passes through untouched so document-level\n * listeners still fire. */\n onEscape?: () => void;\n /**\n * When false, Tab/Shift+Tab are NOT cycled within the container — for\n * non-modal dialogs (e.g. SlidingSidebar, whose z-50 header stays clickable\n * above the open drawer). Escape / initial focus / guarded restore still apply.\n * @default true\n */\n contain?: boolean;\n}\n\n/**\n * Focus management for overlay surfaces (mobile nav panel, sliding sidebar,\n * burger menu, modals): initial focus on activate, container-scoped Tab\n * cycling + Escape, and guarded focus restore on deactivate.\n *\n * CONTRACT: the container element MUST have `tabIndex={-1}`. After a\n * click/tap on a non-focusable area inside the surface, browsers move focus\n * to the nearest focusable ancestor — with the container script-focusable\n * that ancestor is the container itself, so this keydown listener keeps\n * hearing Escape/Tab. Without it, focus falls to `body` and the trap goes\n * deaf.\n *\n * The listener being container-scoped makes it stacking-safe: it only fires\n * while focus is inside the surface, so a modal open above never leaks its\n * Escape into the surface below.\n */\nexport function useFocusTrap(\n containerRef: RefObject<HTMLElement | null>,\n active: boolean,\n { onEscape, contain = true }: UseFocusTrapOptions = {},\n): void {\n // Latest-ref so unstable inline callbacks never tear the trap down and\n // re-yank focus — same stable-callback discipline as `useCloseOnNavigation`.\n // Refreshed after every commit, declared before the trap effect so it wins\n // the same flush. Not in the render body: the reader is a keydown listener,\n // which cannot fire before a commit.\n const onEscapeRef = useRef(onEscape);\n useEffect(() => {\n onEscapeRef.current = onEscape;\n });\n\n useEffect(() => {\n if (!active) return undefined;\n const container = containerRef.current;\n if (!container) return undefined;\n\n const previouslyFocused = document.activeElement as HTMLElement | null;\n\n const focusables = () =>\n Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(\n el => el.getClientRects().length > 0,\n );\n\n const first = focusables()[0];\n (first ?? container).focus({ preventScroll: true });\n\n const handleKeyDown = (e: KeyboardEvent) => {\n if (e.key === 'Escape') {\n // No preventDefault/stopPropagation — pass-through by design.\n onEscapeRef.current?.();\n return;\n }\n if (e.key !== 'Tab' || !contain) return;\n const items = focusables();\n if (items.length === 0) {\n e.preventDefault();\n container.focus({ preventScroll: true });\n return;\n }\n const firstEl = items[0];\n const lastEl = items[items.length - 1];\n const current = document.activeElement;\n if (e.shiftKey && (current === firstEl || current === container)) {\n e.preventDefault();\n lastEl.focus({ preventScroll: true });\n } else if (!e.shiftKey && current === lastEl) {\n e.preventDefault();\n firstEl.focus({ preventScroll: true });\n }\n };\n\n container.addEventListener('keydown', handleKeyDown);\n\n return () => {\n container.removeEventListener('keydown', handleKeyDown);\n // Guarded restore: only when focus is still inside the surface (or fell\n // to body because the surface unmounted) — never steal focus from a\n // dialog that opened meanwhile (e.g. the nav-footer → SSO modal flow).\n const current = document.activeElement;\n if (\n previouslyFocused &&\n previouslyFocused.isConnected &&\n (current === null || current === document.body || container.contains(current))\n ) {\n previouslyFocused.focus({ preventScroll: true });\n }\n };\n }, [containerRef, active, contain]);\n}\n","'use client';\n\nimport { useRef, useState, useCallback } from 'react';\nimport type { MouseEvent, PointerEvent, WheelEvent } from 'react';\n\n/**\n * Custom horizontal scrollbar hook that drives a plain-DOM scrollbar.\n *\n * Returns a callback ref (`scrollRef`) to attach to the scrollable container,\n * plus refs, state, and event handlers for the track + thumb.\n *\n * Uses a **callback ref** so measurement and ResizeObserver setup happen\n * automatically when the scroll element mounts — even if it renders later\n * (e.g. after async data loads).\n */\nexport function useHorizontalScrollbar() {\n const scrollElRef = useRef<HTMLDivElement | null>(null);\n const trackRef = useRef<HTMLDivElement>(null);\n const thumbRef = useRef<HTMLDivElement>(null);\n const roRef = useRef<ResizeObserver | null>(null);\n const moRef = useRef<MutationObserver | null>(null);\n const [thumbRatio, setThumbRatio] = useState(0);\n\n // Edge-fade state\n const [canScrollLeft, setCanScrollLeft] = useState(false);\n const [canScrollRight, setCanScrollRight] = useState(false);\n const prevCanScrollLeftRef = useRef(false);\n const prevCanScrollRightRef = useRef(false);\n\n const isDraggingRef = useRef(false);\n const dragStartRef = useRef({ mouseX: 0, scrollLeft: 0 });\n const rafIdRef = useRef<number>(0);\n\n /** Compute the thumb left% from current scrollLeft and apply to DOM directly */\n const syncThumbToDOM = useCallback(() => {\n const el = scrollElRef.current;\n const thumb = thumbRef.current;\n if (!el || !thumb) return;\n\n const maxScroll = el.scrollWidth - el.clientWidth;\n if (maxScroll <= 0) {\n thumb.style.left = '0%';\n return;\n }\n\n const ratio = el.clientWidth / el.scrollWidth;\n const fraction = Math.min(Math.max(el.scrollLeft, 0), maxScroll) / maxScroll;\n thumb.style.left = `${fraction * (1 - ratio) * 100}%`;\n }, []);\n\n const syncEdgeFades = useCallback(() => {\n const el = scrollElRef.current;\n if (!el) return;\n\n const maxScroll = el.scrollWidth - el.clientWidth;\n const ratio = el.clientWidth / el.scrollWidth;\n if (ratio >= 1 || maxScroll <= 0) {\n if (prevCanScrollLeftRef.current) {\n prevCanScrollLeftRef.current = false;\n setCanScrollLeft(false);\n }\n if (prevCanScrollRightRef.current) {\n prevCanScrollRightRef.current = false;\n setCanScrollRight(false);\n }\n return;\n }\n\n const fraction = Math.min(Math.max(el.scrollLeft, 0), maxScroll) / maxScroll;\n const left = fraction > 0.001;\n const right = fraction < 0.999;\n\n if (left !== prevCanScrollLeftRef.current) {\n prevCanScrollLeftRef.current = left;\n setCanScrollLeft(left);\n }\n if (right !== prevCanScrollRightRef.current) {\n prevCanScrollRightRef.current = right;\n setCanScrollRight(right);\n }\n }, []);\n\n const measure = useCallback(() => {\n const el = scrollElRef.current;\n if (!el) return;\n const ratio = el.clientWidth / el.scrollWidth;\n setThumbRatio(ratio >= 1 ? 0 : ratio);\n syncThumbToDOM();\n syncEdgeFades();\n }, [syncThumbToDOM, syncEdgeFades]);\n\n // Callback ref\n const scrollRef = useCallback(\n (node: HTMLDivElement | null) => {\n // Teardown previous\n if (roRef.current) {\n roRef.current.disconnect();\n roRef.current = null;\n }\n if (moRef.current) {\n moRef.current.disconnect();\n moRef.current = null;\n }\n if (rafIdRef.current) {\n cancelAnimationFrame(rafIdRef.current);\n rafIdRef.current = 0;\n }\n\n scrollElRef.current = node;\n\n if (node) {\n const ratio = node.clientWidth / node.scrollWidth;\n setThumbRatio(ratio >= 1 ? 0 : ratio);\n\n const ro = new ResizeObserver(measure);\n roRef.current = ro;\n const observeAll = () => {\n ro.disconnect();\n ro.observe(node);\n for (const child of Array.from(node.children)) ro.observe(child);\n };\n observeAll();\n\n moRef.current = new MutationObserver(() => {\n observeAll();\n measure();\n });\n moRef.current.observe(node, { childList: true });\n\n requestAnimationFrame(() => {\n syncThumbToDOM();\n syncEdgeFades();\n });\n } else {\n setThumbRatio(0);\n prevCanScrollLeftRef.current = false;\n prevCanScrollRightRef.current = false;\n setCanScrollLeft(false);\n setCanScrollRight(false);\n }\n },\n [measure, syncThumbToDOM, syncEdgeFades],\n );\n\n // RAF-throttled scroll handler\n const onScroll = useCallback(() => {\n if (isDraggingRef.current) return;\n\n if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);\n rafIdRef.current = requestAnimationFrame(() => {\n syncThumbToDOM();\n syncEdgeFades();\n });\n }, [syncThumbToDOM, syncEdgeFades]);\n\n // Track click — read ratio from DOM, smooth scroll\n const onTrackClick = useCallback((e: MouseEvent) => {\n if ((e.target as HTMLElement).closest('[data-scrollbar-thumb]')) return;\n\n const track = trackRef.current;\n const el = scrollElRef.current;\n if (!track || !el) return;\n\n const ratio = el.clientWidth / el.scrollWidth;\n if (ratio >= 1) return;\n\n const rect = track.getBoundingClientRect();\n const thumbWidth = rect.width * ratio;\n const maxThumbOffset = rect.width - thumbWidth;\n if (maxThumbOffset <= 0) return;\n\n const targetFraction = Math.max(0, Math.min(1, (e.clientX - rect.left - thumbWidth / 2) / maxThumbOffset));\n const targetScrollLeft = targetFraction * (el.scrollWidth - el.clientWidth);\n el.scrollTo({ left: targetScrollLeft, behavior: 'smooth' });\n }, []);\n\n // Wheel on track → forward to scroll container\n const onTrackWheel = useCallback((e: WheelEvent) => {\n const el = scrollElRef.current;\n if (!el) return;\n e.preventDefault();\n el.scrollLeft += e.deltaX || e.deltaY;\n }, []);\n\n // Drag: pointer down\n const onThumbPointerDown = useCallback((e: PointerEvent) => {\n e.preventDefault();\n e.stopPropagation();\n const el = scrollElRef.current;\n if (!el) return;\n isDraggingRef.current = true;\n dragStartRef.current = { mouseX: e.clientX, scrollLeft: el.scrollLeft };\n const target = e.currentTarget as HTMLElement;\n target.setPointerCapture(e.pointerId);\n target.style.cursor = 'grabbing';\n }, []);\n\n // Drag: pointer move\n const onThumbPointerMove = useCallback(\n (e: PointerEvent) => {\n if (!isDraggingRef.current) return;\n const el = scrollElRef.current;\n const track = trackRef.current;\n if (!el || !track) return;\n\n const currentThumbRatio = el.clientWidth / el.scrollWidth;\n if (currentThumbRatio >= 1) return;\n\n const trackWidth = track.clientWidth;\n const thumbWidth = trackWidth * currentThumbRatio;\n const maxThumbTravel = trackWidth - thumbWidth;\n if (maxThumbTravel <= 0) return;\n\n const mouseDelta = e.clientX - dragStartRef.current.mouseX;\n const scrollRange = el.scrollWidth - el.clientWidth;\n el.scrollLeft = Math.min(\n Math.max(dragStartRef.current.scrollLeft + (mouseDelta / maxThumbTravel) * scrollRange, 0),\n scrollRange,\n );\n\n syncThumbToDOM();\n syncEdgeFades();\n },\n [syncThumbToDOM, syncEdgeFades],\n );\n\n // Drag: pointer up\n const onThumbPointerUp = useCallback((e: PointerEvent) => {\n isDraggingRef.current = false;\n const target = e.currentTarget as HTMLElement;\n target.releasePointerCapture(e.pointerId);\n target.style.cursor = 'grab';\n }, []);\n\n // Drag: pointer cancel. The browser takes the pointer away mid-drag (a touch\n // that turned into a pan, a palm rejection) and no pointerup ever follows —\n // left as dragging, `onScroll` would skip every sync from then on and the\n // thumb would freeze where it was. Capture is released implicitly right\n // after pointercancel, so there is nothing to release here.\n const onThumbPointerCancel = useCallback((e: PointerEvent) => {\n isDraggingRef.current = false;\n const target = e.currentTarget as HTMLElement;\n target.style.cursor = 'grab';\n }, []);\n\n return {\n scrollRef,\n trackRef,\n thumbRef,\n thumbRatio,\n canScrollLeft,\n canScrollRight,\n onScroll,\n onTrackClick,\n onTrackWheel,\n onThumbPointerDown,\n onThumbPointerMove,\n onThumbPointerUp,\n onThumbPointerCancel,\n };\n}\n","'use client';\n\nimport { useSyncExternalStore } from 'react';\n\n/**\n * Extra `collisionPadding.bottom` (px) a Radix popper needs to clear the\n * software keyboard. 0 on desktop and anywhere the keyboard is down.\n *\n * Radix positions every popper with floating-ui at `strategy: 'fixed'`, and\n * floating-ui takes its collision viewport from `window.visualViewport`.\n * Neither mobile shell shrinks the box a keyboard opens into: iOS runs\n * Capacitor's Keyboard plugin at `resize: 'none'`, which leaves the WKWebView\n * frame alone, and Android's window is edge-to-edge, where the IME is only a\n * WindowInsets type. So `flip`/`shift`/`size` all believe the screen still\n * reaches the bottom, and a dropdown whose own search field just raised the\n * keyboard is placed straight behind it — worst inside modals, which sit near\n * the bottom of the screen by design.\n *\n * The shell publishes the keyboard height as `--of-keyboard-inset` on <html>\n * (openframe-frontend's `keyboard-inset.ts`; unset everywhere else, hence the\n * 0 default). Handing the part floating-ui does not already know about back as\n * collision padding is what makes the top of the keyboard the bottom edge of\n * the screen.\n *\n * ```tsx\n * const keyboardPadding = useKeyboardCollisionPadding()\n * <PopoverPrimitive.Content collisionPadding={{ bottom: keyboardPadding }} />\n * ```\n *\n * Pair it with `max-h-[var(--radix-popper-available-height)]` on the content:\n * padding alone flips a dropdown above its trigger, but a list taller than what\n * is left above the keyboard would then spill back over it.\n */\n\nconst KEYBOARD_INSET_VAR = '--of-keyboard-inset';\n\nfunction measure(): number {\n if (typeof document === 'undefined') return 0;\n const inset = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue(KEYBOARD_INSET_VAR));\n // Also catches the NaN of an unset variable.\n if (!(inset > 0)) return 0;\n // How far floating-ui's own viewport bottom sits below the top of the\n // keyboard. iOS reports the keyboard on the visual viewport and Android does\n // not; measuring the difference rather than branching on the platform keeps\n // both correct and can never reserve the same keyboard twice.\n const layoutHeight = document.documentElement.clientHeight;\n const viewport = window.visualViewport;\n const viewportBottom = viewport ? viewport.offsetTop + viewport.height : layoutHeight;\n return Math.max(0, Math.round(viewportBottom - (layoutHeight - inset)));\n}\n\n// One document-wide subscription for every popper on the page: the keyboard is\n// a single global, and each open dropdown re-reading it would mean another\n// MutationObserver plus a forced style resolve per keystroke-driven re-render.\nconst listeners = new Set<() => void>();\nlet padding = 0;\nlet teardown: (() => void) | null = null;\n\nfunction sync(): void {\n const next = measure();\n if (next === padding) return;\n padding = next;\n for (const notify of listeners) notify();\n}\n\nfunction subscribe(onStoreChange: () => void): () => void {\n listeners.add(onStoreChange);\n if (listeners.size === 1) {\n padding = measure();\n // Two independent signals. The inset lands on <html>'s inline style, so the\n // attribute is the only notification for it; the visual viewport moves on\n // its own schedule (iOS reports the keyboard there, and a pinch-zoom shifts\n // it with no keyboard involved at all) and changes what is left to reserve.\n const observer = new MutationObserver(sync);\n observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });\n const viewport = window.visualViewport;\n viewport?.addEventListener('resize', sync);\n viewport?.addEventListener('scroll', sync);\n teardown = () => {\n observer.disconnect();\n viewport?.removeEventListener('resize', sync);\n viewport?.removeEventListener('scroll', sync);\n };\n }\n return () => {\n listeners.delete(onStoreChange);\n if (listeners.size > 0) return;\n teardown?.();\n teardown = null;\n };\n}\n\nexport function useKeyboardCollisionPadding(): number {\n return useSyncExternalStore(\n subscribe,\n () => padding,\n () => 0,\n );\n}\n","'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\n\nimport { createLocalStorageAdapter } from '../../utils/local-storage-adapter';\nimport { readLocalStorageUpdateDetail } from '../../utils/storage-event';\n\nexport interface UseLocalStorageOptions<T> {\n /**\n * Runtime shape check applied to every value decoded from storage — the\n * first read and each cross-tab update. Returning false discards the stored\n * value and falls back to `initialValue`.\n *\n * Without it the stored JSON is trusted to be a `T`, which only holds while\n * nothing else writes the key and the persisted shape never changes. Pass a\n * guard for anything whose shape has versions.\n *\n * Keep the identity stable (module scope or `useCallback`) — an inline\n * predicate re-subscribes the cross-tab listeners on every render.\n */\n validate?: (parsed: unknown) => parsed is T;\n}\n\n/**\n * Hook to use localStorage with state\n * @param key - localStorage key\n * @param initialValue - Initial value if key doesn't exist\n * @param options - Optional `validate` guard for the stored payload\n * @returns [storedValue, setValue] tuple\n */\nexport function useLocalStorage<T>(\n key: string,\n initialValue: T,\n options?: UseLocalStorageOptions<T>,\n): [T, (value: T | ((val: T) => T)) => void] {\n // Latest `initialValue` for the \"key was cleared elsewhere\" reset path. It\n // cannot be an effect dependency: the listeners register once per `key`, and\n // any caller passing an object or array literal would re-subscribe on every\n // render.\n const initialValueRef = useRef(initialValue);\n useEffect(() => {\n initialValueRef.current = initialValue;\n });\n\n // Single decode path — SSR guard, JSON parse, validation and the quota-safe\n // write all live in the adapter, so nothing here re-implements them.\n const validate = options?.validate;\n const storage = useMemo(\n () => createLocalStorageAdapter<T>({ key, validate, logTag: `[useLocalStorage:${key}]` }),\n [key, validate],\n );\n\n // Lazy initialization reads from localStorage synchronously on first render.\n const [storedValue, setStoredValue] = useState<T>(() => storage.load() ?? initialValue);\n\n // Use a ref to track if we've initialized from localStorage\n const isInitialized = useRef(true); // Set to true since we initialize in useState\n // Use a ref to track if the current state change came from a storage event\n const isFromStorageEvent = useRef(false);\n\n // Listen for storage events to sync with other tabs/components\n useEffect(() => {\n if (!isInitialized.current) return undefined;\n\n // Both events only say \"this key changed\"; the value is read back through\n // the adapter so the payload is decoded and validated in exactly one place.\n // A removed key reads back as null and resets to `initialValue`, whichever\n // event reported it.\n const syncFromStorage = (source: string) => {\n const next = storage.load();\n isFromStorageEvent.current = true;\n setStoredValue(next ?? initialValueRef.current);\n console.log(`🔄 ${next === null ? 'Cleared' : 'Updated'} localStorage key \"${key}\" from ${source}`);\n };\n\n const handleStorageChange = (e: StorageEvent) => {\n if (e.key !== key) return;\n syncFromStorage('storage event');\n };\n\n const handleCustomStorageUpdate = (event: Event) => {\n const detail = readLocalStorageUpdateDetail(event);\n if (detail?.key !== key) return;\n syncFromStorage('custom storage event');\n };\n\n window.addEventListener('storage', handleStorageChange);\n window.addEventListener('localStorageUpdate', handleCustomStorageUpdate);\n\n return () => {\n window.removeEventListener('storage', handleStorageChange);\n window.removeEventListener('localStorageUpdate', handleCustomStorageUpdate);\n };\n }, [key, storage]);\n\n // Update localStorage when state changes, but only after initialization and NOT from storage events\n useEffect(() => {\n if (!isInitialized.current) return;\n\n // Skip localStorage write if this state change came from a storage event\n if (isFromStorageEvent.current) {\n isFromStorageEvent.current = false;\n return;\n }\n\n storage.save(storedValue);\n }, [storage, storedValue]);\n\n // Return a wrapped version of useState's setter function that\n // persists the new value to localStorage\n const setValue = (value: T | ((val: T) => T)) => {\n try {\n // Allow value to be a function so we have same API as useState\n const valueToStore = value instanceof Function ? value(storedValue) : value;\n // Save state\n setStoredValue(valueToStore);\n } catch (error) {\n console.error(`Error setting value for localStorage key \"${key}\":`, error);\n }\n };\n\n return [storedValue, setValue];\n}\n","/**\n * The `localStorageUpdate` CustomEvent contract.\n *\n * `CustomEvent.detail` is typed `any`, and `addEventListener` types a\n * non-standard event name as a bare `Event` — so every listener used to\n * re-derive the payload shape by hand off an `any`. The decode lives here\n * once: an event dispatched by anything on the page is untrusted input, so it\n * is validated rather than asserted.\n *\n * Dispatchers: `saveOnboardingState` (utils/onboarding-storage.ts).\n * Listeners: `useLocalStorage`, `useOnboardingState`.\n */\n\nexport interface LocalStorageUpdateDetail {\n /** Storage key that changed. */\n key: string;\n /** Serialized new value; `null` when the key was removed or cleared. */\n newValue: string | null;\n}\n\n/**\n * Decode a `localStorageUpdate` event payload.\n *\n * Returns `null` — never throws — when the event is not a CustomEvent, carries\n * no object detail, or the detail does not match the contract above. A missing\n * or `undefined` `newValue` is normalized to `null`, which is the \"key was\n * removed\" signal.\n */\nexport function readLocalStorageUpdateDetail(event: Event): LocalStorageUpdateDetail | null {\n if (!('detail' in event)) return null;\n\n const detail: unknown = event.detail;\n if (typeof detail !== 'object' || detail === null) return null;\n if (!('key' in detail) || typeof detail.key !== 'string') return null;\n\n const newValue: unknown = 'newValue' in detail ? detail.newValue : null;\n if (newValue !== null && newValue !== undefined && typeof newValue !== 'string') return null;\n\n return { key: detail.key, newValue: newValue ?? null };\n}\n","'use client';\n\nimport { useCallback, useEffect, useRef } from 'react';\n\n/**\n * A callback whose IDENTITY never changes and whose BODY is always the latest\n * one you passed — the \"latest ref\" pattern.\n *\n * Rewritten 2026-08-25. The previous implementation was broken in three ways\n * that only showed up once the React Compiler rules were switched on:\n *\n * 1. It wrote `callbackRef.current` and `dependenciesRef.current` DURING\n * RENDER. A render that React throws away (Strict Mode, a suspended\n * transition, a re-render that never commits) still left those writes\n * behind, so the \"memoized\" callback could be one from a discarded render.\n * 2. Its dependency array was `[depsChanged]` — a BOOLEAN. That flips true\n * on the render where deps change and false on the next one, so the\n * returned identity changed TWICE per dependency change and was never\n * stable, which is the one thing the hook exists to provide.\n * 3. The doc promised \"deep comparison\"; it compared with `Object.is`.\n *\n * The signature is unchanged, so no call site has to move. `dependencies` is\n * now ignored: the returned function always calls the newest callback, which\n * is what every reader of \"stable callback reference\" expected it to do. If\n * you actually WANT the identity to change with deps, use `useCallback`\n * directly — that is what it is for.\n */\nexport function useMemoizedCallback<T extends (...args: never[]) => unknown>(\n callback: T,\n _dependencies?: readonly unknown[],\n): T {\n const callbackRef = useRef(callback);\n\n // In an effect, not during render: this runs only for renders that commit.\n useEffect(() => {\n callbackRef.current = callback;\n });\n\n const stable = useCallback((...args: Parameters<T>) => callbackRef.current(...args), []);\n return stable as T;\n}\n","'use client';\n\nimport { useCallback, useEffect, useState } from 'react';\n\nexport interface UseNotificationPermissionResult {\n /** Whether the page-context Notification API exists in this browser (false during SSR and on iOS Safari). */\n supported: boolean;\n permission: NotificationPermission;\n /** Prompt the user for permission. Must be called from a user gesture or browsers will auto-deny. */\n request: () => Promise<NotificationPermission>;\n}\n\n/**\n * Tracks the browser's desktop-notification permission, staying in sync when\n * the user changes the site setting externally (Permissions API change event\n * where available, visibility/focus re-read as the Safari fallback).\n */\nexport function useNotificationPermission(): UseNotificationPermissionResult {\n const [supported, setSupported] = useState(false);\n const [permission, setPermission] = useState<NotificationPermission>('default');\n\n useEffect(() => {\n if (typeof window === 'undefined' || !('Notification' in window)) return undefined;\n // Both values are reads of an external system (the browser's permission\n // store), so they belong in the same sync callback rather than as a\n // separate mount-time setState: `supported` cannot be computed during\n // render without a hydration mismatch, and folding it into `sync` means the\n // initial read costs one render instead of two. Re-syncs are free — React\n // bails out when the next value is identical, which it is for every\n // visibility/focus event that did not actually change the permission.\n const sync = () => {\n setSupported(true);\n setPermission(Notification.permission);\n };\n sync();\n\n // The query resolves async; without the flag it would attach the listener after unmount.\n let cancelled = false;\n let status: PermissionStatus | undefined;\n navigator.permissions\n ?.query({ name: 'notifications' })\n .then(s => {\n if (cancelled) return;\n status = s;\n s.addEventListener('change', sync);\n })\n .catch(() => undefined);\n\n document.addEventListener('visibilitychange', sync);\n window.addEventListener('focus', sync);\n return () => {\n cancelled = true;\n status?.removeEventListener('change', sync);\n document.removeEventListener('visibilitychange', sync);\n window.removeEventListener('focus', sync);\n };\n }, []);\n\n const request = useCallback(async () => {\n if (typeof window === 'undefined' || !('Notification' in window)) {\n return 'denied';\n }\n // Older Safari only implements the callback form; resolving from both is harmless.\n // The DOM lib types `requestPermission` as always returning a promise, so testing\n // the value itself is a conditional on something the types call always-truthy —\n // the thenable probe is the check that actually means something at runtime, since\n // the callback-only implementations return undefined here.\n const result = await new Promise<NotificationPermission>(resolve => {\n const maybePromise = Notification.requestPermission(resolve);\n if (typeof maybePromise?.then === 'function') {\n // A rejection here would otherwise leave this promise forever pending, and\n // `request()` never settles: the caller's permission toggle silently stays\n // off. Fall back to whatever the browser currently reports.\n void maybePromise.then(resolve, () => resolve(Notification.permission));\n }\n });\n setPermission(Notification.permission);\n return result;\n }, []);\n\n return { supported, permission, request };\n}\n","'use client';\n\nimport { useState, useEffect, useCallback } from 'react';\nimport type { ReactNode } from 'react';\nimport {\n loadOnboardingState,\n markStepComplete as storageMarkComplete,\n markStepSkipped as storageMarkSkipped,\n dismissOnboarding as storageDismiss,\n markMultipleComplete as storageMarkMultiple,\n resetOnboardingState as storageReset,\n type OnboardingState,\n} from '../../utils/onboarding-storage';\nimport { readLocalStorageUpdateDetail } from '../../utils/storage-event';\n\nexport interface OnboardingStepConfig {\n id: string;\n title: string;\n description: string;\n actionIcon: (color?: string) => ReactNode;\n actionText: string;\n completedText: string;\n onAction: () => void | Promise<void>;\n onSkip?: () => void;\n checkComplete?: () => boolean | Promise<boolean>;\n}\n\nexport type { OnboardingState };\n\n/**\n * Hook for managing onboarding state with localStorage persistence\n * Uses simple storage utilities for atomic updates and reliable re-renders\n */\nexport function useOnboardingState(storageKey: string = 'openframe-onboarding-state') {\n const [state, setState] = useState<OnboardingState>(() => loadOnboardingState(storageKey));\n const [, forceUpdate] = useState(0);\n\n // Listen for storage changes from other tabs\n useEffect(() => {\n const handleStorageUpdate = (event: Event) => {\n const detail = readLocalStorageUpdateDetail(event);\n if (detail?.key !== storageKey) return;\n const newState = loadOnboardingState(storageKey);\n setState(newState);\n forceUpdate(prev => prev + 1);\n };\n\n window.addEventListener('localStorageUpdate', handleStorageUpdate);\n return () => {\n window.removeEventListener('localStorageUpdate', handleStorageUpdate);\n };\n }, [storageKey]);\n\n const markComplete = useCallback(\n (stepId: string) => {\n const newState = storageMarkComplete(storageKey, stepId);\n setState(newState);\n forceUpdate(prev => prev + 1);\n },\n [storageKey],\n );\n\n const markSkipped = useCallback(\n (stepId: string) => {\n const newState = storageMarkSkipped(storageKey, stepId);\n setState(newState);\n forceUpdate(prev => prev + 1);\n },\n [storageKey],\n );\n\n const dismissOnboarding = useCallback(() => {\n const newState = storageDismiss(storageKey);\n setState(newState);\n forceUpdate(prev => prev + 1);\n }, [storageKey]);\n\n const resetOnboarding = useCallback(() => {\n const newState = storageReset(storageKey);\n setState(newState);\n forceUpdate(prev => prev + 1);\n }, [storageKey]);\n\n const markMultipleComplete = useCallback(\n (stepIds: string[]) => {\n const newState = storageMarkMultiple(storageKey, stepIds);\n setState(newState);\n forceUpdate(prev => prev + 1);\n },\n [storageKey],\n );\n\n const isStepComplete = useCallback(\n (stepId: string): boolean => {\n return state.completedSteps.includes(stepId);\n },\n [state.completedSteps],\n );\n\n const isStepSkipped = useCallback(\n (stepId: string): boolean => {\n return state.skippedSteps.includes(stepId);\n },\n [state.skippedSteps],\n );\n\n const allStepsComplete = useCallback(\n (steps: OnboardingStepConfig[]): boolean => {\n return steps.every(step => isStepComplete(step.id) || isStepSkipped(step.id));\n },\n [isStepComplete, isStepSkipped],\n );\n\n return {\n state,\n markComplete,\n markSkipped,\n dismissOnboarding,\n resetOnboarding,\n isStepComplete,\n isStepSkipped,\n allStepsComplete,\n markMultipleComplete,\n };\n}\n","/**\n * Onboarding state storage utilities\n * Simple localStorage wrapper following announcement-storage.ts pattern\n * Provides atomic writes to prevent race conditions\n */\n\nexport interface OnboardingState {\n completedSteps: string[];\n skippedSteps: string[];\n dismissed: boolean;\n lastUpdated: string;\n}\n\nconst DEFAULT_STATE: OnboardingState = {\n completedSteps: [],\n skippedSteps: [],\n dismissed: false,\n lastUpdated: new Date().toISOString(),\n};\n\n/**\n * Save onboarding state to localStorage (atomic write)\n */\nexport function saveOnboardingState(key: string, state: OnboardingState): void {\n if (typeof window === 'undefined') return;\n\n try {\n localStorage.setItem(key, JSON.stringify(state));\n\n // Dispatch custom event for cross-tab sync\n if (typeof window !== 'undefined') {\n window.dispatchEvent(\n new CustomEvent('localStorageUpdate', {\n detail: { key, newValue: JSON.stringify(state) },\n }),\n );\n }\n } catch (err) {\n console.warn('[onboarding-storage] Failed saving to localStorage:', err);\n }\n}\n\n/**\n * Load onboarding state from localStorage\n */\nexport function loadOnboardingState(key: string): OnboardingState {\n if (typeof window === 'undefined') return DEFAULT_STATE;\n\n try {\n const raw = localStorage.getItem(key);\n if (!raw) return DEFAULT_STATE;\n\n const parsed = JSON.parse(raw) as OnboardingState;\n return parsed;\n } catch (err) {\n console.warn('[onboarding-storage] Failed parsing localStorage data:', err);\n return DEFAULT_STATE;\n }\n}\n\n/**\n * Mark multiple steps as complete in a single atomic update\n */\nexport function markMultipleComplete(key: string, stepIds: string[]): OnboardingState {\n const state = loadOnboardingState(key);\n const newState: OnboardingState = {\n ...state,\n completedSteps: [...new Set([...state.completedSteps, ...stepIds])],\n skippedSteps: state.skippedSteps.filter(id => !stepIds.includes(id)),\n lastUpdated: new Date().toISOString(),\n };\n saveOnboardingState(key, newState);\n return newState;\n}\n\n/**\n * Mark a single step as complete\n */\nexport function markStepComplete(key: string, stepId: string): OnboardingState {\n return markMultipleComplete(key, [stepId]);\n}\n\n/**\n * Mark a step as skipped\n */\nexport function markStepSkipped(key: string, stepId: string): OnboardingState {\n const state = loadOnboardingState(key);\n const newState: OnboardingState = {\n ...state,\n skippedSteps: [...new Set([...state.skippedSteps, stepId])],\n completedSteps: state.completedSteps.filter(id => id !== stepId),\n lastUpdated: new Date().toISOString(),\n };\n saveOnboardingState(key, newState);\n return newState;\n}\n\n/**\n * Dismiss the onboarding walkthrough\n */\nexport function dismissOnboarding(key: string): OnboardingState {\n const state = loadOnboardingState(key);\n const newState: OnboardingState = {\n ...state,\n dismissed: true,\n lastUpdated: new Date().toISOString(),\n };\n saveOnboardingState(key, newState);\n return newState;\n}\n\n/**\n * Reset the walkthrough to its initial state: no step completed or skipped,\n * not dismissed. How a dismissed walkthrough is brought back.\n */\nexport function resetOnboardingState(key: string): OnboardingState {\n const newState: OnboardingState = {\n completedSteps: [],\n skippedSteps: [],\n dismissed: false,\n lastUpdated: new Date().toISOString(),\n };\n saveOnboardingState(key, newState);\n return newState;\n}\n\n/**\n * Check if a step is complete\n */\nexport function isStepComplete(key: string, stepId: string): boolean {\n const state = loadOnboardingState(key);\n return state.completedSteps.includes(stepId);\n}\n\n/**\n * Check if a step is skipped\n */\nexport function isStepSkipped(key: string, stepId: string): boolean {\n const state = loadOnboardingState(key);\n return state.skippedSteps.includes(stepId);\n}\n\n/**\n * Check if onboarding is dismissed\n */\nexport function isOnboardingDismissed(key: string): boolean {\n const state = loadOnboardingState(key);\n return state.dismissed;\n}\n","'use client';\n\nimport { useState, useEffect, useCallback, useRef } from 'react';\nimport type { SearchResult } from '../../components/ui/search-input';\nimport { useDebounce } from './use-debounce';\n\nexport interface UseSearchConfig<T> {\n /** Async function that performs the search */\n searchFn: (query: string) => Promise<T[]>;\n /** Maps each raw item to a SearchResult */\n mapResult: (item: T) => SearchResult;\n /** Debounce delay in ms. Default 300 */\n debounceMs?: number;\n /** Minimum characters before searching. Default 2 */\n minQueryLength?: number;\n}\n\nexport interface UseSearchReturn {\n query: string;\n setQuery: (q: string) => void;\n results: SearchResult[];\n isLoading: boolean;\n error: string | null;\n clearResults: () => void;\n}\n\n// One shared empty array: `setResults([])` with a fresh literal is a new\n// identity every time, which re-renders every consumer that keys off `results`.\nconst NO_RESULTS: SearchResult[] = [];\n\n/**\n * Generic search state management hook.\n *\n * Debounces the query, calls `searchFn` when the debounced value meets\n * `minQueryLength`, and maps the raw results via `mapResult`.\n */\nexport function useSearch<T>(config: UseSearchConfig<T>): UseSearchReturn {\n const { searchFn, mapResult, debounceMs = 300, minQueryLength = 2 } = config;\n\n const [query, setQuery] = useState('');\n const [results, setResults] = useState<SearchResult[]>(NO_RESULTS);\n const [isLoading, setIsLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n // `searchFn` and `mapResult` come from the caller and are almost always\n // written inline, so a fresh identity every render. As effect dependencies\n // they would re-fire the search on every parent render; read through refs the\n // effect keys on the QUERY only and still calls the current functions.\n const searchFnRef = useRef(searchFn);\n const mapResultRef = useRef(mapResult);\n useEffect(() => {\n searchFnRef.current = searchFn;\n mapResultRef.current = mapResult;\n });\n\n const debouncedQuery = useDebounce(query, debounceMs);\n\n const clearResults = useCallback(() => {\n setResults(NO_RESULTS);\n setError(null);\n }, []);\n\n // Falling below the threshold clears the panel. That is a reset driven by a\n // value we already have during render, so it uses React's \"adjust state while\n // rendering\" pattern rather than an effect: the keystroke that shortened the\n // query has already scheduled this render, and clearing here means the stale\n // result list never reaches the screen. From an effect it would paint once\n // with the old results still showing.\n const searchable = Boolean(debouncedQuery) && debouncedQuery.length >= minQueryLength;\n const [wasSearchable, setWasSearchable] = useState(searchable);\n if (wasSearchable !== searchable) {\n setWasSearchable(searchable);\n if (!searchable) {\n setResults(NO_RESULTS);\n setIsLoading(false);\n setError(null);\n }\n }\n\n useEffect(() => {\n if (!searchable) return undefined;\n\n let cancelled = false;\n\n const run = async () => {\n setIsLoading(true);\n setError(null);\n\n try {\n const rawResults = await searchFnRef.current(debouncedQuery);\n\n if (!cancelled) {\n setResults(rawResults.map(mapResultRef.current));\n }\n } catch (err) {\n if (!cancelled) {\n setError(err instanceof Error ? err.message : 'Search failed');\n setResults(NO_RESULTS);\n }\n } finally {\n if (!cancelled) {\n setIsLoading(false);\n }\n }\n };\n\n // Never rejects — try/catch/finally, every write gated on `cancelled`.\n void run();\n\n return () => {\n cancelled = true;\n };\n }, [debouncedQuery, minQueryLength, searchable]);\n\n return { query, setQuery, results, isLoading, error, clearResults };\n}\n","'use client';\n\nimport { useMemo } from 'react';\nimport type { CursorPaginationProps } from '../../components/ui/cursor-pagination';\n\n/**\n * Client-side pagination configuration\n */\nexport interface ClientPaginationConfig {\n type: 'client';\n currentPage: number;\n totalPages: number;\n itemCount: number;\n itemName?: string;\n onNext: () => void;\n onPrevious: () => void;\n showInfo?: boolean;\n}\n\n/**\n * Server-side cursor pagination configuration\n */\nexport interface ServerPaginationConfig {\n type: 'server';\n hasNextPage: boolean;\n hasLoadedBeyondFirst: boolean;\n startCursor?: string | null;\n endCursor?: string | null;\n itemCount: number;\n itemName?: string;\n onNext: () => void | Promise<void>;\n onReset: () => void | Promise<void>;\n showInfo?: boolean;\n}\n\nexport type PaginationConfig = ClientPaginationConfig | ServerPaginationConfig;\n\n/**\n * Unified pagination hook that works for both client-side and server-side pagination\n *\n * @example Client-side pagination\n * ```typescript\n * const pagination = useTablePagination({\n * type: 'client',\n * currentPage: 1,\n * totalPages: 10,\n * itemCount: 20,\n * itemName: 'scripts',\n * onNext: () => setPage(p => p + 1),\n * onPrevious: () => setPage(p => p - 1),\n * onReset: () => setPage(1)\n * })\n * ```\n *\n * @example Server-side pagination\n * ```typescript\n * const pagination = useTablePagination({\n * type: 'server',\n * hasNextPage: pageInfo.hasNextPage,\n * hasLoadedBeyondFirst: true,\n * startCursor: pageInfo.startCursor,\n * endCursor: pageInfo.endCursor,\n * itemCount: devices.length,\n * itemName: 'devices',\n * onNext: fetchNextPage,\n * onReset: fetchFirstPage\n * })\n * ```\n */\nexport function useTablePagination(config: PaginationConfig | null): CursorPaginationProps | undefined {\n return useMemo(() => {\n if (!config) return undefined;\n\n if (config.type === 'client') {\n // Client-side pagination: show if more than 1 page\n if (config.totalPages <= 1) return undefined;\n\n return {\n hasNextPage: config.currentPage < config.totalPages,\n hasPreviousPage: config.currentPage > 1,\n isFirstPage: config.currentPage === 1,\n startCursor: (config.currentPage - 1).toString(),\n endCursor: config.currentPage.toString(),\n currentCount: config.itemCount,\n itemName: config.itemName || 'items',\n onNext: () => config.onNext(),\n onPrevious: () => config.onPrevious(),\n showInfo: config.showInfo ?? true,\n };\n } else {\n // Server-side pagination: show if pageInfo exists\n return {\n hasNextPage: config.hasNextPage,\n hasPreviousPage: config.hasLoadedBeyondFirst,\n isFirstPage: !config.hasLoadedBeyondFirst,\n startCursor: config.startCursor,\n endCursor: config.endCursor,\n currentCount: config.itemCount,\n itemName: config.itemName || 'items',\n onNext: config.onNext,\n onPrevious: config.onReset, // Previous button goes back to first page\n showInfo: config.showInfo ?? true,\n };\n }\n }, [config]);\n}\n","'use client';\n\nimport { useState, useEffect, useRef } from 'react';\n\n/**\n * Hook to throttle a value\n * @param value - Value to throttle\n * @param limit - Throttle limit in milliseconds\n * @returns Throttled value\n */\nexport function useThrottle<T>(value: T, limit = 200): T {\n const [throttledValue, setThrottledValue] = useState<T>(value);\n // The clock is read in a lazy `useState` initialiser, which runs exactly\n // once. `useRef(Date.now())` looks equivalent but is not: the argument is\n // evaluated on EVERY render even though only the first call uses it, so the\n // component reads the clock during render forever after.\n const [mountedAt] = useState(() => Date.now());\n const lastUpdated = useRef<number>(mountedAt);\n\n useEffect(() => {\n const now = Date.now();\n const elapsed = now - lastUpdated.current;\n\n // If enough time has elapsed, update the throttled value\n if (elapsed >= limit) {\n setThrottledValue(value);\n lastUpdated.current = now;\n return undefined;\n }\n\n // Otherwise, set up a timeout to update after the limit\n const timerId = setTimeout(() => {\n setThrottledValue(value);\n lastUpdated.current = Date.now();\n }, limit - elapsed);\n\n return () => {\n clearTimeout(timerId);\n };\n }, [value, limit]);\n\n return throttledValue;\n}\n","import { useLayoutEffect, useState } from 'react';\n\n/**\n * Hook to get window dimensions\n * @returns Window width and height\n */\nexport function useWindowSize() {\n const [windowSize, setWindowSize] = useState({\n width: 0,\n height: 0,\n });\n\n useLayoutEffect(() => {\n // The size lives on `window`, not in React — this is the initial read of an\n // external system plus a subscription to it. The identity bail-out matters:\n // `resize` fires for height-only changes (mobile URL bar) and for the same\n // dimensions on orientation lock, and without it every one of those would\n // re-render every consumer of this hook.\n const handleResize = () => {\n setWindowSize(prev =>\n prev.width === window.innerWidth && prev.height === window.innerHeight\n ? prev\n : { width: window.innerWidth, height: window.innerHeight },\n );\n };\n\n // Set initial size\n handleResize();\n\n // Add event listener\n window.addEventListener('resize', handleResize);\n\n // Remove event listener on cleanup\n return () => window.removeEventListener('resize', handleResize);\n }, []);\n\n return windowSize;\n}\n","// UI Hooks exports\nexport * from './use-auto-limit-tags';\nexport * from './use-debounce';\nexport * from './use-deferred-error';\nexport * from './use-drag-and-drop-enabled';\nexport * from './use-focus-trap';\nexport * from './use-header-height';\nexport * from './use-horizontal-scrollbar';\nexport * from './use-image-edge-color';\nexport * from './use-is-truncated';\nexport * from './use-isomorphic-layout-effect';\nexport * from './use-keyboard-collision-padding';\nexport * from './use-local-storage';\nexport * from './use-marquee-engine';\nexport * from './use-media-query';\nexport * from './use-memoized-callback';\nexport * from './use-notification-permission';\nexport * from './use-onboarding-state';\nexport * from './use-reset-on-page-hidden';\nexport * from './use-search';\nexport * from './use-suppress-clone-focus';\nexport * from './use-table-pagination';\nexport * from './use-throttle';\nexport * from './use-window-size';\n\n// Shared ref-counted, iOS-aware body scroll lock (react-aria). Re-exported so\n// hub/app consumers have one import path; lib internals may import the\n// package directly (npm dedupes to one module instance either way).\nexport { usePreventScroll } from '@react-aria/overlays';\n","import { useState, useEffect } from 'react';\nimport type { SelectableOption } from '../../components/features';\nimport type { PlatformConfig, PlatformOption } from '../../types/platform';\nimport { transformPlatformConfigsToOptions } from '../../utils/platform-config';\n\nexport interface UsePlatformConfigResult {\n platforms: PlatformConfig[];\n platformOptions: PlatformOption[];\n selectableOptions: SelectableOption[]; // Rich options with icons and colors\n isLoading: boolean;\n error: Error | null;\n}\n\n// Cache for platform configs to avoid repeated fetches\nlet platformCache: PlatformConfig[] | null = null;\nlet fetchPromise: Promise<PlatformConfig[]> | null = null;\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\n/** `Array.isArray` narrows `unknown` to `any[]`; this keeps the elements `unknown`. */\nfunction isUnknownArray(value: unknown): value is readonly unknown[] {\n return Array.isArray(value);\n}\n\n/**\n * Decode `/api/config/platforms`.\n *\n * `Response.json()` is typed `any`, so `data.platforms || data` used to be\n * cached verbatim — including when it was neither an array nor a list of rows\n * (`{ platforms: null }` fell through the `||` and cached the ENVELOPE).\n * That matters more here than at a normal fetch boundary because\n * `platformCache` is MODULE-level: a bad payload is committed once and then\n * served to every later mount for the rest of the session, and\n * `platforms.map(...)` in the hook body throws on each one. Returning null\n * instead routes the payload into the hook's existing `error` state and leaves\n * the cache empty, so a later mount can retry.\n *\n * Row decoding is deliberately tolerant: `value`/`name` and `label`/\n * `display_name` are documented as the same string, so either spelling\n * satisfies the pair, and the purely presentational fields fall back to '' —\n * a platform missing its description should still appear in the filter rather\n * than vanish from it or render `undefined`.\n */\nfunction toPlatformConfigs(payload: unknown): PlatformConfig[] | null {\n const rows = isRecord(payload) && 'platforms' in payload ? payload.platforms : payload;\n if (!isUnknownArray(rows)) return null;\n\n const configs: PlatformConfig[] = [];\n for (const row of rows) {\n if (!isRecord(row)) continue;\n const read = (key: string): string | undefined => {\n const value = row[key];\n return typeof value === 'string' && value ? value : undefined;\n };\n\n const value = read('value') ?? read('name');\n if (value === undefined) continue;\n const label = read('label') ?? read('display_name') ?? value;\n\n configs.push({\n id: read('id') ?? value,\n value,\n label,\n name: read('name') ?? value,\n display_name: read('display_name') ?? label,\n default_color: read('default_color') ?? '',\n default_icon: read('default_icon') ?? '',\n description: read('description') ?? '',\n });\n }\n return configs;\n}\n\n/**\n * Custom hook to fetch platform configurations from API\n * Provides both full platform configs and simplified options for dropdowns\n * Heavily cached to prevent excessive API calls - should only call once per session\n *\n * NOTE: This hook is designed to work without react-query dependency\n */\nexport function usePlatformConfig(): UsePlatformConfigResult {\n const [platforms, setPlatforms] = useState<PlatformConfig[]>(platformCache || []);\n const [isLoading, setIsLoading] = useState(!platformCache);\n const [error, setError] = useState<Error | null>(null);\n\n useEffect(() => {\n // `platformCache` / `fetchPromise` are module-level: an external store, as\n // far as React is concerned. Every path below hands its outcome to the same\n // pair of commit callbacks instead of repeating three near-identical\n // setState pairs.\n //\n // The identity bail-out is what keeps the cached path free: the useState\n // initialisers above already read `platformCache`, so re-committing the\n // very same array must not schedule a render. It still commits when the\n // cache was filled by another instance's fetch *between* this component's\n // first render and this effect — the only case where the branch does\n // anything at all.\n const commit = (data: PlatformConfig[]) => {\n setPlatforms(prev => (prev === data ? prev : data));\n setIsLoading(false);\n };\n const fail = (err: unknown) => {\n setError(err instanceof Error ? err : new Error(String(err)));\n setIsLoading(false);\n };\n\n // If we already have cached platforms, use them\n if (platformCache) {\n commit(platformCache);\n return;\n }\n\n // If a fetch is already in progress, wait for it\n if (fetchPromise) {\n fetchPromise.then(commit).catch(fail);\n return;\n }\n\n // Start a new fetch\n console.log('🔧 Fetching platform configurations from API (should only happen once)');\n\n fetchPromise = fetch('/api/config/platforms')\n .then((response): Promise<unknown> => {\n if (!response.ok) {\n throw new Error(`Failed to fetch platform config: ${response.statusText}`);\n }\n return response.json();\n })\n .then(payload => {\n const loadedPlatforms = toPlatformConfigs(payload);\n if (!loadedPlatforms) {\n // Deliberately thrown rather than cached: see `toPlatformConfigs`.\n throw new Error('Platform config response did not contain a platform list');\n }\n console.log('✅ Platform configurations loaded:', loadedPlatforms.length, 'platforms');\n platformCache = loadedPlatforms;\n fetchPromise = null;\n return loadedPlatforms;\n });\n\n fetchPromise.then(commit).catch((err: unknown) => {\n console.error('❌ Failed to fetch platform config:', err);\n fail(err);\n fetchPromise = null;\n });\n }, []);\n\n // Create options for dropdowns with \"All Platforms\" option\n const platformOptions: PlatformOption[] = [\n { value: 'all', label: 'All Platforms' },\n ...platforms.map((platform: PlatformConfig) => ({\n value: platform.value,\n label: platform.label,\n })),\n ];\n\n // Create rich selectable options with icons and colors\n const selectableOptions = transformPlatformConfigsToOptions(platforms);\n\n return {\n platforms,\n platformOptions,\n selectableOptions,\n isLoading,\n error,\n };\n}\n\n/**\n * Get platform configuration by value\n */\nexport function usePlatformByValue(value: string): PlatformConfig | undefined {\n const { platforms } = usePlatformConfig();\n return platforms.find(platform => platform.value === value);\n}\n\n/**\n * Check if a platform value is valid\n */\nexport function useValidatePlatform(value: string): boolean {\n const { platforms } = usePlatformConfig();\n return platforms.some(platform => platform.value === value);\n}\n","import { Globe } from 'lucide-react';\nimport type React from 'react';\nimport type { SelectableOption } from '../components/features';\nimport { OpenmspLogo, FlamingoLogo, OpenFrameLogo, MiamiCyberGangLogoFaceOnly, MlgLogo } from '../components/icons';\nimport type { PlatformConfig, PlatformName } from '../types/platform';\nimport { cn } from './cn';\nimport { getPlatformBrandClasses } from './platform-identity';\n\n/**\n * Per-platform ICON rendering. The colour decision lives in `platform-identity`\n * (`PLATFORM_BRAND` → `ODS_STEM_CLASSES`), never here: each mark is tinted by the\n * platform's accent CLASS and paints itself with `currentColor`. There is ONE\n * lookup, parameterized by `className`; the three former hand-written switches\n * (`getPlatformIcon`, `getSmallPlatformIcon`, `getPlatformIconComponent`) are\n * thin wrappers that only pick a size.\n *\n * Two marks cannot take an accent fill and are named exceptions:\n * - `universal` is a lucide `Globe`; lucide spreads `...rest` AFTER `fill: 'none'`,\n * so a `fill` prop would paint a solid disc. It takes the class only.\n * - `tmcg` is a two-colour mark (`MiamiCyberGangLogoFaceOnly`) with no fill prop.\n */\nfunction renderPlatformIcon(platformName: string, className: string): React.ReactNode {\n const tinted = cn(className, getPlatformBrandClasses(platformName).accentText);\n switch (platformName) {\n case 'openframe':\n // `lowerPathColor` is the accent; the upper path is the neutral greys white.\n return (\n <OpenFrameLogo\n className={tinted}\n lowerPathColor=\"currentColor\"\n upperPathColor=\"var(--ods-system-greys-white)\"\n />\n );\n case 'openmsp':\n // `OpenmspLogo`'s paths set their own fills, so a root `fill` is a no-op:\n // the accent is the BACK bubble. The other two are the neutral greys.\n return (\n <OpenmspLogo\n className={tinted}\n backBubbleColor=\"currentColor\"\n frontBubbleColor=\"var(--ods-system-greys-white)\"\n innerFrontBubbleColor=\"var(--ods-system-greys-black)\"\n />\n );\n case 'tmcg':\n return <MiamiCyberGangLogoFaceOnly className={className} />;\n case 'mlg':\n return <MlgLogo className={tinted} size={20} />;\n case 'universal':\n return <Globe className={tinted} />;\n default:\n return <FlamingoLogo className={tinted} fill=\"currentColor\" />;\n }\n}\n\n/** Platform icons mapping (5x5), tinted by each platform's accent class. */\nexport const platformIcons: Record<PlatformName, React.ReactNode> = {\n openframe: renderPlatformIcon('openframe', 'h-5 w-5'),\n openmsp: renderPlatformIcon('openmsp', 'h-5 w-5'),\n flamingo: renderPlatformIcon('flamingo', 'h-5 w-5'),\n 'flamingo-teaser': renderPlatformIcon('flamingo-teaser', 'h-5 w-5'),\n 'marketing-hub': renderPlatformIcon('marketing-hub', 'h-5 w-5'),\n 'product-hub': renderPlatformIcon('product-hub', 'h-5 w-5'),\n 'revenue-hub': renderPlatformIcon('revenue-hub', 'h-5 w-5'),\n 'people-hub': renderPlatformIcon('people-hub', 'h-5 w-5'),\n 'company-hub': renderPlatformIcon('company-hub', 'h-5 w-5'),\n tmcg: renderPlatformIcon('tmcg', 'h-5 w-5'),\n mlg: renderPlatformIcon('mlg', 'h-5 w-5'),\n universal: renderPlatformIcon('universal', 'h-5 w-5'),\n};\n\n// Identity + brand strings/colours live in ONE place; re-exported here so every\n// existing importer of `platform-config` keeps working unchanged.\nexport {\n platformDisplayNames,\n platformShortNames,\n platformDescriptions,\n platformSlogans,\n platformIconNames,\n platformHexColors,\n platformColors,\n getPlatformDisplayName,\n getPlatformShortName,\n getPlatformDescription,\n getPlatformSlogan,\n getPlatformColor,\n getDefaultColorForPlatform,\n getDefaultIconForPlatform,\n} from './platform-identity';\n\nexport function transformPlatformConfigsToOptions(platformConfigs: PlatformConfig[]): SelectableOption[] {\n return platformConfigs.map((platform: PlatformConfig) => ({\n id: platform.id, // Database UUID for matching\n name: platform.name, // Platform name enum\n displayName: platform.display_name, // Human-readable name\n description: platform.description,\n icon: getPlatformIcon(platform.name),\n color: getPlatformColorClass(platform.name),\n }));\n}\n\n/** Get platform icon by name (5x5). */\nexport function getPlatformIcon(platformName: string): React.ReactNode {\n return platformIcons[platformName as PlatformName] ?? platformIcons.universal;\n}\n\n/** Get small platform icon for filter buttons (4x4). */\nexport function getSmallPlatformIcon(platformName: string): React.ReactNode {\n return renderPlatformIcon(platformName, 'h-4 w-4 flex-shrink-0');\n}\n\n/** Get platform icon for admin/selector components (standard 6x6 size). */\nexport function getPlatformIconComponent(platformName: string, className: string = 'h-6 w-6'): React.ReactNode {\n return renderPlatformIcon(platformName, className);\n}\n\n// `getPlatformColor` is re-exported from the identity leaf above; this local alias\n// keeps `transformPlatformConfigsToOptions` readable without shadowing that export.\nfunction getPlatformColorClass(platformName: string): string {\n return getPlatformBrandClasses(platformName).accentBg;\n}\n","import { toast as sonnerToast } from 'sonner';\nimport { showToast, type ToastVariant } from '../components/ui/toaster';\n\nexport interface ToastOptions {\n title?: string;\n description?: string;\n variant?: 'default' | 'success' | 'destructive' | 'info' | 'warning' | 'error';\n duration?: number;\n dismissible?: boolean;\n}\n\nconst normalizeVariant = (variant: ToastOptions['variant']): ToastVariant => {\n if (variant === 'destructive') return 'error';\n if (!variant) return 'default';\n return variant;\n};\n\nexport const toast = (options: ToastOptions | string) => {\n if (typeof options === 'string') {\n return showToast({ title: options });\n }\n\n const { title, description, variant, duration, dismissible } = options;\n return showToast({\n title,\n description,\n variant: normalizeVariant(variant),\n duration,\n dismissible,\n });\n};\n\nexport const useToast = () => ({\n toast,\n dismiss: sonnerToast.dismiss,\n promise: sonnerToast.promise,\n});\n","'use client';\n\nimport { type ComponentProps, type ReactNode, useState } from 'react';\nimport { Toaster as SonnerToaster, toast as sonnerToast } from 'sonner';\nimport type { ExternalToast } from 'sonner';\nimport type { ToolType } from '../../types/tool.types';\nimport { cn } from '../../utils/cn';\nimport { Chevron02DownIcon } from '../icons-v2-generated/arrows/chevron-02-down-icon';\nimport { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon';\nimport { ToolIcon } from '../tool-icon';\n\nexport type ToastVariant = 'default' | 'success' | 'warning' | 'error' | 'info';\n\nexport const dotColorByVariant: Record<ToastVariant, string> = {\n default: 'bg-ods-text-secondary',\n success: 'bg-ods-success',\n warning: 'bg-ods-warning',\n error: 'bg-ods-error',\n info: 'bg-ods-text-secondary',\n};\n\nexport const progressColorByVariant: Record<ToastVariant, string> = {\n default: 'bg-ods-text-secondary',\n success: 'bg-ods-success',\n warning: 'bg-ods-warning',\n error: 'bg-ods-error',\n info: 'bg-ods-text-secondary',\n};\n\ninterface ToastHeaderProps {\n id: string | number;\n variant: ToastVariant;\n title?: ReactNode;\n description?: ReactNode;\n duration?: number;\n dismissible?: boolean;\n className?: string;\n showProgress?: boolean;\n}\n\nfunction ToastHeader({\n id,\n variant,\n title,\n description,\n duration = 4000,\n dismissible = true,\n className,\n showProgress = true,\n}: ToastHeaderProps) {\n return (\n <div className={cn('relative flex w-full items-start gap-2 overflow-hidden bg-ods-card p-3', className)}>\n <div className=\"flex size-6 shrink-0 items-center justify-center\">\n <span className={cn('size-[9px] rounded-full', dotColorByVariant[variant])} />\n </div>\n\n <div className=\"flex min-w-0 flex-1 flex-col justify-center\">\n {title ? (\n <p\n className=\"truncate pr-5 text-ods-text-primary text-h4\"\n title={typeof title === 'string' ? title : undefined}\n >\n {title}\n </p>\n ) : null}\n {description ? (\n <p\n className=\"line-clamp-3 text-ods-text-secondary text-h6\"\n title={typeof description === 'string' ? description : undefined}\n >\n {description}\n </p>\n ) : null}\n </div>\n\n {dismissible ? (\n <button\n type=\"button\"\n aria-label=\"Close\"\n onClick={() => sonnerToast.dismiss(id)}\n className=\"absolute right-[7px] top-[7px] flex size-4 items-center justify-center text-ods-text-secondary transition-colors hover:text-ods-text-primary\"\n >\n <XmarkIcon size={16} />\n </button>\n ) : null}\n\n {showProgress && duration !== Infinity && duration > 0 ? (\n <div\n className={cn('absolute inset-x-0 bottom-0 h-1 origin-left', progressColorByVariant[variant])}\n style={{\n animation: `toast-progress ${duration}ms linear forwards`,\n }}\n />\n ) : null}\n </div>\n );\n}\n\nexport interface ToastCardProps {\n id: string | number;\n variant?: ToastVariant;\n title?: ReactNode;\n description?: ReactNode;\n duration?: number;\n dismissible?: boolean;\n className?: string;\n}\n\nexport function ToastCard({\n id,\n variant = 'default',\n title,\n description,\n duration = 4000,\n dismissible = true,\n className,\n}: ToastCardProps) {\n return (\n <div\n role=\"status\"\n className={cn(\n 'w-[368px] max-w-[calc(100vw-32px)] overflow-hidden rounded-md border border-ods-border bg-ods-card shadow-lg',\n className,\n )}\n >\n <ToastHeader\n id={id}\n variant={variant}\n title={title}\n description={description}\n duration={duration}\n dismissible={dismissible}\n />\n </div>\n );\n}\n\nexport interface CommandApprovalToastProps {\n id: string | number;\n variant?: ToastVariant;\n title?: ReactNode;\n description?: ReactNode;\n command: string;\n toolType?: ToolType;\n approvalDescription?: ReactNode;\n approveLabel?: string;\n rejectLabel?: string;\n onApprove?: () => void;\n onReject?: () => void;\n duration?: number;\n dismissible?: boolean;\n defaultExpanded?: boolean;\n className?: string;\n}\n\nexport function CommandApprovalToast({\n id,\n variant = 'warning',\n title = 'Tech Required',\n description = 'Approval is required to execute the command.',\n command,\n toolType,\n approvalDescription,\n approveLabel = 'Approve',\n rejectLabel = 'Reject',\n onApprove,\n onReject,\n duration = Infinity,\n dismissible = true,\n defaultExpanded = false,\n className,\n}: CommandApprovalToastProps) {\n const [expanded, setExpanded] = useState(defaultExpanded);\n\n const handleApprove = () => {\n onApprove?.();\n sonnerToast.dismiss(id);\n };\n\n const handleReject = () => {\n onReject?.();\n sonnerToast.dismiss(id);\n };\n\n return (\n <div\n role=\"status\"\n className={cn(\n 'flex w-[368px] max-w-[calc(100vw-32px)] flex-col overflow-hidden rounded-md border border-ods-border bg-ods-bg shadow-lg',\n className,\n )}\n >\n <ToastHeader\n id={id}\n variant={variant}\n title={title}\n description={description}\n duration={duration}\n dismissible={dismissible}\n showProgress={!expanded}\n className=\"border-b border-ods-border\"\n />\n\n <div\n className=\"grid transition-[grid-template-rows] duration-300 ease-out\"\n style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}\n aria-hidden={!expanded}\n >\n <div className=\"overflow-hidden\">\n <div className=\"flex h-11 w-full items-center gap-2 border-b border-ods-border bg-ods-card px-3 py-2\">\n <p className=\"min-w-0 flex-1 truncate text-ods-text-primary text-h6\" title={command}>\n {command}\n </p>\n {toolType ? <ToolIcon toolType={toolType} size={16} /> : null}\n </div>\n\n <div className=\"flex flex-col gap-2 bg-ods-bg p-3\">\n {approvalDescription ? <p className=\"text-ods-text-secondary text-h6\">{approvalDescription}</p> : null}\n <div className=\"flex items-center gap-4\">\n <button\n type=\"button\"\n onClick={handleApprove}\n tabIndex={expanded ? 0 : -1}\n className=\"flex flex-1 items-center justify-center rounded-md bg-ods-accent px-2 py-2 text-ods-text-on-accent transition-colors text-h5 hover:bg-ods-accent-hover active:bg-ods-accent-active\"\n >\n {approveLabel}\n </button>\n <button\n type=\"button\"\n onClick={handleReject}\n tabIndex={expanded ? 0 : -1}\n className=\"flex flex-1 items-center justify-center rounded-md border border-ods-border bg-ods-card px-2 py-2 text-ods-text-primary transition-colors text-h5 hover:bg-ods-bg-hover\"\n >\n {rejectLabel}\n </button>\n </div>\n </div>\n </div>\n </div>\n\n {expanded ? null : (\n <button\n type=\"button\"\n onClick={() => setExpanded(true)}\n className=\"flex w-full items-center gap-2 bg-ods-card px-3 py-2 text-left text-ods-text-primary transition-colors text-h6 hover:bg-ods-bg-hover\"\n aria-expanded={false}\n >\n <span className=\"flex-1\">Show Command</span>\n <Chevron02DownIcon size={16} />\n </button>\n )}\n </div>\n );\n}\n\nexport type ToasterProps = ComponentProps<typeof SonnerToaster>;\n\nexport function Toaster({ position = 'bottom-right', offset = 24, gap = 8, toastOptions, ...rest }: ToasterProps = {}) {\n const { classNames: userClassNames, ...restToastOptions } = toastOptions ?? {};\n\n return (\n <>\n <style>{`\n @keyframes toast-progress {\n from { transform: scaleX(1); }\n to { transform: scaleX(0); }\n }\n `}</style>\n <SonnerToaster\n position={position}\n offset={offset}\n gap={gap}\n toastOptions={{\n unstyled: true,\n ...restToastOptions,\n classNames: {\n toast: 'w-full',\n ...userClassNames,\n },\n }}\n {...rest}\n />\n </>\n );\n}\n\nexport interface ShowToastOptions extends Omit<ExternalToast, 'description'> {\n title?: ReactNode;\n description?: ReactNode;\n variant?: ToastVariant;\n}\n\nexport function showToast(options: ShowToastOptions | string) {\n const opts: ShowToastOptions = typeof options === 'string' ? { title: options } : options;\n\n const { title, description, variant = 'default', duration = 4000, dismissible = true, ...rest } = opts;\n\n return sonnerToast.custom(\n id => (\n <ToastCard\n id={id}\n variant={variant}\n title={title}\n description={description}\n duration={duration}\n dismissible={dismissible}\n />\n ),\n { duration, dismissible, ...rest },\n );\n}\n\nexport interface ShowCommandApprovalToastOptions\n extends Omit<ExternalToast, 'description'>, Omit<CommandApprovalToastProps, 'id'> {}\n\nexport function showCommandApprovalToast(options: ShowCommandApprovalToastOptions) {\n const {\n variant,\n title,\n description,\n command,\n toolType,\n approvalDescription,\n approveLabel,\n rejectLabel,\n onApprove,\n onReject,\n defaultExpanded,\n duration = Infinity,\n dismissible = true,\n ...rest\n } = options;\n\n return sonnerToast.custom(\n id => (\n <CommandApprovalToast\n id={id}\n variant={variant}\n title={title}\n description={description}\n command={command}\n toolType={toolType}\n approvalDescription={approvalDescription}\n approveLabel={approveLabel}\n rejectLabel={rejectLabel}\n onApprove={onApprove}\n onReject={onReject}\n defaultExpanded={defaultExpanded}\n duration={duration}\n dismissible={dismissible}\n />\n ),\n { duration, dismissible, ...rest },\n );\n}\n","import { useState, useCallback, useEffect } from 'react';\nimport { useRequiredEndpointsRuntime } from '../contexts/endpoints-runtime-context';\nimport { useRouter } from '../embed-shims/next-navigation';\nimport { apiErrorMessage } from '../utils/common';\nimport { contentFetch } from '../utils/embed-content-fetch';\nimport { useToast } from './use-toast';\n\ninterface ContactSubmissionOptions {\n userId?: string;\n successRedirectUrl?: string;\n successToastMessage?: string;\n onSuccess?: () => void;\n}\n\n/**\n * Mirrors the API contract at POST /api/contact (see `ContactBaseSchema` in\n * the consuming app). Keep optional fields aligned with the Zod schema —\n * anything missing here OR there will be silently dropped on submission.\n *\n * Tracking fields (rdt_cid, utm_*, referrer_url) are forwarded as-is and\n * persisted by the API; they don't need to be declared on the form, but\n * the index signature below preserves any extra keys callers spread in.\n */\ninterface ContactFormData {\n name: string;\n email: string;\n helpCategory: string;\n message: string;\n linkedin_url?: string;\n companySize?: string;\n referralSource?: string;\n rdt_cid?: string;\n // Permissive index signature — tracking metadata, A/B variants, and\n // future per-form fields flow through unchanged. The API decides what\n // to keep via its Zod schema.\n [key: string]: unknown;\n}\n\n/**\n * useContactSubmission\n * --------------------\n * Provides a helper for submitting contact form data to `/api/contact`.\n * Handles loading state, success detection, toast notifications, and redirect.\n * Follows the same pattern as useWaitlistRegistration for consistency.\n */\nexport function useContactSubmission(options: ContactSubmissionOptions = {}) {\n const { userId, successRedirectUrl, successToastMessage, onSuccess } = options;\n const { toast } = useToast();\n const router = useRouter();\n // Endpoint URL injected via context — hub provides hub default, embedded\n // app provides its proxied path. Throws if no provider is mounted.\n const { contactUrl } = useRequiredEndpointsRuntime();\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [isSuccess, setIsSuccess] = useState(false);\n\n const submit = useCallback(\n async (formData: ContactFormData) => {\n if (isSubmitting) return;\n\n setIsSubmitting(true);\n\n try {\n const response = await contentFetch(contactUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n ...formData,\n user_id: userId,\n }),\n });\n\n // Read the body ONLY on failure — nothing here consumes the success\n // payload, and parsing it meant a 2xx with an empty (or non-JSON)\n // body threw and toasted a failure for a message that was delivered.\n if (!response.ok) {\n const failure: unknown = await response.json().catch(() => null);\n throw new Error(apiErrorMessage(failure, 'Failed to submit contact form'));\n }\n\n // Success\n setIsSuccess(true);\n const message = successToastMessage\n ? `Thank you! Your message has been sent. ${successToastMessage}`\n : 'Thank you! Your message has been sent successfully.';\n\n toast({\n title: 'Message sent!',\n description: message,\n variant: 'success',\n });\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Something went wrong. Please try again.';\n toast({\n title: 'Failed to send message',\n description: message,\n variant: 'destructive',\n });\n throw error; // allow caller to handle if needed\n } finally {\n setIsSubmitting(false);\n }\n },\n [isSubmitting, toast, userId, successToastMessage, contactUrl],\n );\n\n // Handle redirect after success\n useEffect(() => {\n if (!isSuccess || !successRedirectUrl) return undefined;\n console.log('🚀 Contact submission successful, redirecting to:', successRedirectUrl);\n const timer = setTimeout(() => {\n console.log('🎯 Performing redirect now to:', successRedirectUrl);\n if (successRedirectUrl.startsWith('http')) {\n window.location.href = successRedirectUrl;\n } else {\n router.push(successRedirectUrl);\n }\n }, 1500);\n\n return () => clearTimeout(timer);\n }, [isSuccess, successRedirectUrl, router]);\n\n // Call onSuccess callback if provided\n useEffect(() => {\n if (isSuccess && onSuccess) {\n onSuccess();\n }\n }, [isSuccess, onSuccess]);\n\n return { submit, isSubmitting, isSuccess, setIsSuccess } as const;\n}\n","'use client';\n\nimport { useCallback, useState } from 'react';\nimport { useToast } from './use-toast';\n\ninterface UseCopyToClipboardOptions {\n successTitle?: string;\n successDescription?: string;\n errorTitle?: string;\n errorDescription?: string;\n resetDelay?: number;\n}\n\nexport function useCopyToClipboard({\n successTitle = 'Copied',\n successDescription = 'Copied to clipboard',\n errorTitle = 'Copy failed',\n errorDescription = 'Could not copy to clipboard',\n resetDelay = 2000,\n}: UseCopyToClipboardOptions = {}) {\n const { toast } = useToast();\n const [copied, setCopied] = useState(false);\n\n const copy = useCallback(\n async (text: string) => {\n try {\n await navigator.clipboard.writeText(text);\n setCopied(true);\n toast({ title: successTitle, description: successDescription, variant: 'success' });\n setTimeout(() => setCopied(false), resetDelay);\n } catch {\n toast({ title: errorTitle, description: errorDescription, variant: 'destructive' });\n }\n },\n [successTitle, successDescription, errorTitle, errorDescription, resetDelay, toast],\n );\n\n return { copy, copied };\n}\n","import { useEffect, useMemo, useState, useRef } from 'react';\n\n/**\n * Configuration for batch image fetching\n */\nexport interface BatchImageFetchConfig {\n /** Base URL for tenant-specific API calls (e.g., 'https://tenant.openframe.dev' or '') */\n tenantHostUrl?: string;\n /** Enable dev mode with Bearer token from localStorage */\n enableDevMode?: boolean;\n /** localStorage key for access token (default: 'of_access_token') */\n accessTokenKey?: string;\n}\n\n/**\n * Global configuration for batch image fetching\n * Can be set once at app initialization\n */\nlet globalBatchImageConfig: BatchImageFetchConfig = {};\n\n/**\n * Configure global settings for batch image fetching\n * Call this once in your app initialization (e.g., _app.tsx or layout.tsx)\n *\n * @example\n * ```typescript\n * // In app initialization\n * configureBatchImageFetch({\n * tenantHostUrl: process.env.NEXT_PUBLIC_TENANT_HOST_URL || '',\n * enableDevMode: process.env.NEXT_PUBLIC_ENABLE_DEV_TICKET_OBSERVER === 'true'\n * })\n * ```\n */\nexport function configureBatchImageFetch(config: BatchImageFetchConfig): void {\n globalBatchImageConfig = { ...globalBatchImageConfig, ...config };\n}\n\n/**\n * Get current batch image fetch configuration\n */\nfunction getBatchImageConfig(): Required<BatchImageFetchConfig> {\n return {\n tenantHostUrl: globalBatchImageConfig.tenantHostUrl || '',\n enableDevMode: globalBatchImageConfig.enableDevMode ?? false,\n accessTokenKey: globalBatchImageConfig.accessTokenKey || 'of_access_token',\n };\n}\n\n/**\n * Fetch multiple images with authentication in batch\n * Returns a map of original imageUrl to fetched blob URL\n *\n * @param imageUrls - Array of image URLs to fetch\n * @param config - Optional configuration override for this batch\n * @returns Promise resolving to map of original URL → blob URL\n *\n * @example\n * ```typescript\n * const images = await batchFetchAuthenticatedImages([\n * '/api/organizations/123/image',\n * '/api/organizations/456/image'\n * ])\n * // { '/api/organizations/123/image': 'blob:...', '/api/organizations/456/image': 'blob:...' }\n * ```\n */\nexport async function batchFetchAuthenticatedImages(\n imageUrls: string[],\n config?: BatchImageFetchConfig,\n): Promise<Record<string, string | undefined>> {\n const results: Record<string, string | undefined> = {};\n\n if (imageUrls.length === 0) {\n return results;\n }\n\n const { tenantHostUrl, enableDevMode, accessTokenKey } = {\n ...getBatchImageConfig(),\n ...config,\n };\n\n const fetchPromises = imageUrls.map(async imageUrl => {\n try {\n // Construct full image URL\n let fullImageUrl: string;\n if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {\n fullImageUrl = imageUrl;\n } else if (imageUrl.startsWith('/api/')) {\n fullImageUrl = `${tenantHostUrl}${imageUrl}`;\n } else if (imageUrl.startsWith('/')) {\n fullImageUrl = `${tenantHostUrl}/api${imageUrl}`;\n } else {\n fullImageUrl = `${tenantHostUrl}/api/${imageUrl}`;\n }\n\n // Add cache buster\n const cacheBuster = `?t=${Date.now()}`;\n fullImageUrl = fullImageUrl + cacheBuster;\n\n // Prepare headers\n const headers: Record<string, string> = {\n Accept: 'image/*',\n 'Cache-Control': 'no-cache, no-store, must-revalidate',\n Pragma: 'no-cache',\n };\n\n // Add Bearer token in dev mode\n if (enableDevMode) {\n try {\n const accessToken = localStorage.getItem(accessTokenKey);\n if (accessToken) {\n headers.Authorization = `Bearer ${accessToken}`;\n }\n } catch {\n // Silently continue without token\n }\n }\n\n // Fetch image\n const response = await fetch(fullImageUrl, {\n method: 'GET',\n credentials: 'include', // Include cookies for authentication\n headers,\n });\n\n if (!response.ok) {\n throw new Error(`Failed to fetch image: ${response.status}`);\n }\n\n // Convert to blob URL\n const blob = await response.blob();\n const objectUrl = URL.createObjectURL(blob);\n\n return { imageUrl, fetchedUrl: objectUrl };\n } catch (error) {\n console.warn(`Failed to fetch image ${imageUrl}:`, error);\n return { imageUrl, fetchedUrl: undefined };\n }\n });\n\n const fetchResults = await Promise.all(fetchPromises);\n\n fetchResults.forEach(({ imageUrl, fetchedUrl }) => {\n results[imageUrl] = fetchedUrl;\n });\n\n return results;\n}\n\n/**\n * React hook to batch fetch images with authentication\n *\n * Features:\n * - Automatically deduplicates URLs\n * - Caches fetched results\n * - Only fetches new/unfetched URLs\n * - Cleans up blob URLs on unmount\n *\n * @param imageUrls - Array of image URLs (can include null/undefined)\n * @param config - Optional configuration override\n * @returns Map of original URL → fetched blob URL\n *\n * @example\n * ```typescript\n * // In a component\n * const imageUrls = useMemo(() =>\n * organizations.map(org => org.imageUrl).filter(Boolean),\n * [organizations]\n * )\n * const fetchedImages = useBatchImages(imageUrls)\n *\n * // Use in render\n * <img src={fetchedImages[org.imageUrl]} alt={org.name} />\n * ```\n */\nexport function useBatchImages(\n imageUrls: (string | null | undefined)[],\n config?: BatchImageFetchConfig,\n): Record<string, string | undefined> {\n const [fetchedImages, setFetchedImages] = useState<Record<string, string | undefined>>({});\n\n // Deduplicate and filter out null/undefined\n const uniqueUrls = useMemo(\n () => Array.from(new Set(imageUrls.filter((url): url is string => Boolean(url)))),\n [imageUrls],\n );\n\n // Track URLs we've already requested to avoid duplicate fetches\n const requestedUrls = useRef<Set<string>>(new Set());\n\n // Revoke on UNMOUNT only, reading the latest map through a ref.\n //\n // This used to be the cleanup of the fetch effect below, closing over\n // `fetchedImages` from the render the effect happened to run in. Two bugs\n // fell out of that: URLs fetched after that render were never revoked\n // (a leak), and a change to `imageUrls`/`config` revoked the whole captured\n // set while state still pointed at those blobs — every image on screen broke.\n const fetchedImagesRef = useRef(fetchedImages);\n useEffect(() => {\n fetchedImagesRef.current = fetchedImages;\n }, [fetchedImages]);\n useEffect(\n () => () => {\n Object.values(fetchedImagesRef.current).forEach(blobUrl => {\n if (blobUrl && blobUrl.startsWith('blob:')) {\n URL.revokeObjectURL(blobUrl);\n }\n });\n },\n [],\n );\n\n useEffect(() => {\n // An empty `imageUrls` used to wipe `fetchedImages` back to `{}` here. That\n // was wrong three ways, and removing it fixes all three:\n // * `requestedUrls` was NOT cleared alongside it, so once a url came back\n // it was filtered out as \"already requested\" and never re-fetched — the\n // image stayed permanently blank;\n // * the wiped blob URLs were dropped from the map the unmount cleanup\n // revokes from, so they leaked;\n // * it was a synchronous setState in an effect body, i.e. a second render\n // pass for a map nobody is indexing into (there are no urls to look up).\n // The map is a url-keyed cache: stale entries are unreachable, not stale\n // data, and it is bounded by the same url set `requestedUrls` already holds.\n\n // Find URLs we haven't fetched yet\n const urlsToFetch = uniqueUrls.filter(url => !requestedUrls.current.has(url));\n\n if (urlsToFetch.length === 0) {\n return; // All URLs already requested\n }\n\n // Mark these URLs as requested\n urlsToFetch.forEach(url => {\n requestedUrls.current.add(url);\n });\n\n batchFetchAuthenticatedImages(urlsToFetch, config)\n .then(newResults => {\n setFetchedImages(prev => ({ ...prev, ...newResults }));\n })\n .catch((error: unknown) => {\n console.error('Failed to batch fetch images:', error);\n });\n }, [uniqueUrls, config]);\n\n return fetchedImages;\n}\n","import { useEffect, useState, useRef } from 'react';\n\n/**\n * Configuration for single image fetching\n * Uses same config as batch image fetching for consistency\n */\nexport interface AuthenticatedImageConfig {\n /** Base URL for tenant-specific API calls (e.g., 'https://tenant.openframe.dev' or '') */\n tenantHostUrl?: string;\n /** Enable dev mode with Bearer token from localStorage */\n enableDevMode?: boolean;\n /** localStorage key for access token (default: 'of_access_token') */\n accessTokenKey?: string;\n}\n\n/**\n * Global configuration for authenticated image fetching\n * Shared with useBatchImages for consistency\n */\nlet globalImageConfig: AuthenticatedImageConfig = {};\n\n/**\n * Global cache for authenticated images\n * Stores blob URLs by cache key\n */\ninterface ImageCacheEntry {\n blobUrl: string;\n timestamp: number;\n refCount: number;\n}\n\nconst imageCache = new Map<string, ImageCacheEntry>();\nconst pendingRequests = new Map<string, Promise<string | undefined>>();\n\n/**\n * Cache cleanup interval (5 minutes)\n */\nconst CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000;\n\n/**\n * Cache entry max age (30 minutes)\n */\nconst CACHE_MAX_AGE = 30 * 60 * 1000;\n\n/**\n * Clean up expired cache entries\n */\nfunction cleanupImageCache() {\n const now = Date.now();\n for (const [key, entry] of imageCache.entries()) {\n if (entry.refCount === 0 && now - entry.timestamp > CACHE_MAX_AGE) {\n URL.revokeObjectURL(entry.blobUrl);\n imageCache.delete(key);\n }\n }\n}\n\n/**\n * Periodic cache cleanup\n */\nif (typeof window !== 'undefined') {\n setInterval(cleanupImageCache, CACHE_CLEANUP_INTERVAL);\n}\n\n/**\n * Configure global settings for authenticated image fetching\n * Call this once in your app initialization (e.g., _app.tsx or layout.tsx)\n *\n * Note: This uses the same configuration as useBatchImages. If you've already\n * called configureBatchImageFetch(), you don't need to call this separately.\n *\n * @example\n * ```typescript\n * // In app initialization\n * configureAuthenticatedImage({\n * tenantHostUrl: process.env.NEXT_PUBLIC_TENANT_HOST_URL || '',\n * enableDevMode: process.env.NEXT_PUBLIC_ENABLE_DEV_TICKET_OBSERVER === 'true'\n * })\n * ```\n */\nexport function configureAuthenticatedImage(config: AuthenticatedImageConfig): void {\n globalImageConfig = { ...globalImageConfig, ...config };\n}\n\n/**\n * Get current authenticated image configuration\n */\nfunction getImageConfig(): Required<AuthenticatedImageConfig> {\n return {\n tenantHostUrl: globalImageConfig.tenantHostUrl || '',\n enableDevMode: globalImageConfig.enableDevMode ?? false,\n accessTokenKey: globalImageConfig.accessTokenKey || 'of_access_token',\n };\n}\n\n/**\n * React hook to fetch a single image with authentication\n *\n * Features:\n * - Fetches image with cookie authentication\n * - Optional Bearer token in dev mode\n * - Converts to blob URL for img src\n * - Automatic cleanup of blob URLs\n * - Cache-busting with refreshKey\n * - Loading and error states\n * - **Global caching** - Prevents duplicate requests for identical URLs\n * - **Automatic deduplication** - Multiple components using same URL share cached result\n * - **Reference counting** - Cached blobs cleaned up when no longer used\n *\n * @param imageUrl - The image URL to fetch (null/undefined = no fetch)\n * @param refreshKey - Optional key to force re-fetch (e.g., version number, timestamp)\n * @param config - Optional configuration override\n * @returns Object with imageUrl (blob), isLoading, and error\n *\n * @example\n * ```typescript\n * // Basic usage\n * const { imageUrl, isLoading, error } = useAuthenticatedImage(\n * organization?.imageUrl\n * )\n *\n * // With refresh key (e.g., after upload)\n * const { imageUrl } = useAuthenticatedImage(\n * organization?.imageUrl,\n * organization?.imageVersion // Timestamp or version number\n * )\n *\n * // In render\n * {imageUrl && <img src={imageUrl} alt=\"Organization\" />}\n * ```\n */\nexport function useAuthenticatedImage(\n imageUrl?: string | null,\n refreshKey?: string | number,\n config?: AuthenticatedImageConfig,\n): {\n imageUrl: string | undefined;\n isLoading: boolean;\n error: string | null;\n} {\n // Result of the LAST completed attempt, tagged with the cache key it belongs\n // to. Tagging is what lets `isLoading` / `error` be derived below instead of\n // being written from the effect: \"still loading\" is simply \"nothing settled\n // for the key we are currently asked about\", which the render already knows.\n const [settled, setSettled] = useState<{ key: string; blobUrl?: string; error?: string } | null>(null);\n const currentCacheKeyRef = useRef<string | null>(null);\n\n const { tenantHostUrl, enableDevMode, accessTokenKey } = {\n ...getImageConfig(),\n ...config,\n };\n\n // Construct full image URL. Pure string work on the arguments — it belongs in\n // render so both the effect and the derived result below agree on one key.\n let fullImageUrl: string | null = null;\n if (imageUrl) {\n if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {\n fullImageUrl = imageUrl;\n } else if (imageUrl.startsWith('/api/')) {\n fullImageUrl = `${tenantHostUrl}${imageUrl}`;\n } else if (imageUrl.startsWith('/')) {\n fullImageUrl = `${tenantHostUrl}/api${imageUrl}`;\n } else {\n fullImageUrl = `${tenantHostUrl}/api/${imageUrl}`;\n }\n }\n\n // Create cache key (use refreshKey if provided, otherwise no cache buster for caching)\n const cacheKey = fullImageUrl === null ? null : refreshKey ? `${fullImageUrl}?v=${refreshKey}` : fullImageUrl;\n\n useEffect(() => {\n if (cacheKey === null) {\n if (currentCacheKeyRef.current) {\n const entry = imageCache.get(currentCacheKeyRef.current);\n if (entry) {\n entry.refCount--;\n }\n currentCacheKeyRef.current = null;\n }\n return;\n }\n\n if (currentCacheKeyRef.current && currentCacheKeyRef.current !== cacheKey) {\n const prevEntry = imageCache.get(currentCacheKeyRef.current);\n if (prevEntry) {\n prevEntry.refCount--;\n }\n }\n\n currentCacheKeyRef.current = cacheKey;\n\n const cachedEntry = imageCache.get(cacheKey);\n if (cachedEntry) {\n // Ref-counting only — the blob is already on screen, because the render\n // below reads the same cache. Publishing it from here instead would mean\n // a cache HIT still cost a spinner frame plus a second render pass.\n cachedEntry.refCount++;\n cachedEntry.timestamp = Date.now();\n return;\n }\n\n const pendingRequest = pendingRequests.get(cacheKey);\n if (pendingRequest) {\n pendingRequest\n .then(blobUrl => {\n const entry = blobUrl ? imageCache.get(cacheKey) : undefined;\n if (entry) entry.refCount++;\n // Settled either way: a request that resolves without a usable blob\n // still ends the spinner, exactly as the old `setIsLoading(false)` in\n // the non-`if` tail of this handler did.\n setSettled({ key: cacheKey, blobUrl: entry ? blobUrl : undefined });\n })\n .catch((err: unknown) => {\n setSettled({ key: cacheKey, error: err instanceof Error ? err.message : 'Failed to fetch image' });\n });\n return;\n }\n\n const requestUrl = refreshKey ? cacheKey : `${fullImageUrl}?t=${Date.now()}`;\n\n // Prepare headers\n const headers: Record<string, string> = {\n Accept: 'image/*',\n 'Cache-Control': 'no-cache, no-store, must-revalidate',\n Pragma: 'no-cache',\n };\n\n // Add Bearer token in dev mode\n if (enableDevMode) {\n try {\n const accessToken = localStorage.getItem(accessTokenKey);\n if (accessToken) {\n headers.Authorization = `Bearer ${accessToken}`;\n }\n } catch {\n // Silently continue without token\n }\n }\n\n const fetchPromise = fetch(requestUrl, {\n method: 'GET',\n credentials: 'include', // Include cookies for authentication\n headers,\n })\n .then(response => {\n if (!response.ok) {\n throw new Error(`Failed to fetch image: ${response.status}`);\n }\n return response.blob();\n })\n .then(blob => {\n const objectUrl = URL.createObjectURL(blob);\n\n imageCache.set(cacheKey, {\n blobUrl: objectUrl,\n timestamp: Date.now(),\n refCount: 1,\n });\n\n setSettled({ key: cacheKey, blobUrl: objectUrl });\n return objectUrl;\n })\n .catch((err: unknown) => {\n setSettled({ key: cacheKey, error: err instanceof Error ? err.message : 'Failed to fetch image' });\n throw err;\n })\n .finally(() => {\n pendingRequests.delete(cacheKey);\n });\n\n pendingRequests.set(cacheKey, fetchPromise);\n // Keyed on the derived `cacheKey` and the two PRIMITIVE config fields the\n // request itself needs — not on the `config` OBJECT. Callers pass `config`\n // inline, so a fresh identity every render used to re-run this effect on\n // every render, and each re-run incremented `refCount` again on a cache hit\n // (see above). Entries whose count never returned to 0 were never eligible\n // for `cleanupImageCache`, so their blob URLs leaked for the whole session.\n }, [cacheKey, fullImageUrl, refreshKey, enableDevMode, accessTokenKey]);\n\n useEffect(() => {\n return () => {\n if (currentCacheKeyRef.current) {\n const entry = imageCache.get(currentCacheKeyRef.current);\n if (entry) {\n entry.refCount--;\n }\n }\n };\n }, []);\n\n // Everything below is derived, not stored.\n //\n // `active` is the settled result for the key we are being asked about right\n // now; a stale one (the caller bumped `refreshKey`) does not count as an\n // answer. The cache is consulted directly so a HIT renders the blob on the\n // very first pass. The final `settled?.blobUrl` fallback keeps the previously\n // resolved image on screen while a new `refreshKey` reloads it — the old code\n // got that for free by never clearing `fetchedImageUrl` on a key change.\n const active = settled && settled.key === cacheKey ? settled : null;\n const cachedBlobUrl = cacheKey === null ? undefined : imageCache.get(cacheKey)?.blobUrl;\n const resolvedBlobUrl = active?.blobUrl ?? cachedBlobUrl ?? settled?.blobUrl;\n\n return {\n imageUrl: cacheKey === null ? undefined : resolvedBlobUrl,\n isLoading: cacheKey !== null && active === null && cachedBlobUrl === undefined,\n error: (cacheKey === null ? undefined : active?.error) ?? null,\n };\n}\n","/**\n * useQueryParams Hook - GraphQL Integration for URL State Management\n *\n * Automatically generates URL state management from GraphQL queries.\n * Parses query AST at runtime, flattens nested input types, and syncs with URL.\n *\n * @example\n * const LOGS_QUERY = gql`\n * query GetLogs($search: String, $filter: LogFilterInput) { ... }\n * `\n *\n * const { variables, setParam } = useQueryParams(LOGS_QUERY)\n * const { data } = useQuery(LOGS_QUERY, { variables })\n *\n * // URL: /logs?search=error&severity=critical\n * // variables: { search: 'error', filter: { severity: ['critical'] } }\n */\n\n'use client';\n\nimport type { DocumentNode } from 'graphql';\nimport { useEffect, useState, useMemo, useCallback, useRef } from 'react';\nimport { useRouter, useSearchParams } from '../../embed-shims/next-navigation';\n\nimport { flattenQueryVariables, mergeDefaults, validateSchema, type FlattenedParam } from './flatten-schema';\nimport { extractVariablesFromQuery } from './graphql-parser';\nimport { introspector } from './introspection';\nimport {\n urlParamsToVariables,\n variablesToUrlParams,\n mergeVariables,\n clearParams,\n type GraphQLVariables,\n} from './url-converter';\n\n/**\n * Options for useQueryParams hook\n */\nexport interface UseQueryParamsOptions {\n /** Default values for parameters */\n defaultValues?: GraphQLVariables;\n\n /** GraphQL endpoint for introspection (defaults to process.env.NEXT_PUBLIC_API_URL/graphql) */\n introspectionEndpoint?: string;\n\n /** HTTP headers for introspection (e.g., authentication) */\n introspectionHeaders?: Record<string, string>;\n\n /** Skip introspection (use only AST parsing, no nested type flattening) */\n skipIntrospection?: boolean;\n\n /** Custom parameter name mapping (override auto-generated names) */\n paramMapping?: Record<string, string>;\n\n /** Enable debug logging */\n debug?: boolean;\n}\n\n/**\n * Return type for useQueryParams hook\n */\nexport interface UseQueryParamsReturn<TVariables = GraphQLVariables> {\n /** GraphQL variables ready for Apollo Client */\n variables: TVariables;\n\n /** Raw URL parameters (before conversion to variables). Repeated keys\n * collapse into an array, in URL order. */\n params: Record<string, string | string[]>;\n\n /** Flattened parameter schema */\n schema: Record<string, FlattenedParam>;\n\n /** Set a single parameter */\n setParam: (key: string, value: unknown) => void;\n\n /** Set multiple parameters at once */\n setParams: (params: GraphQLVariables) => void;\n\n /** Clear specific parameters */\n clearParams: (keys: string[]) => void;\n\n /** Reset all parameters (clear URL) */\n resetParams: () => void;\n\n /** Whether schema is ready (introspection complete) */\n isReady: boolean;\n\n /** Loading state during initialization */\n isLoading: boolean;\n\n /** Error during initialization */\n error: Error | null;\n}\n\n/**\n * useQueryParams - Auto-generate URL state from GraphQL query\n *\n * This hook:\n * 1. Parses GraphQL query AST to extract variable definitions\n * 2. Fetches GraphQL schema via introspection (optional, cached)\n * 3. Flattens nested input types to simple URL parameters\n * 4. Syncs URL ↔ GraphQL variables bidirectionally\n * 5. Provides type-safe parameter updates\n *\n * @param query - GraphQL DocumentNode (from gql`` template tag)\n * @param options - Configuration options\n * @returns Hook API for managing URL state\n */\nexport function useQueryParams<TVariables = GraphQLVariables>(\n query: DocumentNode,\n options: UseQueryParamsOptions = {},\n): UseQueryParamsReturn<TVariables> {\n const router = useRouter();\n const searchParams = useSearchParams();\n\n const [isLoading, setIsLoading] = useState(true);\n const [isReady, setIsReady] = useState(false);\n const [error, setError] = useState<Error | null>(null);\n const [schema, setSchema] = useState<Record<string, FlattenedParam>>({});\n\n // Extract default values. Memoised: `|| {}` minted a new object on every\n // render, so every memo and effect keyed on it re-ran every time.\n const defaultValues = useMemo(() => options.defaultValues || {}, [options.defaultValues]);\n const skipIntrospection = options.skipIntrospection || false;\n const debug = options.debug || false;\n\n // Latest option bag for the one-time initialiser below. These cannot be\n // dependencies: `introspectionHeaders`, `paramMapping` and `defaultValues`\n // are almost always object literals, so as deps they would re-run schema\n // introspection on every render of the calling component.\n const initSourceRef = useRef({\n introspectionHeaders: options.introspectionHeaders,\n paramMapping: options.paramMapping,\n defaultValues,\n });\n useEffect(() => {\n initSourceRef.current = {\n introspectionHeaders: options.introspectionHeaders,\n paramMapping: options.paramMapping,\n defaultValues,\n };\n });\n\n // Initialize: Parse query + fetch schema (once)\n useEffect(() => {\n // `initialize` awaits introspection and schema flattening, so a `query`\n // change can start a second run while the first is still in flight. Without\n // this guard the SUPERSEDED run's `setSchema`/`setIsReady` could land last\n // and leave the hook parsing the URL against the PREVIOUS query's schema —\n // params for the new query then silently drop or coerce to the wrong type.\n let cancelled = false;\n async function initialize() {\n try {\n if (debug) console.log('[useQueryParams] Initializing...');\n\n // 1. Extract variables from query AST\n const queryVariables = extractVariablesFromQuery(query);\n\n if (debug) {\n console.log('[useQueryParams] Extracted variables:', queryVariables);\n }\n\n // 2. Fetch GraphQL schema via introspection (if needed and not skipped)\n if (!skipIntrospection && !introspector.isLoaded()) {\n const endpoint =\n options.introspectionEndpoint ||\n (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_API_URL\n ? `${process.env.NEXT_PUBLIC_API_URL}/graphql`\n : '');\n\n if (endpoint) {\n try {\n await introspector.fetchSchema(endpoint, initSourceRef.current.introspectionHeaders);\n if (debug) console.log('[useQueryParams] Introspection complete');\n } catch (err) {\n console.warn('[useQueryParams] Introspection failed, continuing without it:', err);\n // Continue without introspection - nested types won't be flattened\n }\n }\n }\n\n // 3. Flatten schema (with or without introspection). Synchronous — the\n // schema fetch it depends on was already awaited in step 2 above.\n let flattenedSchema = flattenQueryVariables(queryVariables, introspector);\n\n // Apply custom param mapping if provided\n const { paramMapping, defaultValues: initialDefaults } = initSourceRef.current;\n if (paramMapping) {\n flattenedSchema = applyParamMapping(flattenedSchema, paramMapping);\n }\n\n // Merge default values\n flattenedSchema = mergeDefaults(flattenedSchema, initialDefaults);\n\n // Validate schema\n validateSchema(flattenedSchema);\n\n if (debug) {\n console.log('[useQueryParams] Flattened schema:', flattenedSchema);\n }\n\n if (cancelled) return;\n setSchema(flattenedSchema);\n setIsReady(true);\n } catch (err) {\n if (cancelled) return;\n const initError = err instanceof Error ? err : new Error(String(err));\n console.error('[useQueryParams] Initialization failed:', initError);\n setError(initError);\n } finally {\n if (!cancelled) setIsLoading(false);\n }\n }\n\n // Never rejects — settles every failure into `error` state.\n void initialize();\n return () => {\n cancelled = true;\n };\n }, [query, options.introspectionEndpoint, skipIntrospection, debug]);\n\n // Convert URL params to GraphQL variables. Kept as the plain record shape\n // internally (that is what the converters take); the caller-chosen\n // `TVariables` view is applied once, at the returned value.\n const variables = useMemo<GraphQLVariables>(() => {\n if (!isReady) {\n return defaultValues;\n }\n\n try {\n const varsFromUrl = urlParamsToVariables(searchParams, schema);\n const merged = { ...defaultValues, ...varsFromUrl };\n\n if (debug) {\n console.log('[useQueryParams] Variables from URL:', merged);\n }\n\n return merged;\n } catch (err) {\n console.error('[useQueryParams] Failed to convert URL to variables:', err);\n return defaultValues;\n }\n }, [searchParams, schema, isReady, defaultValues, debug]);\n\n // Raw URL params (before conversion)\n const params = useMemo(() => {\n const result: Record<string, string | string[]> = {};\n searchParams.forEach((value, key) => {\n const existing = result[key];\n if (existing === undefined) {\n result[key] = value;\n } else if (Array.isArray(existing)) {\n // Multiple values - append to the array\n existing.push(value);\n } else {\n result[key] = [existing, value];\n }\n });\n return result;\n }, [searchParams]);\n\n // Update URL with new parameters\n const updateUrl = useCallback(\n (newParams: URLSearchParams) => {\n const url = newParams.toString() ? `?${newParams.toString()}` : window.location.pathname;\n\n if (debug) {\n console.log('[useQueryParams] Updating URL:', url);\n }\n\n // Use replace for shallow routing (no page reload, no history spam)\n router.replace(url, { scroll: false });\n },\n [router, debug],\n );\n\n // Set a single parameter\n const setParam = useCallback(\n (key: string, value: unknown) => {\n if (!isReady) {\n console.warn('[useQueryParams] Schema not ready, cannot set param');\n return;\n }\n\n try {\n const updated = mergeVariables(variables, { [key]: value }, schema);\n const newParams = variablesToUrlParams(updated, schema);\n updateUrl(newParams);\n } catch (err) {\n console.error('[useQueryParams] Failed to set param:', err);\n }\n },\n [variables, schema, isReady, updateUrl],\n );\n\n // Set multiple parameters\n const setParams = useCallback(\n (updates: GraphQLVariables) => {\n if (!isReady) {\n console.warn('[useQueryParams] Schema not ready, cannot set params');\n return;\n }\n\n try {\n const updated = mergeVariables(variables, updates, schema);\n const newParams = variablesToUrlParams(updated, schema);\n updateUrl(newParams);\n } catch (err) {\n console.error('[useQueryParams] Failed to set params:', err);\n }\n },\n [variables, schema, isReady, updateUrl],\n );\n\n // Clear specific parameters\n const clearParamsHandler = useCallback(\n (keys: string[]) => {\n if (!isReady) {\n console.warn('[useQueryParams] Schema not ready, cannot clear params');\n return;\n }\n\n try {\n const updated = clearParams(variables, keys, schema);\n const newParams = variablesToUrlParams(updated, schema);\n updateUrl(newParams);\n } catch (err) {\n console.error('[useQueryParams] Failed to clear params:', err);\n }\n },\n [variables, schema, isReady, updateUrl],\n );\n\n // Reset all parameters\n const resetParams = useCallback(() => {\n if (debug) {\n console.log('[useQueryParams] Resetting params');\n }\n\n router.replace(window.location.pathname, { scroll: false });\n }, [router, debug]);\n\n return {\n // The only place the caller's `TVariables` view is applied — this hook\n // builds the object from the URL, so its exact shape is the caller's\n // claim about their own query, not something we can verify here.\n variables: variables as TVariables,\n params,\n schema,\n setParam,\n setParams,\n clearParams: clearParamsHandler,\n resetParams,\n isReady,\n isLoading,\n error,\n };\n}\n\n/**\n * Apply custom parameter name mapping to schema\n */\nfunction applyParamMapping(\n schema: Record<string, FlattenedParam>,\n mapping: Record<string, string>,\n): Record<string, FlattenedParam> {\n const mapped: Record<string, FlattenedParam> = {};\n\n for (const [key, param] of Object.entries(schema)) {\n const newKey = mapping[key] || key;\n mapped[newKey] = {\n ...param,\n urlParamName: mapping[key] || param.urlParamName,\n };\n }\n\n return mapped;\n}\n","/**\n * Schema Flattening Utilities for URL State Management\n *\n * Converts nested GraphQL input types to flat URL parameter mappings.\n * Example: { filter: { severity: [...] } } → URL params: ?severity=...\n */\n\nimport type { JSType } from '../../utils/search-params';\nimport type { VariableDefinition } from './graphql-parser';\nimport type { GraphQLIntrospector } from './introspection';\n\n/**\n * Flattened URL parameter configuration\n *\n * Maps URL parameter names to their GraphQL variable paths\n */\nexport interface FlattenedParam {\n /** URL parameter name (e.g., \"severity\") */\n urlParamName: string;\n /** Path in GraphQL variables (e.g., \"filter.severity\") */\n graphqlPath: string;\n /** JavaScript type for URL handling */\n type: JSType;\n /** Default value for this parameter */\n defaultValue?: unknown;\n /** Whether this parameter is required */\n required?: boolean;\n /** Whether this parameter is an array */\n isArray?: boolean;\n}\n\n/**\n * Flatten GraphQL query variables to URL parameter schema\n *\n * Takes top-level query variables and flattens nested input objects\n * to create a simple URL parameter mapping.\n *\n * @param queryVariables - Variables extracted from GraphQL query\n * @param introspector - Introspector instance with loaded schema\n * @returns Flattened parameter schema for URL handling\n *\n * @example\n * Input variables:\n * {\n * search: { type: 'string', ... },\n * filter: { type: 'object', graphqlTypeName: 'LogFilterInput' },\n * cursor: { type: 'string', ... }\n * }\n *\n * Output schema:\n * {\n * search: { urlParamName: 'search', graphqlPath: 'search', type: 'string' },\n * severity: { urlParamName: 'severity', graphqlPath: 'filter.severity', type: 'array' },\n * toolType: { urlParamName: 'toolType', graphqlPath: 'filter.toolType', type: 'array' },\n * cursor: { urlParamName: 'cursor', graphqlPath: 'cursor', type: 'string' }\n * }\n *\n * URL: ?search=error&severity=critical&toolType=rmm&cursor=abc\n */\nexport function flattenQueryVariables(\n queryVariables: Record<string, VariableDefinition>,\n introspector: GraphQLIntrospector,\n): Record<string, FlattenedParam> {\n const flattened: Record<string, FlattenedParam> = {};\n\n for (const [varName, varDef] of Object.entries(queryVariables)) {\n // Primitive types or arrays - keep at top level\n if (varDef.type !== 'object') {\n flattened[varName] = {\n urlParamName: varName,\n graphqlPath: varName,\n type: varDef.isArray ? 'array' : varDef.type,\n required: varDef.required,\n isArray: varDef.isArray,\n };\n continue;\n }\n\n // Input object types - flatten fields to top level\n // This requires introspection to know the input type's fields\n if (introspector.isLoaded() && introspector.hasType(varDef.graphqlTypeName)) {\n const fields = introspector.getInputTypeFields(varDef.graphqlTypeName);\n\n for (const [fieldName, fieldDef] of Object.entries(fields)) {\n flattened[fieldName] = {\n urlParamName: fieldName,\n graphqlPath: `${varName}.${fieldName}`,\n type: fieldDef.isArray ? 'array' : fieldDef.type,\n required: fieldDef.required,\n isArray: fieldDef.isArray,\n };\n }\n } else {\n // Introspection not available or type not found\n // Keep as top-level object (will need manual handling)\n flattened[varName] = {\n urlParamName: varName,\n graphqlPath: varName,\n type: 'object',\n required: varDef.required,\n isArray: false,\n };\n }\n }\n\n return flattened;\n}\n\n/**\n * Merge default values into flattened schema\n *\n * @param schema - Flattened parameter schema\n * @param defaults - Default values keyed by URL param name\n * @returns Updated schema with default values\n */\nexport function mergeDefaults(\n schema: Record<string, FlattenedParam>,\n defaults: Record<string, unknown>,\n): Record<string, FlattenedParam> {\n const merged: Record<string, FlattenedParam> = {};\n\n for (const [key, param] of Object.entries(schema)) {\n merged[key] = {\n ...param,\n defaultValue: defaults[key] !== undefined ? defaults[key] : param.defaultValue,\n };\n }\n\n return merged;\n}\n\n/**\n * Validate that flattened schema has no conflicts\n *\n * Ensures that no two parameters map to the same URL param name\n *\n * @param schema - Flattened parameter schema\n * @throws Error if conflicts are detected\n */\nexport function validateSchema(schema: Record<string, FlattenedParam>): void {\n const urlParamNames = new Set<string>();\n\n for (const param of Object.values(schema)) {\n if (urlParamNames.has(param.urlParamName)) {\n throw new Error(`[FlattenSchema] Conflict: Multiple parameters map to URL param \"${param.urlParamName}\"`);\n }\n urlParamNames.add(param.urlParamName);\n }\n}\n\n/**\n * Get all array-type parameters from schema\n *\n * Useful for knowing which URL params should use repeated values\n * (e.g., ?severity=error&severity=warning)\n *\n * @param schema - Flattened parameter schema\n * @returns Array parameter names\n */\nexport function getArrayParams(schema: Record<string, FlattenedParam>): string[] {\n return Object.entries(schema)\n .filter(([_, param]) => param.type === 'array' || param.isArray)\n .map(([key]) => key);\n}\n\n/**\n * Get required parameters from schema\n *\n * @param schema - Flattened parameter schema\n * @returns Required parameter names\n */\nexport function getRequiredParams(schema: Record<string, FlattenedParam>): string[] {\n return Object.entries(schema)\n .filter(([_, param]) => param.required)\n .map(([key]) => key);\n}\n\n// THE omission rule lives in the search-params leaf (the server serializer and\n// this flattened-schema path must agree); re-exported for existing importers.\nexport { shouldIncludeInUrl } from '../../utils/search-params';\n","/**\n * GraphQL AST Parser for URL State Management\n *\n * Extracts variable definitions from GraphQL DocumentNode at runtime\n * to automatically generate URL parameter handling.\n */\n\nimport { type DocumentNode, type VariableDefinitionNode, type TypeNode, visit } from 'graphql';\n\n// `JSType` is OWNED by the search-params leaf (server and client share it).\n// Imported, not re-exported: a second export site is a second place to look\n// when you want to know where the type lives.\nimport type { JSType } from '../../utils/search-params';\n\n/**\n * Variable definition extracted from GraphQL query\n */\nexport interface VariableDefinition {\n /** Variable name (e.g., \"search\", \"filter\") */\n name: string;\n /** JavaScript type for URL parameter handling */\n type: JSType;\n /** Whether the variable is required (non-null in GraphQL) */\n required: boolean;\n /** Whether the variable is an array/list */\n isArray: boolean;\n /** Original GraphQL type name (e.g., \"String\", \"LogFilterInput\") */\n graphqlTypeName: string;\n}\n\n/**\n * Parsed type information from GraphQL TypeNode\n */\ninterface ParsedType {\n typeName: string;\n isNonNull: boolean;\n isList: boolean;\n}\n\n/**\n * Extract all variable definitions from a GraphQL query\n *\n * @param query - GraphQL DocumentNode (from gql template tag)\n * @returns Record of variable definitions keyed by variable name\n *\n * @example\n * const LOGS_QUERY = gql`\n * query GetLogs($search: String, $filter: LogFilterInput) { ... }\n * `\n *\n * const variables = extractVariablesFromQuery(LOGS_QUERY)\n * // {\n * // search: { name: 'search', type: 'string', ... },\n * // filter: { name: 'filter', type: 'object', graphqlTypeName: 'LogFilterInput' }\n * // }\n */\nexport function extractVariablesFromQuery(query: DocumentNode): Record<string, VariableDefinition> {\n const variables: Record<string, VariableDefinition> = {};\n\n visit(query, {\n VariableDefinition(node: VariableDefinitionNode) {\n const name = node.variable.name.value;\n const typeInfo = parseGraphQLType(node.type);\n\n variables[name] = {\n name,\n type: mapGraphQLTypeToJS(typeInfo.typeName),\n required: typeInfo.isNonNull,\n isArray: typeInfo.isList,\n graphqlTypeName: typeInfo.typeName,\n };\n },\n });\n\n return variables;\n}\n\n/**\n * Parse GraphQL TypeNode to extract type information\n * Handles NonNullType, ListType, and NamedType recursively\n *\n * @param typeNode - GraphQL type node from AST\n * @returns Parsed type information\n */\nfunction parseGraphQLType(typeNode: TypeNode): ParsedType {\n let typeName = '';\n let isNonNull = false;\n let isList = false;\n let current: TypeNode = typeNode;\n\n // Unwrap NonNullType wrapper\n if (current.kind === 'NonNullType') {\n isNonNull = true;\n current = current.type;\n }\n\n // Handle ListType\n if (current.kind === 'ListType') {\n isList = true;\n current = current.type;\n\n // ListType can also be wrapped in NonNullType\n if (current.kind === 'NonNullType') {\n current = current.type;\n }\n }\n\n // Extract the base type name\n if (current.kind === 'NamedType') {\n typeName = current.name.value;\n }\n\n return {\n typeName,\n isNonNull,\n isList,\n };\n}\n\n/**\n * Map GraphQL scalar/input types to JavaScript types\n *\n * @param graphqlType - GraphQL type name (e.g., \"String\", \"Int\", \"LogFilterInput\")\n * @returns JavaScript type for URL parameter handling\n */\nfunction mapGraphQLTypeToJS(graphqlType: string): JSType {\n // GraphQL scalar types\n const scalarTypeMap: Record<string, JSType> = {\n String: 'string',\n Int: 'number',\n Float: 'number',\n Boolean: 'boolean',\n ID: 'string',\n };\n\n // Check if it's a known scalar\n if (scalarTypeMap[graphqlType]) {\n return scalarTypeMap[graphqlType];\n }\n\n // Unknown types are assumed to be input objects\n // These will be flattened using introspection\n return 'object';\n}\n\n/**\n * Check if a GraphQL type is a scalar type\n */\nexport function isScalarType(graphqlTypeName: string): boolean {\n const scalars = ['String', 'Int', 'Float', 'Boolean', 'ID'];\n return scalars.includes(graphqlTypeName);\n}\n\n/**\n * Check if a GraphQL type is an input object type\n */\nexport function isInputObjectType(graphqlTypeName: string): boolean {\n return !isScalarType(graphqlTypeName);\n}\n","/**\n * GraphQL Schema Introspection for URL State Management\n *\n * Fetches GraphQL schema via introspection query at runtime (works behind auth).\n * Caches schema in localStorage + memory for performance.\n */\n\nimport {\n getIntrospectionQuery,\n buildClientSchema,\n type IntrospectionQuery,\n type GraphQLSchema,\n type GraphQLInputType,\n isInputObjectType,\n} from 'graphql';\nimport type { JSType } from '../../utils/search-params';\nimport type { VariableDefinition } from './graphql-parser';\n\n/**\n * Schema cache structure stored in localStorage\n */\ninterface SchemaCache {\n schema: IntrospectionQuery;\n timestamp: number;\n version: string;\n}\n\n/**\n * GraphQL Introspection Manager\n *\n * Singleton class that handles schema introspection with caching.\n * Fetches schema once per session and caches for 24 hours.\n */\nexport class GraphQLIntrospector {\n private schema: GraphQLSchema | null = null;\n private cacheKey = 'graphql-schema-cache-v1';\n private cacheDuration = 24 * 60 * 60 * 1000; // 24 hours\n private schemaVersion = '1.0.0';\n\n /**\n * Fetch GraphQL schema via introspection query\n *\n * @param endpoint - GraphQL endpoint URL\n * @param headers - HTTP headers (e.g., authentication)\n * @param skipCache - Force fresh fetch, ignore cache\n *\n * @example\n * await introspector.fetchSchema(\n * 'http://localhost/api/graphql',\n * { 'Authorization': 'Bearer token123' }\n * )\n */\n async fetchSchema(endpoint: string, headers: Record<string, string> = {}, skipCache = false): Promise<void> {\n // Check cache first (unless skipped)\n if (!skipCache) {\n const cached = this.loadFromCache();\n if (cached && Date.now() - cached.timestamp < this.cacheDuration) {\n try {\n this.schema = buildClientSchema(cached.schema);\n return;\n } catch (error) {\n console.warn('[Introspector] Failed to build schema from cache:', error);\n // Continue to fetch fresh schema\n }\n }\n }\n\n // Fetch schema via introspection query\n try {\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n ...headers,\n },\n body: JSON.stringify({\n query: getIntrospectionQuery(),\n }),\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n // Standard GraphQL execution result. `data` carries the introspection\n // payload `buildClientSchema` expects; `errors`, when present, is only\n // ever stringified for the thrown message.\n const { data, errors } = (await response.json()) as {\n data?: IntrospectionQuery;\n errors?: readonly unknown[];\n };\n\n if (errors) {\n throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`);\n }\n\n if (!data) {\n throw new Error('No data returned from introspection query');\n }\n\n // Build and cache schema\n this.schema = buildClientSchema(data);\n this.saveToCache(data);\n } catch (error) {\n console.error('[Introspector] Failed to fetch schema:', error);\n throw error;\n }\n }\n\n /**\n * Get fields from a GraphQL input object type\n *\n * @param typeName - Name of the input type (e.g., \"LogFilterInput\")\n * @returns Record of field definitions\n *\n * @example\n * const fields = introspector.getInputTypeFields('LogFilterInput')\n * // {\n * // severity: { name: 'severity', type: 'array', ... },\n * // toolType: { name: 'toolType', type: 'array', ... }\n * // }\n */\n getInputTypeFields(typeName: string): Record<string, VariableDefinition> {\n if (!this.schema) {\n return {};\n }\n\n const type = this.schema.getType(typeName);\n\n if (!type || !isInputObjectType(type)) {\n return {};\n }\n\n const fields: Record<string, VariableDefinition> = {};\n const typeFields = type.getFields();\n\n for (const [fieldName, field] of Object.entries(typeFields)) {\n const fieldType = this.unwrapType(field.type);\n\n fields[fieldName] = {\n name: fieldName,\n type: this.mapGraphQLTypeToJS(fieldType.typeName),\n required: fieldType.isNonNull,\n isArray: fieldType.isList,\n graphqlTypeName: fieldType.typeName,\n };\n }\n\n return fields;\n }\n\n /**\n * Check if a type exists in the schema\n */\n hasType(typeName: string): boolean {\n if (!this.schema) return false;\n return this.schema.getType(typeName) !== undefined;\n }\n\n /**\n * Get the schema (if loaded)\n */\n getSchema(): GraphQLSchema | null {\n return this.schema;\n }\n\n /**\n * Check if schema is loaded\n */\n isLoaded(): boolean {\n return this.schema !== null;\n }\n\n /**\n * Clear cached schema\n */\n clearCache(): void {\n try {\n localStorage.removeItem(this.cacheKey);\n this.schema = null;\n } catch {\n // Ignore storage errors\n }\n }\n\n /**\n * Unwrap GraphQL type to get base type info\n * Handles NonNull and List wrappers\n */\n private unwrapType(type: GraphQLInputType): {\n typeName: string;\n isNonNull: boolean;\n isList: boolean;\n } {\n let typeName = '';\n let isNonNull = false;\n let isList = false;\n let current: GraphQLInputType = type;\n\n // Unwrap NonNull wrapper\n if (current.toString().endsWith('!')) {\n isNonNull = true;\n current = 'ofType' in current ? current.ofType : current;\n }\n\n // Check for List wrapper\n if (current.toString().startsWith('[')) {\n isList = true;\n current = 'ofType' in current ? current.ofType : current;\n }\n\n // Handle inner NonNull (e.g., [String!])\n if (current.toString().endsWith('!')) {\n current = 'ofType' in current ? current.ofType : current;\n }\n\n // Get base type name\n // Only NAMED types carry `name`; list/non-null wrappers fall back to\n // stripping the wrapper punctuation off their printed form.\n const named = 'name' in current ? current.name : undefined;\n typeName = named || current.toString().replace(/[[\\]!]/g, '');\n\n return { typeName, isNonNull, isList };\n }\n\n /**\n * Map GraphQL types to JavaScript types for URL handling\n */\n private mapGraphQLTypeToJS(graphqlType: string): JSType {\n const typeMap: Record<string, JSType> = {\n String: 'string',\n Int: 'number',\n Float: 'number',\n Boolean: 'boolean',\n ID: 'string',\n };\n\n return typeMap[graphqlType] || 'object';\n }\n\n /**\n * Load schema from localStorage cache\n */\n private loadFromCache(): SchemaCache | null {\n try {\n const cached = localStorage.getItem(this.cacheKey);\n if (!cached) return null;\n\n const parsed = JSON.parse(cached) as SchemaCache;\n\n // Validate cache version\n if (parsed.version !== this.schemaVersion) {\n return null;\n }\n\n return parsed;\n } catch (error) {\n console.warn('[Introspector] Failed to load cache:', error);\n return null;\n }\n }\n\n /**\n * Save schema to localStorage cache\n */\n private saveToCache(schema: IntrospectionQuery): void {\n try {\n const cache: SchemaCache = {\n schema,\n timestamp: Date.now(),\n version: this.schemaVersion,\n };\n\n localStorage.setItem(this.cacheKey, JSON.stringify(cache));\n } catch (error) {\n console.warn('[Introspector] Failed to save cache:', error);\n // Ignore storage errors (e.g., quota exceeded)\n }\n }\n}\n\n/**\n * Singleton introspector instance\n *\n * Use this instance throughout the application to share schema cache\n *\n * @example\n * import { introspector } from '@flamingo/ui-kit/hooks/state'\n *\n * // Initialize after auth\n * await introspector.fetchSchema(endpoint, { Authorization: token })\n *\n * // Get input type fields\n * const fields = introspector.getInputTypeFields('LogFilterInput')\n */\nexport const introspector = new GraphQLIntrospector();\n","/**\n * URL ↔ Variables Converter for URL State Management\n *\n * Bidirectional conversion between URL parameters and GraphQL variables.\n * Handles type coercion, nested paths, and array parameters.\n */\n\nimport { positiveInt } from '../../utils/search-params';\nimport type { JSType } from '../../utils/search-params';\nimport { type FlattenedParam, shouldIncludeInUrl } from './flatten-schema';\n\n/** A single URL-decodable value. */\nexport type UrlScalar = string | number | boolean | null;\n/** What `coerceValue` can produce for one URL parameter. */\nexport type UrlParamValue = UrlScalar | UrlScalar[];\n\n/** GraphQL variables object — nested, values are opaque to this module. */\nexport type GraphQLVariables = Record<string, unknown>;\n\n/** Narrow an unknown to something indexable by string key. Arrays qualify\n * (path segments may address array indices), `null` does not. */\nfunction isIndexable(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\n/**\n * Convert URL search params to GraphQL variables\n *\n * Reads URL parameters and reconstructs the nested GraphQL variables object\n * based on the flattened schema mapping.\n *\n * @param searchParams - URLSearchParams from window.location or Next.js\n * @param schema - Flattened parameter schema\n * @returns GraphQL variables object ready for Apollo Client\n *\n * @example\n * URL: ?search=error&severity=critical&severity=error&cursor=abc\n * Schema: {\n * search: { graphqlPath: 'search', type: 'string' },\n * severity: { graphqlPath: 'filter.severity', type: 'array' },\n * cursor: { graphqlPath: 'cursor', type: 'string' }\n * }\n * Result: {\n * search: 'error',\n * filter: { severity: ['critical', 'error'] },\n * cursor: 'abc'\n * }\n */\nexport function urlParamsToVariables(\n searchParams: URLSearchParams,\n schema: Record<string, FlattenedParam>,\n): GraphQLVariables {\n const variables: GraphQLVariables = {};\n\n for (const [paramName, paramConfig] of Object.entries(schema)) {\n // Read value from URL\n const rawValue =\n paramConfig.type === 'array' || paramConfig.isArray\n ? searchParams.getAll(paramName)\n : searchParams.get(paramName);\n\n // Skip if no value in URL\n if (!rawValue || (Array.isArray(rawValue) && rawValue.length === 0)) {\n // Use default value if available\n if (paramConfig.defaultValue !== undefined) {\n setNestedValue(variables, paramConfig.graphqlPath, paramConfig.defaultValue);\n }\n continue;\n }\n\n // Coerce value to correct type\n const value = coerceValue(rawValue, paramConfig.type);\n\n // Set value at nested path\n setNestedValue(variables, paramConfig.graphqlPath, value);\n }\n\n return variables;\n}\n\n/**\n * Convert GraphQL variables to URL search params\n *\n * Flattens nested GraphQL variables to URL parameters based on schema mapping.\n * Excludes null/undefined/default values to keep URLs clean.\n *\n * @param variables - GraphQL variables object\n * @param schema - Flattened parameter schema\n * @returns URLSearchParams ready for router.push()\n *\n * @example\n * Variables: {\n * search: 'error',\n * filter: { severity: ['critical'], toolType: [] },\n * cursor: null\n * }\n * Result URL: ?search=error&severity=critical\n * (empty arrays and null values excluded)\n */\nexport function variablesToUrlParams(\n variables: GraphQLVariables,\n schema: Record<string, FlattenedParam>,\n): URLSearchParams {\n const params = new URLSearchParams();\n\n for (const [paramName, paramConfig] of Object.entries(schema)) {\n // Get value from nested path\n const value = getNestedValue(variables, paramConfig.graphqlPath);\n\n // Skip if should not include in URL\n if (!shouldIncludeInUrl(value, paramConfig)) {\n continue;\n }\n\n // Add to URL params\n if (Array.isArray(value)) {\n // Array: Use repeated params (e.g., ?tag=foo&tag=bar)\n value.forEach(v => {\n if (v !== null && v !== undefined && v !== '') {\n params.append(paramName, String(v));\n }\n });\n } else {\n // Single value: Use set\n params.set(paramName, String(value));\n }\n }\n\n return params;\n}\n\n/**\n * Coerce URL parameter value to correct JavaScript type\n *\n * @param value - Raw value from URLSearchParams (string or string[])\n * @param type - Target JavaScript type\n * @returns Coerced value\n */\nexport function coerceValue(value: string | string[], type: JSType): UrlParamValue {\n // Array handling — each element is coerced as a scalar ('array' degrades\n // to 'string', same as before).\n if (Array.isArray(value)) {\n return value.map(v => coerceScalar(v, type === 'array' ? 'string' : type));\n }\n\n // Single value under an array-typed param becomes a one-element array.\n if (type === 'array') return [value];\n\n return coerceScalar(value, type);\n}\n\n/** Scalar half of {@link coerceValue}. `'array'` never reaches here. */\nfunction coerceScalar(value: string, type: JSType): UrlScalar {\n switch (type) {\n case 'number': {\n const num = parseFloat(value);\n return isNaN(num) ? null : num;\n }\n\n case 'int':\n // THE integer grammar has ONE owner. This used to inline its own copy,\n // which then drifted (the shared one honours a configured `max`).\n // `positiveInt` returns the fallback for anything unparseable, and this\n // path has no schema config to read a default from, so `null` it is.\n return positiveInt<null>(value, null);\n\n case 'boolean':\n return value === 'true' || value === '1';\n\n case 'string':\n default:\n return value;\n }\n}\n\n/**\n * Set value at nested path in object\n *\n * Creates intermediate objects as needed.\n *\n * @param obj - Target object to modify\n * @param path - Dot-separated path (e.g., \"filter.severity\")\n * @param value - Value to set\n *\n * @example\n * setNestedValue({}, 'filter.severity', ['error'])\n * // Result: { filter: { severity: ['error'] } }\n */\nexport function setNestedValue(obj: GraphQLVariables, path: string, value: unknown): void {\n const parts = path.split('.');\n let current = obj;\n\n // Navigate/create intermediate objects\n for (let i = 0; i < parts.length - 1; i++) {\n const part = parts[i];\n const existing = current[part];\n // `null` counts as \"not an object\" here — the old `typeof x === 'object'`\n // check let a null segment through and then dereferenced it.\n const next = isIndexable(existing) ? existing : {};\n if (next !== existing) current[part] = next;\n current = next;\n }\n\n // Set final value\n const lastPart = parts[parts.length - 1];\n current[lastPart] = value;\n}\n\n/**\n * Get value from nested path in object\n *\n * Returns undefined if path doesn't exist.\n *\n * @param obj - Source object\n * @param path - Dot-separated path (e.g., \"filter.severity\")\n * @returns Value at path or undefined\n *\n * @example\n * getNestedValue({ filter: { severity: ['error'] } }, 'filter.severity')\n * // Result: ['error']\n */\nexport function getNestedValue(obj: unknown, path: string): unknown {\n return path.split('.').reduce<unknown>((current, key) => {\n return isIndexable(current) ? current[key] : undefined;\n }, obj);\n}\n\n/**\n * Merge URL parameters into existing variables\n *\n * Useful for updating specific parameters without losing others.\n *\n * @param currentVariables - Current GraphQL variables\n * @param updates - Parameter updates (flat object)\n * @param schema - Flattened parameter schema\n * @returns Updated variables object\n */\nexport function mergeVariables(\n currentVariables: GraphQLVariables,\n updates: Record<string, unknown>,\n schema: Record<string, FlattenedParam>,\n): GraphQLVariables {\n const merged = { ...currentVariables };\n\n for (const [paramName, value] of Object.entries(updates)) {\n const paramConfig = schema[paramName];\n if (!paramConfig) continue;\n\n setNestedValue(merged, paramConfig.graphqlPath, value);\n }\n\n return merged;\n}\n\n/**\n * Clear specific parameters from variables\n *\n * @param variables - Current GraphQL variables\n * @param paramNames - Parameter names to clear\n * @param schema - Flattened parameter schema\n * @returns Variables with specified params removed\n */\nexport function clearParams(\n variables: GraphQLVariables,\n paramNames: string[],\n schema: Record<string, FlattenedParam>,\n): GraphQLVariables {\n const cleared = { ...variables };\n\n for (const paramName of paramNames) {\n const paramConfig = schema[paramName];\n if (!paramConfig) continue;\n\n // Set to undefined (will be excluded from URL)\n setNestedValue(cleared, paramConfig.graphqlPath, undefined);\n }\n\n return cleared;\n}\n\n/**\n * Validate GraphQL variables against schema\n *\n * Checks for required parameters and type consistency.\n *\n * @param variables - GraphQL variables to validate\n * @param schema - Flattened parameter schema\n * @returns Validation errors (empty array if valid)\n */\nexport function validateVariables(variables: GraphQLVariables, schema: Record<string, FlattenedParam>): string[] {\n const errors: string[] = [];\n\n for (const [paramName, paramConfig] of Object.entries(schema)) {\n const value = getNestedValue(variables, paramConfig.graphqlPath);\n\n // Check required parameters\n if (paramConfig.required && (value === null || value === undefined)) {\n errors.push(`Required parameter \"${paramName}\" is missing`);\n }\n\n // Check type consistency\n if (value !== null && value !== undefined) {\n const actualType = Array.isArray(value) ? 'array' : typeof value;\n const expectedType = paramConfig.type === 'array' ? 'array' : paramConfig.type;\n\n if (actualType !== expectedType && actualType !== 'object') {\n errors.push(`Parameter \"${paramName}\" has wrong type: expected ${expectedType}, got ${actualType}`);\n }\n }\n }\n\n return errors;\n}\n","/**\n * useApiParams Hook - REST API Integration for URL State Management\n *\n * Manual schema definition for REST APIs. Provides same URL sync functionality\n * as useQueryParams but without GraphQL dependency.\n *\n * @example\n * const { params, setParam } = useApiParams({\n * search: { type: 'string', default: '' },\n * page: { type: 'number', default: 1 },\n * tags: { type: 'array', default: [] }\n * })\n *\n * fetch(`/api/items?${new URLSearchParams(params)}`)\n *\n * // URL: /items?search=laptop&page=2&tags=electronics&tags=sale\n * // params: { search: 'laptop', page: 2, tags: ['electronics', 'sale'] }\n */\n\n'use client';\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useRouter, useSearchParams } from '../../embed-shims/next-navigation';\nimport { createSearchParams, parseSchemaParams, type ParamSchema, type JSType } from '../../utils/search-params';\nimport { type FlattenedParam, shouldIncludeInUrl } from './flatten-schema';\n\n/**\n * Returns the previous reference if the JSON-serialized content of `value`\n * hasn't changed across renders. Internal helper used to shield consumers from\n * ref churn caused by:\n * - `useSearchParams()` returning a new `ReadonlyURLSearchParams` instance\n * on every render even when the URL is unchanged (Next.js behavior).\n * - Consumers passing the schema as a fresh object literal on every render.\n */\nfunction useContentStable<T>(value: T, key: string): T {\n // React's \"adjust state while rendering\" pattern rather than a ref written\n // during render: the cache must not be updated by a render attempt React\n // discards (concurrent interruption, error retry), or a value that never\n // committed would be handed to every later render as the stable one. The\n // extra render pass only happens when the content key actually changes,\n // which for the one caller (the param schema) is essentially never after\n // mount.\n const [cached, setCached] = useState<{ value: T; key: string }>({ value, key });\n if (cached.key !== key) setCached({ value, key });\n // Same object either way: the pass that stores `value` also returns it.\n return cached.key === key ? cached.value : value;\n}\n\n/**\n * Reuses a previous array reference if its content (shallow string equality)\n * matches the freshly parsed array. Lets `params.tier` etc. stay\n * reference-stable across renders that don't actually change those values.\n */\nfunction reuseIfShallowEqual<T extends string | number | boolean>(prev: unknown, next: T[]): T[] {\n if (!Array.isArray(prev) || prev.length !== next.length) return next;\n for (let i = 0; i < next.length; i++) {\n if (prev[i] !== next[i]) return next;\n }\n return prev as T[];\n}\n\n/** Cached params snapshot plus the raw parse it was derived from. */\ninterface ParamsCache {\n src: Record<string, unknown>;\n value: Record<string, unknown>;\n}\n\n/**\n * Copy of `next` in which every array field whose content matches `prev`'s\n * keeps `prev`'s instance. Returns `next` untouched when nothing was reusable,\n * so an unrelated change costs no extra allocation.\n */\nfunction reuseArrayRefs(prev: Record<string, unknown>, next: Record<string, unknown>): Record<string, unknown> {\n let merged: Record<string, unknown> | undefined;\n for (const [key, value] of Object.entries(next)) {\n if (!Array.isArray(value)) continue;\n const reused = reuseIfShallowEqual(prev[key], value as (string | number | boolean)[]);\n if (reused === value) continue;\n merged ??= { ...next };\n merged[key] = reused;\n }\n return merged ?? next;\n}\n\n/**\n * Type mapping from JSType to TypeScript types for OUTPUT (reading params)\n */\ntype OutputTypeMap = {\n string: string;\n number: number;\n boolean: boolean;\n array: string[];\n object: Record<string, unknown>;\n int: number;\n};\n\n/**\n * Type mapping from JSType to TypeScript types for INPUT (setting params)\n * More permissive to allow null/undefined in arrays which get filtered\n */\ntype InputTypeMap = {\n string: string | null | undefined;\n number: number | null | undefined;\n boolean: boolean | null | undefined;\n array: (string | null | undefined)[];\n object: Record<string, unknown> | null | undefined;\n int: number | null | undefined;\n};\n\n/**\n * Get the TypeScript type for OUTPUT (reading from params)\n */\ntype OutputTypeForJSType<T extends JSType> = OutputTypeMap[T];\n\n/**\n * Get the TypeScript type for INPUT (setting params)\n */\ntype InputTypeForJSType<T extends JSType> = InputTypeMap[T];\n\n// Schema types + the schema helper live in the search-params leaf, so the hook's\n// `params` ARE the same parsed contract a server route produces for that URL.\nexport type { ParamConfig, ParamSchema } from '../../utils/search-params';\nexport { defineParamSchema } from '../../utils/search-params';\n\n/**\n * Options for useApiParams hook\n */\nexport interface UseApiParamsOptions {\n /** Enable debug logging */\n debug?: boolean;\n /**\n * What an ABSENT scalar with no declared `default` reads as. Passed straight\n * through to `parseSchemaParams`, so the hook's `params` ARE the same parsed\n * contract the server produces for the same URL.\n */\n absent?: 'undefined' | 'null';\n}\n\n/**\n * Infer the OUTPUT params type from a ParamSchema (for reading)\n * Maps each key in the schema to its corresponding TypeScript type\n *\n * @example\n * const schema = defineParamSchema({\n * search: { type: 'string', default: '' },\n * page: { type: 'number', default: 1 },\n * tags: { type: 'array', default: [] }\n * })\n * type Params = InferParamsFromSchema<typeof schema>\n * // { search: string; page: number; tags: string[] }\n */\nexport type InferParamsFromSchema<TSchema extends ParamSchema> = {\n [K in keyof TSchema]: TSchema[K]['type'] extends infer T\n ? T extends JSType\n ? OutputTypeForJSType<T>\n : never\n : never;\n};\n\n/**\n * Infer the INPUT params type from a ParamSchema (for setting)\n * More permissive to allow null/undefined values\n */\nexport type InferInputParamsFromSchema<TSchema extends ParamSchema> = {\n [K in keyof TSchema]: TSchema[K]['type'] extends infer T ? (T extends JSType ? InputTypeForJSType<T> : never) : never;\n};\n\n/**\n * Type for parameter values that can be set\n * Allows setting values that match the schema types or can be coerced to them\n */\nexport type ParamValue =\n string | number | boolean | string[] | (string | null | undefined)[] | Record<string, unknown> | null | undefined;\n\n/**\n * Return type for useApiParams hook with strict typing\n */\nexport interface UseApiParamsReturn<TSchema extends ParamSchema, TParams = InferParamsFromSchema<TSchema>> {\n /** Parsed parameters object with strict typing */\n params: TParams;\n\n /**\n * `params` of the last write this hook issued, or `params` itself when no\n * write is in flight. The URL is authoritative; this is the latest INTENT.\n */\n pendingParams: TParams;\n\n /** URLSearchParams for fetch/axios */\n urlSearchParams: URLSearchParams;\n\n /** Set a single parameter with type-safe key and value */\n setParam: <K extends keyof TSchema & string>(key: K, value: InferInputParamsFromSchema<Pick<TSchema, K>>[K]) => void;\n\n /** Set multiple parameters at once */\n setParams: (updates: Partial<InferInputParamsFromSchema<TSchema>>) => void;\n\n /** Clear specific parameters */\n clearParams: (keys: (keyof TSchema & string)[]) => void;\n\n /** Reset all parameters (clear URL) */\n resetParams: () => void;\n}\n\n/**\n * useApiParams - Manual URL state for REST APIs\n *\n * This hook:\n * 1. Reads URL search parameters\n * 2. Coerces to correct types based on schema\n * 3. Provides type-safe parameter updates\n * 4. Syncs changes to URL automatically\n *\n * @param schema - Parameter schema definition\n * @param options - Configuration options\n * @returns Hook API for managing URL state\n */\nexport function useApiParams<TSchema extends ParamSchema>(\n schema: TSchema,\n options: UseApiParamsOptions = {},\n): UseApiParamsReturn<TSchema> {\n const router = useRouter();\n const searchParamsLive = useSearchParams();\n const debug = options.debug || false;\n\n // ───── Reference-stability layer ──────────────────────────────────────\n //\n // Goal: `params`, `params.<arrayField>`, and the setter callbacks must keep\n // the SAME reference across renders unless the URL or schema content\n // actually changes. Otherwise consumer `useMemo`/`useEffect` deps that\n // include `params.foo` invalidate on every parent re-render.\n //\n // Without this, every call site has to defensively `JSON.stringify` filter\n // arrays into a content-key — a known footgun. The stability is provided\n // here, once, instead of in 17 consumers.\n\n // 1. URL string is the canonical, value-stable representation of search params.\n const searchString = searchParamsLive.toString();\n\n // Serialized query strings this hook has written and not yet seen committed.\n const pendingRef = useRef<string[]>([]);\n // The queue's TAIL, mirrored into state: `pendingParams` is render output, and\n // reading a ref during render is both a lint error and a correctness hazard\n // (the value would not re-render the consumer). The ref stays the mutable\n // queue the commit effect drains; this is the one part render may see.\n const [pendingTail, setPendingTail] = useState<string | undefined>(undefined);\n const syncPendingTail = useCallback(() => {\n setPendingTail(pendingRef.current[pendingRef.current.length - 1]);\n }, []);\n\n // 2. Schema reference stabilized by content. Consumers commonly pass an\n // object literal each render, which would otherwise invalidate every memo.\n const schemaKey = useMemo(() => JSON.stringify(schema), [schema]);\n const stableSchema = useContentStable(schema, schemaKey);\n\n // ──────────────────────────────────────────────────────────────────────\n\n // Convert schema to flattened format for reuse\n const flattenedSchema = useMemo((): Record<string, FlattenedParam> => {\n const flattened: Record<string, FlattenedParam> = {};\n\n for (const [key, config] of Object.entries(stableSchema)) {\n flattened[key] = {\n urlParamName: key,\n graphqlPath: key,\n type: config.type,\n defaultValue: config.default,\n required: config.required,\n isArray: config.type === 'array',\n };\n }\n\n return flattened;\n }, [stableSchema]);\n\n // Parse URL parameters. PURE — it reads nothing but its own dependencies, so\n // a render attempt React discards leaves no trace.\n //\n // THE reader, and the SAME one `pendingParams` uses. This used to walk the\n // schema itself through `coerceValue`, which knows only a param's TYPE — so\n // `min`/`max`/`default` were applied to the pending params and not to the\n // committed ones. `?pageSize=9999` then read as 9999 here and 100 there, and\n // `?page=abc` as `null` here and `1` there, which is enough to desynchronize\n // any adapter that compares the two.\n const rawParams = useMemo((): Record<string, unknown> => {\n const result = parseSchemaParams(stableSchema, new URLSearchParams(searchString), {\n absent: options.absent === 'null' ? 'null' : 'undefined',\n }) as Record<string, unknown>;\n\n if (debug) {\n console.log('[useApiParams] Parsed params:', result);\n }\n\n return result;\n }, [searchString, debug, stableSchema, options.absent]);\n\n // Carry the previously COMMITTED array instances forward when their content\n // is unchanged, so `params.<arrayField>` stays reference-stable across a URL\n // change that touched some OTHER param. Held in state and updated with\n // React's \"adjust state while rendering\" pattern rather than in a ref: the\n // carry-forward source must be a params object that actually committed, or a\n // discarded render attempt would seed it with array instances no consumer\n // ever saw. The extra render pass only runs when `rawParams` is recomputed,\n // i.e. when the URL or the schema really changed.\n const [paramsCache, setParamsCache] = useState<ParamsCache>(() => ({ src: rawParams, value: rawParams }));\n let stableParams = paramsCache.value;\n if (paramsCache.src !== rawParams) {\n stableParams = reuseArrayRefs(paramsCache.value, rawParams);\n setParamsCache({ src: rawParams, value: stableParams });\n }\n const params = stableParams as InferParamsFromSchema<TSchema>;\n\n // Helper: Add parameter value to URLSearchParams.\n //\n // Delegates to `createSearchParams`, which owns the ENCODING rule (skip\n // null/undefined/'', repeat the key per array element, JSON objects,\n // `String()` scalars). The rule used to be spelled out here as well; the two\n // sat side by side in one file, and hoisting the shared one into the leaf\n // put them in different modules where drift is much easier.\n const addParamToSearchParams = useCallback((searchParams: URLSearchParams, key: string, value: ParamValue): void => {\n for (const [k, v] of createSearchParams({ [key]: value })) searchParams.append(k, v);\n }, []);\n\n // Get URLSearchParams for fetch/axios. Iterates `stableSchema` (not raw\n // `schema`) so consumers passing an inline schema literal don't invalidate\n // this memo on every render.\n const urlSearchParams = useMemo((): URLSearchParams => {\n const newParams = new URLSearchParams();\n\n for (const key of Object.keys(stableSchema)) {\n const value = (params as Record<string, unknown>)[key];\n const paramConfig = flattenedSchema[key];\n\n // Skip if should not include\n if (!shouldIncludeInUrl(value, paramConfig)) {\n continue;\n }\n\n addParamToSearchParams(newParams, key, value as ParamValue);\n }\n\n return newParams;\n }, [params, flattenedSchema, addParamToSearchParams, stableSchema]);\n\n // Update URL with new parameters (preserve other params not managed by this\n // hook). Depends only on value-stable inputs (`searchString`, `schemaKey`),\n // so the callback ref itself is stable across renders that don't change URL\n // or schema — important for consumers that put `setParam`/`setParams` in\n // `useEffect` deps.\n const updateUrl = useCallback(\n (newParams: URLSearchParams, keysToRemove: string[] = []) => {\n // Base the write on the LAST WRITE WE ISSUED, not on the committed URL.\n // Next commits each `router.replace` as its own navigation, so two writes\n // fired before the first commits would both rebase on the pre-first URL\n // and the first one's keys would be lost.\n const base = pendingRef.current[pendingRef.current.length - 1] ?? searchString;\n const finalParams = new URLSearchParams(base);\n\n // Remove keys that are explicitly marked for removal\n keysToRemove.forEach(key => {\n if (key in stableSchema) {\n finalParams.delete(key);\n }\n });\n\n // Remove keys that are being updated (from newParams)\n // This preserves other schema parameters that aren't being changed\n newParams.forEach((_, key) => {\n // Only remove keys that are in our schema\n if (key in stableSchema) {\n finalParams.delete(key);\n }\n });\n\n // Add all new values (including multiple values for array params)\n // Only add parameters that are in our schema to avoid duplicating external params\n newParams.forEach((value, key) => {\n // Only process keys that are in our schema\n if (key in stableSchema) {\n if (finalParams.has(key)) {\n // Key already exists (from array params), append\n finalParams.append(key, value);\n } else {\n // First value for this key, use set\n finalParams.set(key, value);\n }\n }\n });\n\n const merged = finalParams.toString();\n\n // A NO-OP write (re-selecting the current option, clearing an already\n // empty picker) must not enter the queue: `router.replace` with an\n // identical URL produces no commit, so the entry would never drain and\n // every later write would rebase on a phantom.\n if (merged === base) return;\n\n pendingRef.current = [...pendingRef.current, merged];\n syncPendingTail();\n\n const url = merged ? `?${merged}` : window.location.pathname;\n\n if (debug) {\n console.log('[useApiParams] Updating URL:', url);\n }\n\n // Use replace for shallow routing (no page reload, no history spam)\n router.replace(url, { scroll: false });\n },\n [router, debug, searchString, stableSchema, syncPendingTail],\n );\n\n // Drain the queue as commits arrive. A commit EQUAL to a queued write drops\n // that entry and everything before it (so `[A, B, A']` survives A's commit\n // with `[B, A']` intact); a commit matching NOTHING is a FOREIGN navigation\n // (Back/Forward, another hook's replace) and invalidates the whole base.\n useEffect(() => {\n const queue = pendingRef.current;\n if (queue.length === 0) return;\n const hit = queue.indexOf(searchString);\n pendingRef.current = hit === -1 ? [] : queue.slice(hit + 1);\n syncPendingTail();\n }, [searchString, syncPendingTail]);\n\n // Set a single parameter\n const setParam = useCallback(\n <K extends keyof TSchema & string>(key: K, value: InferInputParamsFromSchema<Pick<TSchema, K>>[K]) => {\n const config = stableSchema[key];\n\n if (!config) {\n console.warn(`[useApiParams] Unknown parameter: ${key}`);\n return;\n }\n\n const newParams = new URLSearchParams();\n\n // THE omission rule, the same one `setParams` uses. This branch moved\n // `setParams` onto `shouldIncludeInUrl` (so a value equal to its schema\n // default is dropped) and left `setParam` on a local emptiness check\n // that knew nothing about defaults — so `setParam('page', 1)` kept\n // `?page=1` on a URL `setParams({ page: 1 })` left clean.\n if (shouldIncludeInUrl(value, config)) {\n addParamToSearchParams(newParams, key, value);\n updateUrl(newParams);\n } else {\n updateUrl(newParams, [key]);\n }\n },\n [updateUrl, addParamToSearchParams, stableSchema],\n );\n\n // Set multiple parameters\n const setParams = useCallback(\n (updates: Partial<InferInputParamsFromSchema<TSchema>>) => {\n const newParams = new URLSearchParams();\n const keysToRemove: string[] = [];\n\n for (const [key, value] of Object.entries(updates)) {\n const config = stableSchema[key];\n\n if (!config) {\n console.warn(`[useApiParams] Unknown parameter: ${key}`);\n continue;\n }\n\n // The SAME omission rule the reader uses (`urlSearchParams`): empty\n // values AND values equal to the schema default drop out. Writing a\n // default back into the URL left `?page=1&pageSize=15` on a pristine\n // board and made the canonical URL un-shareable.\n if (shouldIncludeInUrl(value, flattenedSchema[key])) {\n addParamToSearchParams(newParams, key, value);\n } else {\n keysToRemove.push(key);\n }\n }\n\n updateUrl(newParams, keysToRemove);\n },\n [updateUrl, addParamToSearchParams, stableSchema, flattenedSchema],\n );\n\n // Clear specific parameters\n const clearParams = useCallback(\n (keys: (keyof TSchema & string)[]) => {\n const newParams = new URLSearchParams();\n updateUrl(newParams, keys);\n },\n [updateUrl],\n );\n\n // Reset all parameters\n const resetParams = useCallback(() => {\n if (debug) {\n console.log('[useApiParams] Resetting params');\n }\n\n router.replace(window.location.pathname, { scroll: false });\n }, [router, debug]);\n\n // The params of the LAST write we issued, or `params` when nothing is in\n // flight. Adapters read this as \"the user's latest intent\" so a decision made\n // while a `router.replace` is uncommitted compares against the click, not the\n // stale URL.\n const pendingParams = useMemo((): InferParamsFromSchema<TSchema> => {\n if (pendingTail === undefined || pendingTail === searchString) return params;\n return parseSchemaParams(stableSchema, new URLSearchParams(pendingTail), {\n absent: options.absent === 'null' ? 'null' : 'undefined',\n }) as InferParamsFromSchema<TSchema>;\n }, [pendingTail, searchString, params, stableSchema, options.absent]);\n\n return {\n params,\n pendingParams,\n urlSearchParams,\n setParam,\n setParams,\n clearParams,\n resetParams,\n };\n}\n\n// THE serializer lives in the search-params leaf (one array/object/omission\n// rule for the hook and for server code); re-exported for existing importers.\nexport { createSearchParams } from '../../utils/search-params';\n","/**\n * Unified Cursor Pagination State Hook\n *\n * Manages all common cursor-based pagination state logic:\n * - URL state management with useApiParams\n * - Debounced search input\n * - hasLoadedBeyondFirst tracking\n * - Initial load detection\n * - Search change detection\n * - Pagination handlers (next, reset)\n *\n * This eliminates ~60-80 lines of boilerplate from each paginated component.\n *\n * @example\n * const {\n * searchInput, setSearchInput,\n * hasLoadedBeyondFirst,\n * handleNextPage, handleResetToFirstPage\n * } = useCursorPaginationState({\n * onInitialLoad: (search, cursor) => fetchDialogs(false, search, true, cursor),\n * onSearchChange: (search) => fetchDialogs(false, search)\n * })\n */\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { useApiParams, type UseApiParamsReturn } from './use-api-params';\n\nexport interface UseCursorPaginationStateOptions {\n /**\n * Debounce delay for search input (default: 300ms)\n */\n debounceMs?: number;\n\n /**\n * Callback for initial page load\n * Called once on mount with current search and cursor from URL\n */\n onInitialLoad: (search: string, cursor: string | null) => void | Promise<unknown>;\n\n /**\n * Callback when search term changes (after debounce)\n * Called after URL is updated, cursor is already reset\n */\n onSearchChange: (search: string) => void | Promise<unknown>;\n}\n\nconst urlSchema = {\n search: { type: 'string' as const, default: '' },\n cursor: { type: 'string' as const, default: '' },\n};\n\ntype ApiParamsReturn = UseApiParamsReturn<typeof urlSchema>;\n\n/** Pagination params managed by this hook */\nexport type PaginationParams = ApiParamsReturn['params'];\n\nexport interface CursorPaginationStateReturn {\n // Search\n searchInput: string;\n setSearchInput: (value: string) => void;\n\n // Pagination tracking\n hasLoadedBeyondFirst: boolean;\n setHasLoadedBeyondFirst: (value: boolean) => void;\n\n // Handlers for useTablePagination\n handleNextPage: (endCursor: string, fetchFn: () => Promise<unknown>) => Promise<void>;\n handleResetToFirstPage: (fetchFn: () => Promise<unknown>) => Promise<void>;\n\n // URL params access (for advanced use cases)\n params: PaginationParams;\n setParam: ApiParamsReturn['setParam'];\n setParams: ApiParamsReturn['setParams'];\n}\n\nexport function useCursorPaginationState(options: UseCursorPaginationStateOptions): CursorPaginationStateReturn {\n const { onInitialLoad, onSearchChange } = options;\n\n const { params, setParam, setParams } = useApiParams(urlSchema);\n\n // Local search input with debounce\n const [searchInput, setSearchInput] = useState(params.search || '');\n\n // Pagination tracking\n const [hasLoadedBeyondFirst, setHasLoadedBeyondFirst] = useState(false);\n // Use a counter instead of boolean to ensure effects see the latest state\n const [initialLoadCount, setInitialLoadCount] = useState(0);\n // Initialize to null to distinguish \"never set\" from \"set to empty string\"\n const lastSearchRef = useRef<string | null>(null);\n // Track if we're syncing from URL to prevent loops\n const isSyncingFromUrl = useRef(false);\n // Track if initial load is in progress to block ALL other effects\n const isInitialLoadInProgress = useRef(true);\n\n // Latest values for the effects below, none of which may take them as\n // dependencies: the URL->input sync would revert the user's keystrokes the\n // moment they typed, and the mount-once initial load would re-fire on every\n // param change. Declared first so the sync runs before either of them.\n const searchInputRef = useRef(searchInput);\n const onInitialLoadRef = useRef(onInitialLoad);\n const paramsRef = useRef(params);\n useEffect(() => {\n searchInputRef.current = searchInput;\n onInitialLoadRef.current = onInitialLoad;\n paramsRef.current = params;\n });\n\n // Sync local input with URL param (for tab switches that clear params)\n // Only sync if the URL changed externally (not from our own debounce update)\n useEffect(() => {\n // Block during initial load\n if (isInitialLoadInProgress.current) return;\n\n const urlSearch = params.search || '';\n // Only sync if URL differs from current input\n if (urlSearch !== searchInputRef.current) {\n isSyncingFromUrl.current = true;\n setSearchInput(urlSearch);\n // Reset the flag after a tick to allow normal operation\n setTimeout(() => {\n isSyncingFromUrl.current = false;\n }, 0);\n }\n }, [params.search, initialLoadCount]); // Add initialLoadCount to re-run after initial load\n\n // Sync debounced search to URL, reset cursor when search changes\n useEffect(() => {\n // Block during initial load\n if (isInitialLoadInProgress.current) return;\n // Skip if we're syncing from URL to prevent loops\n if (isSyncingFromUrl.current) return;\n\n if (searchInput !== params.search) {\n setParams({\n search: searchInput,\n cursor: '', // Reset cursor when search changes\n });\n }\n }, [searchInput, params.search, setParams, initialLoadCount]);\n\n // Initial load effect - runs once and blocks all other effects until complete\n useEffect(() => {\n if (initialLoadCount === 0) {\n const cursor = paramsRef.current.cursor || null;\n const search = paramsRef.current.search || '';\n\n // Set all refs BEFORE calling onInitialLoad\n lastSearchRef.current = search;\n\n // If we have a cursor in URL (page refresh), we're beyond first page\n if (cursor) {\n setHasLoadedBeyondFirst(true);\n }\n\n // Call the initial load and wait for it to complete before\n // marking initial load as done (handles async onInitialLoad)\n // `.catch` BEFORE `.finally` so a rejecting `onInitialLoad` is reported\n // instead of becoming an unhandled rejection, while the unblock below\n // still runs on both paths (it must: otherwise every other effect in\n // this hook stays permanently gated behind `isInitialLoadInProgress`).\n void Promise.resolve(onInitialLoadRef.current(search, cursor))\n .catch((err: unknown) => {\n console.error('[useCursorPaginationState] onInitialLoad failed:', err);\n })\n .finally(() => {\n isInitialLoadInProgress.current = false;\n setInitialLoadCount(1);\n });\n }\n }, [initialLoadCount]);\n\n // Search change detection - only after initial load is fully complete\n useEffect(() => {\n // Block during initial load\n if (isInitialLoadInProgress.current) return;\n if (initialLoadCount === 0) return;\n\n const currentSearch = params.search || '';\n // Only trigger search change if:\n // 1. lastSearchRef has been set (not null - means initial load happened)\n // 2. The search actually changed\n if (lastSearchRef.current !== null && currentSearch !== lastSearchRef.current) {\n lastSearchRef.current = currentSearch;\n setHasLoadedBeyondFirst(false);\n // `onSearchChange` is declared `void | Promise<unknown>`; normalise so a\n // rejecting async implementation is logged rather than left unhandled.\n void Promise.resolve(onSearchChange(currentSearch)).catch((err: unknown) => {\n console.error('[useCursorPaginationState] onSearchChange failed:', err);\n });\n }\n }, [params.search, onSearchChange, initialLoadCount]);\n\n // Pagination handlers\n const handleNextPage = useCallback(\n async (endCursor: string, fetchFn: () => Promise<unknown>) => {\n setParam('cursor', endCursor);\n await fetchFn();\n setHasLoadedBeyondFirst(true);\n },\n [setParam],\n );\n\n const handleResetToFirstPage = useCallback(\n async (fetchFn: () => Promise<unknown>) => {\n setParam('cursor', '');\n await fetchFn();\n setHasLoadedBeyondFirst(false);\n },\n [setParam],\n );\n\n return {\n searchInput,\n setSearchInput,\n hasLoadedBeyondFirst,\n setHasLoadedBeyondFirst,\n handleNextPage,\n handleResetToFirstPage,\n params,\n setParam,\n setParams,\n };\n}\n","import { useEffect, useMemo, useState } from 'react';\n\nimport type { NatsClient, NatsClientOptions, NatsStatus } from '../../nats';\nimport { createNatsClient } from '../../nats';\n\nexport interface UseNatsClientOptions {\n /**\n * When true, connect on mount and close on unmount.\n */\n autoConnect?: boolean;\n}\n\nexport interface UseNatsClientResult {\n client: NatsClient | null;\n status: NatsStatus;\n isConnected: boolean;\n lastError: unknown;\n}\n\n/**\n * React hook for managing a shared NATS WebSocket connection lifecycle.\n *\n * Important: pass a memoized `clientOptions` (e.g. via `useMemo`) to avoid\n * reconnecting on every render.\n */\nexport function useNatsClient(\n clientOptions: NatsClientOptions | null,\n options: UseNatsClientOptions = {},\n): UseNatsClientResult {\n const { autoConnect = true } = options;\n\n const client = useMemo(() => {\n if (!clientOptions) return null;\n return createNatsClient(clientOptions);\n }, [clientOptions]);\n\n const [status, setStatus] = useState<NatsStatus>('disconnected');\n const [lastError, setLastError] = useState<unknown>(null);\n const [statusClient, setStatusClient] = useState(client);\n\n // Reset while rendering (React's documented prop-sync pattern) rather than\n // from the effect below. `client.onStatus` does NOT replay the current status\n // to a new listener, so a swapped-in client emits nothing until it actually\n // changes state — leaving `status`/`lastError` describing the PREVIOUS\n // client. A fresh client from `createNatsClient` is genuinely disconnected\n // and error-free at this point, so that is what we report until it says\n // otherwise.\n if (statusClient !== client) {\n setStatusClient(client);\n setStatus('disconnected');\n setLastError(null);\n }\n\n useEffect(() => {\n if (!client) return undefined;\n\n const off = client.onStatus(event => {\n setStatus(event.status);\n if (event.status === 'error') setLastError(event.data);\n });\n\n if (autoConnect) {\n client.connect().catch((e: unknown) => {\n setLastError(e);\n setStatus('error');\n });\n }\n\n return () => {\n off();\n client.close().catch(() => {\n // ignore\n });\n };\n }, [client, autoConnect]);\n\n return {\n client,\n status,\n isConnected: status === 'connected',\n lastError,\n };\n}\n","'use client';\n\n/**\n * Access Code Integration Hook\n *\n * React-side wrapper around the pure `access-code-client` utilities.\n * Lives in `hooks/` (client bundle) so the `createContext()` call in\n * `endpoints-runtime-context` doesn't get pulled into the server-safe\n * `utils/index` bundle.\n *\n * The pure standalone functions (`validateAccessCode`,\n * `consumeAccessCode`, `validateAndConsumeAccessCode`) remain importable\n * from `@flamingo-stack/openframe-frontend-core/utils` — they take the\n * endpoints object as an argument. This hook binds those endpoints from\n * `EndpointsRuntimeContext` so React callers don't have to plumb URLs.\n */\n\nimport React from 'react';\n\nimport { useRequiredEndpointsRuntime } from '../contexts/endpoints-runtime-context';\nimport { validateAccessCode, consumeAccessCode, validateAndConsumeAccessCode } from '../utils/access-code-client';\n\n/**\n * Resolves access-code endpoints from `EndpointsRuntimeContext` (throws\n * if no provider is mounted) and exposes loading-state-aware wrappers\n * around the standalone helpers in `utils/access-code-client`.\n *\n * @returns the following fields. The `validate` / `consume` /\n * `validateAndConsume` functions and the returned object identity are\n * NOT memoized — they're re-created each render. Wrap with\n * `useCallback` / `useMemo` at the call site if downstream effect\n * dep arrays depend on stable identities.\n * - `validate(email, code)`: validates only.\n * - `consume(email, code)`: consumes only.\n * - `validateAndConsume(email, code)`: one-step validate-then-consume.\n * - `isValidating: boolean`: a validate call is in flight.\n * - `isConsuming: boolean`: a consume call is in flight.\n * - `isProcessing: boolean`: convenience — `isValidating || isConsuming`.\n * Use this for a single \"in-flight\" indicator on UI affordances that\n * should disable during both phases.\n *\n * @example\n * const { validate, consume, isProcessing } = useAccessCodeIntegration();\n *\n * const handleRegistration = async (formData) => {\n * const validation = await validate(formData.email, formData.accessCode);\n * if (!validation.valid) {\n * setError(validation.message);\n * return;\n * }\n *\n * // Process registration...\n * const registrationResult = await registerUser(formData);\n *\n * if (registrationResult.success) {\n * await consume(formData.email, formData.accessCode);\n * }\n * };\n */\nexport function useAccessCodeIntegration() {\n const runtime = useRequiredEndpointsRuntime();\n const endpoints = runtime.accessCode;\n const [isValidating, setIsValidating] = React.useState(false);\n const [isConsuming, setIsConsuming] = React.useState(false);\n\n const validate = async (email: string, code: string) => {\n setIsValidating(true);\n try {\n return await validateAccessCode(email, code, endpoints);\n } finally {\n setIsValidating(false);\n }\n };\n\n const consume = async (email: string, code: string) => {\n setIsConsuming(true);\n try {\n return await consumeAccessCode(email, code, endpoints);\n } finally {\n setIsConsuming(false);\n }\n };\n\n const validateAndConsume = async (email: string, code: string) => {\n setIsValidating(true);\n setIsConsuming(true);\n try {\n return await validateAndConsumeAccessCode(email, code, endpoints);\n } finally {\n setIsValidating(false);\n setIsConsuming(false);\n }\n };\n\n return {\n validate,\n consume,\n validateAndConsume,\n isValidating,\n isConsuming,\n isProcessing: isValidating || isConsuming,\n };\n}\n","/**\n * Access Code Client Utilities — pure standalone functions.\n *\n * Endpoint paths are NOT hardcoded — every function takes an\n * `endpoints` argument. The React-side wrapper that binds them from\n * `EndpointsRuntimeContext` lives separately at\n * `hooks/use-access-code-integration.ts` (`useAccessCodeIntegration`).\n *\n * Keep this file **free of React imports** — it lives in the\n * server-safe `utils/index` tsup bundle. Any module-top-level call\n * into `createContext()` (which the runtime context file does) would\n * be pulled into the server bundle and crash SSR with\n * `createContext is not a function`.\n */\n\nimport type { AccessCodeValidationResponse, AccessCodeConsumptionResponse } from '../types/access-code-cohorts';\nimport { apiErrorMessage } from './common';\n\n/** Endpoints required by the standalone client utilities. The\n * `useAccessCodeIntegration` hook (in `hooks/`) resolves these from\n * `EndpointsRuntimeContext.accessCode` automatically. */\nexport interface AccessCodeEndpoints {\n validateUrl: string;\n consumeUrl: string;\n}\n\n/**\n * Validate an access code for a given email\n *\n * @param email - User's email address\n * @param code - Access code to validate\n * @returns Promise with validation result\n *\n * @example\n * const result = await validateAccessCode('user@example.com', 'ABC123XY');\n * if (result.valid) {\n * // Allow user to proceed with registration\n * console.log(`Welcome to ${result.cohort_name}!`);\n * } else {\n * // Show error message\n * console.error(result.message);\n * }\n */\nexport async function validateAccessCode(\n email: string,\n code: string,\n endpoints: AccessCodeEndpoints,\n): Promise<AccessCodeValidationResponse> {\n try {\n const response = await fetch(endpoints.validateUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ email, code }),\n });\n\n if (!response.ok) {\n const failure: unknown = await response.json().catch(() => null);\n throw new Error(apiErrorMessage(failure, 'Validation request failed'));\n }\n\n return (await response.json()) as AccessCodeValidationResponse;\n } catch (error) {\n return {\n valid: false,\n message: error instanceof Error ? error.message : 'Validation failed',\n };\n }\n}\n\n/**\n * Consume an access code after successful registration\n *\n * Call this ONLY after the user has successfully completed registration.\n * This marks the code as used and prevents further usage.\n *\n * @param email - User's email address\n * @param code - Access code to consume\n * @returns Promise with consumption result\n *\n * @example\n * // After successful registration\n * const result = await consumeAccessCode('user@example.com', 'ABC123XY');\n * if (result.consumed) {\n * console.log('Access code consumed successfully');\n * } else {\n * console.warn('Failed to consume access code:', result.message);\n * }\n */\nexport async function consumeAccessCode(\n email: string,\n code: string,\n endpoints: AccessCodeEndpoints,\n): Promise<AccessCodeConsumptionResponse> {\n try {\n const response = await fetch(endpoints.consumeUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ email, code }),\n });\n\n if (!response.ok) {\n const failure: unknown = await response.json().catch(() => null);\n throw new Error(apiErrorMessage(failure, 'Consumption request failed'));\n }\n\n return (await response.json()) as AccessCodeConsumptionResponse;\n } catch (error) {\n return {\n success: false,\n consumed: false,\n message: error instanceof Error ? error.message : 'Consumption failed',\n };\n }\n}\n\n/**\n * Complete access code flow: validate then consume\n *\n * This is a convenience function that validates an access code and,\n * if valid, immediately consumes it. Use this when you want to\n * validate and consume in one step during registration.\n *\n * @param email - User's email address\n * @param code - Access code to validate and consume\n * @returns Promise with validation and consumption results\n *\n * @example\n * const result = await validateAndConsumeAccessCode('user@example.com', 'ABC123XY');\n * if (result.valid && result.consumed) {\n * // Registration successful\n * console.log(`Welcome to ${result.cohort_name}!`);\n * } else {\n * console.error(result.message);\n * }\n */\nexport async function validateAndConsumeAccessCode(\n email: string,\n code: string,\n endpoints: AccessCodeEndpoints,\n): Promise<AccessCodeValidationResponse & { consumed?: boolean }> {\n // First validate\n const validation = await validateAccessCode(email, code, endpoints);\n\n if (!validation.valid) {\n return validation;\n }\n\n // If valid, consume the code\n const consumption = await consumeAccessCode(email, code, endpoints);\n\n return {\n ...validation,\n consumed: consumption.consumed,\n message: consumption.consumed\n ? `Access granted for ${validation.cohort_name}`\n : consumption.message || validation.message,\n };\n}\n\n// `useAccessCodeIntegration` (the React-side wrapper) lives in\n// `hooks/use-access-code-integration.ts`. It binds the endpoints from\n// `EndpointsRuntimeContext` so React callers don't have to plumb URLs.\n","'use client';\n\nimport { useEffect, useState } from 'react';\nimport { useEndpointsRuntime } from '../contexts/endpoints-runtime-context';\nimport { toClaudeMirrorPath } from '../utils/embed-url-converters';\n\nexport type ClaudeMirrorStatus =\n /** No mirror candidate at all (no proxy configured, or a non-mirrorable url). */\n | 'absent'\n /** Probing the host for a self-hosted mirror (or minting one on first view). */\n | 'probing'\n /** A mirror exists and `src` points at it. */\n | 'found';\n\nexport interface ClaudeMirrorState {\n /** The mirror src when found (with a cache-bust once a re-publish is\n * detected), else `null`. */\n src: string | null;\n status: ClaudeMirrorStatus;\n}\n\n/**\n * Ask the host's storage-view proxy whether the cached mirror is still the\n * artifact's CURRENT version, re-minting it silently if a re-publish moved\n * it. Returns a cache-bust token (the new version) when the mirror changed,\n * else null. Best-effort: any failure just leaves the shown mirror in place.\n */\nasync function revalidateMirror(mirrorPath: string): Promise<string | null> {\n try {\n const res = await fetch(`${mirrorPath}?revalidate=1`);\n if (!res.ok) return null;\n const body = (await res.json()) as { updated?: boolean; version?: string | null };\n return body?.updated ? String(body.version ?? Date.now()) : null;\n } catch {\n return null;\n }\n}\n\n/**\n * The host's SELF-HOSTED MIRROR src for a claude artifact url, plus a\n * `status` so the viewer can show a loading state while it resolves — THE\n * hook behind `ClaudeEmbed`'s transparent mirror leg, extracted so the\n * component stays presentational.\n *\n * Fully under the hood, three moves:\n * 1. Derive the candidate mirror path from the url's artifact id under the\n * host's configured storage-view proxy base\n * (`EndpointsRuntime.storageViewBaseUrl`) and probe it with a 1-byte\n * ranged GET. On a cold first view that probe IS the server-side\n * self-heal (~1-2s), which is exactly why `status: 'probing'` exists —\n * the viewer shows a spinner instead of the empty \"no embeddable view\"\n * flash. Exists → `found`; missing / no proxy / blip → `absent`, and the\n * caller's claude.ai fallback stands (the pre-mirror behavior).\n * 2. Once found, fire ONE background revalidation. If the artifact was\n * re-published since the mirror was cached, the server re-mints it and\n * reports the new version; the hook bumps a cache-bust on `src` so the\n * iframe silently reloads the fresh bytes. Unchanged → nothing happens.\n *\n * A host that mounts no EndpointsRuntime provider (or omits the entry) skips\n * the mirror leg entirely (`absent`).\n */\nexport function useClaudeMirrorSrc(url: string): ClaudeMirrorState {\n const endpoints = useEndpointsRuntime();\n const mirrorPath = toClaudeMirrorPath(url, endpoints?.storageViewBaseUrl);\n\n // Keyed on `mirrorPath` and DERIVED rather than seeded by the effect: when\n // the url changes, the fallback below is what a reader sees, so a previous\n // artifact's `found` src can never be shown for a new one. Doing that with a\n // synchronous `setState` in the effect body is the cascading-render pattern\n // `react-hooks/set-state-in-effect` rules out; the effect now only settles\n // state from its async callbacks.\n const [resolved, setResolved] = useState<{ key: string; value: ClaudeMirrorState } | null>(null);\n const state: ClaudeMirrorState =\n resolved && resolved.key === mirrorPath ? resolved.value : { src: null, status: mirrorPath ? 'probing' : 'absent' };\n\n useEffect(() => {\n if (!mirrorPath) return undefined;\n let cancelled = false;\n const settle = (value: ClaudeMirrorState) => {\n if (!cancelled) setResolved({ key: mirrorPath, value });\n };\n fetch(mirrorPath, { headers: { Range: 'bytes=0-0' } })\n .then(res => {\n if (cancelled) return;\n if (!(res.ok || res.status === 206)) {\n settle({ src: null, status: 'absent' });\n return;\n }\n settle({ src: mirrorPath, status: 'found' });\n // Background freshness check — reload in place only if it changed.\n void revalidateMirror(mirrorPath).then(bust => {\n if (bust) settle({ src: `${mirrorPath}?v=${encodeURIComponent(bust)}`, status: 'found' });\n });\n })\n .catch(() => {\n // No proxy on this host / network blip — the caller's claude.ai\n // fallback stands, same as before mirrors existed.\n settle({ src: null, status: 'absent' });\n });\n return () => {\n cancelled = true;\n };\n }, [mirrorPath]);\n\n return state;\n}\n","/**\n * Shared utilities for converting external service URLs to embeddable formats.\n * Used by lib's embed components and the hub's admin document editor.\n */\n\nexport function toGoogleSheetsEmbedUrl(url: string): string {\n if (url.includes('/htmlembed')) return url;\n\n const match = url.match(/\\/spreadsheets\\/d\\/([a-zA-Z0-9-_]+)/);\n if (!match) return url;\n\n const gidMatch = url.match(/[#?&]gid=(\\d+)/);\n const gid = gidMatch ? gidMatch[1] : '0';\n\n return `https://docs.google.com/spreadsheets/d/${match[1]}/htmlembed?widget=true&chrome=false&headers=false&gid=${gid}`;\n}\n\nexport function toGoogleSheetsOriginalUrl(url: string): string {\n const match = url.match(/\\/spreadsheets\\/d\\/([a-zA-Z0-9-_]+)/);\n if (!match) return url;\n\n const gidMatch = url.match(/[#?&]gid=(\\d+)/);\n const gid = gidMatch ? gidMatch[1] : '0';\n\n return `https://docs.google.com/spreadsheets/d/${match[1]}/edit#gid=${gid}`;\n}\n\n/**\n * Convert a Figma URL to an embeddable URL.\n * Slides/deck URLs map to `deck` (present) by default; `slidesView: 'browse'` switches to `slides` (browse).\n */\nexport function toFigmaEmbedUrl(url: string, opts?: { slidesView?: 'present' | 'browse' }): string {\n if (url.includes('embed.figma.com')) return url;\n if (url.includes('figma.com/embed')) return url;\n\n const match = url.match(/figma\\.com\\/(design|file|proto|board|slides|deck)\\/([a-zA-Z0-9]+)(?:\\/([^?]*))?(\\?.*)?$/);\n\n if (match) {\n const [, urlType, fileKey, titleSlug, queryString] = match;\n const isSlides = urlType === 'slides' || urlType === 'deck';\n const embedType =\n urlType === 'proto' ? 'proto' : isSlides ? (opts?.slidesView === 'browse' ? 'slides' : 'deck') : 'design';\n const pathSuffix = titleSlug ? `/${titleSlug}` : '';\n\n const params = new URLSearchParams(queryString?.replace(/^\\?/, '') || '');\n if (!params.has('embed-host')) {\n params.set('embed-host', 'flamingo');\n }\n const clientId = process.env.NEXT_PUBLIC_FIGMA_CLIENT_ID;\n if (clientId && !params.has('client-id')) {\n params.set('client-id', clientId);\n }\n\n return `https://embed.figma.com/${embedType}/${fileKey}${pathSuffix}?${params.toString()}`;\n }\n\n return `https://www.figma.com/embed?embed-host=flamingo&url=${encodeURIComponent(url)}`;\n}\n\nexport function isFigmaSlidesUrl(url: string): boolean {\n if (!url) return false;\n return /(?:www\\.|embed\\.)?figma\\.com\\/(?:slides|deck)\\/[a-zA-Z0-9]+/.test(url);\n}\n\nexport function toFigmaOriginalUrl(url: string): string {\n if (url.includes('embed.figma.com')) {\n return url.replace('embed.figma.com', 'www.figma.com').replace(/\\?.*$/, '');\n }\n return url.replace(/\\?.*$/, '');\n}\n\n/**\n * The self-hosted MIRROR url for a claude artifact url — the whole\n * mirror mechanism is UNDER THE HOOD: the viewer detects a claude url,\n * DERIVES the mirror location from the artifact's own id under the\n * host's configured storage-view proxy base, and frames the downloaded\n * copy from there. No per-link data, no props, no shortcode changes —\n * 100% transparent to every consumer.\n *\n * `storageViewBaseUrl` comes from `EndpointsRuntime` — the SAME\n * host-overridable proxy-path mechanism every other lib→API call uses\n * (a reverse-proxied embedder remaps it; a host without the proxy omits\n * it and gets `null` here, i.e. no mirror leg at all).\n *\n * `null` when the base is absent or the url carries no artifact id. The\n * derived url is the configured base + a validated uuid — safe as a\n * frame source without further checks. KEEP THE OBJECT NAMING IN SYNC\n * with the hub's `lib/data/design-brief-mirror.cjs` (bucket +\n * `<uuid>.html`) — that module owns the ingest/audit half.\n */\nexport function toClaudeMirrorPath(\n url: string | null | undefined,\n storageViewBaseUrl: string | null | undefined,\n): string | null {\n if (!url || !storageViewBaseUrl) return null;\n try {\n const u = new URL(url);\n const host = u.hostname.toLowerCase();\n const id =\n (host === 'claude.ai' &&\n /^\\/(?:code\\/artifact|public\\/artifacts)\\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[/?#]|$)/i.exec(\n u.pathname,\n )?.[1]) ||\n (host === 'claude.site' &&\n /^\\/artifacts\\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[/?#]|$)/i.exec(\n u.pathname,\n )?.[1]) ||\n null;\n return id ? `${storageViewBaseUrl.replace(/\\/$/, '')}/design-briefs/${id.toLowerCase()}.html` : null;\n } catch {\n return null;\n }\n}\n\n/**\n * The EMBED url for a published Claude artifact, or `null` when the link is not\n * one we can frame — the Claude counterpart of `toFigmaEmbedUrl`.\n *\n * claude.ai serves `frame-ancestors 'self'` on the artifact page itself, but a\n * PUBLISHED artifact also has an `/embed` route whose `frame-ancestors` carries\n * the domains its author allow-listed under \"Get embed code → Allowed domains\".\n * That is Anthropic's supported way to put an artifact on another site, so that\n * is what the iframe points at.\n *\n * Returns `null` for everything else, including a Claude CODE url\n * (`claude.ai/code/artifact/…`), whose Share dialog exposes no Embed settings\n * and whose `/embed` path answers `frame-ancestors 'self'` (verified against\n * real artifacts, 2026-08). A trailing `/embed` is accepted because that is the\n * url \"Get embed code\" hands the author.\n */\nexport function toClaudeEmbedUrl(url: string | null | undefined): string | null {\n if (!url) return null;\n try {\n const u = new URL(url);\n const host = u.hostname.toLowerCase();\n // `claude.site/artifacts/<id>` 308-redirects to the claude.ai public url.\n const publicId =\n (host === 'claude.ai' && /^\\/public\\/artifacts\\/([\\w-]+)(?:\\/embed)?\\/?$/.exec(u.pathname)?.[1]) ||\n (host === 'claude.site' && /^\\/artifacts\\/([\\w-]+)(?:\\/embed)?\\/?$/.exec(u.pathname)?.[1]) ||\n null;\n return publicId ? `https://claude.ai/public/artifacts/${publicId}/embed` : null;\n } catch {\n return null;\n }\n}\n","'use client';\n\nimport { useEffect } from 'react';\nimport { getHashTargetElement, normalizeHashFragment } from '../utils/same-page-hash-nav';\nimport { scrollElementIntoView } from '../utils/scroll-into-view';\n\n/** ~1s at 60fps — long enough to outlast Radix accordion expand + SWR\n * mount, short enough that a missed anchor doesn't hang the page. */\nconst MAX_POLL_FRAMES = 60;\n\nexport interface UseScrollToHashOptions {\n /** Pixels to subtract for sticky chrome. */\n headerOffset?: number;\n}\n\n/**\n * Scroll the page to `window.location.hash` once `readyDep` resolves\n * to a truthy value. Polls via rAF for ~1s so lazy-mounted rows (Radix\n * accordion, SWR fetch) have time to render. Re-runs on `readyDep`\n * reference change AND on `hashchange` (browser back/forward + the\n * synthetic event `navigateSamePageHash` dispatches).\n *\n * Skipped when `readyDep == null || readyDep === false`. Default\n * `true` makes the hook run on mount for pages whose target is in the\n * initial SSR render.\n */\nexport function useScrollToHash(readyDep: unknown = true, options?: UseScrollToHashOptions): void {\n const headerOffset = options?.headerOffset ?? 0;\n useEffect(() => {\n if (typeof window === 'undefined') return undefined;\n if (readyDep === null || readyDep === false) return undefined;\n let rafId: number | null = null;\n const cancelPoll = () => {\n if (rafId !== null) {\n cancelAnimationFrame(rafId);\n rafId = null;\n }\n };\n const tryScrollToHash = () => {\n // `normalizeHashFragment` heals a malformed multi-fragment hash\n // so `getElementById` resolves on deep-link entries that bypass\n // `navigateSamePageHash`'s own normalize.\n const hash = normalizeHashFragment(window.location.hash).slice(1);\n if (!hash) return;\n // Cancel any in-flight poll from a prior invocation so two\n // concurrent ticks can't both call scrollElementIntoView.\n cancelPoll();\n let frames = 0;\n const tick = () => {\n // Raw-then-decoded lookup — encoded fragments from href\n // composers must match the unencoded ids rows render.\n const el = getHashTargetElement(hash);\n if (el) {\n rafId = null;\n scrollElementIntoView(el, { headerOffset });\n return;\n }\n if (frames++ < MAX_POLL_FRAMES) {\n rafId = requestAnimationFrame(tick);\n } else {\n rafId = null;\n }\n };\n tick();\n };\n tryScrollToHash();\n window.addEventListener('hashchange', tryScrollToHash);\n return () => {\n window.removeEventListener('hashchange', tryScrollToHash);\n cancelPoll();\n };\n }, [readyDep, headerOffset]);\n}\n","'use client';\n\nimport { useCallback, useRef, useState } from 'react';\nimport { HONEYPOT_FIELD, ELAPSED_MS_FIELD, type HumanitySignals } from '../utils/humanity-signals';\n\n/**\n * useHumanitySignals — client primitive backing the invisible bot-protection\n * layer. Owns the honeypot <input> ref + a mount timestamp, and exposes the\n * props to render the field plus a getter that produces the wire object to\n * merge into a form's POST body. Reused by every public form (contact,\n * waitlist, ticket, review) so the signal shape lives in exactly one place.\n *\n * Usage:\n * const { honeypotInputProps, getSignals, resetSignals } = useHumanitySignals()\n * // render: <HoneypotField {...honeypotInputProps} />\n * // submit: fetch(url, { body: JSON.stringify({ ...formData, ...getSignals() }) })\n * // after a successful submit: resetSignals()\n *\n * The two signals are origin-independent (they ride in the body), so they keep\n * working when the form is embedded behind a reverse-proxy / prefixed URL.\n */\nexport function useHumanitySignals() {\n const ref = useRef<HTMLInputElement>(null);\n // performance.now() is monotonic (immune to wall-clock skew). SSR-guarded\n // even though the hook only executes on the client.\n //\n // Sampled in a lazy `useState` initialiser, then copied into a ref. Not\n // `useRef(performance.now())`: that argument is re-evaluated on EVERY render\n // even though only the first call uses it, so the component would read the\n // clock during render forever. And not state alone: `resetSignals` restarts\n // the window after a successful submit, which through state would re-render\n // the whole form for a value nothing renders.\n const [mountedAt] = useState(() => (typeof performance !== 'undefined' ? performance.now() : 0));\n const startedAtRef = useRef<number>(mountedAt);\n\n const getSignals = useCallback(\n (): HumanitySignals => ({\n [HONEYPOT_FIELD]: ref.current?.value ?? '',\n [ELAPSED_MS_FIELD]: typeof performance !== 'undefined' ? Math.round(performance.now() - startedAtRef.current) : 0,\n }),\n [],\n );\n\n const resetSignals = useCallback(() => {\n if (ref.current) ref.current.value = '';\n if (typeof performance !== 'undefined') startedAtRef.current = performance.now();\n }, []);\n\n return { honeypotInputProps: { ref, name: HONEYPOT_FIELD }, getSignals, resetSignals } as const;\n}\n","/**\n * Humanity signals — invisible bot-protection primitives shared by the lib's\n * public forms (client) and the hub's per-route `verifyHuman` gate (server).\n *\n * PURE + React-free on purpose: this module is a tsup SERVER entry (no\n * \"use client\" banner) so the hub can import it server-side without pulling a\n * client-reference boundary — same pattern as `schemas/contact-schema` and\n * `components/features/mux-origins`.\n *\n * Two origin-independent signals travel in the POST body: a honeypot (a hidden\n * field real users never fill) and timing (ms from form mount to submit).\n * `evaluateHumanitySignals` is the SINGLE source of truth for the block/allow\n * decision — the hub imports + calls it rather than re-implementing the rules.\n *\n * FALSE-POSITIVE HISTORY (2026-08-27): the honeypot was named\n * `contact_url_confirm`, and browser/password-manager autofill (which ignores\n * `autocomplete=\"off\"` and matches \"url\"/\"confirm\" name heuristics) filled it\n * for REAL users — every production `BOT_DETECTED` in the log window was a\n * legitimate Chrome user whose autofill tripped the decoy. Three layers now\n * prevent a recurrence; keep all three when touching this system:\n * 1. The field name avoids every autofill-heuristic token (name/email/\n * phone/url/website/confirm/company/address/code/…).\n * 2. `HoneypotField` renders `readOnly`-until-focus + password-manager\n * ignore attributes — autofill skips read-only inputs.\n * 3. `evaluateHumanitySignals` forgives a filled decoy whose value was\n * COPIED from another field in the same body (the autofill signature —\n * a human, not a bot). See `findHoneypotCopySource` for the match rules.\n *\n * ACCEPTED TRADEOFF of layer 3 (do not \"fix\" by removing the forgiveness): a\n * bot that fills EVERY field with one identical value now passes the honeypot\n * check. That bot class always had a strictly easier evasion — send the decoy\n * empty — so no new attacker capability is admitted; it remains covered by the\n * timing check, per-IP rate limits, route Zod validation, and first-party\n * BotID. Every forgiven allow is warn-logged by the hub gate for monitoring.\n */\n\n/** Hidden honeypot field name. Deliberately free of autofill-heuristic tokens (see module doc). */\nexport const HONEYPOT_FIELD = 'form_extra_note';\n/** Client-measured ms between form mount and submit. */\nexport const ELAPSED_MS_FIELD = 'form_elapsed_ms';\n/** Default minimum fill time (ms). A submit faster than this is treated as a bot. */\nexport const DEFAULT_MIN_FILL_MS = 700;\n\n/**\n * Every humanity-signal key that rides in a public form's POST body.\n * Server-side handlers that forward form payloads upstream (HubSpot booking,\n * CRM pushes, …) MUST strip by THIS array — never hand-typed strings — so a\n * field rename here propagates everywhere and the honeypot value can never\n * silently leak into an upstream record.\n */\nexport const HUMANITY_SIGNAL_KEYS = [HONEYPOT_FIELD, ELAPSED_MS_FIELD] as const;\n\n/** Is this body key one of the humanity-signal wire fields? */\nexport const isHumanitySignalKey = (key: string): boolean => (HUMANITY_SIGNAL_KEYS as readonly string[]).includes(key);\n\n/** Keyed wire object produced by `useHumanitySignals().getSignals()` and spread into the POST body. */\nexport type HumanitySignals = Record<string, string | number>;\n\n/**\n * Diagnostics every verdict carries so callers LOG what this module already\n * computed instead of re-deriving it (a re-derived predicate silently diverges\n * the day the rules here change):\n * - `honeypotLength`: decoy length — never the typed value (log-safe by\n * construction).\n * - `timingAffirmed`: the submission POSITIVELY proved human timing (a PRESENT\n * elapsed-ms at/above the floor — merely-missing timing does not affirm).\n * The hub gate keys its BotID form-downgrade on this.\n */\nexport type HumanityVerdictDiagnostics = {\n honeypotLength: number;\n timingAffirmed: boolean;\n};\n\n/** Result of {@link evaluateHumanitySignals}. */\nexport type HumanityVerdict = HumanityVerdictDiagnostics &\n (\n | {\n ok: true;\n /** Present when a filled decoy was forgiven as autofill; `sourceField` names the body field it was copied from. */\n note?: 'honeypot_autofill';\n sourceField?: string;\n }\n | { ok: false; reason: 'honeypot' | 'too_fast' }\n );\n\n/** Tolerant reader — never throws; missing/garbage timing → null. */\nexport function extractHumanitySignals(body: unknown): { honeypot: string; elapsedMs: number | null } {\n const b = (body ?? {}) as Record<string, unknown>;\n const rawHp = b[HONEYPOT_FIELD];\n // A legit client always sends a STRING here (getSignals → ref.value ?? ''),\n // so ANY present non-string value is a bot dodging the empty-check — coerce\n // to a NON-EMPTY string so it still trips (JSON.stringify keeps `[]`/`{}`\n // non-empty where String() would collapse them to '').\n // null/undefined → '' = the correct \"field absent / unfilled\" allow case.\n const honeypot = rawHp == null ? '' : typeof rawHp === 'string' ? rawHp : (JSON.stringify(rawHp) ?? String(rawHp));\n const rawMs = b[ELAPSED_MS_FIELD];\n const elapsedMs = typeof rawMs === 'number' && Number.isFinite(rawMs) ? rawMs : null;\n return { honeypot, elapsedMs };\n}\n\n/**\n * Comparison normalization for the copy-match: autofill may fill the decoy\n * with a differently-FORMATTED rendition of the value the client posts (the\n * waitlist normalizes phones to E.164 before POST while a manager fills the\n * stored \"(555) 123-4567\"), so exact equality misses real humans. Normalized\n * equality still requires the decoy to mirror a real field's CONTENT, which a\n * bot gains nothing from — it could always send the decoy empty instead.\n */\nconst normalizeForCopyMatch = (s: string): string => s.normalize('NFKC').trim().toLowerCase();\nconst digitsOf = (s: string): string => s.replace(/\\D/g, '');\n\n/** Minimum normalized length for a copy-match — a 1-char echo is coincidence, not autofill. */\nconst MIN_COPY_MATCH_LENGTH = 2;\n/** Digits-only phone matching needs a real phone-sized run to be meaningful. */\nconst MIN_PHONE_DIGITS = 7;\n\n/**\n * Phone-sized digit runs match when one ENDS WITH the other: a manager fills\n * the stored national format (\"(555) 123-4567\") while the client posts E.164\n * (\"+15551234567\") — same phone, differing only by the country-code prefix.\n */\nconst phoneDigitsMatch = (a: string, b: string): boolean =>\n a.length >= MIN_PHONE_DIGITS && b.length >= MIN_PHONE_DIGITS && (a.endsWith(b) || b.endsWith(a));\n\n/**\n * Find the body field the decoy value was COPIED from — the autofill\n * signature: browsers and password-manager extensions fill the hidden input\n * with the same datum they put in a visible field (email, phone, …) — a human\n * with autofill, not a bot. Scans top-level string values, string arrays, and\n * one nested level (the booking form's custom `formFields` object). Matches\n * normalized equality, plus digits-only equality for phone-sized values.\n *\n * Returns the matched field's path (`email`, `formFields.phone`, `tags[]`) —\n * the SSOT for both the verdict and the hub gate's `sameAs` log diagnostic —\n * or `null` when nothing matches.\n */\nexport function findHoneypotCopySource(body: unknown, honeypot: string): string | null {\n const hpNorm = normalizeForCopyMatch(honeypot);\n const hpDigits = digitsOf(honeypot);\n const phoneCandidate = hpDigits.length >= MIN_PHONE_DIGITS;\n if (hpNorm.length < MIN_COPY_MATCH_LENGTH && !phoneCandidate) return null;\n\n const matches = (value: unknown): boolean => {\n if (typeof value !== 'string' || value.trim() === '') return false;\n // No length re-check: the top guard already rejected sub-minimum values\n // (phoneCandidate implies ≥7 chars survive normalization — digits do).\n if (normalizeForCopyMatch(value) === hpNorm) return true;\n return phoneCandidate && phoneDigitsMatch(digitsOf(value), hpDigits);\n };\n const matchInArray = (value: unknown): boolean => Array.isArray(value) && value.some(matches);\n\n const b = (body ?? {}) as Record<string, unknown>;\n for (const [key, value] of Object.entries(b)) {\n if (isHumanitySignalKey(key)) continue;\n if (matches(value)) return key;\n if (matchInArray(value)) return `${key}[]`;\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n for (const [nestedKey, nested] of Object.entries(value as Record<string, unknown>)) {\n if (matches(nested)) return `${key}.${nestedKey}`;\n if (matchInArray(nested)) return `${key}.${nestedKey}[]`;\n }\n }\n }\n return null;\n}\n\n/**\n * SINGLE decision fn for honeypot + timing (the hub's `verifyHuman` imports + calls this):\n * - honeypot non-empty → bot (real users never fill the off-screen field) — UNLESS the\n * value was copied from another field in the body (autofill reached the decoy → human;\n * the verdict carries `note: 'honeypot_autofill'` + the `sourceField` so callers log it)\n * - elapsed below `minFillMs` → bot (humans take time; a MISSING timing value never\n * blocks — and the too-fast check still applies to autofill-forgiven submissions)\n */\nexport function evaluateHumanitySignals(body: unknown, opts: { minFillMs: number }): HumanityVerdict {\n const { honeypot, elapsedMs } = extractHumanitySignals(body);\n const diagnostics: HumanityVerdictDiagnostics = {\n honeypotLength: honeypot.length,\n timingAffirmed: elapsedMs !== null && elapsedMs >= opts.minFillMs,\n };\n const filled = honeypot.trim() !== '';\n const sourceField = filled ? findHoneypotCopySource(body, honeypot) : null;\n if (filled && sourceField === null) return { ...diagnostics, ok: false, reason: 'honeypot' };\n if (elapsedMs !== null && elapsedMs < opts.minFillMs) return { ...diagnostics, ok: false, reason: 'too_fast' };\n return sourceField !== null\n ? { ...diagnostics, ok: true, note: 'honeypot_autofill', sourceField }\n : { ...diagnostics, ok: true };\n}\n\n/** Parse a comma-separated env string → trimmed, non-empty entries (undefined → []). */\nexport const splitCsvEnv = (s?: string): string[] =>\n s\n ?.split(',')\n .map(t => t.trim())\n .filter(Boolean) ?? [];\n","'use client';\n\nimport { useCallback, useEffect, useMemo, useRef } from 'react';\nimport { useEndpointsRuntime } from '../contexts/endpoints-runtime-context';\nimport { contentFetch } from '../utils/embed-content-fetch';\nimport {\n captureFormRescueEvent,\n FORM_RESCUE_ATTEMPT_FIELD,\n FORM_RESCUE_DEBOUNCE_MS,\n FORM_RESCUE_EVENTS,\n FORM_RESCUE_RESUME_FIELD,\n FORM_RESCUE_RESUME_PARAM,\n filledRescueFields,\n isFormAttemptId,\n isResumeToken,\n isRescueEmail,\n rescueCompletionPct,\n sanitizeRescueFieldName,\n sanitizeRescueValues,\n type FormDraftProgress,\n type FormDraftResumeResponse,\n type FormDraftSaveRequest,\n type FormRescueDefinition,\n} from '../utils/form-rescue';\n\n/** A local draft older than this is ignored and replaced. */\nconst LOCAL_DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1000;\nconst STORAGE_PREFIX = 'form-rescue:v1:';\nconst UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term'] as const;\n\ninterface LocalDraft {\n attemptId: string;\n values: Record<string, string>;\n savedAt: number;\n}\n\nexport interface UseFormRescueOptions {\n /** Which form this is (`defineRescueForm`); `null` turns rescue off. */\n form: FormRescueDefinition | null;\n /** Every field the visitor can see. Hidden fields are never listed, so never sent. */\n fieldNames: readonly string[];\n /** Called once after mount with values from a resume link, else from this device's local draft. */\n onRestore?: (values: Record<string, string>) => void;\n /** The form's humanity signals, so the draft endpoint can run the same bot gate as the submit. */\n getSignals?: () => Record<string, string | number>;\n}\n\nexport interface FormRescueHandle {\n /** Report the form's current values after a change (`lastField` = the field just edited). */\n track: (values: Record<string, unknown>, lastField?: string | null) => void;\n /** Keys to spread into the SUBMIT body so the host closes this attempt's draft. */\n submitFields: () => Record<string, string>;\n /** Call after a successful submit: clears the local draft and starts a fresh attempt. */\n complete: () => void;\n}\n\nfunction newAttemptId(): string {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();\n // RFC 4122 v4 from getRandomValues for older browsers.\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n bytes[6] = (bytes[6] & 0x0f) | 0x40;\n bytes[8] = (bytes[8] & 0x3f) | 0x80;\n const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n\nfunction readLocalDraft(formId: string): LocalDraft | null {\n try {\n const raw = window.localStorage.getItem(STORAGE_PREFIX + formId);\n if (!raw) return null;\n const parsed = JSON.parse(raw) as Partial<LocalDraft>;\n if (!isFormAttemptId(parsed.attemptId) || typeof parsed.savedAt !== 'number') return null;\n if (Date.now() - parsed.savedAt > LOCAL_DRAFT_TTL_MS) return null;\n return { attemptId: parsed.attemptId, values: sanitizeRescueValues(parsed.values), savedAt: parsed.savedAt };\n } catch {\n return null;\n }\n}\n\nfunction writeLocalDraft(formId: string, draft: LocalDraft): void {\n try {\n window.localStorage.setItem(STORAGE_PREFIX + formId, JSON.stringify(draft));\n } catch {\n // Storage full or blocked: the server draft still works.\n }\n}\n\nfunction clearLocalDraft(formId: string): void {\n try {\n window.localStorage.removeItem(STORAGE_PREFIX + formId);\n } catch {\n // Nothing to clear.\n }\n}\n\n/**\n * Close a rescue attempt after a successful submit, for a form that has already\n * unmounted by then (the meeting scheduler's details-first flow): clears this\n * device's draft and reports the submit. The hook's `complete` calls it too.\n */\nexport function completeFormRescue(form: FormRescueDefinition | null, attemptId?: unknown): void {\n if (!form || typeof window === 'undefined') return;\n const local = readLocalDraft(form.id);\n captureFormRescueEvent(FORM_RESCUE_EVENTS.submitted, {\n form_id: form.id,\n attempt_id: isFormAttemptId(attemptId) ? attemptId : (local?.attemptId ?? null),\n });\n clearLocalDraft(form.id);\n}\n\nfunction readUtm(): FormDraftSaveRequest['utm'] {\n try {\n const params = new URLSearchParams(window.location.search);\n const utm: NonNullable<FormDraftSaveRequest['utm']> = {};\n for (const key of UTM_KEYS) {\n const value = params.get(`utm_${key}`);\n if (value) utm[key] = value.slice(0, 200);\n }\n return Object.keys(utm).length ? utm : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * useFormRescue — saves a public form's progress so a visitor who leaves\n * before submitting can be followed up with, and restores it when they return.\n *\n * Every public lead form calls it the same way (next to `useHumanitySignals`):\n *\n * const rescue = useFormRescue({ form: RESCUE_FORMS.waitlist, fieldNames, onRestore, getSignals })\n * // on change: rescue.track(values, changedField)\n * // submit: body = { ...data, ...getSignals(), ...rescue.submitFields() }\n * // success: rescue.complete()\n *\n * Saves go to the host's `EndpointsRuntime.formDraftsUrl` (debounced, plus one\n * final keepalive save when the tab is hidden). A host that does not provide\n * the url gets the local draft only. Nothing here can block or delay a submit:\n * every network and storage call is fire-and-forget and swallows its errors.\n */\nexport function useFormRescue({ form, fieldNames, onRestore, getSignals }: UseFormRescueOptions): FormRescueHandle {\n const draftsUrl = useEndpointsRuntime()?.formDraftsUrl;\n const formId = form?.id ?? null;\n\n const attemptIdRef = useRef<string | null>(null);\n const resumeTokenRef = useRef<string | null>(null);\n const latestRef = useRef<FormDraftProgress | null>(null);\n const dirtyRef = useRef(false);\n const startedRef = useRef(false);\n const leadCapturedRef = useRef(false);\n const completedRef = useRef(false);\n const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n // Callers pass inline arrays and callbacks; read them through refs so the\n // returned handle stays stable and effects run once. Written in an effect\n // (declared first, so it runs before the restore below), never during render.\n const fieldNamesRef = useRef(fieldNames);\n const onRestoreRef = useRef(onRestore);\n const getSignalsRef = useRef(getSignals);\n useEffect(() => {\n fieldNamesRef.current = fieldNames;\n onRestoreRef.current = onRestore;\n getSignalsRef.current = getSignals;\n });\n\n const attemptId = useCallback((): string => {\n if (!attemptIdRef.current) attemptIdRef.current = newAttemptId();\n return attemptIdRef.current;\n }, []);\n\n const send = useCallback(\n (keepalive: boolean) => {\n if (!formId || !draftsUrl || !dirtyRef.current || completedRef.current || !latestRef.current) return;\n dirtyRef.current = false;\n const body: FormDraftSaveRequest = {\n attempt_id: attemptId(),\n form_id: formId,\n ...latestRef.current,\n source_path: typeof window !== 'undefined' ? window.location.pathname : '/',\n utm: readUtm(),\n ...(resumeTokenRef.current ? { resume_token: resumeTokenRef.current } : {}),\n };\n try {\n void contentFetch(draftsUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ ...body, ...(getSignalsRef.current?.() ?? {}) }),\n keepalive,\n }).catch(() => undefined);\n } catch {\n // A failed save is retried by the next change.\n }\n captureFormRescueEvent(FORM_RESCUE_EVENTS.progress, {\n form_id: formId,\n attempt_id: attemptId(),\n fields_filled: body.fields_filled,\n last_field: body.last_field,\n completion_pct: body.completion_pct,\n });\n },\n [attemptId, draftsUrl, formId],\n );\n\n // Restore once on mount: a resume link wins over this device's local draft.\n useEffect(() => {\n if (!formId || typeof window === 'undefined') return undefined;\n let cancelled = false;\n const token = new URLSearchParams(window.location.search).get(FORM_RESCUE_RESUME_PARAM);\n\n const restoreLocal = () => {\n const local = readLocalDraft(formId);\n if (!local) return;\n attemptIdRef.current = local.attemptId;\n if (Object.keys(local.values).length) onRestoreRef.current?.(local.values);\n };\n\n if (isResumeToken(token) && draftsUrl) {\n contentFetch(`${draftsUrl}/resume/${encodeURIComponent(token)}`)\n .then(async res => (res.ok ? ((await res.json()) as FormDraftResumeResponse) : null))\n .then(data => {\n if (cancelled) return;\n if (!data || data.form_id !== formId) {\n restoreLocal();\n return;\n }\n resumeTokenRef.current = token;\n onRestoreRef.current?.(sanitizeRescueValues(data.values));\n captureFormRescueEvent(FORM_RESCUE_EVENTS.resumed, { form_id: formId, attempt_id: attemptId() });\n })\n .catch(() => {\n if (!cancelled) restoreLocal();\n });\n } else {\n restoreLocal();\n }\n return () => {\n cancelled = true;\n };\n }, [attemptId, draftsUrl, formId]);\n\n // Final save when the visitor hides or leaves the tab.\n useEffect(() => {\n if (!formId || typeof document === 'undefined') return undefined;\n const onHide = () => {\n if (completedRef.current || !startedRef.current) return;\n if (timerRef.current) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n send(true);\n captureFormRescueEvent(FORM_RESCUE_EVENTS.abandoned, {\n form_id: formId,\n attempt_id: attemptId(),\n last_field: latestRef.current?.last_field ?? null,\n completion_pct: latestRef.current?.completion_pct ?? 0,\n });\n };\n const onVisibility = () => {\n if (document.visibilityState === 'hidden') onHide();\n };\n document.addEventListener('visibilitychange', onVisibility);\n window.addEventListener('pagehide', onHide);\n return () => {\n document.removeEventListener('visibilitychange', onVisibility);\n window.removeEventListener('pagehide', onHide);\n if (timerRef.current) clearTimeout(timerRef.current);\n };\n }, [attemptId, formId, send]);\n\n const track = useCallback(\n (values: Record<string, unknown>, lastField?: string | null) => {\n if (!formId || completedRef.current) return;\n const names = fieldNamesRef.current;\n const visible: Record<string, unknown> = {};\n for (const name of names) visible[name] = values[name];\n const filled = filledRescueFields(visible, names);\n if (!filled.length) return;\n\n if (!startedRef.current) {\n startedRef.current = true;\n captureFormRescueEvent(FORM_RESCUE_EVENTS.started, { form_id: formId, attempt_id: attemptId() });\n }\n const clean = sanitizeRescueValues(visible);\n if (!leadCapturedRef.current && isRescueEmail(clean.email)) {\n leadCapturedRef.current = true;\n captureFormRescueEvent(FORM_RESCUE_EVENTS.leadCaptured, { form_id: formId, attempt_id: attemptId() });\n }\n latestRef.current = {\n values: clean,\n fields_filled: filled,\n last_field: sanitizeRescueFieldName(lastField),\n completion_pct: rescueCompletionPct(filled.length, names.length),\n };\n dirtyRef.current = true;\n writeLocalDraft(formId, { attemptId: attemptId(), values: clean, savedAt: Date.now() });\n\n if (timerRef.current) clearTimeout(timerRef.current);\n timerRef.current = setTimeout(() => {\n timerRef.current = null;\n send(false);\n }, FORM_RESCUE_DEBOUNCE_MS);\n },\n [attemptId, formId, send],\n );\n\n const submitFields = useCallback((): Record<string, string> => {\n if (!formId) return {};\n return {\n [FORM_RESCUE_ATTEMPT_FIELD]: attemptId(),\n ...(resumeTokenRef.current ? { [FORM_RESCUE_RESUME_FIELD]: resumeTokenRef.current } : {}),\n };\n }, [attemptId, formId]);\n\n const complete = useCallback(() => {\n if (!formId) return;\n if (timerRef.current) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n completeFormRescue(form, attemptId());\n // A fresh attempt for a second submission from the same page.\n attemptIdRef.current = null;\n resumeTokenRef.current = null;\n latestRef.current = null;\n dirtyRef.current = false;\n startedRef.current = false;\n leadCapturedRef.current = false;\n }, [attemptId, form, formId]);\n\n return useMemo(() => ({ track, submitFields, complete }), [track, submitFields, complete]);\n}\n","/**\n * Form rescue — the shared rules for saving a half-filled public form so the\n * team can follow up with a visitor who left before submitting.\n *\n * Pure and server-safe: the browser hook (`hooks/use-form-rescue`) and the\n * host's draft endpoint import the SAME allowlist, exclusions, caps and\n * completion math, so the server re-applies exactly the filter the client ran\n * and a rename here reaches both sides at once. Also exported through the\n * granular subpath `./utils/form-rescue` for server-only consumers.\n *\n * What a draft may carry:\n * - values of ALLOWLISTED contact fields only (`FORM_RESCUE_VALUE_FIELDS`),\n * trimmed and capped at `FORM_RESCUE_MAX_VALUE_CHARS`;\n * - for every other field, only whether it is filled (never its value);\n * - nothing whose name looks like a credential or payment field\n * (`isExcludedRescueField`), whatever the allowlist says.\n *\n * Field values go to the host only, never to analytics: the PostHog events\n * (`captureFormRescueEvent`) carry ids, field names and percentages.\n */\n\n/**\n * A rescuable form, defined ONCE beside the form that uses it (the strategy a\n * form plugs in with). The browser sends `id`; the host validates it with\n * `getRescueForm` and shows `label` to the team. A new form is one\n * `defineRescueForm` call: no central list, no host change.\n */\nexport interface FormRescueDefinition {\n /** Stable wire id: lower snake case, stored on every draft of this form. */\n readonly id: string;\n /** What the team reads on the alert (\"Contact form\"). */\n readonly label: string;\n}\n\nconst FORM_ID_PATTERN = /^[a-z][a-z0-9_]{1,47}$/;\nconst definitions = new Map<string, FormRescueDefinition>();\n\n/**\n * Register a rescuable form. Idempotent for the same id and label (a module\n * evaluated twice registers once); a second, different definition of one id\n * throws, so two forms can never share a draft vocabulary.\n */\nexport function defineRescueForm(definition: FormRescueDefinition): FormRescueDefinition {\n if (!FORM_ID_PATTERN.test(definition.id)) throw new Error(`[form-rescue] invalid form id \"${definition.id}\"`);\n const existing = definitions.get(definition.id);\n if (existing) {\n if (existing.label !== definition.label) {\n throw new Error(`[form-rescue] form id \"${definition.id}\" is already defined as \"${existing.label}\"`);\n }\n return existing;\n }\n const frozen = Object.freeze({ id: definition.id, label: definition.label });\n definitions.set(frozen.id, frozen);\n return frozen;\n}\n\n/** The registered definition for a wire id, or null (unknown ids are refused by the host). */\nexport function getRescueForm(id: unknown): FormRescueDefinition | null {\n return typeof id === 'string' ? (definitions.get(id) ?? null) : null;\n}\n\n/** Every registered form, for a host listing them. */\nexport function listRescueForms(): FormRescueDefinition[] {\n return [...definitions.values()];\n}\n\n/** The lib's own public forms. A host defines its own forms the same way, beside them. */\nexport const RESCUE_FORMS = {\n contact: defineRescueForm({ id: 'contact', label: 'Contact form' }),\n caseStudyPitch: defineRescueForm({ id: 'case_study_pitch', label: 'Case study pitch' }),\n dataRoomRequest: defineRescueForm({ id: 'data_room_request', label: 'Data room request' }),\n trustCenterRequest: defineRescueForm({ id: 'trust_center_request', label: 'Trust Center document request' }),\n meetingBooking: defineRescueForm({ id: 'meeting_booking', label: 'Meeting booking' }),\n waitlist: defineRescueForm({ id: 'waitlist', label: 'Waitlist' }),\n} as const;\n\n/**\n * Fields whose VALUES a draft keeps: who the visitor is and where they work.\n * Covers both naming schemes in use (the contact form's `name` / `companySize`,\n * the meeting booking's HubSpot `firstName` / `company` / `jobtitle`).\n * Free text (`message`) and phone numbers are reported as filled only.\n */\nexport const FORM_RESCUE_VALUE_FIELDS = [\n 'email',\n 'name',\n 'firstName',\n 'lastName',\n 'company',\n 'jobtitle',\n 'companySize',\n 'linkedin_url',\n] as const;\n\n/** Never send or store these, whatever the allowlist says. */\nconst EXCLUDED_FIELD_PATTERN =\n /pass(word|code)?|card|cvv|cvc|ssn|social.?security|iban|bank|routing|token|secret|otp|pin$/i;\n\nexport function isExcludedRescueField(name: string): boolean {\n return EXCLUDED_FIELD_PATTERN.test(name);\n}\n\n/** Longest value a draft keeps per field. */\nexport const FORM_RESCUE_MAX_VALUE_CHARS = 200;\n/** Save this long after the visitor stops typing. */\nexport const FORM_RESCUE_DEBOUNCE_MS = 1500;\n/** Most fields a draft reports (filled names), a bound on the payload. */\nexport const FORM_RESCUE_MAX_FIELDS = 40;\n\n/** Body keys a form's SUBMIT carries so the host can close the matching draft. */\nexport const FORM_RESCUE_ATTEMPT_FIELD = 'form_attempt_id';\nexport const FORM_RESCUE_RESUME_FIELD = 'form_resume_token';\n/** Every rescue key that rides in a submit body. Hosts forwarding a payload upstream strip by THIS array. */\nexport const FORM_RESCUE_SUBMIT_KEYS = [FORM_RESCUE_ATTEMPT_FIELD, FORM_RESCUE_RESUME_FIELD] as const;\n\n/** Query parameter a resume link carries. */\nexport const FORM_RESCUE_RESUME_PARAM = 'resume';\n\n/** A resume token's shape (base64url, as the host mints it). THE check for every reader of one. */\nconst RESUME_TOKEN_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;\n\nexport function isResumeToken(value: unknown): value is string {\n return typeof value === 'string' && RESUME_TOKEN_PATTERN.test(value);\n}\n\n/** Draft lifecycle. `submitted` is final; `rescued` can still become `submitted`. */\nexport const FORM_DRAFT_STATUSES = ['started', 'rescuable', 'rescued', 'submitted'] as const;\nexport type FormDraftStatus = (typeof FORM_DRAFT_STATUSES)[number];\n\n/** Why a due draft was settled WITHOUT alerting the team (stored on the draft). */\nexport const FORM_RESCUE_SKIP_REASONS = {\n finishedElsewhere: 'finished_elsewhere',\n alreadyRescued: 'already_rescued',\n internal: 'internal_email',\n} as const;\nexport type FormRescueSkipReason = (typeof FORM_RESCUE_SKIP_REASONS)[keyof typeof FORM_RESCUE_SKIP_REASONS];\n\n/** Same shape the host's `contact_submissions.email` check accepts. */\nconst EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$/;\n\nexport function isRescueEmail(value: unknown): value is string {\n return typeof value === 'string' && value.length <= FORM_RESCUE_MAX_VALUE_CHARS && EMAIL_PATTERN.test(value.trim());\n}\n\nconst UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nexport function isFormAttemptId(value: unknown): value is string {\n return typeof value === 'string' && UUID_PATTERN.test(value);\n}\n\nfunction isFilled(value: unknown): boolean {\n if (value == null) return false;\n if (typeof value === 'string') return value.trim().length > 0;\n if (typeof value === 'boolean') return value;\n if (Array.isArray(value)) return value.length > 0;\n if (typeof value === 'object') return Object.values(value as Record<string, unknown>).some(isFilled);\n return true;\n}\n\n/**\n * Keep only allowlisted, non-excluded string values, trimmed and capped.\n * The ONE filter, run by the browser before sending and by the host before storing.\n */\nexport function sanitizeRescueValues(values: Record<string, unknown> | null | undefined): Record<string, string> {\n const out: Record<string, string> = {};\n if (!values || typeof values !== 'object') return out;\n for (const key of FORM_RESCUE_VALUE_FIELDS) {\n if (isExcludedRescueField(key)) continue;\n const raw = values[key];\n if (typeof raw !== 'string') continue;\n const trimmed = raw.trim().slice(0, FORM_RESCUE_MAX_VALUE_CHARS);\n if (trimmed) out[key] = trimmed;\n }\n return out;\n}\n\n/** Names of the given fields that hold something, credentials never included. */\nexport function filledRescueFields(values: Record<string, unknown>, fieldNames: readonly string[]): string[] {\n return fieldNames\n .filter(name => !isExcludedRescueField(name) && isFilled(values[name]))\n .slice(0, FORM_RESCUE_MAX_FIELDS);\n}\n\nexport function rescueCompletionPct(filledCount: number, totalCount: number): number {\n if (totalCount <= 0) return 0;\n return Math.max(0, Math.min(100, Math.round((filledCount / totalCount) * 100)));\n}\n\n/** A field NAME the host stores (`last_field`, `fields_filled`); bounded, printable. */\nexport function sanitizeRescueFieldName(value: unknown): string | null {\n if (typeof value !== 'string') return null;\n const trimmed = value.trim();\n return /^[A-Za-z0-9_.-]{1,64}$/.test(trimmed) && !isExcludedRescueField(trimmed) ? trimmed : null;\n}\n\n/** A form's progress at one moment: allowlisted values, filled field names, position. */\nexport interface FormDraftProgress {\n values: Record<string, string>;\n fields_filled: string[];\n last_field: string | null;\n completion_pct: number;\n}\n\n/** What the browser sends on every save (`POST <formDraftsUrl>`), beside the humanity signals. */\nexport interface FormDraftSaveRequest extends FormDraftProgress {\n /** The attempt's idempotency key: every save of one attempt updates one draft. */\n attempt_id: string;\n /** A `FormRescueDefinition.id`. */\n form_id: string;\n source_path: string;\n utm?: Partial<Record<'source' | 'medium' | 'campaign' | 'content' | 'term', string>>;\n /** Set when the visitor came back through a resume link: the host saves onto\n * THAT draft, so one person's return never starts a second draft. */\n resume_token?: string;\n}\n\nconst UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term'] as const;\n\n/** Longest source path a draft keeps. */\nconst MAX_SOURCE_PATH_CHARS = 500;\n\n/**\n * THE server-side read of a draft save: turns an untrusted body into a\n * `FormDraftSaveRequest` with every rule re-applied (known form, uuid attempt,\n * allowlisted and capped values, bounded field names, a same-site path, capped\n * UTM), or null when the form or attempt is unknown. `dropped` names the value\n * keys the allowlist removed, for a log line that never carries a value.\n */\nexport function parseFormDraftSaveRequest(\n body: Record<string, unknown>,\n): { request: FormDraftSaveRequest; dropped: string[] } | null {\n const form = getRescueForm(body.form_id);\n if (!isFormAttemptId(body.attempt_id) || !form) return null;\n\n const rawValues = body.values && typeof body.values === 'object' ? (body.values as Record<string, unknown>) : {};\n const values = sanitizeRescueValues(rawValues);\n const fieldsFilled = (Array.isArray(body.fields_filled) ? body.fields_filled : [])\n .map(sanitizeRescueFieldName)\n .filter((name): name is string => name !== null)\n .slice(0, FORM_RESCUE_MAX_FIELDS);\n const path = typeof body.source_path === 'string' ? body.source_path : '';\n const utmIn = body.utm && typeof body.utm === 'object' ? (body.utm as Record<string, unknown>) : {};\n const utm: NonNullable<FormDraftSaveRequest['utm']> = {};\n for (const key of UTM_KEYS) {\n const value = utmIn[key];\n if (typeof value === 'string' && value.trim()) utm[key] = value.trim().slice(0, FORM_RESCUE_MAX_VALUE_CHARS);\n }\n\n return {\n request: {\n attempt_id: body.attempt_id,\n form_id: form.id,\n values,\n fields_filled: fieldsFilled,\n last_field: sanitizeRescueFieldName(body.last_field),\n completion_pct: typeof body.completion_pct === 'number' ? rescueCompletionPct(body.completion_pct, 100) : 0,\n // Same-site only: a path, never a scheme-relative or absolute URL.\n source_path: path.startsWith('/') && !path.startsWith('//') ? path.slice(0, MAX_SOURCE_PATH_CHARS) : '',\n ...(Object.keys(utm).length ? { utm } : {}),\n ...(isResumeToken(body.resume_token) ? { resume_token: body.resume_token } : {}),\n },\n dropped: Object.keys(rawValues).filter(key => !(key in values)),\n };\n}\n\n/** What the host answers a resume link with: the allowlisted values, nothing internal. */\nexport interface FormDraftResumeResponse {\n form_id: string;\n values: Record<string, string>;\n}\n\n/** The PostHog events this feature emits. Properties are ids, names and numbers, never values. */\nexport const FORM_RESCUE_EVENTS = {\n started: 'form_started',\n progress: 'form_progress',\n leadCaptured: 'form_lead_captured',\n abandoned: 'form_abandoned',\n submitted: 'form_submitted',\n rescued: 'form_rescued',\n resumed: 'form_resumed',\n} as const;\n\ntype AnalyticsValue = string | number | boolean | string[] | null;\n\n/**\n * Send one form event to PostHog when the page loaded it (GTM exposes\n * `window.posthog`); a no-op otherwise. Never throws.\n */\nexport function captureFormRescueEvent(event: string, properties: Record<string, AnalyticsValue>): void {\n try {\n if (typeof window === 'undefined') return;\n const ph = (window as unknown as { posthog?: { capture?: (e: string, p: object) => void } }).posthog;\n if (typeof ph?.capture === 'function') ph.capture(event, properties);\n } catch {\n // Analytics never breaks a form.\n }\n}\n","'use client';\n\nimport { useEffect } from 'react';\nimport type { FieldValues, Path, PathValue, UseFormReturn } from 'react-hook-form';\nimport type { FormRescueDefinition } from '../utils/form-rescue';\nimport { useFormRescue, type FormRescueHandle } from './use-form-rescue';\n\nexport interface UseRescuedFormOptions<T extends FieldValues> {\n /** The form's definition (`defineRescueForm` / `RESCUE_FORMS`); `null` turns rescue off. */\n rescue: FormRescueDefinition | null;\n /** The fields the visitor can see, by the name a draft stores them under. Hidden fields are never listed. */\n fields: readonly string[];\n /** The form's humanity signals, so draft saves pass the same bot gate as the submit. */\n getSignals?: () => Record<string, string | number>;\n /** Where a stored field lives in the form, when it is nested (`company` → `formFields.company`). Default: the same name. */\n fieldPath?: (name: string) => Path<T>;\n}\n\n/** One level of nesting flattened (`{ formFields: { company } }` → `{ company }`), so nested answers read like top-level ones. */\nfunction flattenOneLevel(values: Record<string, unknown>): Record<string, unknown> {\n const flat: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(values)) {\n if (value && typeof value === 'object' && !Array.isArray(value)) Object.assign(flat, value);\n else flat[key] = value;\n }\n return flat;\n}\n\n/**\n * useRescuedForm — form rescue for ANY react-hook-form form, in one call.\n *\n * Wires what every rescued form needs so no form hand-rolls it: every edit is\n * tracked (`watch`), a resume link or this device's draft fills the empty\n * fields (`setValue`), and the handle's `submitFields()` / `complete()` close\n * the attempt on submit. A form that is not react-hook-form uses\n * `useFormRescue` directly.\n *\n * const rescue = useRescuedForm(form, { rescue: RESCUE_FORMS.contact, fields, getSignals })\n * // submit: body = { ...data, ...getSignals(), ...rescue.submitFields() }\n * // success: rescue.complete()\n */\nexport function useRescuedForm<T extends FieldValues>(\n form: Pick<UseFormReturn<T>, 'watch' | 'getValues' | 'setValue'>,\n { rescue, fields, getSignals, fieldPath }: UseRescuedFormOptions<T>,\n): FormRescueHandle {\n const { watch, getValues, setValue } = form;\n\n const handle = useFormRescue({\n form: rescue,\n fieldNames: fields,\n getSignals,\n onRestore: values => {\n for (const name of fields) {\n const value = values[name];\n if (!value) continue;\n const path = fieldPath ? fieldPath(name) : (name as Path<T>);\n // Only an empty field is filled: never overwrite what the visitor typed.\n if (!getValues(path)) setValue(path, value as PathValue<T, Path<T>>, { shouldDirty: true });\n }\n },\n });\n\n useEffect(() => {\n const subscription = watch((values, { name }) => {\n handle.track(flattenOneLevel(values), name ? (name.split('.').pop() ?? null) : null);\n });\n return () => subscription.unsubscribe();\n }, [watch, handle]);\n\n return handle;\n}\n"]}