@flamingo-stack/openframe-frontend-core 0.0.239 → 0.0.241

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-PN64LWNT.js → chunk-2GMHLKEO.js} +8 -8
  2. package/dist/{chunk-36G2QXYZ.cjs → chunk-3TDLFCCS.cjs} +37 -23
  3. package/dist/chunk-3TDLFCCS.cjs.map +1 -0
  4. package/dist/{chunk-ORJREQ2W.js → chunk-4XLJWX2N.js} +4 -4
  5. package/dist/chunk-5KAQW6Q3.js +285 -0
  6. package/dist/chunk-5KAQW6Q3.js.map +1 -0
  7. package/dist/{chunk-7LLS3TWN.js → chunk-6IFN7F3C.js} +5 -5
  8. package/dist/{chunk-5VU66BD2.cjs → chunk-EFT34DLB.cjs} +498 -461
  9. package/dist/chunk-EFT34DLB.cjs.map +1 -0
  10. package/dist/{chunk-Y6463TD6.js → chunk-FEZSQQTH.js} +2 -2
  11. package/dist/{chunk-IJOJWBME.cjs → chunk-GRD7M6IZ.cjs} +27 -27
  12. package/dist/{chunk-IJOJWBME.cjs.map → chunk-GRD7M6IZ.cjs.map} +1 -1
  13. package/dist/{chunk-ELMYRGWG.cjs → chunk-JEPYFYQP.cjs} +12 -12
  14. package/dist/{chunk-ELMYRGWG.cjs.map → chunk-JEPYFYQP.cjs.map} +1 -1
  15. package/dist/{chunk-Y7DCQUNL.js → chunk-KOHE4F6Q.js} +91 -54
  16. package/dist/chunk-KOHE4F6Q.js.map +1 -0
  17. package/dist/{chunk-JDQY7JCV.js → chunk-MKVKVJQQ.js} +36 -22
  18. package/dist/chunk-MKVKVJQQ.js.map +1 -0
  19. package/dist/{chunk-MTROCWO4.cjs → chunk-QJULZ4PB.cjs} +7 -7
  20. package/dist/{chunk-MTROCWO4.cjs.map → chunk-QJULZ4PB.cjs.map} +1 -1
  21. package/dist/{chunk-FSS2MIEA.cjs → chunk-SGJBUL2Y.cjs} +20 -3
  22. package/dist/chunk-SGJBUL2Y.cjs.map +1 -0
  23. package/dist/{chunk-SZ3BBI6H.cjs → chunk-SWXWKVCC.cjs} +12 -12
  24. package/dist/{chunk-SZ3BBI6H.cjs.map → chunk-SWXWKVCC.cjs.map} +1 -1
  25. package/dist/{chunk-LJGBFD52.js → chunk-VAUI3CND.js} +18 -1
  26. package/dist/chunk-VAUI3CND.js.map +1 -0
  27. package/dist/{chunk-OB45JHDY.cjs → chunk-VFKQMAUF.cjs} +3 -3
  28. package/dist/{chunk-OB45JHDY.cjs.map → chunk-VFKQMAUF.cjs.map} +1 -1
  29. package/dist/chunk-WKZS5HWH.cjs +285 -0
  30. package/dist/chunk-WKZS5HWH.cjs.map +1 -0
  31. package/dist/{chunk-G6YZ7O27.js → chunk-Z3657YGL.js} +6 -6
  32. package/dist/components/chat/hooks/use-chat-card-item.d.ts.map +1 -1
  33. package/dist/components/chat/index.cjs +6 -6
  34. package/dist/components/chat/index.js +5 -5
  35. package/dist/components/chat/types/entities/content-ref.d.ts +5 -20
  36. package/dist/components/chat/types/entities/content-ref.d.ts.map +1 -1
  37. package/dist/components/chevron-button.d.ts +3 -1
  38. package/dist/components/chevron-button.d.ts.map +1 -1
  39. package/dist/components/contact/index.cjs +7 -7
  40. package/dist/components/contact/index.js +6 -6
  41. package/dist/components/faq/faq-section.d.ts +8 -2
  42. package/dist/components/faq/faq-section.d.ts.map +1 -1
  43. package/dist/components/faq/index.cjs +3 -3
  44. package/dist/components/faq/index.js +2 -2
  45. package/dist/components/faq/json-ld.cjs +1 -1
  46. package/dist/components/faq/json-ld.cjs.map +1 -1
  47. package/dist/components/faq/{faq-json-ld.d.ts → json-ld.d.ts} +1 -1
  48. package/dist/components/faq/json-ld.d.ts.map +1 -0
  49. package/dist/components/faq/json-ld.js +1 -1
  50. package/dist/components/faq/json-ld.js.map +1 -1
  51. package/dist/components/faq-accordion.d.ts.map +1 -1
  52. package/dist/components/features/index.cjs +6 -6
  53. package/dist/components/features/index.js +5 -5
  54. package/dist/components/index.cjs +89 -85
  55. package/dist/components/index.cjs.map +1 -1
  56. package/dist/components/index.d.ts +1 -0
  57. package/dist/components/index.d.ts.map +1 -1
  58. package/dist/components/index.js +31 -27
  59. package/dist/components/index.js.map +1 -1
  60. package/dist/components/navigation/index.cjs +6 -6
  61. package/dist/components/navigation/index.js +5 -5
  62. package/dist/components/onboarding-guides/index.cjs +30 -30
  63. package/dist/components/onboarding-guides/index.cjs.map +1 -1
  64. package/dist/components/onboarding-guides/index.js +11 -11
  65. package/dist/components/related-content/index.cjs +26 -0
  66. package/dist/components/related-content/index.cjs.map +1 -0
  67. package/dist/components/related-content/index.d.ts +2 -0
  68. package/dist/components/related-content/index.d.ts.map +1 -0
  69. package/dist/components/related-content/index.js +26 -0
  70. package/dist/components/related-content/index.js.map +1 -0
  71. package/dist/components/related-content/related-content-section.d.ts +141 -0
  72. package/dist/components/related-content/related-content-section.d.ts.map +1 -0
  73. package/dist/components/tickets/index.cjs +73 -73
  74. package/dist/components/tickets/index.cjs.map +1 -1
  75. package/dist/components/tickets/index.js +15 -15
  76. package/dist/components/ui/index.cjs +11 -11
  77. package/dist/components/ui/index.cjs.map +1 -1
  78. package/dist/components/ui/index.js +13 -13
  79. package/dist/embed-shims/index.cjs +3 -3
  80. package/dist/embed-shims/index.cjs.map +1 -1
  81. package/dist/embed-shims/index.js +5 -5
  82. package/dist/index.cjs +27 -11
  83. package/dist/index.cjs.map +1 -1
  84. package/dist/index.js +55 -39
  85. package/dist/types/content-ref.d.ts +35 -0
  86. package/dist/types/content-ref.d.ts.map +1 -0
  87. package/dist/types/index.d.ts +1 -0
  88. package/dist/types/index.d.ts.map +1 -1
  89. package/dist/utils/content-ref-groups.d.ts +58 -0
  90. package/dist/utils/content-ref-groups.d.ts.map +1 -0
  91. package/dist/utils/extract-items.d.ts +18 -0
  92. package/dist/utils/extract-items.d.ts.map +1 -0
  93. package/dist/utils/index.cjs +81 -0
  94. package/dist/utils/index.cjs.map +1 -1
  95. package/dist/utils/index.d.ts +4 -1
  96. package/dist/utils/index.d.ts.map +1 -1
  97. package/dist/utils/index.js +74 -1
  98. package/dist/utils/index.js.map +1 -1
  99. package/dist/utils/list-url.d.ts +6 -0
  100. package/dist/utils/list-url.d.ts.map +1 -1
  101. package/dist/utils/suggestion-url.d.ts +26 -0
  102. package/dist/utils/suggestion-url.d.ts.map +1 -0
  103. package/package.json +7 -1
  104. package/src/components/chat/hooks/use-chat-card-item.ts +3 -38
  105. package/src/components/chat/types/entities/content-ref.ts +5 -21
  106. package/src/components/chevron-button.tsx +5 -1
  107. package/src/components/faq/faq-section.tsx +71 -26
  108. package/src/components/faq-accordion.tsx +2 -1
  109. package/src/components/index.ts +1 -0
  110. package/src/components/related-content/index.ts +9 -0
  111. package/src/components/related-content/related-content-section.tsx +603 -0
  112. package/src/types/content-ref.ts +34 -0
  113. package/src/types/index.ts +1 -0
  114. package/src/utils/content-ref-groups.ts +89 -0
  115. package/src/utils/extract-items.ts +48 -0
  116. package/src/utils/index.ts +16 -1
  117. package/src/utils/list-url.ts +9 -0
  118. package/src/utils/suggestion-url.ts +39 -0
  119. package/dist/chunk-36G2QXYZ.cjs.map +0 -1
  120. package/dist/chunk-5VU66BD2.cjs.map +0 -1
  121. package/dist/chunk-FSS2MIEA.cjs.map +0 -1
  122. package/dist/chunk-JDQY7JCV.js.map +0 -1
  123. package/dist/chunk-LJGBFD52.js.map +0 -1
  124. package/dist/chunk-Y7DCQUNL.js.map +0 -1
  125. package/dist/components/faq/faq-json-ld.d.ts.map +0 -1
  126. /package/dist/{chunk-PN64LWNT.js.map → chunk-2GMHLKEO.js.map} +0 -0
  127. /package/dist/{chunk-ORJREQ2W.js.map → chunk-4XLJWX2N.js.map} +0 -0
  128. /package/dist/{chunk-7LLS3TWN.js.map → chunk-6IFN7F3C.js.map} +0 -0
  129. /package/dist/{chunk-Y6463TD6.js.map → chunk-FEZSQQTH.js.map} +0 -0
  130. /package/dist/{chunk-G6YZ7O27.js.map → chunk-Z3657YGL.js.map} +0 -0
  131. /package/src/components/faq/{faq-json-ld.ts → json-ld.ts} +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.239",
3
+ "version": "0.0.241",
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",
@@ -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';
@@ -10,6 +10,8 @@ interface ChevronButtonProps {
10
10
  backgroundColor?: string;
11
11
  borderColor?: string;
12
12
  onClick?: (e: React.MouseEvent) => void;
13
+ /** Forwarded to the inner <button> for assistive tech. */
14
+ 'aria-label'?: string;
13
15
  }
14
16
 
15
17
  export function ChevronButton({
@@ -18,7 +20,8 @@ export function ChevronButton({
18
20
  size = 'icon',
19
21
  backgroundColor = 'transparent',
20
22
  borderColor = 'transparent',
21
- onClick
23
+ onClick,
24
+ 'aria-label': ariaLabel,
22
25
  }: ChevronButtonProps) {
23
26
  const [isHovered, setIsHovered] = React.useState(false);
24
27
 
@@ -37,6 +40,7 @@ export function ChevronButton({
37
40
  variant="transparent"
38
41
  size="icon"
39
42
  onClick={onClick}
43
+ aria-label={ariaLabel}
40
44
  onMouseEnter={() => setIsHovered(true)}
41
45
  onMouseLeave={() => setIsHovered(false)}
42
46
  className={cn(
@@ -1,10 +1,11 @@
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 { buildFaqJsonLdFromFaqs, type FaqSchemaOptions } from './faq-json-ld'
7
+ import { buildSuggestionUrl } from '../../utils/suggestion-url'
8
+ import { buildFaqJsonLdFromFaqs, type FaqSchemaOptions } from './json-ld'
8
9
 
9
10
  export interface FaqSectionProps {
10
11
  /**
@@ -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[] }> {
@@ -60,7 +71,7 @@ function groupBySection(faqs: Faq[]): Array<{ section: string | null; items: Faq
60
71
  function FaqSkeleton() {
61
72
  return (
62
73
  <div className="space-y-8 animate-pulse">
63
- <div className="h-[48px] md:h-[56px] w-2/3 rounded bg-ods-border" />
74
+ <div className="h-12 md:h-14 w-2/3 rounded bg-ods-border" />
64
75
  <div className="rounded-3xl border border-ods-border overflow-hidden bg-ods-card divide-y divide-ods-border w-full">
65
76
  {Array.from({ length: 8 }).map((_, idx) => (
66
77
  <div key={idx} className="flex items-center justify-between px-6 md:px-8 py-6">
@@ -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"
@@ -79,8 +79,9 @@ export function FaqAccordion({ items, defaultOpenIds = [] }: FaqAccordionProps)
79
79
  <h3>
80
80
  {item.question}
81
81
  </h3>
82
- <div className="flex-shrink-0" aria-label={isOpen ? 'Collapse question' : 'Expand question'}>
82
+ <div className="flex-shrink-0">
83
83
  <ChevronButton
84
+ aria-label={isOpen ? 'Collapse question' : 'Expand question'}
84
85
  size="md"
85
86
  isExpanded={isOpen}
86
87
  backgroundColor="transparent"
@@ -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'