@flamingo-stack/openframe-frontend-core 0.0.285 → 0.0.286-snapshot.20260618154650

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 (224) hide show
  1. package/dist/chunk-3MCHAFHB.js +89 -0
  2. package/dist/chunk-3MCHAFHB.js.map +1 -0
  3. package/dist/chunk-3SDBXXDP.cjs +1594 -0
  4. package/dist/chunk-3SDBXXDP.cjs.map +1 -0
  5. package/dist/{chunk-IMNGGLKL.js → chunk-3ZXUQQL4.js} +2 -2
  6. package/dist/chunk-5KD3S25X.cjs +312 -0
  7. package/dist/chunk-5KD3S25X.cjs.map +1 -0
  8. package/dist/{chunk-44ZFLMYC.cjs → chunk-6AW25OS6.cjs} +30 -30
  9. package/dist/{chunk-44ZFLMYC.cjs.map → chunk-6AW25OS6.cjs.map} +1 -1
  10. package/dist/{chunk-XDI5JSWB.cjs → chunk-6CSW5TMS.cjs} +45 -41
  11. package/dist/chunk-6CSW5TMS.cjs.map +1 -0
  12. package/dist/chunk-7EYWERFT.js +1594 -0
  13. package/dist/chunk-7EYWERFT.js.map +1 -0
  14. package/dist/{chunk-XX7P4IL2.js → chunk-7NM7DEUK.js} +4 -143
  15. package/dist/chunk-7NM7DEUK.js.map +1 -0
  16. package/dist/{chunk-GVSTRN36.cjs → chunk-D3LEFMOA.cjs} +3 -3
  17. package/dist/{chunk-GVSTRN36.cjs.map → chunk-D3LEFMOA.cjs.map} +1 -1
  18. package/dist/chunk-D6RK5YXX.cjs +2439 -0
  19. package/dist/chunk-D6RK5YXX.cjs.map +1 -0
  20. package/dist/{chunk-VQW7CEGS.js → chunk-EFYXPR43.js} +1831 -3672
  21. package/dist/chunk-EFYXPR43.js.map +1 -0
  22. package/dist/{chunk-PVXCVPJD.cjs → chunk-EI4WALN2.cjs} +67 -1626
  23. package/dist/chunk-EI4WALN2.cjs.map +1 -0
  24. package/dist/{chunk-ZIODAAFY.cjs → chunk-EL5GCMPU.cjs} +20 -159
  25. package/dist/chunk-EL5GCMPU.cjs.map +1 -0
  26. package/dist/{chunk-53CFL7PH.js → chunk-GJDXIVEQ.js} +11 -7
  27. package/dist/{chunk-53CFL7PH.js.map → chunk-GJDXIVEQ.js.map} +1 -1
  28. package/dist/chunk-I6ZPGKZ2.cjs +151 -0
  29. package/dist/chunk-I6ZPGKZ2.cjs.map +1 -0
  30. package/dist/{chunk-P45Q4QCU.cjs → chunk-JQ4I743L.cjs} +19 -15
  31. package/dist/chunk-JQ4I743L.cjs.map +1 -0
  32. package/dist/{chunk-J66U3LPZ.cjs → chunk-KBKZYJRI.cjs} +33 -332
  33. package/dist/chunk-KBKZYJRI.cjs.map +1 -0
  34. package/dist/chunk-MV67MBV3.js +290 -0
  35. package/dist/chunk-MV67MBV3.js.map +1 -0
  36. package/dist/chunk-MWS25U4U.cjs +290 -0
  37. package/dist/chunk-MWS25U4U.cjs.map +1 -0
  38. package/dist/{chunk-USPATRSC.js → chunk-N6ZM5PYZ.js} +45 -344
  39. package/dist/chunk-N6ZM5PYZ.js.map +1 -0
  40. package/dist/chunk-ODR6A6FC.js +2439 -0
  41. package/dist/chunk-ODR6A6FC.js.map +1 -0
  42. package/dist/{chunk-DPKUSBQH.cjs → chunk-OXC72UIP.cjs} +1073 -2914
  43. package/dist/chunk-OXC72UIP.cjs.map +1 -0
  44. package/dist/{chunk-RGALV4UX.js → chunk-POKKCWKF.js} +3 -82
  45. package/dist/chunk-POKKCWKF.js.map +1 -0
  46. package/dist/{chunk-Z3V2SGQZ.js → chunk-R2KT5GDD.js} +324 -1883
  47. package/dist/chunk-R2KT5GDD.js.map +1 -0
  48. package/dist/{chunk-RJUGNL2A.cjs → chunk-RG6FNZUA.cjs} +73 -31
  49. package/dist/chunk-RG6FNZUA.cjs.map +1 -0
  50. package/dist/{chunk-23UOJUDF.js → chunk-RWCA2ZQK.js} +4 -4
  51. package/dist/chunk-TFSYSWPS.cjs +89 -0
  52. package/dist/chunk-TFSYSWPS.cjs.map +1 -0
  53. package/dist/{chunk-KNCDUWY2.js → chunk-TY2EB7VK.js} +65 -23
  54. package/dist/chunk-TY2EB7VK.js.map +1 -0
  55. package/dist/chunk-TYZEMPPH.js +312 -0
  56. package/dist/chunk-TYZEMPPH.js.map +1 -0
  57. package/dist/chunk-VRSXJ5QJ.js +1599 -0
  58. package/dist/chunk-VRSXJ5QJ.js.map +1 -0
  59. package/dist/chunk-Y4JNA4W6.cjs +1599 -0
  60. package/dist/chunk-Y4JNA4W6.cjs.map +1 -0
  61. package/dist/{chunk-ZSUNQJRO.cjs → chunk-YETA25JW.cjs} +2 -81
  62. package/dist/chunk-YETA25JW.cjs.map +1 -0
  63. package/dist/chunk-ZLN6SM2U.js +151 -0
  64. package/dist/chunk-ZLN6SM2U.js.map +1 -0
  65. package/dist/{chunk-O5N57CRV.js → chunk-ZYLQMCHW.js} +12 -8
  66. package/dist/{chunk-O5N57CRV.js.map → chunk-ZYLQMCHW.js.map} +1 -1
  67. package/dist/components/chat/chat-attachment-bar.d.ts.map +1 -1
  68. package/dist/components/chat/index.cjs +12 -6
  69. package/dist/components/chat/index.cjs.map +1 -1
  70. package/dist/components/chat/index.js +45 -39
  71. package/dist/components/contact/index.cjs +11 -7
  72. package/dist/components/contact/index.cjs.map +1 -1
  73. package/dist/components/contact/index.js +10 -6
  74. package/dist/components/docs/doc-navigation-context.d.ts +39 -0
  75. package/dist/components/docs/doc-navigation-context.d.ts.map +1 -0
  76. package/dist/components/docs/doc-viewer.d.ts +66 -0
  77. package/dist/components/docs/doc-viewer.d.ts.map +1 -0
  78. package/dist/components/docs/index.cjs +34 -0
  79. package/dist/components/docs/index.cjs.map +1 -0
  80. package/dist/components/docs/index.d.ts +8 -0
  81. package/dist/components/docs/index.d.ts.map +1 -0
  82. package/dist/components/docs/index.js +34 -0
  83. package/dist/components/docs/index.js.map +1 -0
  84. package/dist/components/docs/use-document-tree.d.ts +41 -0
  85. package/dist/components/docs/use-document-tree.d.ts.map +1 -0
  86. package/dist/components/docs/use-scroll-spy.d.ts +16 -0
  87. package/dist/components/docs/use-scroll-spy.d.ts.map +1 -0
  88. package/dist/components/embeds/embed-iframe.d.ts +29 -0
  89. package/dist/components/embeds/embed-iframe.d.ts.map +1 -0
  90. package/dist/components/embeds/figma-embed.d.ts +27 -0
  91. package/dist/components/embeds/figma-embed.d.ts.map +1 -0
  92. package/dist/components/embeds/google-sheets-viewer.d.ts +8 -0
  93. package/dist/components/embeds/google-sheets-viewer.d.ts.map +1 -0
  94. package/dist/components/embeds/index.cjs +37 -0
  95. package/dist/components/embeds/index.cjs.map +1 -0
  96. package/dist/components/embeds/index.d.ts +9 -0
  97. package/dist/components/embeds/index.d.ts.map +1 -0
  98. package/dist/components/embeds/index.js +37 -0
  99. package/dist/components/embeds/index.js.map +1 -0
  100. package/dist/components/embeds/pdf-viewer.d.ts +10 -0
  101. package/dist/components/embeds/pdf-viewer.d.ts.map +1 -0
  102. package/dist/components/faq/faq-section.d.ts.map +1 -1
  103. package/dist/components/faq/index.cjs +12 -8
  104. package/dist/components/faq/index.cjs.map +1 -1
  105. package/dist/components/faq/index.js +11 -7
  106. package/dist/components/faq-accordion.d.ts.map +1 -1
  107. package/dist/components/features/index.cjs +10 -6
  108. package/dist/components/features/index.cjs.map +1 -1
  109. package/dist/components/features/index.js +9 -5
  110. package/dist/components/index.cjs +247 -1120
  111. package/dist/components/index.cjs.map +1 -1
  112. package/dist/components/index.d.ts +2 -0
  113. package/dist/components/index.d.ts.map +1 -1
  114. package/dist/components/index.js +645 -1518
  115. package/dist/components/index.js.map +1 -1
  116. package/dist/components/navigation/index.cjs +15 -6
  117. package/dist/components/navigation/index.cjs.map +1 -1
  118. package/dist/components/navigation/index.d.ts +2 -0
  119. package/dist/components/navigation/index.d.ts.map +1 -1
  120. package/dist/components/navigation/index.js +15 -6
  121. package/dist/components/navigation/multi-level-navigation.d.ts +28 -0
  122. package/dist/components/navigation/multi-level-navigation.d.ts.map +1 -0
  123. package/dist/components/onboarding-guides/index.cjs +34 -29
  124. package/dist/components/onboarding-guides/index.cjs.map +1 -1
  125. package/dist/components/onboarding-guides/index.js +13 -8
  126. package/dist/components/onboarding-guides/index.js.map +1 -1
  127. package/dist/components/related-content/index.cjs +12 -8
  128. package/dist/components/related-content/index.cjs.map +1 -1
  129. package/dist/components/related-content/index.js +11 -7
  130. package/dist/components/tickets/index.cjs +75 -70
  131. package/dist/components/tickets/index.cjs.map +1 -1
  132. package/dist/components/tickets/index.js +16 -11
  133. package/dist/components/tickets/index.js.map +1 -1
  134. package/dist/components/ui/index.cjs +12 -6
  135. package/dist/components/ui/index.cjs.map +1 -1
  136. package/dist/components/ui/index.js +65 -59
  137. package/dist/embed-shims/index.cjs +3 -3
  138. package/dist/embed-shims/index.cjs.map +1 -1
  139. package/dist/embed-shims/index.js +8 -8
  140. package/dist/hooks/index.cjs +6 -3
  141. package/dist/hooks/index.cjs.map +1 -1
  142. package/dist/hooks/index.js +7 -4
  143. package/dist/index.cjs +68 -6
  144. package/dist/index.cjs.map +1 -1
  145. package/dist/index.js +263 -201
  146. package/dist/types/doc-source.cjs +4 -0
  147. package/dist/types/doc-source.cjs.map +1 -0
  148. package/dist/types/doc-source.d.ts +86 -0
  149. package/dist/types/doc-source.d.ts.map +1 -0
  150. package/dist/types/doc-source.js +3 -0
  151. package/dist/types/doc-source.js.map +1 -0
  152. package/dist/types/index.d.ts +1 -0
  153. package/dist/types/index.d.ts.map +1 -1
  154. package/dist/types/marketing.d.ts +1 -1
  155. package/dist/types/marketing.d.ts.map +1 -1
  156. package/dist/utils/doc-path-utils.d.ts +24 -0
  157. package/dist/utils/doc-path-utils.d.ts.map +1 -0
  158. package/dist/utils/doc-tree-nav.d.ts +35 -0
  159. package/dist/utils/doc-tree-nav.d.ts.map +1 -0
  160. package/dist/utils/embed-url-converters.d.ts +16 -0
  161. package/dist/utils/embed-url-converters.d.ts.map +1 -0
  162. package/dist/utils/faq-anchor.d.ts +51 -0
  163. package/dist/utils/faq-anchor.d.ts.map +1 -0
  164. package/dist/utils/format.d.ts +9 -0
  165. package/dist/utils/format.d.ts.map +1 -1
  166. package/dist/utils/index.cjs +320 -1
  167. package/dist/utils/index.cjs.map +1 -1
  168. package/dist/utils/index.d.ts +9 -1
  169. package/dist/utils/index.d.ts.map +1 -1
  170. package/dist/utils/index.js +296 -2
  171. package/dist/utils/index.js.map +1 -1
  172. package/dist/utils/list-url.d.ts.map +1 -1
  173. package/dist/utils/markdown-section-extractor.d.ts +23 -0
  174. package/dist/utils/markdown-section-extractor.d.ts.map +1 -0
  175. package/dist/utils/markdown-to-plain.d.ts +32 -0
  176. package/dist/utils/markdown-to-plain.d.ts.map +1 -0
  177. package/dist/utils/tree-builder.d.ts +30 -0
  178. package/dist/utils/tree-builder.d.ts.map +1 -0
  179. package/package.json +19 -1
  180. package/src/components/chat/chat-attachment-bar.tsx +6 -11
  181. package/src/components/docs/doc-navigation-context.tsx +123 -0
  182. package/src/components/docs/doc-viewer.tsx +345 -0
  183. package/src/components/docs/index.ts +12 -0
  184. package/src/components/docs/use-document-tree.ts +427 -0
  185. package/src/components/docs/use-scroll-spy.ts +93 -0
  186. package/src/components/embeds/embed-iframe.tsx +103 -0
  187. package/src/components/embeds/figma-embed.tsx +147 -0
  188. package/src/components/embeds/google-sheets-viewer.tsx +54 -0
  189. package/src/components/embeds/index.ts +11 -0
  190. package/src/components/embeds/pdf-viewer.tsx +64 -0
  191. package/src/components/faq/faq-section.tsx +78 -26
  192. package/src/components/faq-accordion.tsx +8 -1
  193. package/src/components/index.ts +2 -0
  194. package/src/components/navigation/index.ts +5 -0
  195. package/src/components/navigation/multi-level-navigation.tsx +358 -0
  196. package/src/types/doc-source.ts +88 -0
  197. package/src/types/index.ts +4 -0
  198. package/src/types/marketing.ts +1 -0
  199. package/src/utils/doc-path-utils.ts +47 -0
  200. package/src/utils/doc-tree-nav.ts +111 -0
  201. package/src/utils/embed-url-converters.ts +77 -0
  202. package/src/utils/faq-anchor.ts +70 -0
  203. package/src/utils/format.ts +16 -0
  204. package/src/utils/index.ts +18 -0
  205. package/src/utils/list-url.ts +1 -0
  206. package/src/utils/markdown-section-extractor.ts +106 -0
  207. package/src/utils/markdown-to-plain.ts +69 -0
  208. package/src/utils/tree-builder.ts +114 -0
  209. package/dist/chunk-DPKUSBQH.cjs.map +0 -1
  210. package/dist/chunk-J66U3LPZ.cjs.map +0 -1
  211. package/dist/chunk-KNCDUWY2.js.map +0 -1
  212. package/dist/chunk-P45Q4QCU.cjs.map +0 -1
  213. package/dist/chunk-PVXCVPJD.cjs.map +0 -1
  214. package/dist/chunk-RGALV4UX.js.map +0 -1
  215. package/dist/chunk-RJUGNL2A.cjs.map +0 -1
  216. package/dist/chunk-USPATRSC.js.map +0 -1
  217. package/dist/chunk-VQW7CEGS.js.map +0 -1
  218. package/dist/chunk-XDI5JSWB.cjs.map +0 -1
  219. package/dist/chunk-XX7P4IL2.js.map +0 -1
  220. package/dist/chunk-Z3V2SGQZ.js.map +0 -1
  221. package/dist/chunk-ZIODAAFY.cjs.map +0 -1
  222. package/dist/chunk-ZSUNQJRO.cjs.map +0 -1
  223. /package/dist/{chunk-IMNGGLKL.js.map → chunk-3ZXUQQL4.js.map} +0 -0
  224. /package/dist/{chunk-23UOJUDF.js.map → chunk-RWCA2ZQK.js.map} +0 -0
@@ -0,0 +1,147 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Button, ToggleGroup, ToggleGroupItem } from '../ui'
5
+ import { FigmaIcon } from '../icons-v2-generated'
6
+ import { ExternalLink, Play, LayoutGrid } from 'lucide-react'
7
+ import { toFigmaEmbedUrl, toFigmaOriginalUrl, isFigmaSlidesUrl } from '../../utils/embed-url-converters'
8
+ import { EmbedIframe } from './embed-iframe'
9
+
10
+ export interface FigmaEmbedProps {
11
+ /** Any Figma URL (design/file/proto/board/slides/deck) or an already-resolved embed URL. */
12
+ url: string
13
+ /** Heading shown above the embed. Defaults to "Figma Design". */
14
+ title?: string
15
+ /**
16
+ * iframe height (CSS value). The data-room document viewer omits it (full
17
+ * height, `calc(100vh - 250px)`); inline markdown passes e.g. `"70vh"` so the
18
+ * embed sits naturally inside article content.
19
+ */
20
+ height?: string
21
+ /** iframe loading strategy. Defaults to `"lazy"`; the data-room viewer passes `"eager"`. */
22
+ loading?: 'eager' | 'lazy'
23
+ }
24
+
25
+ type SlidesView = 'present' | 'browse'
26
+
27
+ /**
28
+ * Two-state present/browse toggle for Figma Slides. `present` (default) uses
29
+ * Figma's deck viewer (full-bleed slide + `‹ n/N ›` nav bar + keyboard nav);
30
+ * `browse` uses the thumbnail-rail + zoom viewer.
31
+ */
32
+ function SlidesViewToggle({
33
+ view,
34
+ onChange,
35
+ }: {
36
+ view: SlidesView
37
+ onChange: (v: SlidesView) => void
38
+ }) {
39
+ const options: { key: SlidesView; label: string; Icon: typeof Play }[] = [
40
+ { key: 'present', label: 'Present', Icon: Play },
41
+ { key: 'browse', label: 'Browse', Icon: LayoutGrid },
42
+ ]
43
+ return (
44
+ <ToggleGroup
45
+ type="single"
46
+ value={view}
47
+ onValueChange={(v: string) => {
48
+ if (v && v !== view) onChange(v as SlidesView)
49
+ }}
50
+ aria-label="Figma slides view mode"
51
+ className="flex shrink-0 items-center gap-0.5 rounded-lg border border-ods-border bg-ods-card p-0.5"
52
+ >
53
+ {options.map(({ key, label, Icon }) => {
54
+ const active = view === key
55
+ return (
56
+ <ToggleGroupItem
57
+ key={key}
58
+ value={key}
59
+ aria-label={label}
60
+ className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium transition-colors ${
61
+ active
62
+ ? 'bg-ods-accent text-ods-text-on-accent'
63
+ : 'text-ods-text-secondary hover:text-ods-text-primary hover:bg-ods-bg-hover'
64
+ }`}
65
+ >
66
+ <Icon className="h-4 w-4 shrink-0" />
67
+ {label}
68
+ </ToggleGroupItem>
69
+ )
70
+ })}
71
+ </ToggleGroup>
72
+ )
73
+ }
74
+
75
+ /**
76
+ * Single source of truth for every Figma surface — the data-room document viewer
77
+ * and in-article markdown both render this. A header (icon + title + "Open in
78
+ * Figma") over an interactive Figma iframe, built from the canonical
79
+ * `toFigmaEmbedUrl` / `toFigmaOriginalUrl` converters + the shared `<EmbedIframe>`.
80
+ * Only height/loading differ per surface.
81
+ *
82
+ * For Slides decks, a present/browse toggle (default = present) lets viewers flip
83
+ * slides with Figma's native nav bar + keyboard, or switch to the thumbnail-rail
84
+ * browse view.
85
+ */
86
+ export function FigmaEmbed({ url, title, height, loading = 'lazy' }: FigmaEmbedProps) {
87
+ const [view, setView] = useState<SlidesView>('present')
88
+ const isSlides = url ? isFigmaSlidesUrl(url) : false
89
+ const embedSrc = url ? toFigmaEmbedUrl(url, { slidesView: view }) : null
90
+ const originalUrl = (() => {
91
+ if (!url) return null
92
+ try {
93
+ const parsed = new URL(toFigmaOriginalUrl(url))
94
+ const host = parsed.hostname.toLowerCase()
95
+ const okHost = host === 'figma.com' || host.endsWith('.figma.com')
96
+ const okProtocol = parsed.protocol === 'https:' || parsed.protocol === 'http:'
97
+ return okHost && okProtocol ? parsed.toString() : null
98
+ } catch {
99
+ return null
100
+ }
101
+ })()
102
+ const heading = title || 'Figma Design'
103
+
104
+ return (
105
+ <div className="my-6 space-y-3">
106
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
107
+ <div className="flex items-center gap-2 min-w-0">
108
+ <FigmaIcon className="w-5 h-5 shrink-0" />
109
+ <span className="font-sans text-base font-semibold text-ods-text-primary truncate">
110
+ {heading}
111
+ </span>
112
+ </div>
113
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
114
+ {isSlides && embedSrc && <SlidesViewToggle view={view} onChange={setView} />}
115
+ {originalUrl && (
116
+ <Button
117
+ variant="outline"
118
+ size="small-legacy"
119
+ href={originalUrl}
120
+ openInNewTab
121
+ leftIcon={<FigmaIcon className="w-4 h-4" />}
122
+ rightIcon={<ExternalLink className="w-4 h-4" />}
123
+ className="w-full sm:w-auto"
124
+ >
125
+ Open in Figma
126
+ </Button>
127
+ )}
128
+ </div>
129
+ </div>
130
+ {embedSrc ? (
131
+ <EmbedIframe
132
+ src={embedSrc}
133
+ title={heading}
134
+ allow="clipboard-write; clipboard-read; fullscreen"
135
+ loading={loading}
136
+ height={height}
137
+ allowFullScreen
138
+ />
139
+ ) : (
140
+ <div className="flex flex-col items-center justify-center py-16 text-center">
141
+ <FigmaIcon className="w-16 h-16 text-ods-text-secondary mb-4" />
142
+ <p className="text-ods-text-secondary">Figma URL not configured</p>
143
+ </div>
144
+ )}
145
+ </div>
146
+ )
147
+ }
@@ -0,0 +1,54 @@
1
+ "use client"
2
+
3
+ import React from 'react'
4
+ import { Button } from '../ui'
5
+ import { ExternalLink } from 'lucide-react'
6
+ import { GoogleSheetsIcon } from '../icons-v2-generated'
7
+ import { EmbedIframe } from './embed-iframe'
8
+ import { toGoogleSheetsEmbedUrl, toGoogleSheetsOriginalUrl } from '../../utils/embed-url-converters'
9
+
10
+ export interface GoogleSheetsViewerProps {
11
+ externalUrl: string
12
+ fileName?: string
13
+ height?: string
14
+ }
15
+
16
+ export function GoogleSheetsViewer({ externalUrl, fileName, height }: GoogleSheetsViewerProps) {
17
+ const displayName = fileName || 'Google Sheet'
18
+
19
+ if (!externalUrl) {
20
+ return (
21
+ <div className="flex flex-col items-center justify-center py-16 text-center">
22
+ <GoogleSheetsIcon className="w-16 h-16 text-ods-text-secondary mb-4" />
23
+ <p className="text-ods-text-secondary">Google Sheet URL not configured</p>
24
+ </div>
25
+ )
26
+ }
27
+
28
+ return (
29
+ <div className="space-y-4">
30
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
31
+ <div className="flex items-center gap-2 min-w-0">
32
+ <GoogleSheetsIcon className="w-5 h-5 shrink-0" />
33
+ <h2 className="text-xl font-semibold text-ods-text-primary truncate">{displayName}</h2>
34
+ </div>
35
+ <Button
36
+ variant="outline"
37
+ size="small-legacy"
38
+ href={toGoogleSheetsOriginalUrl(externalUrl)}
39
+ openInNewTab
40
+ leftIcon={<GoogleSheetsIcon className="w-4 h-4" />}
41
+ rightIcon={<ExternalLink className="w-4 h-4" />}
42
+ className="w-full sm:w-auto"
43
+ >
44
+ Open in Google Sheets
45
+ </Button>
46
+ </div>
47
+ <EmbedIframe
48
+ src={toGoogleSheetsEmbedUrl(externalUrl)}
49
+ title={displayName}
50
+ height={height}
51
+ />
52
+ </div>
53
+ )
54
+ }
@@ -0,0 +1,11 @@
1
+ export { EmbedIframe } from './embed-iframe'
2
+ export type { EmbedIframeProps } from './embed-iframe'
3
+
4
+ export { PdfViewer } from './pdf-viewer'
5
+ export type { PdfViewerProps } from './pdf-viewer'
6
+
7
+ export { GoogleSheetsViewer } from './google-sheets-viewer'
8
+ export type { GoogleSheetsViewerProps } from './google-sheets-viewer'
9
+
10
+ export { FigmaEmbed } from './figma-embed'
11
+ export type { FigmaEmbedProps } from './figma-embed'
@@ -0,0 +1,64 @@
1
+ "use client"
2
+
3
+ import React from 'react'
4
+ import { Button } from '../ui'
5
+ import { Download, Eye } from 'lucide-react'
6
+ import { AdobePdfIcon } from '../icons-v2-generated'
7
+ import { EmbedIframe } from './embed-iframe'
8
+
9
+ export interface PdfViewerProps {
10
+ src: string
11
+ fileName?: string
12
+ onPreview?: () => void
13
+ onDownload?: () => void
14
+ height?: string
15
+ }
16
+
17
+ export function PdfViewer({ src, fileName, onPreview, onDownload, height }: PdfViewerProps) {
18
+ const displayName = fileName || 'PDF Document'
19
+
20
+ if (!src) {
21
+ return (
22
+ <div className="flex flex-col items-center justify-center py-16 text-center">
23
+ <AdobePdfIcon className="w-16 h-16 text-ods-text-secondary mb-4" />
24
+ <p className="text-ods-text-secondary">PDF file not available</p>
25
+ </div>
26
+ )
27
+ }
28
+
29
+ return (
30
+ <div className="space-y-4">
31
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
32
+ <div className="flex items-center gap-2 min-w-0">
33
+ <AdobePdfIcon className="w-5 h-5 shrink-0" />
34
+ <h2 className="text-xl font-semibold text-ods-text-primary truncate">{displayName}</h2>
35
+ </div>
36
+ <div className="flex items-center gap-2 w-full sm:w-auto">
37
+ <Button
38
+ variant="outline"
39
+ size="small-legacy"
40
+ href={onPreview ? undefined : src}
41
+ openInNewTab={!onPreview}
42
+ onClick={onPreview}
43
+ leftIcon={<Eye className="w-4 h-4" />}
44
+ className="flex-1 sm:flex-initial"
45
+ >
46
+ Preview
47
+ </Button>
48
+ <Button
49
+ variant="outline"
50
+ size="small-legacy"
51
+ href={onDownload ? undefined : src}
52
+ openInNewTab={!onDownload}
53
+ onClick={onDownload}
54
+ leftIcon={<Download className="w-4 h-4" />}
55
+ className="flex-1 sm:flex-initial"
56
+ >
57
+ Download
58
+ </Button>
59
+ </div>
60
+ </div>
61
+ <EmbedIframe src={src} title={displayName} height={height} />
62
+ </div>
63
+ )
64
+ }
@@ -7,6 +7,7 @@ import { useSelfFetch } from '../../hooks/use-self-fetch'
7
7
  import { buildSuggestionUrl } from '../../utils/suggestion-url'
8
8
  import { serializeJsonLd } from '../../utils/common'
9
9
  import { scrollElementIntoView } from '../../utils/scroll-into-view'
10
+ import { faqSectionSlug, faqItemAnchor, parseFaqHash, type FaqHashTarget } from '../../utils/faq-anchor'
10
11
  import { cn } from '../../utils/cn'
11
12
  import { buildFaqJsonLdFromFaqs, type FaqSchemaOptions } from './json-ld'
12
13
  import { SECTION_HEADING_CLASS } from '../layout/page-heading'
@@ -59,19 +60,6 @@ function buildFaqsUrl(
59
60
  return buildSuggestionUrl('/api/faqs', { apiBaseUrl, entityType, entityId, count: minResults })
60
61
  }
61
62
 
62
- /** Stable, URL-safe anchor id for a category. Prefixed so it can't collide
63
- * with other in-page ids, and so a bare numeric/blank section still yields a
64
- * valid id. */
65
- function sectionSlug(section: string): string {
66
- return (
67
- 'faq-' +
68
- section
69
- .toLowerCase()
70
- .replace(/[^a-z0-9]+/g, '-')
71
- .replace(/^-+|-+$/g, '')
72
- )
73
- }
74
-
75
63
  interface FaqGroup {
76
64
  /** null → the uncategorized bucket: no heading, no jump pill, rendered last. */
77
65
  section: string | null
@@ -98,7 +86,7 @@ function groupFaqsBySection(faqs: Faq[]): FaqGroup[] {
98
86
  }
99
87
  let group = byName.get(name)
100
88
  if (!group) {
101
- group = { section: name, slug: sectionSlug(name), items: [] }
89
+ group = { section: name, slug: faqSectionSlug(name), items: [] }
102
90
  byName.set(name, group)
103
91
  order.push(name)
104
92
  }
@@ -113,6 +101,12 @@ function groupFaqsBySection(faqs: Faq[]): FaqGroup[] {
113
101
  * scroll uses, so a category jump lands below the header, not under it. */
114
102
  const FAQ_NAV_HEADER_OFFSET = 96
115
103
 
104
+ /** Map key for the uncategorized bucket — `group.slug` is null for it, so
105
+ * every per-group map (default-open ids, accordion keys) uses this sentinel
106
+ * to keep the lookup typed. */
107
+ const UNCATEGORIZED_KEY = '__uncategorized__'
108
+ const groupKey = (g: FaqGroup): string => g.slug ?? UNCATEGORIZED_KEY
109
+
116
110
  /**
117
111
  * Grouped FAQ layout: a category jump-nav above stacked `<h2>` category
118
112
  * sections (each its own accordion). Isolated into its own component so the
@@ -177,6 +171,54 @@ function GroupedFaqList({
177
171
  // eslint-disable-next-line react-hooks/exhaustive-deps
178
172
  }, [slugKey])
179
173
 
174
+ // ─── Hash dispatch — `/faqs#faq-item-<id>` or `/faqs#faq-<section-slug>` ──
175
+ // Tracks the current hash so:
176
+ // 1. an item-kind hash seeds `defaultOpenIds` on the matching accordion
177
+ // (auto-expands the cited question);
178
+ // 2. either kind triggers the cancellation-proof tween scroll with the
179
+ // sticky-header offset (native browser hash scroll runs once, ignores
180
+ // our offset — re-running the tween puts the target in the right spot).
181
+ // Listens to `hashchange` so back/forward replays the same behavior. SSR-
182
+ // safe: initial null state matches the server render; the first effect
183
+ // tick on the client updates it.
184
+ const [hashTarget, setHashTarget] = useState<FaqHashTarget | null>(null)
185
+ useEffect(() => {
186
+ const refresh = () => setHashTarget(parseFaqHash(window.location.hash))
187
+ refresh()
188
+ window.addEventListener('hashchange', refresh)
189
+ return () => window.removeEventListener('hashchange', refresh)
190
+ }, [])
191
+
192
+ // Per-group default-open set when the hash points at an item. The map key
193
+ // matches `groupKey(group)` so the render-time lookup is O(1) per group.
194
+ const defaultOpenByGroupKey = useMemo(() => {
195
+ if (hashTarget?.kind !== 'item') return null
196
+ const targetId = hashTarget.rawId
197
+ const result = new Map<string, (string | number)[]>()
198
+ for (const group of groups) {
199
+ const hit = group.items.find((i) => String(i.id) === targetId)
200
+ if (hit) result.set(groupKey(group), [hit.id])
201
+ }
202
+ return result.size > 0 ? result : null
203
+ }, [groups, hashTarget])
204
+
205
+ // Accordion is uncontrolled — `defaultOpenIds` is only consumed at mount,
206
+ // so a new item hash needs a remount to honor it. Keying off the item-id
207
+ // suffix triggers exactly the remount we need (and stays stable when the
208
+ // hash points at a section, so category navigation never disturbs the
209
+ // accordion's open state).
210
+ const accordionKeySuffix =
211
+ hashTarget?.kind === 'item' ? `item:${hashTarget.rawId}` : 'default'
212
+
213
+ useEffect(() => {
214
+ if (!hashTarget) return
215
+ const elId =
216
+ hashTarget.kind === 'item' ? faqItemAnchor(hashTarget.rawId) : hashTarget.slug
217
+ const el = document.getElementById(elId)
218
+ if (el) scrollElementIntoView(el, { headerOffset: FAQ_NAV_HEADER_OFFSET })
219
+ if (hashTarget.kind === 'section') setActiveSlug(hashTarget.slug)
220
+ }, [hashTarget])
221
+
180
222
  const handleJump = useCallback(
181
223
  (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
182
224
  e.preventDefault()
@@ -215,18 +257,28 @@ function GroupedFaqList({
215
257
  </nav>
216
258
  )}
217
259
  <div className="space-y-10">
218
- {groups.map((group) => (
219
- <section
220
- key={group.slug ?? 'faq-uncategorized'}
221
- id={group.slug ?? undefined}
222
- className="scroll-mt-24 space-y-4"
223
- >
224
- {group.section && (
225
- <CategoryHeading className={SECTION_HEADING_CLASS}>{group.section}</CategoryHeading>
226
- )}
227
- <FaqAccordion items={group.items} />
228
- </section>
229
- ))}
260
+ {groups.map((group) => {
261
+ const key = groupKey(group)
262
+ return (
263
+ <section
264
+ key={key}
265
+ id={group.slug ?? undefined}
266
+ className="scroll-mt-24 space-y-4"
267
+ >
268
+ {group.section && (
269
+ <CategoryHeading className={SECTION_HEADING_CLASS}>{group.section}</CategoryHeading>
270
+ )}
271
+ <FaqAccordion
272
+ // Re-key on item-hash changes so the remount picks up the new
273
+ // `defaultOpenIds` (the accordion is uncontrolled). Stable for
274
+ // section hashes — category navigation doesn't disturb state.
275
+ key={`${key}:${accordionKeySuffix}`}
276
+ items={group.items}
277
+ defaultOpenIds={defaultOpenByGroupKey?.get(key)}
278
+ />
279
+ </section>
280
+ )
281
+ })}
230
282
  </div>
231
283
  </div>
232
284
  )
@@ -3,6 +3,7 @@
3
3
  import React, { useRef, useState, useEffect, useCallback } from 'react'
4
4
  import { ChevronButton } from './ui/chevron-button'
5
5
  import { cn } from "../utils/cn"
6
+ import { faqItemAnchor } from "../utils/faq-anchor"
6
7
 
7
8
  export interface FaqItem {
8
9
  id: number | string
@@ -60,7 +61,13 @@ export function FaqAccordion({ items, defaultOpenIds = [] }: FaqAccordionProps)
60
61
  return (
61
62
  <div
62
63
  key={item.id}
63
- className={cn('group transition-colors hover:bg-[#1E1E1E]', isOpen ? 'bg-ods-bg' : 'bg-transparent')}
64
+ // Per-row anchor — chat citation chips (`/faqs#faq-item-<id>`) land
65
+ // here via native browser hash scroll AND via `FaqSection`'s tween
66
+ // dispatch. `scroll-mt-24` keeps the row header below the 96px
67
+ // sticky nav offset (matches `<section>`'s scroll-margin for
68
+ // category anchors).
69
+ id={faqItemAnchor(item.id)}
70
+ className={cn('group scroll-mt-24 transition-colors hover:bg-[#1E1E1E]', isOpen ? 'bg-ods-bg' : 'bg-transparent')}
64
71
  >
65
72
  {/* Header */}
66
73
  <div
@@ -2,6 +2,8 @@
2
2
 
3
3
  // Components exports
4
4
  export * from './chat'
5
+ export * from './docs'
6
+ export * from './embeds'
5
7
  export * from './features'
6
8
  export * from './icons'
7
9
  export * from './navigation'
@@ -56,6 +56,11 @@ export type { HeaderGlobalSearchProps } from './header-global-search'
56
56
  export { HeaderOrganizationFilter } from './header-organization-filter'
57
57
  export type { HeaderOrganizationFilterOrganization, HeaderOrganizationFilterProps } from './header-organization-filter'
58
58
 
59
+ // Multi-level navigation — sidebar + mobile dropdown for the doc-viewer
60
+ // (DocViewer / DocSourceViewer) and any other tree-shaped navigation surface.
61
+ export { MultiLevelNavigation, MobileNavigationDropdown } from './multi-level-navigation'
62
+ export type { NavigationNode } from './multi-level-navigation'
63
+
59
64
  // Re-export types from navigation types
60
65
  export type {
61
66
  MobileNavConfig, NavigationItem, NavigationSidebarConfig,