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

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 (128) hide show
  1. package/dist/{chunk-36G2QXYZ.cjs → chunk-3TDLFCCS.cjs} +37 -23
  2. package/dist/chunk-3TDLFCCS.cjs.map +1 -0
  3. package/dist/{chunk-ORJREQ2W.js → chunk-4XLJWX2N.js} +4 -4
  4. package/dist/{chunk-SZ3BBI6H.cjs → chunk-5JTDFOYB.cjs} +12 -12
  5. package/dist/{chunk-SZ3BBI6H.cjs.map → chunk-5JTDFOYB.cjs.map} +1 -1
  6. package/dist/{chunk-G6YZ7O27.js → chunk-7BWJF43J.js} +6 -6
  7. package/dist/{chunk-7LLS3TWN.js → chunk-B3NI3KT4.js} +5 -5
  8. package/dist/{chunk-MTROCWO4.cjs → chunk-CDLMGHSB.cjs} +7 -7
  9. package/dist/{chunk-MTROCWO4.cjs.map → chunk-CDLMGHSB.cjs.map} +1 -1
  10. package/dist/{chunk-JDQY7JCV.js → chunk-MKVKVJQQ.js} +36 -22
  11. package/dist/chunk-MKVKVJQQ.js.map +1 -0
  12. package/dist/{chunk-5VU66BD2.cjs → chunk-NE2DYD6K.cjs} +494 -461
  13. package/dist/chunk-NE2DYD6K.cjs.map +1 -0
  14. package/dist/chunk-POZHDJSK.js +276 -0
  15. package/dist/chunk-POZHDJSK.js.map +1 -0
  16. package/dist/{chunk-FSS2MIEA.cjs → chunk-SGJBUL2Y.cjs} +20 -3
  17. package/dist/chunk-SGJBUL2Y.cjs.map +1 -0
  18. package/dist/{chunk-IJOJWBME.cjs → chunk-SI4LEFLR.cjs} +27 -27
  19. package/dist/{chunk-IJOJWBME.cjs.map → chunk-SI4LEFLR.cjs.map} +1 -1
  20. package/dist/chunk-TE6LSK2X.cjs +276 -0
  21. package/dist/chunk-TE6LSK2X.cjs.map +1 -0
  22. package/dist/{chunk-LJGBFD52.js → chunk-VAUI3CND.js} +18 -1
  23. package/dist/chunk-VAUI3CND.js.map +1 -0
  24. package/dist/{chunk-OB45JHDY.cjs → chunk-VFKQMAUF.cjs} +3 -3
  25. package/dist/{chunk-OB45JHDY.cjs.map → chunk-VFKQMAUF.cjs.map} +1 -1
  26. package/dist/{chunk-Y6463TD6.js → chunk-XWUA273B.js} +2 -2
  27. package/dist/{chunk-Y7DCQUNL.js → chunk-YH6KPNIH.js} +87 -54
  28. package/dist/chunk-YH6KPNIH.js.map +1 -0
  29. package/dist/{chunk-ELMYRGWG.cjs → chunk-YY6S4T67.cjs} +12 -12
  30. package/dist/{chunk-ELMYRGWG.cjs.map → chunk-YY6S4T67.cjs.map} +1 -1
  31. package/dist/{chunk-PN64LWNT.js → chunk-ZYPOIFBS.js} +8 -8
  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 +135 -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 +25 -11
  83. package/dist/index.cjs.map +1 -1
  84. package/dist/index.js +53 -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 +77 -0
  94. package/dist/utils/index.cjs.map +1 -1
  95. package/dist/utils/index.d.ts +3 -0
  96. package/dist/utils/index.d.ts.map +1 -1
  97. package/dist/utils/index.js +71 -1
  98. package/dist/utils/index.js.map +1 -1
  99. package/dist/utils/suggestion-url.d.ts +26 -0
  100. package/dist/utils/suggestion-url.d.ts.map +1 -0
  101. package/package.json +7 -1
  102. package/src/components/chat/hooks/use-chat-card-item.ts +3 -38
  103. package/src/components/chat/types/entities/content-ref.ts +5 -21
  104. package/src/components/chevron-button.tsx +5 -1
  105. package/src/components/faq/faq-section.tsx +71 -26
  106. package/src/components/faq-accordion.tsx +2 -1
  107. package/src/components/index.ts +1 -0
  108. package/src/components/related-content/index.ts +9 -0
  109. package/src/components/related-content/related-content-section.tsx +585 -0
  110. package/src/types/content-ref.ts +34 -0
  111. package/src/types/index.ts +1 -0
  112. package/src/utils/content-ref-groups.ts +89 -0
  113. package/src/utils/extract-items.ts +48 -0
  114. package/src/utils/index.ts +15 -0
  115. package/src/utils/suggestion-url.ts +39 -0
  116. package/dist/chunk-36G2QXYZ.cjs.map +0 -1
  117. package/dist/chunk-5VU66BD2.cjs.map +0 -1
  118. package/dist/chunk-FSS2MIEA.cjs.map +0 -1
  119. package/dist/chunk-JDQY7JCV.js.map +0 -1
  120. package/dist/chunk-LJGBFD52.js.map +0 -1
  121. package/dist/chunk-Y7DCQUNL.js.map +0 -1
  122. package/dist/components/faq/faq-json-ld.d.ts.map +0 -1
  123. /package/dist/{chunk-ORJREQ2W.js.map → chunk-4XLJWX2N.js.map} +0 -0
  124. /package/dist/{chunk-G6YZ7O27.js.map → chunk-7BWJF43J.js.map} +0 -0
  125. /package/dist/{chunk-7LLS3TWN.js.map → chunk-B3NI3KT4.js.map} +0 -0
  126. /package/dist/{chunk-Y6463TD6.js.map → chunk-XWUA273B.js.map} +0 -0
  127. /package/dist/{chunk-PN64LWNT.js.map → chunk-ZYPOIFBS.js.map} +0 -0
  128. /package/src/components/faq/{faq-json-ld.ts → json-ld.ts} +0 -0
@@ -0,0 +1,585 @@
1
+ "use client";
2
+
3
+ /**
4
+ * RelatedContentSection
5
+ *
6
+ * Renders content references grouped by type using the canonical card
7
+ * components. MOVED from the hub (`components/shared/related-content-card.tsx`)
8
+ * so any consuming app can embed it; the hub keeps a thin wrapper that
9
+ * pre-binds its host-specific injections (nav hook, URL recomposition,
10
+ * program configs, admin campaign card).
11
+ *
12
+ * THREE data modes (precedence top-down):
13
+ * 1. CONTROLLED — `contentRefs` provided (even `[]`): render exactly those
14
+ * refs, no suggestion fetch (the original investor-update behavior).
15
+ * 2. SUGGESTION — `entityType` + `entityId` provided: self-fetch
16
+ * `GET {apiBaseUrl}/api/related-content?entityType&entityId[&count][&excludeTypes]`
17
+ * (the generic 5-tier engine's second web service). `minResults` maps to
18
+ * `count`; absent → param not sent (server default applies). Each ref
19
+ * carries a `reason` (data-only — never rendered, matching the
20
+ * FaqSection/FaqWithReason precedent).
21
+ * 3. SSR-HYDRATED suggestion — also pass `initialItems` (the server page
22
+ * called the engine directly); the first client fetch is skipped per the
23
+ * `useSelfFetch` initialData contract.
24
+ *
25
+ * Group layout (list vs grid) + card size (lg vs default) come from
26
+ * `CONTENT_REF_GROUPS` in `../../utils/content-ref-groups` — single source of
27
+ * truth, no per-type logic in this file. Skeletons come from
28
+ * `renderSkeletonForType` so the placeholder height matches the loaded card
29
+ * exactly (zero layout shift on resolve).
30
+ *
31
+ * One API call per content type via the shared list-URL builder
32
+ * (`buildListUrl` — injectable; defaults to the lib's byte-parity-tested
33
+ * builder prefixed with `apiBaseUrl`). Fetching uses `useSelfFetch` (plain
34
+ * fetch, NO react-query) so third-party embedders need no QueryClientProvider;
35
+ * cards are imported via DEEP module paths (not the chat barrel) so this
36
+ * chunk never reaches `@tanstack/react-query`.
37
+ *
38
+ * LOCKSTEP NOTE: this file's per-type card/skeleton dispatch is the SIZED
39
+ * sibling of the chat-side `CHAT_CARD_REGISTRY` (`../chat/entity-cards/
40
+ * dispatch.tsx`), which renders compact `size='sm'` cards wired to the chat
41
+ * runtime. Two dispatchers by design — when registering a new fetch-mode
42
+ * content type, add it BOTH there and here (cards + skeleton + list URL).
43
+ */
44
+
45
+ import React, { useMemo } from 'react';
46
+ import {
47
+ CONTENT_REF_GROUPS,
48
+ getContentRefLabelOrTitleCase,
49
+ orderContentRefTypes,
50
+ type ContentRefGroupConfig,
51
+ } from '../../utils/content-ref-groups';
52
+ import type { ContentRef, ContentRefWithReason } from '../../types/content-ref';
53
+ import { useSelfFetch } from '../../hooks/use-self-fetch';
54
+ import { extractItems } from '../../utils/extract-items';
55
+ import { buildListUrl as libBuildListUrl } from '../../utils/list-url';
56
+ import { buildSuggestionUrl } from '../../utils/suggestion-url';
57
+ import { decideNewTab } from '../chat/utils/decide-new-tab';
58
+ // DEEP card imports — NOT the `../chat` barrel (the barrel statically reaches
59
+ // @tanstack/react-query via embeddable-chat + its hooks). Deep paths keep this
60
+ // component's SOURCE graph react-query-free. Note: tsup's shared-chunk
61
+ // splitting may still colocate the cards with chat hooks in one dist chunk
62
+ // (react-query is a required peerDep, so resolution always succeeds) — the
63
+ // guarantee that matters here is the RUNTIME one: nothing on this path ever
64
+ // instantiates a QueryClient, so embedders need NO QueryClientProvider.
65
+ import { BlogCard, BlogCardSkeleton } from '../chat/entity-cards/blog-card';
66
+ import { CaseStudyCard, CaseStudyCardSkeleton } from '../chat/entity-cards/case-study-card';
67
+ import { CustomerInterviewCard, CustomerInterviewCardSkeleton } from '../chat/entity-cards/customer-interview-card';
68
+ import { ProductReleaseCard, ProductReleaseCardSkeleton } from '../chat/entity-cards/product-release-card';
69
+ import { buildProductReleaseCardProps } from '../chat/entity-cards/product-release-card-defaults';
70
+ import { ProgramCard, ProgramCardSkeleton } from '../chat/entity-cards/program-card';
71
+ import { InvestorUpdateCard, InvestorUpdateCardSkeleton } from '../chat/entity-cards/investor-update-card';
72
+ import { OnboardingGuideCard, OnboardingGuideCardSkeleton } from '../chat/entity-cards/onboarding-guide-card';
73
+ import { RoadmapCard, RoadmapCardSkeleton } from '../chat/entity-cards/roadmap-card';
74
+ // Type-only — erased at build, no runtime dependency on the dispatch module.
75
+ import type { ChatCardDispatchExtras } from '../chat/entity-cards/dispatch';
76
+
77
+ type CardSize = 'lg' | 'default' | 'sm';
78
+
79
+ /** Anchor prop bundle the per-card link surface receives — same shape the
80
+ * hub's `useNavLink` returns and the chat dispatcher's anchor builders
81
+ * produce. `null` = non-anchor mode (no URL). */
82
+ export interface CardLinkAnchorProps {
83
+ href: string;
84
+ target?: '_blank';
85
+ rel?: 'noopener noreferrer';
86
+ onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
87
+ }
88
+
89
+ /** Render-prop component injection for the navigation decision — keeps hook
90
+ * calls legal (hooks live INSIDE the injected component; `CardForType`
91
+ * itself calls zero hooks). The hub injects a `useNavLink`-backed provider;
92
+ * the default is hook-free (pure `decideNewTab`). MUST be defined at module
93
+ * scope by hosts — an inline arrow would remount every card each render. */
94
+ export interface CardLinkProviderProps {
95
+ href: string | null;
96
+ targetPlatform: string | null;
97
+ children: (linkProps: CardLinkAnchorProps | null) => React.ReactElement | null;
98
+ }
99
+ export type CardLinkProvider = React.ComponentType<CardLinkProviderProps>;
100
+
101
+ /** Default link provider for standalone embeds: relative/-same-origin hrefs
102
+ * stay same-tab, cross-origin pops a new tab (pure `decideNewTab` with no
103
+ * platform context — `currentSource: ''` falls through to the origin
104
+ * check). No router integration, no hooks. */
105
+ function DefaultLinkPropsProvider({ href, targetPlatform, children }: CardLinkProviderProps): React.ReactElement | null {
106
+ if (!href) return children(null);
107
+ const newTab = decideNewTab({ href, targetPlatform, currentSource: '' });
108
+ return children(
109
+ newTab
110
+ ? { href, target: '_blank', rel: 'noopener noreferrer' }
111
+ : { href },
112
+ );
113
+ }
114
+
115
+ /** Default href resolution: trust the ref's stored url/targetPlatform as the
116
+ * API composed them. The hub overrides this with its `buildContentURL`
117
+ * re-composition so dev gets localhost and prod gets platform domains. */
118
+ function defaultResolveHref(ref: ContentRef): { href: string | null; targetPlatform: string | null } {
119
+ return { href: ref.url || null, targetPlatform: ref.targetPlatform ?? null };
120
+ }
121
+
122
+ /** Host-injected renderer pair for the admin-only `marketing_campaign` type.
123
+ * Absent (every non-hub embed) → the type renders nothing (its list URL
124
+ * hits `/api/admin`, unreachable outside the hub anyway). */
125
+ export interface AdminCampaignCardSlot {
126
+ Card: React.ComponentType<{ campaign: any }>;
127
+ Skeleton: React.ComponentType<{ size?: 'default' | 'sm' }>;
128
+ }
129
+
130
+ /**
131
+ * Per-type skeleton dispatch — returns the SAME colocated skeleton the
132
+ * resolved card renders, sized to match (zero layout shift on resolve).
133
+ * The chat-side `CHAT_CARD_REGISTRY` already does this via
134
+ * `entry.skeleton()`; this surface exposes the same discipline to the
135
+ * related-content rail.
136
+ */
137
+ function renderSkeletonForType(
138
+ type: string,
139
+ size: CardSize,
140
+ adminCampaignCard?: AdminCampaignCardSlot,
141
+ ): React.ReactNode {
142
+ // Most card skeletons accept only `{default, sm}`. `'lg'` collapses to
143
+ // `'default'`. ProductReleaseCardSkeleton uses lg/sm pair.
144
+ const legacySize: 'default' | 'sm' = size === 'sm' ? 'sm' : 'default';
145
+ switch (type) {
146
+ case 'blog_post_existing':
147
+ return <BlogCardSkeleton size={legacySize} />;
148
+ case 'case_study':
149
+ return <CaseStudyCardSkeleton size={legacySize} />;
150
+ case 'customer_interview':
151
+ return <CustomerInterviewCardSkeleton size={legacySize} />;
152
+ case 'product_release':
153
+ return <ProductReleaseCardSkeleton size={size === 'sm' ? 'sm' : 'lg'} />;
154
+ case 'podcast':
155
+ case 'webinar':
156
+ case 'event':
157
+ return <ProgramCardSkeleton size={legacySize} />;
158
+ case 'investor_update':
159
+ return <InvestorUpdateCardSkeleton size={legacySize} />;
160
+ case 'onboarding_guide':
161
+ return <OnboardingGuideCardSkeleton size={legacySize} />;
162
+ case 'marketing_campaign':
163
+ return adminCampaignCard ? <adminCampaignCard.Skeleton size={legacySize} /> : null;
164
+ case 'roadmap_item':
165
+ case 'delivery_item':
166
+ case 'internal_task':
167
+ return <RoadmapCardSkeleton size={legacySize} />;
168
+ default:
169
+ return null;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Per-type card dispatch — renders the right card with the right size.
175
+ * Sized cards (`'lg'` / `'default'`) are unique to this rail — the chat
176
+ * dispatcher only renders `'sm'`, so we go directly through the per-type
177
+ * cards here.
178
+ *
179
+ * PURE FUNCTION COMPONENT WITH ZERO HOOK CALLS: the placeholder comes from a
180
+ * plain `extras.buildOgPlaceholderUrl` call (the chat `dispatch.tsx`
181
+ * pattern) and the anchor-prop bundle arrives via the `LinkProvider`
182
+ * render-prop from the parent — so per-card hook legality is owned by the
183
+ * injected provider component, not by this switch.
184
+ *
185
+ * `href` comes from the host's `resolveHref(ref)` (hub: live
186
+ * `buildContentURL` recomposition; default: the ref's stored url).
187
+ */
188
+ function CardForType({
189
+ type,
190
+ item,
191
+ size,
192
+ href,
193
+ targetPlatform,
194
+ linkProps,
195
+ extras,
196
+ adminCampaignCard,
197
+ }: {
198
+ type: string;
199
+ item: any;
200
+ contentRef: ContentRef;
201
+ size: CardSize;
202
+ href: string;
203
+ targetPlatform: string | null;
204
+ linkProps: CardLinkAnchorProps | null;
205
+ extras?: ChatCardDispatchExtras;
206
+ adminCampaignCard?: AdminCampaignCardSlot;
207
+ }): React.ReactNode {
208
+ // Most card variants accept only `{default, sm}`. `'lg'` collapses to
209
+ // `'default'` for those. ProductReleaseCard uses its own lg/sm pair.
210
+ const legacySize: 'default' | 'sm' = size === 'sm' ? 'sm' : 'default';
211
+ // OG placeholder URL — injected into the pure-presentation cards so they
212
+ // render a branded fallback when the row's featured image is null. Plain
213
+ // function call (NOT a hook). Title is the universal field across all card
214
+ // item shapes used here.
215
+ const placeholderUrl =
216
+ extras?.buildOgPlaceholderUrl?.((item?.title as string | undefined) ?? '') ?? undefined;
217
+
218
+ // Top-level target/rel for cards that take them as separate props
219
+ // (BlogCard, CaseStudyCard, …). ProductReleaseCard takes the bundle as a
220
+ // single `anchorProps={...}` and uses `linkProps` directly. When the host
221
+ // didn't surface a URL, `linkProps` is null and the card stays in
222
+ // non-anchor mode.
223
+ const anchorAttrs: Pick<CardLinkAnchorProps, 'target' | 'rel'> = linkProps
224
+ ? { target: linkProps.target, rel: linkProps.rel }
225
+ : {};
226
+
227
+ switch (type) {
228
+ case 'blog_post_existing':
229
+ return <BlogCard post={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />;
230
+ case 'case_study':
231
+ return <CaseStudyCard study={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />;
232
+ case 'customer_interview':
233
+ return <CustomerInterviewCard interview={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />;
234
+ case 'product_release': {
235
+ // Anchor-prop pattern: build product-release lg-variant props from the
236
+ // shared `buildProductReleaseCardProps` so this rail and the /releases
237
+ // catalog page render byte-identically. The card wraps in
238
+ // `<a {...anchorProps}>` ONLY when `anchorProps.href` is set — pass
239
+ // `undefined` (not an empty object) when href is empty so the card
240
+ // stays in non-anchor mode without rendering a dead <a> tag.
241
+ const releaseSize = size === 'sm' ? 'sm' : 'lg';
242
+ const buildReleaseProps = extras?.buildProductReleaseCardProps ?? buildProductReleaseCardProps;
243
+ const releaseProps = buildReleaseProps(item);
244
+ return (
245
+ <ProductReleaseCard
246
+ size={releaseSize}
247
+ title={item.title}
248
+ summary={item.summary}
249
+ version={item.version}
250
+ {...releaseProps}
251
+ anchorProps={linkProps ?? undefined}
252
+ />
253
+ );
254
+ }
255
+ case 'podcast':
256
+ return extras?.programConfigs?.podcast
257
+ ? <ProgramCard config={extras.programConfigs.podcast} item={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />
258
+ : null;
259
+ case 'webinar':
260
+ return extras?.programConfigs?.webinar
261
+ ? <ProgramCard config={extras.programConfigs.webinar} item={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />
262
+ : null;
263
+ case 'event':
264
+ return extras?.programConfigs?.event
265
+ ? <ProgramCard config={extras.programConfigs.event} item={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />
266
+ : null;
267
+ case 'investor_update':
268
+ return <InvestorUpdateCard update={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />;
269
+ case 'onboarding_guide':
270
+ return <OnboardingGuideCard guide={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />;
271
+ case 'marketing_campaign':
272
+ return adminCampaignCard ? <adminCampaignCard.Card campaign={item} /> : null;
273
+ case 'roadmap_item':
274
+ case 'delivery_item':
275
+ case 'internal_task':
276
+ return (
277
+ <RoadmapCard
278
+ item={item}
279
+ href={href ?? ''}
280
+ targetPlatform={targetPlatform}
281
+ userVote={null}
282
+ onVote={() => {}}
283
+ size={legacySize}
284
+ cardType={type as 'roadmap_item' | 'delivery_item' | 'internal_task'}
285
+ {...anchorAttrs}
286
+ />
287
+ );
288
+ default:
289
+ return null;
290
+ }
291
+ }
292
+
293
+ // =============================================================================
294
+ // Fetch all items for a type in ONE server-sorted call, via the injectable
295
+ // list-URL builder. `useSelfFetch` (URL = cache key) replaces the hub's old
296
+ // react-query usage: `enabled` ≙ `url === null`, `!res.ok`/network error ≙
297
+ // `error → items null → group renders nothing`. Accepted deltas vs
298
+ // react-query: no retry/backoff, no focus refetch, no cross-mount cache.
299
+ // =============================================================================
300
+
301
+ function useGroupItems(
302
+ type: string,
303
+ refs: ContentRef[],
304
+ buildUrl: (type: string, ids: string[]) => string | null,
305
+ ) {
306
+ const ids = refs.map((r) => r.id);
307
+ const url = ids.length > 0 ? buildUrl(type, ids) : null;
308
+ const { data, isLoading } = useSelfFetch<unknown>(url);
309
+ const items = data != null ? extractItems(data) : null;
310
+ return { items, isLoading };
311
+ }
312
+
313
+ // =============================================================================
314
+ // Per-group renderer — one API call, server-sorted, then render cards via the
315
+ // dispatcher with per-type skeletons + per-type layout from CONTENT_REF_GROUPS.
316
+ // =============================================================================
317
+
318
+ /** Map columns prop → tailwind grid class. Only consulted for grid-layout
319
+ * groups; list-layout groups stack vertically. */
320
+ function gridClassFor(columns: 2 | 3): string {
321
+ return columns === 3
322
+ ? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6'
323
+ : 'grid grid-cols-1 sm:grid-cols-2 gap-6';
324
+ }
325
+
326
+ /** Resolve the group config for a type, falling back to a grid layout with
327
+ * the default card size for unregistered types so the section still renders
328
+ * rather than silently dropping them. The `label` field on the fallback is
329
+ * intentionally a placeholder — the section heading goes through
330
+ * `getContentRefLabelOrTitleCase(type)` instead so cross-surface labels
331
+ * stay consistent between this rail and the investor-email builder. */
332
+ function resolveGroupConfig(type: string): ContentRefGroupConfig {
333
+ return CONTENT_REF_GROUPS[type] ?? {
334
+ label: type,
335
+ order: 999,
336
+ layout: 'grid',
337
+ gridSize: 'default',
338
+ };
339
+ }
340
+
341
+ function ContentGroup({
342
+ type,
343
+ refs,
344
+ columns,
345
+ buildUrl,
346
+ resolveHref,
347
+ LinkProvider,
348
+ extras,
349
+ adminCampaignCard,
350
+ }: {
351
+ type: string;
352
+ refs: ContentRef[];
353
+ columns: 2 | 3;
354
+ buildUrl: (type: string, ids: string[]) => string | null;
355
+ resolveHref: (ref: ContentRef) => { href: string | null; targetPlatform: string | null };
356
+ LinkProvider: CardLinkProvider;
357
+ extras?: ChatCardDispatchExtras;
358
+ adminCampaignCard?: AdminCampaignCardSlot;
359
+ }) {
360
+ const { items, isLoading } = useGroupItems(type, refs, buildUrl);
361
+ const config = resolveGroupConfig(type);
362
+ const isListLayout = config.layout === 'list';
363
+ const cardSize = config.gridSize;
364
+
365
+ // Skeleton gate: `isLoading && !items` — SSR HTML and the client's first
366
+ // paint render identical skeletons (useSelfFetch starts isLoading=true on
367
+ // both sides), and once items exist they are never replaced by skeletons.
368
+ if (isLoading && !items) {
369
+ const skeletons = refs.map((r) => (
370
+ <div key={r.id}>{renderSkeletonForType(type, cardSize, adminCampaignCard)}</div>
371
+ ));
372
+ return isListLayout ? (
373
+ <div className="space-y-4">{skeletons}</div>
374
+ ) : (
375
+ <div className={gridClassFor(columns)}>{skeletons}</div>
376
+ );
377
+ }
378
+
379
+ if (!items || items.length === 0) return null;
380
+
381
+ // Match fetched items to refs (for url/visibility data).
382
+ const refMap = new Map(refs.map((r) => [String(r.id), r]));
383
+
384
+ // Items come back server-sorted (date descending). Render in that order.
385
+ const cards = items
386
+ .map((item: any) => {
387
+ const itemId = String(item.id);
388
+ const contentRef = refMap.get(itemId);
389
+ if (!contentRef) return null;
390
+ // Re-compose the URL via the host's resolver (hub: buildContentURL so
391
+ // dev gets localhost and prod the right platform domain; default: the
392
+ // ref's stored url as the API composed it).
393
+ const resolved = resolveHref(contentRef);
394
+ const href = resolved.href ?? '';
395
+ const targetPlatform = resolved.targetPlatform ?? contentRef.targetPlatform ?? null;
396
+ return (
397
+ <div key={itemId}>
398
+ <LinkProvider href={href || null} targetPlatform={targetPlatform}>
399
+ {(linkProps) => (
400
+ <CardForType
401
+ type={type}
402
+ item={item}
403
+ contentRef={contentRef}
404
+ size={cardSize}
405
+ href={href}
406
+ targetPlatform={targetPlatform}
407
+ linkProps={linkProps}
408
+ extras={extras}
409
+ adminCampaignCard={adminCampaignCard}
410
+ />
411
+ )}
412
+ </LinkProvider>
413
+ </div>
414
+ );
415
+ })
416
+ .filter(Boolean);
417
+
418
+ if (cards.length === 0) return null;
419
+
420
+ return isListLayout ? (
421
+ <div className="space-y-4">{cards}</div>
422
+ ) : (
423
+ <div className={gridClassFor(columns)}>{cards}</div>
424
+ );
425
+ }
426
+
427
+ // =============================================================================
428
+ // Main component
429
+ // =============================================================================
430
+
431
+ interface RelatedContentResponse {
432
+ refs: ContentRefWithReason[];
433
+ }
434
+
435
+ export interface RelatedContentSectionProps {
436
+ /** CONTROLLED mode (the original behavior). When defined — even `[]` — no
437
+ * suggestion fetch runs and exactly these refs render. */
438
+ contentRefs?: ContentRef[];
439
+ /** SUGGESTION mode (with `entityId`): self-fetch suggestions for this host
440
+ * entity from `{apiBaseUrl}/api/related-content`. Ignored when
441
+ * `contentRefs` is provided. */
442
+ entityType?: string;
443
+ entityId?: number | string;
444
+ /** Maps to the suggestion API's `count` param. Absent → param not sent
445
+ * (server default applies). */
446
+ minResults?: number;
447
+ /** SSR hydrate for suggestion mode — the server page ran the engine and
448
+ * drills the refs here; the first client fetch is skipped (useSelfFetch
449
+ * initialData contract). */
450
+ initialItems?: ContentRefWithReason[];
451
+ /** Section title (default: "Related Content") */
452
+ title?: string;
453
+ /**
454
+ * Grid columns at desktop. 2 = denser cards / wider summary (original
455
+ * investor-update layout); 3 = more cards per row for dashboards.
456
+ * Only consulted for grid-layout groups. Default: 2.
457
+ */
458
+ columns?: 2 | 3;
459
+ /**
460
+ * ContentRef.type values to exclude. Honored in ALL modes — controlled
461
+ * mode post-filters (original behavior); suggestion mode ALSO forwards the
462
+ * list verbatim as the API's `excludeTypes=` param so excluded types never
463
+ * consume engine fill slots (`minResults` stays honored). The subtraction
464
+ * happens SERVER-side — this component never mirrors the hub's candidate
465
+ * list.
466
+ */
467
+ excludeTypes?: string[];
468
+ /** Fetch-URL prefix for third-party embeds / reverse proxies
469
+ * ('' = same-origin). Applied to BOTH the suggestion fetch and the
470
+ * default per-group list fetches. */
471
+ apiBaseUrl?: string;
472
+ /** Host injection bundle — REUSES the chat dispatcher's
473
+ * `ChatCardDispatchExtras` (programConfigs, buildOgPlaceholderUrl,
474
+ * buildProductReleaseCardProps override). Program groups render nothing
475
+ * when their config is absent. */
476
+ extras?: ChatCardDispatchExtras;
477
+ /** Hub injects its `buildContentURL` recomposition; default uses the
478
+ * ref's stored `url`/`targetPlatform` as the API composed them. */
479
+ resolveHref?: (ref: ContentRef) => { href: string | null; targetPlatform: string | null };
480
+ /** Hub injects its registry-driven entity-list-api builder; default = the
481
+ * lib's `buildListUrl(type, ids, apiBaseUrl)`. */
482
+ buildListUrl?: (type: string, ids: string[]) => string | null;
483
+ /** Hub injects a `useNavLink`-backed render-prop provider; default = pure
484
+ * anchor via `decideNewTab`. MUST be a module-scope component. */
485
+ LinkProvider?: CardLinkProvider;
486
+ /** Renderer pair for the admin-only `marketing_campaign` type. Absent →
487
+ * the type renders nothing. */
488
+ adminCampaignCard?: AdminCampaignCardSlot;
489
+ }
490
+
491
+ export function RelatedContentSection({
492
+ contentRefs,
493
+ entityType,
494
+ entityId,
495
+ minResults,
496
+ initialItems,
497
+ title = 'Related Content',
498
+ columns = 2,
499
+ excludeTypes,
500
+ apiBaseUrl = '',
501
+ extras,
502
+ resolveHref = defaultResolveHref,
503
+ buildListUrl,
504
+ LinkProvider = DefaultLinkPropsProvider,
505
+ adminCampaignCard,
506
+ }: RelatedContentSectionProps) {
507
+ // ── Hooks above EVERY early return (the original `if (!contentRefs.length)
508
+ // return null` guard moved below them). ──
509
+
510
+ // Suggestion-mode fetch URL — null in controlled mode (contentRefs defined,
511
+ // even []) or when the entity scope is incomplete.
512
+ const suggestUrl =
513
+ contentRefs === undefined && entityType && entityId !== undefined && entityId !== null && entityId !== ''
514
+ ? buildSuggestionUrl('/api/related-content', {
515
+ apiBaseUrl,
516
+ entityType,
517
+ entityId,
518
+ count: minResults,
519
+ extraParams: {
520
+ excludeTypes: excludeTypes && excludeTypes.length > 0 ? excludeTypes.join(',') : undefined,
521
+ },
522
+ })
523
+ : null;
524
+ // Memoize the initialData wrapper — useSelfFetch re-syncs on [initialData],
525
+ // and a fresh per-render object would loop setState under re-rendering
526
+ // parents (the latent FaqSection bug, fixed there in the same change).
527
+ const initialData = useMemo<RelatedContentResponse | undefined>(
528
+ () => (initialItems ? { refs: initialItems } : undefined),
529
+ [initialItems],
530
+ );
531
+ const { data } = useSelfFetch<RelatedContentResponse>(suggestUrl, { initialData });
532
+
533
+ // Default group fetcher: the lib's byte-parity-tested builder, prefixed for
534
+ // embeds. Memoized so group-fetch URLs stay value-stable across renders.
535
+ const effectiveBuildListUrl = useMemo(
536
+ () => buildListUrl ?? ((type: string, ids: string[]) => libBuildListUrl(type, ids, apiBaseUrl)),
537
+ [buildListUrl, apiBaseUrl],
538
+ );
539
+
540
+ const refs: ContentRef[] = contentRefs ?? data?.refs ?? [];
541
+
542
+ // Per-consumer type gating — drops refs whose `type` is in the exclude
543
+ // list. In suggestion mode the server already subtracted these (the param
544
+ // is forwarded above); the client filter stays as an idempotent guard and
545
+ // IS the mechanism in controlled mode (original behavior).
546
+ const exclude = new Set(excludeTypes || []);
547
+ const visibleRefs = exclude.size > 0 ? refs.filter((r) => !exclude.has(r.type)) : refs;
548
+ // Zero refs (still loading in suggestion mode, or genuinely empty) → no
549
+ // empty shell.
550
+ if (!visibleRefs.length) return null;
551
+
552
+ const grouped: Record<string, ContentRef[]> = {};
553
+ for (const ref of visibleRefs) {
554
+ if (!grouped[ref.type]) grouped[ref.type] = [];
555
+ grouped[ref.type].push(ref);
556
+ }
557
+
558
+ // Registered types in CONTENT_REF_GROUPS order, then any unregistered
559
+ // types appended (same shape the investor-email builder uses — both
560
+ // consume `orderContentRefTypes` so cross-surface ordering matches).
561
+ const orderedTypes = orderContentRefTypes(Object.keys(grouped));
562
+
563
+ return (
564
+ <div className="space-y-8">
565
+ <h2 className="text-2xl font-bold text-ods-text-primary">{title}</h2>
566
+ {orderedTypes.map((type) => (
567
+ <div key={type} className="space-y-4">
568
+ <h3 className="font-['Azeret_Mono'] text-[14px] font-semibold uppercase text-ods-text-secondary tracking-wider">
569
+ {getContentRefLabelOrTitleCase(type)}
570
+ </h3>
571
+ <ContentGroup
572
+ type={type}
573
+ refs={grouped[type]}
574
+ columns={columns}
575
+ buildUrl={effectiveBuildListUrl}
576
+ resolveHref={resolveHref}
577
+ LinkProvider={LinkProvider}
578
+ extras={extras}
579
+ adminCampaignCard={adminCampaignCard}
580
+ />
581
+ </div>
582
+ ))}
583
+ </div>
584
+ );
585
+ }
@@ -0,0 +1,34 @@
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). RELOCATED from `components/chat/types/entities/`
9
+ * to this server-safe home when `RelatedContentSection` moved into the
10
+ * lib — the chat path re-exports from here, so there is exactly ONE
11
+ * `ContentRef` definition package-wide.
12
+ */
13
+
14
+ export interface ContentRef {
15
+ type: string;
16
+ id: string;
17
+ slug: string;
18
+ url: string;
19
+ targetPlatform?: string | null;
20
+ title: string;
21
+ summary?: string;
22
+ image_url?: string;
23
+ image_bg_color?: string;
24
+ visibility: 'public' | 'internal';
25
+ display_order: number;
26
+ }
27
+
28
+ /**
29
+ * A ContentRef as returned by the suggestion service (`/api/related-content`)
30
+ * — carries the 5-tier engine's placement reason. Widened to `string` here
31
+ * (the narrow reason union is hub-server vocabulary); data-only, never
32
+ * rendered by the rail.
33
+ */
34
+ export type ContentRefWithReason = ContentRef & { reason: string };
@@ -38,6 +38,7 @@ export * from './waitlist'
38
38
 
39
39
  // Business logic types
40
40
  export * from './faq'
41
+ export * from './content-ref'
41
42
  export * from './report'
42
43
  export * from './stack'
43
44