@flamingo-stack/openframe-frontend-core 0.0.244 → 0.0.245

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 (131) hide show
  1. package/dist/{chunk-DV3YZWGN.cjs → chunk-3TDLFCCS.cjs} +33 -20
  2. package/dist/chunk-3TDLFCCS.cjs.map +1 -0
  3. package/dist/{chunk-ORJREQ2W.js → chunk-4XLJWX2N.js} +4 -4
  4. package/dist/{chunk-P5C732SC.js → chunk-7ZHFCEJH.js} +6 -6
  5. package/dist/{chunk-EJ7J4QAD.cjs → chunk-BKPS6M2F.cjs} +12 -12
  6. package/dist/{chunk-EJ7J4QAD.cjs.map → chunk-BKPS6M2F.cjs.map} +1 -1
  7. package/dist/{chunk-EPXROXYC.cjs → chunk-CZITXP77.cjs} +645 -713
  8. package/dist/chunk-CZITXP77.cjs.map +1 -0
  9. package/dist/{chunk-Z6GGYZDR.cjs → chunk-DUZBTUXF.cjs} +27 -27
  10. package/dist/{chunk-Z6GGYZDR.cjs.map → chunk-DUZBTUXF.cjs.map} +1 -1
  11. package/dist/{chunk-M7DPUWAL.js → chunk-FRRMXLU5.js} +120 -188
  12. package/dist/chunk-FRRMXLU5.js.map +1 -0
  13. package/dist/chunk-GIDH6UL4.js +287 -0
  14. package/dist/chunk-GIDH6UL4.js.map +1 -0
  15. package/dist/{chunk-PTSAF7T2.js → chunk-JJV3XGCY.js} +2 -2
  16. package/dist/{chunk-APGMNHPY.js → chunk-MKVKVJQQ.js} +32 -19
  17. package/dist/chunk-MKVKVJQQ.js.map +1 -0
  18. package/dist/chunk-ML2ROCYA.cjs +287 -0
  19. package/dist/chunk-ML2ROCYA.cjs.map +1 -0
  20. package/dist/{chunk-STKMIMLP.cjs → chunk-MP5HOP7N.cjs} +12 -12
  21. package/dist/{chunk-STKMIMLP.cjs.map → chunk-MP5HOP7N.cjs.map} +1 -1
  22. package/dist/{chunk-NKGNRFSS.js → chunk-SEVJT2HP.js} +8 -8
  23. package/dist/{chunk-FSS2MIEA.cjs → chunk-SGJBUL2Y.cjs} +20 -3
  24. package/dist/chunk-SGJBUL2Y.cjs.map +1 -0
  25. package/dist/{chunk-WD2MZVJI.js → chunk-V35PZSUA.js} +5 -5
  26. package/dist/{chunk-LJGBFD52.js → chunk-VAUI3CND.js} +18 -1
  27. package/dist/chunk-VAUI3CND.js.map +1 -0
  28. package/dist/{chunk-OB45JHDY.cjs → chunk-VFKQMAUF.cjs} +3 -3
  29. package/dist/{chunk-OB45JHDY.cjs.map → chunk-VFKQMAUF.cjs.map} +1 -1
  30. package/dist/{chunk-RJC3L5DT.cjs → chunk-VXP553WT.cjs} +7 -7
  31. package/dist/{chunk-RJC3L5DT.cjs.map → chunk-VXP553WT.cjs.map} +1 -1
  32. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  33. package/dist/components/chat/hooks/use-chat-card-item.d.ts.map +1 -1
  34. package/dist/components/chat/index.cjs +6 -16
  35. package/dist/components/chat/index.cjs.map +1 -1
  36. package/dist/components/chat/index.js +5 -15
  37. package/dist/components/chat/types/entities/content-ref.d.ts +5 -20
  38. package/dist/components/chat/types/entities/content-ref.d.ts.map +1 -1
  39. package/dist/components/chat/utils/index.d.ts +0 -1
  40. package/dist/components/chat/utils/index.d.ts.map +1 -1
  41. package/dist/components/contact/index.cjs +7 -7
  42. package/dist/components/contact/index.js +6 -6
  43. package/dist/components/faq/faq-section.d.ts +7 -1
  44. package/dist/components/faq/faq-section.d.ts.map +1 -1
  45. package/dist/components/faq/index.cjs +3 -3
  46. package/dist/components/faq/index.js +2 -2
  47. package/dist/components/features/index.cjs +6 -6
  48. package/dist/components/features/index.js +5 -5
  49. package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
  50. package/dist/components/index.cjs +87 -93
  51. package/dist/components/index.cjs.map +1 -1
  52. package/dist/components/index.d.ts +1 -0
  53. package/dist/components/index.d.ts.map +1 -1
  54. package/dist/components/index.js +31 -37
  55. package/dist/components/index.js.map +1 -1
  56. package/dist/components/navigation/index.cjs +6 -6
  57. package/dist/components/navigation/index.js +5 -5
  58. package/dist/components/onboarding-guides/index.cjs +30 -30
  59. package/dist/components/onboarding-guides/index.cjs.map +1 -1
  60. package/dist/components/onboarding-guides/index.js +11 -11
  61. package/dist/components/related-content/index.cjs +26 -0
  62. package/dist/components/related-content/index.cjs.map +1 -0
  63. package/dist/components/related-content/index.d.ts +2 -0
  64. package/dist/components/related-content/index.d.ts.map +1 -0
  65. package/dist/components/related-content/index.js +26 -0
  66. package/dist/components/related-content/index.js.map +1 -0
  67. package/dist/components/related-content/related-content-section.d.ts +149 -0
  68. package/dist/components/related-content/related-content-section.d.ts.map +1 -0
  69. package/dist/components/tickets/index.cjs +73 -73
  70. package/dist/components/tickets/index.cjs.map +1 -1
  71. package/dist/components/tickets/index.js +15 -15
  72. package/dist/components/ui/index.cjs +9 -19
  73. package/dist/components/ui/index.cjs.map +1 -1
  74. package/dist/components/ui/index.js +13 -23
  75. package/dist/embed-shims/index.cjs +3 -3
  76. package/dist/embed-shims/index.cjs.map +1 -1
  77. package/dist/embed-shims/index.js +5 -5
  78. package/dist/index.cjs +17 -11
  79. package/dist/index.cjs.map +1 -1
  80. package/dist/index.js +55 -49
  81. package/dist/types/content-ref.d.ts +35 -0
  82. package/dist/types/content-ref.d.ts.map +1 -0
  83. package/dist/types/index.d.ts +1 -0
  84. package/dist/types/index.d.ts.map +1 -1
  85. package/dist/utils/content-ref-groups.d.ts +58 -0
  86. package/dist/utils/content-ref-groups.d.ts.map +1 -0
  87. package/dist/utils/extract-items.d.ts +18 -0
  88. package/dist/utils/extract-items.d.ts.map +1 -0
  89. package/dist/utils/index.cjs +81 -0
  90. package/dist/utils/index.cjs.map +1 -1
  91. package/dist/utils/index.d.ts +4 -1
  92. package/dist/utils/index.d.ts.map +1 -1
  93. package/dist/utils/index.js +74 -1
  94. package/dist/utils/index.js.map +1 -1
  95. package/dist/utils/list-url.d.ts +6 -0
  96. package/dist/utils/list-url.d.ts.map +1 -1
  97. package/dist/utils/suggestion-url.d.ts +26 -0
  98. package/dist/utils/suggestion-url.d.ts.map +1 -0
  99. package/package.json +7 -1
  100. package/src/components/chat/chat-message-list.tsx +49 -74
  101. package/src/components/chat/hooks/use-chat-card-item.ts +3 -38
  102. package/src/components/chat/types/entities/content-ref.ts +5 -21
  103. package/src/components/chat/utils/index.ts +0 -12
  104. package/src/components/faq/faq-section.tsx +69 -24
  105. package/src/components/features/policy-configuration-panel.tsx +0 -1
  106. package/src/components/index.ts +1 -0
  107. package/src/components/related-content/index.ts +9 -0
  108. package/src/components/related-content/related-content-section.tsx +630 -0
  109. package/src/types/content-ref.ts +34 -0
  110. package/src/types/index.ts +1 -0
  111. package/src/utils/content-ref-groups.ts +89 -0
  112. package/src/utils/extract-items.ts +48 -0
  113. package/src/utils/index.ts +16 -1
  114. package/src/utils/list-url.ts +9 -0
  115. package/src/utils/suggestion-url.ts +39 -0
  116. package/dist/chunk-APGMNHPY.js.map +0 -1
  117. package/dist/chunk-DV3YZWGN.cjs.map +0 -1
  118. package/dist/chunk-EPXROXYC.cjs.map +0 -1
  119. package/dist/chunk-FSS2MIEA.cjs.map +0 -1
  120. package/dist/chunk-LJGBFD52.js.map +0 -1
  121. package/dist/chunk-M7DPUWAL.js.map +0 -1
  122. package/dist/components/chat/utils/history-merge.d.ts +0 -92
  123. package/dist/components/chat/utils/history-merge.d.ts.map +0 -1
  124. package/src/components/chat/__tests__/chat-message-list.test.tsx +0 -92
  125. package/src/components/chat/utils/__tests__/history-merge.test.ts +0 -339
  126. package/src/components/chat/utils/history-merge.ts +0 -255
  127. /package/dist/{chunk-ORJREQ2W.js.map → chunk-4XLJWX2N.js.map} +0 -0
  128. /package/dist/{chunk-P5C732SC.js.map → chunk-7ZHFCEJH.js.map} +0 -0
  129. /package/dist/{chunk-PTSAF7T2.js.map → chunk-JJV3XGCY.js.map} +0 -0
  130. /package/dist/{chunk-NKGNRFSS.js.map → chunk-SEVJT2HP.js.map} +0 -0
  131. /package/dist/{chunk-WD2MZVJI.js.map → chunk-V35PZSUA.js.map} +0 -0
@@ -27,6 +27,12 @@
27
27
  * Pure + server-safe (no React, no browser APIs) so the hub's server-side
28
28
  * mappers can import it from `@flamingo-stack/openframe-frontend-core/utils`.
29
29
  */
30
+ /** Resolve a ContentRef rail-vocab type to its canonical `documentType`
31
+ * (registry vocabulary) — `blog_post_existing` → `blog_post`, everything
32
+ * else passes through. Exported so consumers comparing rail group keys to
33
+ * registry entityTypes (e.g. the related-content rail's same-type-first
34
+ * ordering) share THIS alias map instead of re-declaring it. */
35
+ export declare function canonicalContentRefType(contentRefType: string): string;
30
36
  /**
31
37
  * Build a list-API URL that returns full rows for the given ids, or `null`
32
38
  * when the type has no list endpoint (the caller skips fetching rather than
@@ -1 +1 @@
1
- {"version":3,"file":"list-url.d.ts","sourceRoot":"","sources":["../../src/utils/list-url.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAqCH;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,IAAI,SAAK,GAAG,MAAM,GAAG,IAAI,CAa5F"}
1
+ {"version":3,"file":"list-url.d.ts","sourceRoot":"","sources":["../../src/utils/list-url.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAWH;;;;iEAIiE;AACjE,wBAAgB,uBAAuB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAEtE;AA4BD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,IAAI,SAAK,GAAG,MAAM,GAAG,IAAI,CAa5F"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared fetch-URL composer for the two suggestion sections (FaqSection +
3
+ * RelatedContentSection) — ONE owner of the entityType/entityId/count query
4
+ * shape so the sections can never drift. Pure + server-safe.
5
+ *
6
+ * Byte-parity contract with the original `buildFaqsUrl`: when only
7
+ * entityType/entityId are set, the param order is `entityType`, `entityId`;
8
+ * with nothing set, the bare `path` is returned (no `?`).
9
+ */
10
+ export interface SuggestionUrlOptions {
11
+ /** Fetch-URL prefix for third-party embeds / reverse proxies
12
+ * ('' = same-origin relative). */
13
+ apiBaseUrl?: string;
14
+ /** Both required together for entity scope; partial → bare path. */
15
+ entityType?: string;
16
+ entityId?: number | string;
17
+ /** Maps to the server's `count` param. Undefined → param not sent
18
+ * (server default applies). */
19
+ count?: number;
20
+ /** Extra query params appended after the shared trio (e.g. the
21
+ * related-content `excludeTypes` deny-list). Empty/undefined values
22
+ * are skipped. */
23
+ extraParams?: Record<string, string | undefined>;
24
+ }
25
+ export declare function buildSuggestionUrl(path: string, opts?: SuggestionUrlOptions): string;
26
+ //# sourceMappingURL=suggestion-url.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"suggestion-url.d.ts","sourceRoot":"","sources":["../../src/utils/suggestion-url.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,WAAW,oBAAoB;IACnC;uCACmC;IACnC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,oEAAoE;IACpE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IAC1B;oCACgC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;uBAEmB;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CACjD;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,oBAAyB,GAAG,MAAM,CAaxF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.244",
3
+ "version": "0.0.245",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -60,6 +60,12 @@
60
60
  "require": "./dist/components/faq/index.cjs",
61
61
  "default": "./dist/components/faq/index.js"
62
62
  },
63
+ "./components/related-content": {
64
+ "types": "./dist/components/related-content/index.d.ts",
65
+ "import": "./dist/components/related-content/index.js",
66
+ "require": "./dist/components/related-content/index.cjs",
67
+ "default": "./dist/components/related-content/index.js"
68
+ },
63
69
  "./components/faq/json-ld": {
64
70
  "types": "./dist/components/faq/json-ld.d.ts",
65
71
  "import": "./dist/components/faq/json-ld.js",
@@ -130,11 +130,6 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
130
130
  onLoadMoreRef.current = onLoadMore
131
131
  const isFetchingRef = useRef(isFetchingNextPage)
132
132
  isFetchingRef.current = isFetchingNextPage
133
- // Fresh dialogId for the IntersectionObserver callback (its effect must
134
- // not depend on `dialogId` or it would re-create on every dialog switch
135
- // mid-stream; the ref keeps the snapshot's dialog tag current).
136
- const dialogIdRef = useRef(dialogId)
137
- dialogIdRef.current = dialogId
138
133
 
139
134
  // Tracks previous render state for explicit "force-stick" decisions
140
135
  // (dialog change, first message, new user message). The library
@@ -145,19 +140,15 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
145
140
  lastMessageId: string | undefined
146
141
  }>({ dialogId: undefined, messageCount: 0, lastMessageId: undefined })
147
142
 
148
- // Geometry snapshot captured the instant the top sentinel triggers
149
- // onLoadMore (BEFORE the older page lands), applied once when the taller
150
- // content commits. Keyed on the LOAD LIFECYCLE, not `messages[0].id`: the
151
- // history processor groups consecutive assistant turns into one bubble
152
- // keyed by the group's LAST id, so an older page that is all continuation
153
- // of the first bubble grows that bubble in place WITHOUT changing its id.
154
- // An id-based anchor misses that growth and leaves the reader pinned at
155
- // the top (and the still-visible sentinel never re-fires).
156
- const loadOlderAnchorRef = useRef<{
157
- dialogId: string | undefined
143
+ // Snapshot of the first message id + scrollHeight observed BEFORE
144
+ // a load-older prepend, so we can preserve the user's viewport
145
+ // position when older messages stream in above their current
146
+ // reading position.
147
+ const prependRef = useRef<{
148
+ firstMessageId: string | undefined
149
+ firstMessageContent: unknown
158
150
  scrollHeight: number
159
- scrollTop: number
160
- } | null>(null)
151
+ }>({ firstMessageId: undefined, firstMessageContent: undefined, scrollHeight: 0 })
161
152
 
162
153
  // ---- Force-stick: dialog change / first-load / new user message --
163
154
  useEffect(() => {
@@ -165,7 +156,6 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
165
156
 
166
157
  const dialogChanged = dialogId !== prevRenderRef.current.dialogId
167
158
  const prevCount = prevRenderRef.current.messageCount
168
- const prevLastId = prevRenderRef.current.lastMessageId
169
159
  const newCount = messages.length
170
160
  const currentLastId = messages[messages.length - 1]?.id
171
161
  prevRenderRef.current = { dialogId, messageCount: newCount, lastMessageId: currentLastId }
@@ -187,18 +177,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
187
177
  // user message DID arrive). When the user just sent a
188
178
  // message, they expect their bubble pinned to the bottom,
189
179
  // regardless of where they were scrolled.
190
- //
191
- // The tail must be located via the previous LAST message id,
192
- // not `slice(prevCount)`: when load-older PREPENDS a page,
193
- // count grows but the new messages are at the HEAD —
194
- // `slice(prevCount)` would return already-rendered tail
195
- // messages (which contain user bubbles) and force-scroll the
196
- // reader away from the older page they just loaded. With the
197
- // id-based slice a pure prepend yields an empty tail → no
198
- // scroll, and the prepend-anchoring effect below keeps their
199
- // viewport position.
200
- const prevLastIdx = prevLastId ? messages.findIndex((m) => m.id === prevLastId) : -1
201
- const newSlice = prevLastIdx >= 0 ? messages.slice(prevLastIdx + 1) : messages.slice(prevCount)
180
+ const newSlice = messages.slice(prevCount)
202
181
  const hasNewUser = newSlice.some((m) => m.role === 'user')
203
182
  if (hasNewUser) {
204
183
  void scrollToBottom({ animation: 'instant', ignoreEscapes: true })
@@ -251,35 +230,49 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
251
230
  }, [autoScroll, messages, dialogId, scrollToBottom, contentRef])
252
231
 
253
232
  // ---- Prepend anchoring (load-older) ------------------------------
254
- // The library doesn't preserve user position when content grows ABOVE
255
- // the viewport. We do: from the pre-load geometry snapshot (taken in the
256
- // sentinel callback), add the height the list gained and re-pin scrollTop
257
- // so the message the user was reading stays put. `useLayoutEffect` so the
258
- // write lands before paint (no visible jump). Covers both a pure prepend
259
- // (new ids on top) and a boundary-merge (first bubble grows in place) —
260
- // it measures total height delta, not message identity.
233
+ // The library doesn't preserve user position when content prepends
234
+ // ABOVE the viewport. We do it ourselves: snapshot scrollHeight
235
+ // BEFORE the new render, then add the delta after to keep the
236
+ // user on the same message they were reading. `useLayoutEffect`
237
+ // so the scrollTop write lands before paint (no visible jump).
261
238
  useLayoutEffect(() => {
262
239
  const el = scrollRef.current
263
- const anchor = loadOlderAnchorRef.current
264
- if (!el || !anchor) return
265
- // Dialog switched out from under a pending load — abandon; the
266
- // force-stick effect owns the new dialog's position.
267
- if (anchor.dialogId !== dialogId) {
268
- loadOlderAnchorRef.current = null
240
+ if (!el) {
241
+ prependRef.current = { firstMessageId: undefined, firstMessageContent: undefined, scrollHeight: 0 }
242
+ return
243
+ }
244
+
245
+ const currentFirstId = messages[0]?.id
246
+ const currentFirstContent = messages[0]?.content
247
+ const prevFirstId = prependRef.current.firstMessageId
248
+ const prevHeight = prependRef.current.scrollHeight
249
+
250
+ if (currentFirstId !== prevFirstId) {
251
+ // First-id changed → older messages prepended (verified by
252
+ // confirming the previous-first is still in the array at
253
+ // idx > 0; otherwise this is some other mutation).
254
+ if (prevFirstId && prevHeight > 0) {
255
+ const prevIdx = messages.findIndex((m) => m.id === prevFirstId)
256
+ if (prevIdx > 0) {
257
+ const addedHeight = el.scrollHeight - prevHeight
258
+ if (addedHeight > 0) el.scrollTop += addedHeight
259
+ }
260
+ }
261
+ prependRef.current = {
262
+ firstMessageId: currentFirstId,
263
+ firstMessageContent: currentFirstContent,
264
+ scrollHeight: el.scrollHeight,
265
+ }
269
266
  return
270
267
  }
271
- // Hold the anchor across the intermediate renders between firing
272
- // onLoadMore and the older page committing (the isFetchingNextPage
273
- // flip, and in some hosts a two-stage store update) — those don't grow
274
- // the list. Apply on the first render where it actually grew taller,
275
- // which is the prepend (new ids on top) or a boundary-merge (first
276
- // bubble grew in place). Same-id boundary-merge is why this can't key
277
- // on message count.
278
- const addedHeight = el.scrollHeight - anchor.scrollHeight
279
- if (addedHeight <= 0) return
280
- loadOlderAnchorRef.current = null
281
- el.scrollTop = anchor.scrollTop + addedHeight
282
- }, [messages, dialogId, scrollRef])
268
+ // Always keep `scrollHeight` snapshot fresh so the next prepend
269
+ // computes the right delta. The id/content updates only when
270
+ // those actually change to avoid churn.
271
+ prependRef.current.scrollHeight = el.scrollHeight
272
+ if (currentFirstContent !== prependRef.current.firstMessageContent) {
273
+ prependRef.current.firstMessageContent = currentFirstContent
274
+ }
275
+ }, [messages, scrollRef])
283
276
 
284
277
  // ---- Per-message scrollAnchor (server-driven) --------------------
285
278
  // Server emits `scrollAnchor: 'top'` on the metadata leading frame
@@ -389,14 +382,6 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
389
382
  // ---- Load-more (infinite-scroll UP) ------------------------------
390
383
  // Distinct from stick-to-bottom; uses its own IntersectionObserver
391
384
  // on the top sentinel.
392
- //
393
- // `isFetchingNextPage` is in the deps so the observer is RE-CREATED when a
394
- // fetch completes: IntersectionObserver fires no new callback while an
395
- // element stays continuously intersecting, so after an older page lands
396
- // with the sentinel still in view (small prepend, or rootMargin overlap)
397
- // nothing would re-trigger until the user scrolled it out and back in.
398
- // Re-observing re-evaluates the current intersection and fires again if
399
- // the sentinel is still visible — load continues until it's pushed out.
400
385
  useEffect(() => {
401
386
  const scrollContainer = scrollRef.current
402
387
  const sentinelElement = sentinelRef.current
@@ -407,16 +392,6 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
407
392
  const entry = entries[0]
408
393
  if (!entry) return
409
394
  if (entry.isIntersecting && !isFetchingRef.current) {
410
- // Snapshot pre-load geometry so the prepend-anchoring layout
411
- // effect can re-pin scrollTop once the taller content commits.
412
- const el = scrollRef.current
413
- if (el) {
414
- loadOlderAnchorRef.current = {
415
- dialogId: dialogIdRef.current,
416
- scrollHeight: el.scrollHeight,
417
- scrollTop: el.scrollTop,
418
- }
419
- }
420
395
  onLoadMoreRef.current?.()
421
396
  }
422
397
  },
@@ -424,7 +399,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
424
399
  )
425
400
  observer.observe(sentinelElement)
426
401
  return () => observer.disconnect()
427
- }, [hasNextPage, isFetchingNextPage, scrollRef])
402
+ }, [hasNextPage, scrollRef])
428
403
 
429
404
  // Expose the scroll container ref to parents that need it (rare,
430
405
  // but the existing public contract). Library's `scrollRef` is a
@@ -22,6 +22,7 @@
22
22
  import { useQuery } from '@tanstack/react-query'
23
23
  import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
24
24
  import { embedAuthedFetch } from '../../../utils/embed-authed-fetch'
25
+ import { extractItems, extractItemId } from '../../../utils/extract-items'
25
26
 
26
27
  export interface UseChatCardItemResult<T = unknown> {
27
28
  item: T | undefined
@@ -29,44 +30,8 @@ export interface UseChatCardItemResult<T = unknown> {
29
30
  isError: boolean
30
31
  }
31
32
 
32
- /** Extract the items array from each endpoint's response shape (some
33
- * return `{ items }`, others `{ posts }`, etc.). Single normalization
34
- * point — callers always read `Item[]`. */
35
- function extractItems(data: unknown): unknown[] {
36
- if (!data || typeof data !== 'object') return []
37
- const obj = data as Record<string, unknown>
38
- if (Array.isArray(obj.items)) return obj.items
39
- if (Array.isArray(obj.posts)) return obj.posts
40
- if (Array.isArray(obj.campaigns)) return obj.campaigns
41
- if (Array.isArray(obj.completed) || Array.isArray(obj.inProgress)) {
42
- // Delivery endpoint splits into completed/inProgress arrays — flatten.
43
- const completed = Array.isArray(obj.completed) ? obj.completed : []
44
- const inProgress = Array.isArray(obj.inProgress) ? obj.inProgress : []
45
- return [...completed, ...inProgress]
46
- }
47
- if (Array.isArray(obj.data)) return obj.data
48
- if (Array.isArray(data)) return data
49
- return []
50
- }
51
-
52
- /** Extract a stable id from a fetched item — different shapes use
53
- * `id` vs `external_id`. Used by the chat loader to match the fetched
54
- * array element back to the marker's id. */
55
- function extractItemId(type: string, item: unknown): string | null {
56
- if (!item || typeof item !== 'object') return null
57
- const obj = item as Record<string, unknown>
58
- if (type === 'roadmap_item' || type === 'delivery_item' || type === 'internal_task') {
59
- const ext = obj.external_id
60
- if (typeof ext === 'string') return ext
61
- }
62
- // RoadmapItem-shaped responses use `id` to carry the external_id; the
63
- // mapper renames external_id→id at the API boundary. Treat both id and
64
- // external_id as primary-key candidates for these clickup-backed types.
65
- const id = obj.id
66
- if (typeof id === 'string') return id
67
- if (typeof id === 'number') return String(id)
68
- return null
69
- }
33
+ // `extractItems` / `extractItemId` hoisted to `src/utils/extract-items.ts`
34
+ // (shared with the related-content rail react-query-free home).
70
35
 
71
36
  export function useChatCardItem<T = unknown>(
72
37
  type: string,
@@ -1,23 +1,7 @@
1
1
  /**
2
- * ContentRef — the unified link/embed shape stored in JSONB columns
3
- * across investor updates, performance baselines, and any other entity
4
- * that surfaces "related content" rails.
5
- *
6
- * Lifted from `lib/data/investor-update-utils-shared.ts` in the hub
7
- * (the server-side data utils in the same file stay hub-side; only the
8
- * TYPE moves here).
2
+ * RELOCATED — the canonical `ContentRef` now lives at `src/types/content-ref.ts`
3
+ * (server-safe home, shared by the related-content rail + chat entities).
4
+ * This stub keeps the chat entities barrel + `entities/investor-update.ts`
5
+ * compiling unchanged. One definition package-wide.
9
6
  */
10
-
11
- export interface ContentRef {
12
- type: string;
13
- id: string;
14
- slug: string;
15
- url: string;
16
- targetPlatform?: string | null;
17
- title: string;
18
- summary?: string;
19
- image_url?: string;
20
- image_bg_color?: string;
21
- visibility: 'public' | 'internal';
22
- display_order: number;
23
- }
7
+ export type { ContentRef, ContentRefWithReason } from '../../../../types/content-ref';
@@ -31,18 +31,6 @@ export {
31
31
  extractIncompleteMessageState,
32
32
  } from './extract-incomplete-message-state'
33
33
 
34
- // History <-> realtime reconciliation
35
- export {
36
- mergeHistoryWithRealtime,
37
- computeHistoryPrepend,
38
- flattenMessagePagesChronological,
39
- maxPersistedStreamSeq,
40
- SYNTHETIC_REALTIME_ID_PREFIXES,
41
- type MergeableChatMessage,
42
- type HistoryMergeInput,
43
- type HistoryPrependResult,
44
- } from './history-merge'
45
-
46
34
  // Tool call helpers
47
35
  export { getCommandText } from './tool-call-helpers'
48
36
 
@@ -1,9 +1,10 @@
1
1
  "use client"
2
2
 
3
- import React from 'react'
3
+ import React, { useMemo } from 'react'
4
4
  import type { Faq } from '../../types/faq'
5
5
  import { FaqAccordion, type FaqItem } from '../faq-accordion'
6
6
  import { useSelfFetch } from '../../hooks/use-self-fetch'
7
+ import { buildSuggestionUrl } from '../../utils/suggestion-url'
7
8
  import { buildFaqJsonLdFromFaqs, type FaqSchemaOptions } from './json-ld'
8
9
 
9
10
  export interface FaqSectionProps {
@@ -28,16 +29,26 @@ export interface FaqSectionProps {
28
29
  /** Overrides for the JSON-LD's name/description/url. */
29
30
  jsonLd?: FaqSchemaOptions
30
31
  className?: string
32
+ /** Maps to /api/faqs `?count=` (the 5-tier fill target). Absent → param
33
+ * not sent (server default applies). */
34
+ minResults?: number
35
+ /** Fetch-URL prefix for third-party embeds / reverse proxies
36
+ * ('' = same-origin relative). */
37
+ apiBaseUrl?: string
31
38
  }
32
39
 
33
40
  const DEFAULT_HEADING_TEXT = 'Frequently Asked Questions'
34
41
 
35
- function buildFaqsUrl(entityType?: string, entityId?: number | string): string {
36
- if (!entityType || entityId === undefined || entityId === null || entityId === '') {
37
- return '/api/faqs'
38
- }
39
- const qs = new URLSearchParams({ entityType, entityId: String(entityId) })
40
- return `/api/faqs?${qs.toString()}`
42
+ /** URL composition shared with RelatedContentSection (`buildSuggestionUrl`)
43
+ * byte-identical to the historical `buildFaqsUrl` output when
44
+ * `minResults`/`apiBaseUrl` are absent. */
45
+ function buildFaqsUrl(
46
+ entityType?: string,
47
+ entityId?: number | string,
48
+ minResults?: number,
49
+ apiBaseUrl = '',
50
+ ): string {
51
+ return buildSuggestionUrl('/api/faqs', { apiBaseUrl, entityType, entityId, count: minResults })
41
52
  }
42
53
 
43
54
  function groupBySection(faqs: Faq[]): Array<{ section: string | null; items: FaqItem[] }> {
@@ -97,9 +108,16 @@ export function FaqSection({
97
108
  emitJsonLd = false,
98
109
  jsonLd,
99
110
  className,
111
+ minResults,
112
+ apiBaseUrl = '',
100
113
  }: FaqSectionProps) {
101
- const url = buildFaqsUrl(entityType, entityId)
102
- const initialData = initialFaqs ? { faqs: initialFaqs } : undefined
114
+ const url = buildFaqsUrl(entityType, entityId, minResults, apiBaseUrl)
115
+ // Memoized useSelfFetch re-syncs on [initialData]; a fresh per-render
116
+ // wrapper object would setState-loop under re-rendering parents.
117
+ const initialData = useMemo<FaqsResponse | undefined>(
118
+ () => (initialFaqs ? { faqs: initialFaqs } : undefined),
119
+ [initialFaqs],
120
+ )
103
121
  const { data, isLoading, error } = useSelfFetch<FaqsResponse>(url, { initialData })
104
122
 
105
123
  const faqs = data?.faqs ?? []
@@ -116,7 +134,24 @@ export function FaqSection({
116
134
  return null
117
135
  }
118
136
 
137
+ // Embedded mode, zero FAQs after load: render nothing — an embedded host
138
+ // page (blog/case-study detail) must not show an empty section shell.
139
+ // The standalone /faqs page (heading set) keeps its existing behavior.
140
+ if (!isLoading && !error && faqs.length === 0 && !showHeading) {
141
+ return null
142
+ }
143
+
119
144
  if (isLoading && faqs.length === 0) {
145
+ // Embedded mode renders skeletons WITHOUT the standalone page shell —
146
+ // a host detail page already provides <main> + gutters; nesting them
147
+ // here would emit invalid markup and double padding.
148
+ if (!showHeading) {
149
+ return (
150
+ <div className={className}>
151
+ <FaqSkeleton />
152
+ </div>
153
+ )
154
+ }
120
155
  return (
121
156
  <main className={className ?? 'bg-ods-bg'}>
122
157
  <div className="max-w-[1920px] px-6 md:px-20 py-6 md:py-10 mx-auto">
@@ -136,23 +171,33 @@ export function FaqSection({
136
171
 
137
172
  const schema = emitJsonLd ? buildFaqJsonLdFromFaqs(faqs, jsonLd) : null
138
173
 
174
+ const groupNodes = groups.map(({ section, items }) => (
175
+ <div key={section || 'default'} className="space-y-4">
176
+ {section && (
177
+ <h2 className="text-h2 tracking-[-0.04em] text-ods-text-primary mb-3 md:mb-4">
178
+ {section}
179
+ </h2>
180
+ )}
181
+ <FaqAccordion items={items} />
182
+ </div>
183
+ ))
184
+
185
+ // Embedded mode (heading === null): no <main> page shell, no page gutters —
186
+ // the host page owns layout. Standalone mode keeps the historical markup.
187
+ const body = showHeading ? (
188
+ <main className={className ?? 'bg-ods-bg'}>
189
+ <div className="max-w-[1920px] px-6 md:px-20 py-6 md:py-10 mx-auto space-y-10">
190
+ {headingNode}
191
+ {groupNodes}
192
+ </div>
193
+ </main>
194
+ ) : (
195
+ <section className={className ?? 'space-y-10'}>{groupNodes}</section>
196
+ )
197
+
139
198
  return (
140
199
  <>
141
- <main className={className ?? 'bg-ods-bg'}>
142
- <div className="max-w-[1920px] px-6 md:px-20 py-6 md:py-10 mx-auto space-y-10">
143
- {showHeading && headingNode}
144
- {groups.map(({ section, items }) => (
145
- <div key={section || 'default'} className="space-y-4">
146
- {section && (
147
- <h2 className="text-h2 tracking-[-0.04em] text-ods-text-primary mb-3 md:mb-4">
148
- {section}
149
- </h2>
150
- )}
151
- <FaqAccordion items={items} />
152
- </div>
153
- ))}
154
- </div>
155
- </main>
200
+ {body}
156
201
  {schema && (
157
202
  <script
158
203
  type="application/ld+json"
@@ -204,7 +204,6 @@ const CategorySection: React.FC<{
204
204
 
205
205
  {/* Expand/Collapse Button */}
206
206
  <Button
207
- type="button"
208
207
  variant="transparent"
209
208
  onClick={(e) => {
210
209
  e.stopPropagation();
@@ -26,6 +26,7 @@ export * from './faq-accordion'
26
26
  // dedicated server-safe subpath "./components/faq/json-ld" (built without the
27
27
  // client banner under the server/universal block of tsup.config.ts).
28
28
  export * from './faq'
29
+ export * from './related-content'
29
30
  export * from './filter-chip'
30
31
  export * from './footer'
31
32
  export * from './unified-filter-logic'
@@ -0,0 +1,9 @@
1
+ // CLIENT subpath barrel — `@flamingo-stack/openframe-frontend-core/components/related-content`
2
+ export {
3
+ RelatedContentSection,
4
+ type RelatedContentSectionProps,
5
+ type CardLinkProvider,
6
+ type CardLinkProviderProps,
7
+ type CardLinkAnchorProps,
8
+ type AdminCampaignCardSlot,
9
+ } from './related-content-section'