@flamingo-stack/openframe-frontend-core 0.0.257 → 0.0.259
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.
- package/dist/{chunk-ZJP2BB3E.cjs → chunk-3UAV25YB.cjs} +39 -15
- package/dist/chunk-3UAV25YB.cjs.map +1 -0
- package/dist/{chunk-IJHORDN5.js → chunk-ACJPRIZC.js} +1 -1
- package/dist/{chunk-IJHORDN5.js.map → chunk-ACJPRIZC.js.map} +1 -1
- package/dist/{chunk-4MXBBBHB.cjs → chunk-KQQDN2WK.cjs} +1 -1
- package/dist/{chunk-4MXBBBHB.cjs.map → chunk-KQQDN2WK.cjs.map} +1 -1
- package/dist/{chunk-J3QMPOS6.js → chunk-OVXKTNEP.js} +39 -15
- package/dist/chunk-OVXKTNEP.js.map +1 -0
- package/dist/components/authors/author-detail-view.d.ts +34 -0
- package/dist/components/authors/author-detail-view.d.ts.map +1 -0
- package/dist/components/faq/faq-section.d.ts +20 -4
- package/dist/components/faq/faq-section.d.ts.map +1 -1
- package/dist/components/faq/index.cjs +2 -2
- package/dist/components/faq/index.js +1 -1
- package/dist/components/faq-accordion.d.ts +4 -0
- package/dist/components/faq-accordion.d.ts.map +1 -1
- package/dist/components/index.cjs +59 -5
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +56 -2
- package/dist/components/index.js.map +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +1 -1
- package/dist/components/shared/article-author-byline.d.ts +3 -4
- package/dist/components/shared/article-author-byline.d.ts.map +1 -1
- package/dist/types/entity-author.d.ts +25 -0
- package/dist/types/entity-author.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/authors/author-detail-view.tsx +130 -0
- package/src/components/faq/faq-section.tsx +67 -31
- package/src/components/faq-accordion.tsx +14 -3
- package/src/components/index.ts +7 -2
- package/src/components/shared/article-author-byline.tsx +3 -4
- package/src/types/entity-author.ts +27 -0
- package/dist/chunk-J3QMPOS6.js.map +0 -1
- package/dist/chunk-ZJP2BB3E.cjs.map +0 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
var
|
|
7
|
+
var _chunkKQQDN2WKcjs = require('../../chunk-KQQDN2WK.cjs');
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
var _chunkT7AJBKZPcjs = require('../../chunk-T7AJBKZP.cjs');
|
|
@@ -140,7 +140,7 @@ function OnboardingGuidesCatalogView({
|
|
|
140
140
|
});
|
|
141
141
|
};
|
|
142
142
|
const source = _nullishCoalesce(_optionalChain([runtime, 'optionalAccess', _4 => _4.source]), () => ( "openframe"));
|
|
143
|
-
const docSearch =
|
|
143
|
+
const docSearch = _chunkKQQDN2WKcjs.useDocSearch.call(void 0, {
|
|
144
144
|
source,
|
|
145
145
|
baseRoute: basePath,
|
|
146
146
|
onNavigate: (path) => router.push(path),
|
|
@@ -166,7 +166,7 @@ function OnboardingGuidesCatalogView({
|
|
|
166
166
|
const renderCardFn = _nullishCoalesce(renderCard, () => ( defaultRenderCard));
|
|
167
167
|
const preControls = /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: "space-y-4", children: [
|
|
168
168
|
/* @__PURE__ */ _jsxruntime.jsx.call(void 0,
|
|
169
|
-
|
|
169
|
+
_chunkKQQDN2WKcjs.DocSearchBar,
|
|
170
170
|
{
|
|
171
171
|
placeholder: "Search onboarding guides, releases, case studies\u2026",
|
|
172
172
|
query: docSearch.query,
|
|
@@ -238,7 +238,7 @@ function OnboardingGuideDetailView({
|
|
|
238
238
|
return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkEB76VWM4cjs.LoadError, { message: "Guide not found.", onRetry: reload });
|
|
239
239
|
}
|
|
240
240
|
if (!guide) {
|
|
241
|
-
return /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
|
|
241
|
+
return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkKQQDN2WKcjs.DetailPageSkeleton, { showImageGallery: false });
|
|
242
242
|
}
|
|
243
243
|
const captionsUrl = _chunkEB76VWM4cjs.getCaptionsUrl.call(void 0, "onboarding_guide", guide.id, guide.srt_content);
|
|
244
244
|
const videoPoster = guide.main_video_thumbnail || guide.featured_image || guide.og_image_url || _optionalChain([runtime, 'optionalAccess', _9 => _9.resolvePlaceholderUrl, 'optionalCall', _10 => _10(guide.title, { aspect: "wide" })]) || void 0;
|
|
@@ -307,7 +307,7 @@ function OnboardingGuideDetailView({
|
|
|
307
307
|
}
|
|
308
308
|
),
|
|
309
309
|
/* @__PURE__ */ _jsxruntime.jsx.call(void 0,
|
|
310
|
-
|
|
310
|
+
_chunkKQQDN2WKcjs.ArticleAuthorByline,
|
|
311
311
|
{
|
|
312
312
|
author: _nullishCoalesce(_optionalChain([guide, 'access', _12 => _12.author, 'optionalAccess', _13 => _13.full_name]), () => ( null)),
|
|
313
313
|
avatar: _optionalChain([guide, 'access', _14 => _14.author, 'optionalAccess', _15 => _15.avatar_url]),
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
* Shared "author byline" card used by article-shaped detail pages
|
|
3
3
|
* (blog post, product release, onboarding guide, investor update).
|
|
4
4
|
*
|
|
5
|
-
* MOVED from the hub
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* from `getAppConfig()`).
|
|
5
|
+
* MOVED from the hub so any consuming app can embed it; hub call sites
|
|
6
|
+
* import it directly and pass their platform-aware copy explicitly
|
|
7
|
+
* (`fallbackBio={defaultAuthorFallbackBio()}` from the hub's app-config).
|
|
9
8
|
*
|
|
10
9
|
* Embed-readiness contract:
|
|
11
10
|
* - `Link` / `Image` render through the embed-shims (plain `<a>` / `<img>`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"article-author-byline.d.ts","sourceRoot":"","sources":["../../../src/components/shared/article-author-byline.tsx"],"names":[],"mappings":"AAEA
|
|
1
|
+
{"version":3,"file":"article-author-byline.d.ts","sourceRoot":"","sources":["../../../src/components/shared/article-author-byline.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAA;AASzB,MAAM,WAAW,wBAAwB;IACvC,uEAAuE;IACvE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,yDAAyD;IACzD,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,sDAAsD;IACtD,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAAA;IAClC,uEAAuE;IACvE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,+DAA+D;IAC/D,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAA;IAClB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAA;IACvC,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAiBD,wBAAgB,mBAAmB,CAAC,EAClC,MAAM,EACN,MAAM,EACN,GAAG,EACH,QAAQ,EACR,WAAW,EACX,IAAI,EACJ,WAAW,EACX,IAAW,EACX,aAAa,EACb,SAAS,GACV,EAAE,wBAAwB,4BA4F1B"}
|
|
@@ -18,4 +18,29 @@ export interface EntityAuthor {
|
|
|
18
18
|
/** Present only on server/admin payloads — public DALs strip it. */
|
|
19
19
|
email?: string | null;
|
|
20
20
|
}
|
|
21
|
+
/** One curated social link on an author profile (host junction:
|
|
22
|
+
* `profile_social_links` ordered by display_order in the hub). */
|
|
23
|
+
export interface AuthorSocialLink {
|
|
24
|
+
/** Platform key fed to `SocialIconRow` (e.g. 'linkedin', 'x'). */
|
|
25
|
+
platform: string;
|
|
26
|
+
url: string;
|
|
27
|
+
username?: string | null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Public author-page view model — what `<AuthorDetailView>` renders. The
|
|
31
|
+
* hub's `PublicAuthor` (authors DAL) extends this with server-side extras
|
|
32
|
+
* (`updatedAt`); embedders construct it from whatever directory they have.
|
|
33
|
+
*/
|
|
34
|
+
export interface AuthorProfile {
|
|
35
|
+
id: string;
|
|
36
|
+
slug: string;
|
|
37
|
+
fullName: string;
|
|
38
|
+
avatarUrl: string | null;
|
|
39
|
+
about: string | null;
|
|
40
|
+
jobTitle: string | null;
|
|
41
|
+
company: string | null;
|
|
42
|
+
knowsAbout: string[];
|
|
43
|
+
/** Ordered by display_order. */
|
|
44
|
+
socialLinks: AuthorSocialLink[];
|
|
45
|
+
}
|
|
21
46
|
//# sourceMappingURL=entity-author.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entity-author.d.ts","sourceRoot":"","sources":["../../src/types/entity-author.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB;2EACuE;IACvE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB;8EAC0E;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB"}
|
|
1
|
+
{"version":3,"file":"entity-author.d.ts","sourceRoot":"","sources":["../../src/types/entity-author.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB;2EACuE;IACvE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB;8EAC0E;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED;mEACmE;AACnE,MAAM,WAAW,gBAAgB;IAC/B,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAA;IAChB,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACzB;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,gCAAgC;IAChC,WAAW,EAAE,gBAAgB,EAAE,CAAA;CAChC"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Author detail-page body — identity header (avatar → name → job title →
|
|
5
|
+
* socials), bio, expertise badges, and a `children` slot for the authored-
|
|
6
|
+
* content rail. THE one implementation behind the hub's `/authors/[slug]`
|
|
7
|
+
* page AND embedded author pages (react-embedding-example) — hosts fetch
|
|
8
|
+
* the `AuthorProfile` however they like and drill it in.
|
|
9
|
+
*
|
|
10
|
+
* Embed-readiness contract (same rules as ArticleAuthorByline):
|
|
11
|
+
* - `Image` renders through the embed shim (plain `<img>` in non-Next
|
|
12
|
+
* hosts; the real `next/image` once registered at app init).
|
|
13
|
+
* - Avatar proxying rides the OPTIONAL ambient `ChatRuntime`
|
|
14
|
+
* (`endpoints.imageProxyUrlPrefix`) — no provider ⇒ raw URL, never
|
|
15
|
+
* throws. `proxyImageUrl` prop wins over the runtime.
|
|
16
|
+
* - The authored-content rail is a SLOT (`children`), not a baked-in
|
|
17
|
+
* fetch: the hub passes its pre-bound `RelatedContentSection` with SSR
|
|
18
|
+
* `initialItems`; embedders pass the lib section with `apiBaseUrl`.
|
|
19
|
+
* The view stays data-in, markup-out.
|
|
20
|
+
*
|
|
21
|
+
* Heading semantics are the HOST's: the view renders the name as a styled
|
|
22
|
+
* `<p>` (like the hub profile header) so a hosting page can own its `<h1>`
|
|
23
|
+
* (the hub uses an sr-only h1 for crawlers).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import React from 'react'
|
|
27
|
+
import Image from '../../embed-shims/next-image'
|
|
28
|
+
import { cn } from '../../utils/cn'
|
|
29
|
+
import { formatBioText } from '../../utils/format'
|
|
30
|
+
import { getProxiedImageUrl } from '../../utils/image-proxy'
|
|
31
|
+
import { useChatRuntime } from '../../contexts/chat-runtime-context'
|
|
32
|
+
import { SocialIconRow } from '../social-icon-row'
|
|
33
|
+
import { StatusBadge } from '../ui/status-badge'
|
|
34
|
+
import type { AuthorProfile } from '../../types/entity-author'
|
|
35
|
+
|
|
36
|
+
export interface AuthorDetailViewProps {
|
|
37
|
+
author: AuthorProfile
|
|
38
|
+
/** Host-injected avatar-URL mapper — wins over the ambient runtime. */
|
|
39
|
+
proxyImageUrl?: (url: string) => string
|
|
40
|
+
/** Authored-content rail (or anything else) rendered below the profile. */
|
|
41
|
+
children?: React.ReactNode
|
|
42
|
+
className?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function AuthorDetailView({
|
|
46
|
+
author,
|
|
47
|
+
proxyImageUrl,
|
|
48
|
+
children,
|
|
49
|
+
className,
|
|
50
|
+
}: AuthorDetailViewProps) {
|
|
51
|
+
// Optional runtime — null-safe outside a provider (bare embeds render the
|
|
52
|
+
// raw avatar URL; the hub's app-wide HubRuntimeProvider supplies the proxy).
|
|
53
|
+
const runtime = useChatRuntime()
|
|
54
|
+
|
|
55
|
+
const proxiedAvatar = author.avatarUrl
|
|
56
|
+
? proxyImageUrl
|
|
57
|
+
? proxyImageUrl(author.avatarUrl)
|
|
58
|
+
: (getProxiedImageUrl(author.avatarUrl, {
|
|
59
|
+
proxyPrefix: runtime?.endpoints.imageProxyUrlPrefix,
|
|
60
|
+
skipDomains: runtime?.endpoints.imageProxySkipDomains,
|
|
61
|
+
directHttps: true,
|
|
62
|
+
}) ?? author.avatarUrl)
|
|
63
|
+
: null
|
|
64
|
+
|
|
65
|
+
const subtitle = [author.jobTitle, author.company].filter(Boolean).join(' @ ')
|
|
66
|
+
const bioText = formatBioText(author.about)
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className={cn('flex flex-col gap-5 md:gap-6', className)}>
|
|
70
|
+
{/* Identity header: avatar left; name → subtitle → socials stacked right. */}
|
|
71
|
+
<div className="flex gap-6 w-full items-start">
|
|
72
|
+
<div className="relative shrink-0 h-24 w-24">
|
|
73
|
+
<div className="rounded-full overflow-hidden bg-ods-bg-secondary border border-ods-border w-full h-full relative">
|
|
74
|
+
{proxiedAvatar ? (
|
|
75
|
+
<Image
|
|
76
|
+
src={proxiedAvatar}
|
|
77
|
+
alt={author.fullName}
|
|
78
|
+
fill
|
|
79
|
+
className="object-cover"
|
|
80
|
+
unoptimized
|
|
81
|
+
/>
|
|
82
|
+
) : (
|
|
83
|
+
<div className="flex items-center justify-center h-full w-full text-3xl text-ods-text-secondary font-['Azeret_Mono']">
|
|
84
|
+
{author.fullName.charAt(0).toUpperCase()}
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div className="min-w-0 flex flex-col justify-center gap-2">
|
|
91
|
+
<p className="text-h2 text-ods-text-primary leading-none truncate">
|
|
92
|
+
{author.fullName}
|
|
93
|
+
</p>
|
|
94
|
+
{subtitle && (
|
|
95
|
+
<p className="font-body text-lg text-ods-text-secondary leading-none truncate">
|
|
96
|
+
{subtitle}
|
|
97
|
+
</p>
|
|
98
|
+
)}
|
|
99
|
+
{author.socialLinks.length > 0 && (
|
|
100
|
+
// Ghost compact row (transparent 32px) — metadata, not CTAs.
|
|
101
|
+
<SocialIconRow
|
|
102
|
+
compact
|
|
103
|
+
links={author.socialLinks.map((link) => ({
|
|
104
|
+
platform: link.platform,
|
|
105
|
+
href: link.url,
|
|
106
|
+
label: `${author.fullName}'s ${link.platform}`,
|
|
107
|
+
}))}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{bioText && (
|
|
114
|
+
<p className="font-body text-base leading-relaxed text-ods-text-secondary">
|
|
115
|
+
{bioText}
|
|
116
|
+
</p>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{author.knowsAbout.length > 0 && (
|
|
120
|
+
<div className="flex flex-wrap gap-2">
|
|
121
|
+
{author.knowsAbout.map((topic) => (
|
|
122
|
+
<StatusBadge key={topic} text={topic} variant="button" colorScheme="cyan" singleLine />
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{children}
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
@@ -19,11 +19,27 @@ export interface FaqSectionProps {
|
|
|
19
19
|
entityType?: string
|
|
20
20
|
entityId?: number | string
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* <PageHeading> component without the lib
|
|
22
|
+
* Heading node. `undefined` → variant default (page: <h1>, embedded: <h2>
|
|
23
|
+
* "Frequently Asked Questions"); `null` → no heading. React node so
|
|
24
|
+
* platforms can drill their local <PageHeading> component without the lib
|
|
25
|
+
* referencing platform state.
|
|
25
26
|
*/
|
|
26
27
|
heading?: React.ReactNode | null
|
|
28
|
+
/**
|
|
29
|
+
* Layout variant.
|
|
30
|
+
* - 'page' — the standalone /faqs surface: <main> shell + page gutters,
|
|
31
|
+
* FAQs GROUPED by `faq.section` with one <h2> per section.
|
|
32
|
+
* - 'embedded' — a section inside a host page (entity detail rails, the
|
|
33
|
+
* global nested-page block): no shell, ONE flat accordion with each
|
|
34
|
+
* row's `faq.section` rendered as a neutral chip (never a heading) and
|
|
35
|
+
* a single default <h2> above. Exactly one FAQ block per page — the
|
|
36
|
+
* per-tag heading loop is page-variant-only.
|
|
37
|
+
* Back-compat default: `heading === null` → 'embedded', else 'page' (the
|
|
38
|
+
* pre-variant discriminator, kept so older embedders don't shift layout
|
|
39
|
+
* until they opt in — they DO pick up the flat chip list, which is the
|
|
40
|
+
* point of the consolidation).
|
|
41
|
+
*/
|
|
42
|
+
variant?: 'page' | 'embedded'
|
|
27
43
|
/** Inject FAQPage schema.org JSON-LD as a <script>. Off by default so embeds
|
|
28
44
|
* don't emit duplicate schema. */
|
|
29
45
|
emitJsonLd?: boolean
|
|
@@ -106,6 +122,7 @@ export function FaqSection({
|
|
|
106
122
|
entityType,
|
|
107
123
|
entityId,
|
|
108
124
|
heading,
|
|
125
|
+
variant,
|
|
109
126
|
emitJsonLd = false,
|
|
110
127
|
jsonLd,
|
|
111
128
|
className,
|
|
@@ -122,23 +139,30 @@ export function FaqSection({
|
|
|
122
139
|
const { data, isLoading, error } = useSelfFetch<FaqsResponse>(url, { initialData })
|
|
123
140
|
|
|
124
141
|
const faqs = data?.faqs ?? []
|
|
125
|
-
const
|
|
142
|
+
const isEmbedded = variant ? variant === 'embedded' : heading === null
|
|
126
143
|
const headingNode =
|
|
127
|
-
heading === undefined
|
|
128
|
-
?
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
144
|
+
heading === undefined ? (
|
|
145
|
+
isEmbedded ? (
|
|
146
|
+
// Embedded default is an <h2> — a host page already owns the <h1>,
|
|
147
|
+
// and the schema/heading pair must read "Frequently Asked Questions"
|
|
148
|
+
// (one FAQ heading per page, never tag/section names).
|
|
149
|
+
<h2 className="text-h2 tracking-[-0.04em] text-ods-text-primary">{DEFAULT_HEADING_TEXT}</h2>
|
|
150
|
+
) : (
|
|
151
|
+
<h1 className="text-h1 tracking-[-0.04em] text-ods-text-primary">{DEFAULT_HEADING_TEXT}</h1>
|
|
152
|
+
)
|
|
153
|
+
) : (
|
|
154
|
+
heading
|
|
155
|
+
)
|
|
132
156
|
|
|
133
|
-
// Embedded mode
|
|
134
|
-
if (error &&
|
|
157
|
+
// Embedded mode: degrade silently on error.
|
|
158
|
+
if (error && isEmbedded) {
|
|
135
159
|
return null
|
|
136
160
|
}
|
|
137
161
|
|
|
138
162
|
// Embedded mode, zero FAQs after load: render nothing — an embedded host
|
|
139
163
|
// page (blog/case-study detail) must not show an empty section shell.
|
|
140
|
-
// The standalone /faqs page
|
|
141
|
-
if (!isLoading && !error && faqs.length === 0 &&
|
|
164
|
+
// The standalone /faqs page keeps its existing behavior.
|
|
165
|
+
if (!isLoading && !error && faqs.length === 0 && isEmbedded) {
|
|
142
166
|
return null
|
|
143
167
|
}
|
|
144
168
|
|
|
@@ -146,7 +170,7 @@ export function FaqSection({
|
|
|
146
170
|
// Embedded mode renders skeletons WITHOUT the standalone page shell —
|
|
147
171
|
// a host detail page already provides <main> + gutters; nesting them
|
|
148
172
|
// here would emit invalid markup and double padding.
|
|
149
|
-
if (
|
|
173
|
+
if (isEmbedded) {
|
|
150
174
|
return (
|
|
151
175
|
<div className={className}>
|
|
152
176
|
<FaqSkeleton />
|
|
@@ -172,28 +196,40 @@ export function FaqSection({
|
|
|
172
196
|
|
|
173
197
|
const schema = emitJsonLd ? buildFaqJsonLdFromFaqs(faqs, jsonLd) : null
|
|
174
198
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
<FaqAccordion
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
199
|
+
// Embedded: ONE flat accordion — `faq.section` becomes a per-row chip, NOT
|
|
200
|
+
// a heading, so the host page's outline stays h2("Frequently Asked
|
|
201
|
+
// Questions") + h3(questions). Order is the server's (post-specific first,
|
|
202
|
+
// then reused) — grouping by section here would destroy it.
|
|
203
|
+
const body = isEmbedded ? (
|
|
204
|
+
<section className={className ?? 'space-y-6'}>
|
|
205
|
+
{headingNode}
|
|
206
|
+
<FaqAccordion
|
|
207
|
+
items={faqs.map((faq) => ({
|
|
208
|
+
id: faq.id,
|
|
209
|
+
question: faq.question,
|
|
210
|
+
answer: faq.answer,
|
|
211
|
+
badge: faq.section || undefined,
|
|
212
|
+
}))}
|
|
213
|
+
/>
|
|
214
|
+
</section>
|
|
215
|
+
) : (
|
|
216
|
+
// Page variant (standalone /faqs): historical markup — <main> shell,
|
|
217
|
+
// page gutters, FAQs grouped by section with one <h2> per section.
|
|
189
218
|
<main className={className ?? 'bg-ods-bg'}>
|
|
190
219
|
<div className="max-w-[1920px] px-6 md:px-20 py-6 md:py-10 mx-auto space-y-10">
|
|
191
220
|
{headingNode}
|
|
192
|
-
{
|
|
221
|
+
{groupBySection(faqs).map(({ section, items }) => (
|
|
222
|
+
<div key={section || 'default'} className="space-y-4">
|
|
223
|
+
{section && (
|
|
224
|
+
<h2 className="text-h2 tracking-[-0.04em] text-ods-text-primary mb-3 md:mb-4">
|
|
225
|
+
{section}
|
|
226
|
+
</h2>
|
|
227
|
+
)}
|
|
228
|
+
<FaqAccordion items={items} />
|
|
229
|
+
</div>
|
|
230
|
+
))}
|
|
193
231
|
</div>
|
|
194
232
|
</main>
|
|
195
|
-
) : (
|
|
196
|
-
<section className={className ?? 'space-y-10'}>{groupNodes}</section>
|
|
197
233
|
)
|
|
198
234
|
|
|
199
235
|
return (
|
|
@@ -8,6 +8,10 @@ export interface FaqItem {
|
|
|
8
8
|
id: number | string
|
|
9
9
|
question: string
|
|
10
10
|
answer: string
|
|
11
|
+
/** Optional category label rendered as a neutral chip under the question
|
|
12
|
+
* (NOT a heading — keeps the document outline to h2/h3 only). Mirrors the
|
|
13
|
+
* hub's FaqSectionBadge recipe so admin + public chips stay identical. */
|
|
14
|
+
badge?: string
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
interface FaqAccordionProps {
|
|
@@ -76,9 +80,16 @@ export function FaqAccordion({ items, defaultOpenIds = [] }: FaqAccordionProps)
|
|
|
76
80
|
aria-expanded={isOpen}
|
|
77
81
|
className="flex w-full items-center justify-between px-6 md:px-8 py-6 text-left focus:outline-none transition-colors cursor-pointer"
|
|
78
82
|
>
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
<div className="flex min-w-0 flex-col gap-2 pr-4">
|
|
84
|
+
<h3>
|
|
85
|
+
{item.question}
|
|
86
|
+
</h3>
|
|
87
|
+
{item.badge && (
|
|
88
|
+
<span className="w-fit shrink-0 px-2 py-1 rounded text-xs font-medium font-['DM_Sans'] bg-ods-border text-ods-text-secondary">
|
|
89
|
+
{item.badge}
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
82
93
|
<div className="flex-shrink-0">
|
|
83
94
|
<ChevronButton
|
|
84
95
|
aria-label={isOpen ? 'Collapse question' : 'Expand question'}
|
package/src/components/index.ts
CHANGED
|
@@ -91,9 +91,14 @@ export * from './shared/legal-document'
|
|
|
91
91
|
export { DetailPageSkeleton, type DetailPageSkeletonProps } from './shared/detail-page-skeleton'
|
|
92
92
|
|
|
93
93
|
// Author byline card (end-of-article author description) — embeddable via
|
|
94
|
-
// embed-shims + optional-runtime avatar proxying;
|
|
95
|
-
//
|
|
94
|
+
// embed-shims + optional-runtime avatar proxying; hosts pass their own
|
|
95
|
+
// fallbackBio copy (the hub uses defaultAuthorFallbackBio()).
|
|
96
96
|
export { ArticleAuthorByline, type ArticleAuthorBylineProps } from './shared/article-author-byline'
|
|
97
97
|
|
|
98
|
+
// Author detail-page body (identity + socials + bio + expertise, rail as
|
|
99
|
+
// children) — the one implementation behind /authors/[slug] and embedded
|
|
100
|
+
// author pages.
|
|
101
|
+
export { AuthorDetailView, type AuthorDetailViewProps } from './authors/author-detail-view'
|
|
102
|
+
|
|
98
103
|
// Priority UI components that exist in main components directory
|
|
99
104
|
// Note: These are re-exported from ./ui already, no need to duplicate
|
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
* Shared "author byline" card used by article-shaped detail pages
|
|
5
5
|
* (blog post, product release, onboarding guide, investor update).
|
|
6
6
|
*
|
|
7
|
-
* MOVED from the hub
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* from `getAppConfig()`).
|
|
7
|
+
* MOVED from the hub so any consuming app can embed it; hub call sites
|
|
8
|
+
* import it directly and pass their platform-aware copy explicitly
|
|
9
|
+
* (`fallbackBio={defaultAuthorFallbackBio()}` from the hub's app-config).
|
|
11
10
|
*
|
|
12
11
|
* Embed-readiness contract:
|
|
13
12
|
* - `Link` / `Image` render through the embed-shims (plain `<a>` / `<img>`
|
|
@@ -18,3 +18,30 @@ export interface EntityAuthor {
|
|
|
18
18
|
/** Present only on server/admin payloads — public DALs strip it. */
|
|
19
19
|
email?: string | null
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
/** One curated social link on an author profile (host junction:
|
|
23
|
+
* `profile_social_links` ordered by display_order in the hub). */
|
|
24
|
+
export interface AuthorSocialLink {
|
|
25
|
+
/** Platform key fed to `SocialIconRow` (e.g. 'linkedin', 'x'). */
|
|
26
|
+
platform: string
|
|
27
|
+
url: string
|
|
28
|
+
username?: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Public author-page view model — what `<AuthorDetailView>` renders. The
|
|
33
|
+
* hub's `PublicAuthor` (authors DAL) extends this with server-side extras
|
|
34
|
+
* (`updatedAt`); embedders construct it from whatever directory they have.
|
|
35
|
+
*/
|
|
36
|
+
export interface AuthorProfile {
|
|
37
|
+
id: string
|
|
38
|
+
slug: string
|
|
39
|
+
fullName: string
|
|
40
|
+
avatarUrl: string | null
|
|
41
|
+
about: string | null
|
|
42
|
+
jobTitle: string | null
|
|
43
|
+
company: string | null
|
|
44
|
+
knowsAbout: string[]
|
|
45
|
+
/** Ordered by display_order. */
|
|
46
|
+
socialLinks: AuthorSocialLink[]
|
|
47
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/components/faq/faq-section.tsx","../src/components/faq-accordion.tsx","../src/components/faq/json-ld.ts"],"sourcesContent":["\"use client\"\n\nimport React, { useMemo } from 'react'\nimport type { Faq } from '../../types/faq'\nimport { FaqAccordion, type FaqItem } from '../faq-accordion'\nimport { useSelfFetch } from '../../hooks/use-self-fetch'\nimport { buildSuggestionUrl } from '../../utils/suggestion-url'\nimport { serializeJsonLd } from '../../utils/common'\nimport { buildFaqJsonLdFromFaqs, type FaqSchemaOptions } from './json-ld'\n\nexport interface FaqSectionProps {\n /**\n * SSR hydrate. When provided, the hook skips the first client fetch (per\n * useSelfFetch contract). The consuming server page resolves FAQs then drills\n * them into this prop — the lib never re-fetches what the host already gated on.\n */\n initialFaqs?: Faq[]\n /** Both required together for entity-attached FAQs; partial → bare /api/faqs. */\n entityType?: string\n entityId?: number | string\n /**\n * Page heading. Pass null to render in embedded mode (no <h1>, error branch\n * suppresses any banner). React node so platforms can drill their local\n * <PageHeading> component without the lib referencing platform state.\n */\n heading?: React.ReactNode | null\n /** Inject FAQPage schema.org JSON-LD as a <script>. Off by default so embeds\n * don't emit duplicate schema. */\n emitJsonLd?: boolean\n /** Overrides for the JSON-LD's name/description/url. */\n jsonLd?: FaqSchemaOptions\n className?: string\n /** Maps to /api/faqs `?count=` (the 5-tier fill target). Absent → param\n * not sent (server default applies). */\n minResults?: number\n /** Fetch-URL prefix for third-party embeds / reverse proxies\n * ('' = same-origin relative). */\n apiBaseUrl?: string\n}\n\nconst DEFAULT_HEADING_TEXT = 'Frequently Asked Questions'\n\n/** URL composition shared with RelatedContentSection (`buildSuggestionUrl`)\n * — byte-identical to the historical `buildFaqsUrl` output when\n * `minResults`/`apiBaseUrl` are absent. */\nfunction buildFaqsUrl(\n entityType?: string,\n entityId?: number | string,\n minResults?: number,\n apiBaseUrl = '',\n): string {\n return buildSuggestionUrl('/api/faqs', { apiBaseUrl, entityType, entityId, count: minResults })\n}\n\nfunction groupBySection(faqs: Faq[]): Array<{ section: string | null; items: FaqItem[] }> {\n const map = new Map<string, FaqItem[]>()\n for (const faq of faqs) {\n const key = faq.section || ''\n let arr = map.get(key)\n if (!arr) {\n arr = []\n map.set(key, arr)\n }\n arr.push({ id: faq.id, question: faq.question, answer: faq.answer })\n }\n return Array.from(map.entries()).map(([section, items]) => ({\n section: section || null,\n items,\n }))\n}\n\nfunction FaqSkeleton() {\n return (\n <div className=\"space-y-8 animate-pulse\">\n <div className=\"h-12 md:h-14 w-2/3 rounded bg-ods-border\" />\n <div className=\"rounded-3xl border border-ods-border overflow-hidden bg-ods-card divide-y divide-ods-border w-full\">\n {Array.from({ length: 8 }).map((_, idx) => (\n <div key={idx} className=\"flex items-center justify-between px-6 md:px-8 py-6\">\n <div className=\"h-6 w-5/6 rounded bg-ods-border\" />\n <div className=\"h-10 w-10 rounded-md bg-ods-border\" />\n </div>\n ))}\n </div>\n </div>\n )\n}\n\ninterface FaqsResponse {\n faqs: Faq[]\n}\n\n/**\n * Generic, embeddable FAQ display surface.\n *\n * - Standalone /faqs page: pass `initialFaqs` from the server + `heading` +\n * `emitJsonLd` with `jsonLd` overrides for SEO.\n * - Per-entity embed: pass `entityType` + `entityId` (no `initialFaqs`); the\n * hook self-fetches and the public /api/faqs endpoint picks override-vs-fallback.\n *\n * CONTRACT: the consuming app MUST implement `GET /api/faqs`. Without it,\n * useSelfFetch reports `error:true` (logged once); in embedded mode (heading\n * === null) this component renders nothing so the host page isn't disfigured.\n */\nexport function FaqSection({\n initialFaqs,\n entityType,\n entityId,\n heading,\n emitJsonLd = false,\n jsonLd,\n className,\n minResults,\n apiBaseUrl = '',\n}: FaqSectionProps) {\n const url = buildFaqsUrl(entityType, entityId, minResults, apiBaseUrl)\n // Memoized — useSelfFetch re-syncs on [initialData]; a fresh per-render\n // wrapper object would setState-loop under re-rendering parents.\n const initialData = useMemo<FaqsResponse | undefined>(\n () => (initialFaqs ? { faqs: initialFaqs } : undefined),\n [initialFaqs],\n )\n const { data, isLoading, error } = useSelfFetch<FaqsResponse>(url, { initialData })\n\n const faqs = data?.faqs ?? []\n const showHeading = heading !== null\n const headingNode =\n heading === undefined\n ? <h1 className=\"text-h1 tracking-[-0.04em] text-ods-text-primary\">{DEFAULT_HEADING_TEXT}</h1>\n : heading\n\n const groups = groupBySection(faqs)\n\n // Embedded mode (no heading): degrade silently on error.\n if (error && !showHeading) {\n return null\n }\n\n // Embedded mode, zero FAQs after load: render nothing — an embedded host\n // page (blog/case-study detail) must not show an empty section shell.\n // The standalone /faqs page (heading set) keeps its existing behavior.\n if (!isLoading && !error && faqs.length === 0 && !showHeading) {\n return null\n }\n\n if (isLoading && faqs.length === 0) {\n // Embedded mode renders skeletons WITHOUT the standalone page shell —\n // a host detail page already provides <main> + gutters; nesting them\n // here would emit invalid markup and double padding.\n if (!showHeading) {\n return (\n <div className={className}>\n <FaqSkeleton />\n </div>\n )\n }\n return (\n <main className={className ?? 'bg-ods-bg'}>\n <div className=\"max-w-[1920px] px-6 md:px-20 py-6 md:py-10 mx-auto\">\n <FaqSkeleton />\n </div>\n </main>\n )\n }\n\n if (error && faqs.length === 0) {\n return (\n <main className={className ?? 'bg-ods-bg flex items-center justify-center py-20'}>\n <p className=\"text-ods-text-primary font-medium\">Failed to load FAQs. Please try again later.</p>\n </main>\n )\n }\n\n const schema = emitJsonLd ? buildFaqJsonLdFromFaqs(faqs, jsonLd) : null\n\n const groupNodes = groups.map(({ section, items }) => (\n <div key={section || 'default'} className=\"space-y-4\">\n {section && (\n <h2 className=\"text-h2 tracking-[-0.04em] text-ods-text-primary mb-3 md:mb-4\">\n {section}\n </h2>\n )}\n <FaqAccordion items={items} />\n </div>\n ))\n\n // Embedded mode (heading === null): no <main> page shell, no page gutters —\n // the host page owns layout. Standalone mode keeps the historical markup.\n const body = showHeading ? (\n <main className={className ?? 'bg-ods-bg'}>\n <div className=\"max-w-[1920px] px-6 md:px-20 py-6 md:py-10 mx-auto space-y-10\">\n {headingNode}\n {groupNodes}\n </div>\n </main>\n ) : (\n <section className={className ?? 'space-y-10'}>{groupNodes}</section>\n )\n\n return (\n <>\n {body}\n {schema && (\n <script\n type=\"application/ld+json\"\n // eslint-disable-next-line react/no-danger\n // serializeJsonLd, NOT raw JSON.stringify — FAQ answers are\n // admin-entered; an embedded \"</script>\" must not break the tag.\n dangerouslySetInnerHTML={{ __html: serializeJsonLd(schema) }}\n />\n )}\n </>\n )\n}\n","\"use client\"\n\nimport React, { useRef, useState, useEffect, useCallback } from 'react'\nimport { ChevronButton } from './ui/chevron-button'\nimport { cn } from \"../utils/cn\"\n\nexport interface FaqItem {\n id: number | string\n question: string\n answer: string\n}\n\ninterface FaqAccordionProps {\n items: FaqItem[]\n defaultOpenIds?: (number | string)[]\n}\n\n// Utility to measure scrollHeight outside render cycle\nconst useMeasuredHeight = (isOpen: boolean) => {\n const ref = useRef<HTMLDivElement | null>(null)\n const [maxHeight, setMaxHeight] = useState<string>('0px')\n\n const measure = useCallback(() => {\n if (ref.current) {\n const height = ref.current.scrollHeight\n setMaxHeight(`${height}px`)\n }\n }, [])\n\n // Update height only when section is open\n useEffect(() => {\n if (isOpen) {\n measure()\n } else {\n setMaxHeight('0px')\n }\n }, [isOpen, measure])\n\n return { ref, maxHeight }\n}\n\nexport function FaqAccordion({ items, defaultOpenIds = [] }: FaqAccordionProps) {\n const [openSet, setOpenSet] = useState<Set<string | number>>(new Set(defaultOpenIds))\n\n const toggle = (id: string | number) => {\n setOpenSet(prev => {\n const next = new Set(prev)\n if (next.has(id)) next.delete(id)\n else next.add(id)\n return next\n })\n }\n\n return (\n <div className=\"rounded-3xl border border-ods-border divide-y divide-ods-border bg-ods-card overflow-hidden\">\n {items.map(item => {\n const isOpen = openSet.has(item.id)\n const { ref, maxHeight } = useMeasuredHeight(isOpen)\n\n return (\n <div\n key={item.id}\n className={cn('group transition-colors hover:bg-[#1E1E1E]', isOpen ? 'bg-ods-bg' : 'bg-transparent')}\n >\n {/* Header */}\n <div\n role=\"button\"\n tabIndex={0}\n onClick={() => toggle(item.id)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n toggle(item.id);\n }\n }}\n aria-expanded={isOpen}\n className=\"flex w-full items-center justify-between px-6 md:px-8 py-6 text-left focus:outline-none transition-colors cursor-pointer\"\n >\n <h3>\n {item.question}\n </h3>\n <div className=\"flex-shrink-0\">\n <ChevronButton\n aria-label={isOpen ? 'Collapse question' : 'Expand question'}\n size=\"md\"\n isExpanded={isOpen}\n backgroundColor=\"transparent\"\n borderColor=\"#3A3A3A\"\n />\n </div>\n </div>\n {/* Content wrapper with max-height animation */}\n <div\n style={{ maxHeight, transition: 'max-height 0.35s ease-in-out, opacity 0.35s ease-in-out', opacity: isOpen ? 1 : 0 }}\n className=\"overflow-hidden group-hover:bg-[#1E1E1E]/30\"\n >\n <div ref={ref} className=\"px-6 md:px-8 pb-6 text-ods-text-primary text-h4\">\n {item.answer}\n </div>\n </div>\n </div>\n )\n })}\n </div>\n )\n} ","/**\n * Pure FAQ JSON-LD builder. No React, no client-only deps — safe to import from\n * Server Components via the `./components/faq` subpath export. (Do NOT import\n * through the root `./components` barrel — that barrel is `\"use client\"` and\n * dragging it into a Server Component would force the client graph into the\n * server.)\n *\n * The hub used to harcode `name`/`description` here for OpenMSP; in the lib we\n * accept overrides so every embedder can supply its own platform branding.\n */\nimport type { Faq } from '../../types/faq'\n\nexport interface FaqSchemaOptions {\n name?: string\n description?: string\n url?: string\n}\n\nconst DEFAULT_NAME = 'Frequently Asked Questions'\nconst DEFAULT_DESCRIPTION =\n 'Answers to common questions.'\n\nexport function baseFaqSchema(opts: FaqSchemaOptions = {}) {\n return {\n '@context': 'https://schema.org',\n '@type': 'FAQPage',\n name: opts.name ?? DEFAULT_NAME,\n description: opts.description ?? DEFAULT_DESCRIPTION,\n ...(opts.url ? { url: opts.url } : {}),\n } as const\n}\n\nexport function buildFaqJsonLdFromFaqs(faqs: Faq[], opts: FaqSchemaOptions = {}) {\n return {\n ...baseFaqSchema(opts),\n mainEntity: faqs.map((faq) => ({\n '@type': 'Question',\n name: faq.question,\n acceptedAnswer: {\n '@type': 'Answer',\n text: faq.answer,\n },\n })),\n } as const\n}\n"],"mappings":"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","names":["jsx","jsxs"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-ZJP2BB3E.cjs","../src/components/faq/faq-section.tsx","../src/components/faq-accordion.tsx","../src/components/faq/json-ld.ts"],"names":["jsx"],"mappings":"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","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-ZJP2BB3E.cjs","sourcesContent":[null,"\"use client\"\n\nimport React, { useMemo } from 'react'\nimport type { Faq } from '../../types/faq'\nimport { FaqAccordion, type FaqItem } from '../faq-accordion'\nimport { useSelfFetch } from '../../hooks/use-self-fetch'\nimport { buildSuggestionUrl } from '../../utils/suggestion-url'\nimport { serializeJsonLd } from '../../utils/common'\nimport { buildFaqJsonLdFromFaqs, type FaqSchemaOptions } from './json-ld'\n\nexport interface FaqSectionProps {\n /**\n * SSR hydrate. When provided, the hook skips the first client fetch (per\n * useSelfFetch contract). The consuming server page resolves FAQs then drills\n * them into this prop — the lib never re-fetches what the host already gated on.\n */\n initialFaqs?: Faq[]\n /** Both required together for entity-attached FAQs; partial → bare /api/faqs. */\n entityType?: string\n entityId?: number | string\n /**\n * Page heading. Pass null to render in embedded mode (no <h1>, error branch\n * suppresses any banner). React node so platforms can drill their local\n * <PageHeading> component without the lib referencing platform state.\n */\n heading?: React.ReactNode | null\n /** Inject FAQPage schema.org JSON-LD as a <script>. Off by default so embeds\n * don't emit duplicate schema. */\n emitJsonLd?: boolean\n /** Overrides for the JSON-LD's name/description/url. */\n jsonLd?: FaqSchemaOptions\n className?: string\n /** Maps to /api/faqs `?count=` (the 5-tier fill target). Absent → param\n * not sent (server default applies). */\n minResults?: number\n /** Fetch-URL prefix for third-party embeds / reverse proxies\n * ('' = same-origin relative). */\n apiBaseUrl?: string\n}\n\nconst DEFAULT_HEADING_TEXT = 'Frequently Asked Questions'\n\n/** URL composition shared with RelatedContentSection (`buildSuggestionUrl`)\n * — byte-identical to the historical `buildFaqsUrl` output when\n * `minResults`/`apiBaseUrl` are absent. */\nfunction buildFaqsUrl(\n entityType?: string,\n entityId?: number | string,\n minResults?: number,\n apiBaseUrl = '',\n): string {\n return buildSuggestionUrl('/api/faqs', { apiBaseUrl, entityType, entityId, count: minResults })\n}\n\nfunction groupBySection(faqs: Faq[]): Array<{ section: string | null; items: FaqItem[] }> {\n const map = new Map<string, FaqItem[]>()\n for (const faq of faqs) {\n const key = faq.section || ''\n let arr = map.get(key)\n if (!arr) {\n arr = []\n map.set(key, arr)\n }\n arr.push({ id: faq.id, question: faq.question, answer: faq.answer })\n }\n return Array.from(map.entries()).map(([section, items]) => ({\n section: section || null,\n items,\n }))\n}\n\nfunction FaqSkeleton() {\n return (\n <div className=\"space-y-8 animate-pulse\">\n <div className=\"h-12 md:h-14 w-2/3 rounded bg-ods-border\" />\n <div className=\"rounded-3xl border border-ods-border overflow-hidden bg-ods-card divide-y divide-ods-border w-full\">\n {Array.from({ length: 8 }).map((_, idx) => (\n <div key={idx} className=\"flex items-center justify-between px-6 md:px-8 py-6\">\n <div className=\"h-6 w-5/6 rounded bg-ods-border\" />\n <div className=\"h-10 w-10 rounded-md bg-ods-border\" />\n </div>\n ))}\n </div>\n </div>\n )\n}\n\ninterface FaqsResponse {\n faqs: Faq[]\n}\n\n/**\n * Generic, embeddable FAQ display surface.\n *\n * - Standalone /faqs page: pass `initialFaqs` from the server + `heading` +\n * `emitJsonLd` with `jsonLd` overrides for SEO.\n * - Per-entity embed: pass `entityType` + `entityId` (no `initialFaqs`); the\n * hook self-fetches and the public /api/faqs endpoint picks override-vs-fallback.\n *\n * CONTRACT: the consuming app MUST implement `GET /api/faqs`. Without it,\n * useSelfFetch reports `error:true` (logged once); in embedded mode (heading\n * === null) this component renders nothing so the host page isn't disfigured.\n */\nexport function FaqSection({\n initialFaqs,\n entityType,\n entityId,\n heading,\n emitJsonLd = false,\n jsonLd,\n className,\n minResults,\n apiBaseUrl = '',\n}: FaqSectionProps) {\n const url = buildFaqsUrl(entityType, entityId, minResults, apiBaseUrl)\n // Memoized — useSelfFetch re-syncs on [initialData]; a fresh per-render\n // wrapper object would setState-loop under re-rendering parents.\n const initialData = useMemo<FaqsResponse | undefined>(\n () => (initialFaqs ? { faqs: initialFaqs } : undefined),\n [initialFaqs],\n )\n const { data, isLoading, error } = useSelfFetch<FaqsResponse>(url, { initialData })\n\n const faqs = data?.faqs ?? []\n const showHeading = heading !== null\n const headingNode =\n heading === undefined\n ? <h1 className=\"text-h1 tracking-[-0.04em] text-ods-text-primary\">{DEFAULT_HEADING_TEXT}</h1>\n : heading\n\n const groups = groupBySection(faqs)\n\n // Embedded mode (no heading): degrade silently on error.\n if (error && !showHeading) {\n return null\n }\n\n // Embedded mode, zero FAQs after load: render nothing — an embedded host\n // page (blog/case-study detail) must not show an empty section shell.\n // The standalone /faqs page (heading set) keeps its existing behavior.\n if (!isLoading && !error && faqs.length === 0 && !showHeading) {\n return null\n }\n\n if (isLoading && faqs.length === 0) {\n // Embedded mode renders skeletons WITHOUT the standalone page shell —\n // a host detail page already provides <main> + gutters; nesting them\n // here would emit invalid markup and double padding.\n if (!showHeading) {\n return (\n <div className={className}>\n <FaqSkeleton />\n </div>\n )\n }\n return (\n <main className={className ?? 'bg-ods-bg'}>\n <div className=\"max-w-[1920px] px-6 md:px-20 py-6 md:py-10 mx-auto\">\n <FaqSkeleton />\n </div>\n </main>\n )\n }\n\n if (error && faqs.length === 0) {\n return (\n <main className={className ?? 'bg-ods-bg flex items-center justify-center py-20'}>\n <p className=\"text-ods-text-primary font-medium\">Failed to load FAQs. Please try again later.</p>\n </main>\n )\n }\n\n const schema = emitJsonLd ? buildFaqJsonLdFromFaqs(faqs, jsonLd) : null\n\n const groupNodes = groups.map(({ section, items }) => (\n <div key={section || 'default'} className=\"space-y-4\">\n {section && (\n <h2 className=\"text-h2 tracking-[-0.04em] text-ods-text-primary mb-3 md:mb-4\">\n {section}\n </h2>\n )}\n <FaqAccordion items={items} />\n </div>\n ))\n\n // Embedded mode (heading === null): no <main> page shell, no page gutters —\n // the host page owns layout. Standalone mode keeps the historical markup.\n const body = showHeading ? (\n <main className={className ?? 'bg-ods-bg'}>\n <div className=\"max-w-[1920px] px-6 md:px-20 py-6 md:py-10 mx-auto space-y-10\">\n {headingNode}\n {groupNodes}\n </div>\n </main>\n ) : (\n <section className={className ?? 'space-y-10'}>{groupNodes}</section>\n )\n\n return (\n <>\n {body}\n {schema && (\n <script\n type=\"application/ld+json\"\n // eslint-disable-next-line react/no-danger\n // serializeJsonLd, NOT raw JSON.stringify — FAQ answers are\n // admin-entered; an embedded \"</script>\" must not break the tag.\n dangerouslySetInnerHTML={{ __html: serializeJsonLd(schema) }}\n />\n )}\n </>\n )\n}\n","\"use client\"\n\nimport React, { useRef, useState, useEffect, useCallback } from 'react'\nimport { ChevronButton } from './ui/chevron-button'\nimport { cn } from \"../utils/cn\"\n\nexport interface FaqItem {\n id: number | string\n question: string\n answer: string\n}\n\ninterface FaqAccordionProps {\n items: FaqItem[]\n defaultOpenIds?: (number | string)[]\n}\n\n// Utility to measure scrollHeight outside render cycle\nconst useMeasuredHeight = (isOpen: boolean) => {\n const ref = useRef<HTMLDivElement | null>(null)\n const [maxHeight, setMaxHeight] = useState<string>('0px')\n\n const measure = useCallback(() => {\n if (ref.current) {\n const height = ref.current.scrollHeight\n setMaxHeight(`${height}px`)\n }\n }, [])\n\n // Update height only when section is open\n useEffect(() => {\n if (isOpen) {\n measure()\n } else {\n setMaxHeight('0px')\n }\n }, [isOpen, measure])\n\n return { ref, maxHeight }\n}\n\nexport function FaqAccordion({ items, defaultOpenIds = [] }: FaqAccordionProps) {\n const [openSet, setOpenSet] = useState<Set<string | number>>(new Set(defaultOpenIds))\n\n const toggle = (id: string | number) => {\n setOpenSet(prev => {\n const next = new Set(prev)\n if (next.has(id)) next.delete(id)\n else next.add(id)\n return next\n })\n }\n\n return (\n <div className=\"rounded-3xl border border-ods-border divide-y divide-ods-border bg-ods-card overflow-hidden\">\n {items.map(item => {\n const isOpen = openSet.has(item.id)\n const { ref, maxHeight } = useMeasuredHeight(isOpen)\n\n return (\n <div\n key={item.id}\n className={cn('group transition-colors hover:bg-[#1E1E1E]', isOpen ? 'bg-ods-bg' : 'bg-transparent')}\n >\n {/* Header */}\n <div\n role=\"button\"\n tabIndex={0}\n onClick={() => toggle(item.id)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n toggle(item.id);\n }\n }}\n aria-expanded={isOpen}\n className=\"flex w-full items-center justify-between px-6 md:px-8 py-6 text-left focus:outline-none transition-colors cursor-pointer\"\n >\n <h3>\n {item.question}\n </h3>\n <div className=\"flex-shrink-0\">\n <ChevronButton\n aria-label={isOpen ? 'Collapse question' : 'Expand question'}\n size=\"md\"\n isExpanded={isOpen}\n backgroundColor=\"transparent\"\n borderColor=\"#3A3A3A\"\n />\n </div>\n </div>\n {/* Content wrapper with max-height animation */}\n <div\n style={{ maxHeight, transition: 'max-height 0.35s ease-in-out, opacity 0.35s ease-in-out', opacity: isOpen ? 1 : 0 }}\n className=\"overflow-hidden group-hover:bg-[#1E1E1E]/30\"\n >\n <div ref={ref} className=\"px-6 md:px-8 pb-6 text-ods-text-primary text-h4\">\n {item.answer}\n </div>\n </div>\n </div>\n )\n })}\n </div>\n )\n} ","/**\n * Pure FAQ JSON-LD builder. No React, no client-only deps — safe to import from\n * Server Components via the `./components/faq` subpath export. (Do NOT import\n * through the root `./components` barrel — that barrel is `\"use client\"` and\n * dragging it into a Server Component would force the client graph into the\n * server.)\n *\n * The hub used to harcode `name`/`description` here for OpenMSP; in the lib we\n * accept overrides so every embedder can supply its own platform branding.\n */\nimport type { Faq } from '../../types/faq'\n\nexport interface FaqSchemaOptions {\n name?: string\n description?: string\n url?: string\n}\n\nconst DEFAULT_NAME = 'Frequently Asked Questions'\nconst DEFAULT_DESCRIPTION =\n 'Answers to common questions.'\n\nexport function baseFaqSchema(opts: FaqSchemaOptions = {}) {\n return {\n '@context': 'https://schema.org',\n '@type': 'FAQPage',\n name: opts.name ?? DEFAULT_NAME,\n description: opts.description ?? DEFAULT_DESCRIPTION,\n ...(opts.url ? { url: opts.url } : {}),\n } as const\n}\n\nexport function buildFaqJsonLdFromFaqs(faqs: Faq[], opts: FaqSchemaOptions = {}) {\n return {\n ...baseFaqSchema(opts),\n mainEntity: faqs.map((faq) => ({\n '@type': 'Question',\n name: faq.question,\n acceptedAnswer: {\n '@type': 'Answer',\n text: faq.answer,\n },\n })),\n } as const\n}\n"]}
|