@flamingo-stack/openframe-frontend-core 0.0.266 → 0.0.267
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-G5GQVAPI.cjs → chunk-5QJQAFTP.cjs} +126 -57
- package/dist/chunk-5QJQAFTP.cjs.map +1 -0
- package/dist/chunk-722TTRIJ.cjs +29 -0
- package/dist/chunk-722TTRIJ.cjs.map +1 -0
- package/dist/{chunk-QXRXIHY6.cjs → chunk-AA77CC2Y.cjs} +12 -12
- package/dist/{chunk-QXRXIHY6.cjs.map → chunk-AA77CC2Y.cjs.map} +1 -1
- package/dist/{chunk-WB2JXKEI.cjs → chunk-CI5XE4LB.cjs} +37 -37
- package/dist/{chunk-WB2JXKEI.cjs.map → chunk-CI5XE4LB.cjs.map} +1 -1
- package/dist/{chunk-UUARMJJE.cjs → chunk-CICK3FUU.cjs} +100 -5
- package/dist/chunk-CICK3FUU.cjs.map +1 -0
- package/dist/{chunk-K4K4X62E.js → chunk-CYMMGPPV.js} +125 -56
- package/dist/chunk-CYMMGPPV.js.map +1 -0
- package/dist/{chunk-FRU4LQJ4.js → chunk-GJVSLS2R.js} +5 -7
- package/dist/{chunk-FRU4LQJ4.js.map → chunk-GJVSLS2R.js.map} +1 -1
- package/dist/{chunk-HTR3ARN6.js → chunk-ICOMPUMX.js} +343 -248
- package/dist/chunk-ICOMPUMX.js.map +1 -0
- package/dist/{chunk-DTJ32U7J.js → chunk-J6O57C7S.js} +8 -72
- package/dist/chunk-J6O57C7S.js.map +1 -0
- package/dist/{chunk-QAQ7AROS.cjs → chunk-O6UA475M.cjs} +302 -366
- package/dist/chunk-O6UA475M.cjs.map +1 -0
- package/dist/chunk-OO4ZGDRO.js +29 -0
- package/dist/chunk-OO4ZGDRO.js.map +1 -0
- package/dist/{chunk-TX2VP43R.js → chunk-P7JHUX4X.js} +2 -2
- package/dist/{chunk-NR535XIY.js → chunk-T2URJAX2.js} +66 -1
- package/dist/chunk-T2URJAX2.js.map +1 -0
- package/dist/{chunk-VYGS24RO.cjs → chunk-TPZI7NBN.cjs} +16 -18
- package/dist/chunk-TPZI7NBN.cjs.map +1 -0
- package/dist/{chunk-SRM7HE2A.js → chunk-XSK72QRR.js} +3 -3
- package/dist/{chunk-7VMEOFEA.cjs → chunk-Y2B6WDU6.cjs} +67 -2
- package/dist/chunk-Y2B6WDU6.cjs.map +1 -0
- package/dist/{chunk-Q52JZP3P.cjs → chunk-YV55JZAD.cjs} +27 -27
- package/dist/{chunk-Q52JZP3P.cjs.map → chunk-YV55JZAD.cjs.map} +1 -1
- package/dist/{chunk-KUYYJ2XM.js → chunk-Z6MKG53Z.js} +4 -4
- package/dist/components/chat/index.cjs +5 -5
- package/dist/components/chat/index.js +4 -4
- package/dist/components/contact/index.cjs +6 -6
- package/dist/components/contact/index.js +5 -5
- package/dist/components/faq/faq-section.d.ts +20 -23
- package/dist/components/faq/faq-section.d.ts.map +1 -1
- package/dist/components/faq/index.cjs +4 -4
- package/dist/components/faq/index.js +3 -3
- package/dist/components/faq-accordion.d.ts +0 -5
- package/dist/components/faq-accordion.d.ts.map +1 -1
- package/dist/components/features/index.cjs +7 -8
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +6 -7
- package/dist/components/index.cjs +84 -84
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +13 -13
- package/dist/components/navigation/index.cjs +5 -5
- package/dist/components/navigation/index.js +4 -4
- package/dist/components/onboarding-guides/index.cjs +24 -25
- package/dist/components/onboarding-guides/index.cjs.map +1 -1
- package/dist/components/onboarding-guides/index.js +4 -5
- package/dist/components/onboarding-guides/index.js.map +1 -1
- package/dist/components/related-content/index.cjs +6 -6
- package/dist/components/related-content/index.js +5 -5
- package/dist/components/tickets/index.cjs +69 -69
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.js +10 -10
- package/dist/components/ui/index.cjs +7 -7
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +7 -7
- package/dist/index.cjs +10 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +10 -10
- package/package.json +1 -1
- package/src/components/faq/faq-section.tsx +212 -104
- package/src/components/faq-accordion.tsx +1 -15
- package/dist/chunk-7VMEOFEA.cjs.map +0 -1
- package/dist/chunk-DTJ32U7J.js.map +0 -1
- package/dist/chunk-FREG2KXV.js +0 -128
- package/dist/chunk-FREG2KXV.js.map +0 -1
- package/dist/chunk-G5GQVAPI.cjs.map +0 -1
- package/dist/chunk-HTR3ARN6.js.map +0 -1
- package/dist/chunk-JMU4RU5J.cjs +0 -128
- package/dist/chunk-JMU4RU5J.cjs.map +0 -1
- package/dist/chunk-K4K4X62E.js.map +0 -1
- package/dist/chunk-NR535XIY.js.map +0 -1
- package/dist/chunk-QAQ7AROS.cjs.map +0 -1
- package/dist/chunk-UUARMJJE.cjs.map +0 -1
- package/dist/chunk-VYGS24RO.cjs.map +0 -1
- /package/dist/{chunk-TX2VP43R.js.map → chunk-P7JHUX4X.js.map} +0 -0
- /package/dist/{chunk-SRM7HE2A.js.map → chunk-XSK72QRR.js.map} +0 -0
- /package/dist/{chunk-KUYYJ2XM.js.map → chunk-Z6MKG53Z.js.map} +0 -0
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import React, { useMemo } from 'react'
|
|
3
|
+
import React, { useCallback, useEffect, useMemo, useState } 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
7
|
import { buildSuggestionUrl } from '../../utils/suggestion-url'
|
|
8
8
|
import { serializeJsonLd } from '../../utils/common'
|
|
9
|
+
import { scrollElementIntoView } from '../../utils/scroll-into-view'
|
|
10
|
+
import { cn } from '../../utils/cn'
|
|
9
11
|
import { buildFaqJsonLdFromFaqs, type FaqSchemaOptions } from './json-ld'
|
|
10
|
-
import {
|
|
12
|
+
import { SECTION_HEADING_CLASS } from '../layout/page-heading'
|
|
11
13
|
|
|
12
14
|
export interface FaqSectionProps {
|
|
13
15
|
/**
|
|
@@ -20,23 +22,15 @@ export interface FaqSectionProps {
|
|
|
20
22
|
entityType?: string
|
|
21
23
|
entityId?: number | string
|
|
22
24
|
/**
|
|
23
|
-
* Heading node. `undefined` →
|
|
24
|
-
* "Frequently Asked Questions"
|
|
25
|
-
*
|
|
26
|
-
* referencing platform
|
|
25
|
+
* Heading node above the grouped list. `undefined` → default
|
|
26
|
+
* `<h2>`"Frequently Asked Questions". `null` → no heading (the host page
|
|
27
|
+
* owns the `<h1>`, as the standalone /faqs surface does). A React node lets a
|
|
28
|
+
* platform drill its own <PageHeading> without the lib referencing platform
|
|
29
|
+
* state. Also drives category nesting so the document outline stays correct:
|
|
30
|
+
* `null` → categories render `<h2>` (directly under the page `<h1>`);
|
|
31
|
+
* otherwise categories render `<h3>` beneath this heading.
|
|
27
32
|
*/
|
|
28
33
|
heading?: React.ReactNode | null
|
|
29
|
-
/**
|
|
30
|
-
* Shell variant — the FAQ LIST itself renders identically everywhere (ONE
|
|
31
|
-
* flat accordion, `faq.section` as a per-row chip, never a heading):
|
|
32
|
-
* - 'page' — the standalone /faqs surface: <main> shell + page gutters,
|
|
33
|
-
* default <h1> heading.
|
|
34
|
-
* - 'embedded' — a section inside a host page (entity detail rails, the
|
|
35
|
-
* global nested-page block): bare <section>, default <h2> heading.
|
|
36
|
-
* Back-compat default: `heading === null` → 'embedded', else 'page' (the
|
|
37
|
-
* pre-variant discriminator).
|
|
38
|
-
*/
|
|
39
|
-
variant?: 'page' | 'embedded'
|
|
40
34
|
/** Inject FAQPage schema.org JSON-LD as a <script>. Off by default so embeds
|
|
41
35
|
* don't emit duplicate schema. */
|
|
42
36
|
emitJsonLd?: boolean
|
|
@@ -65,16 +59,177 @@ function buildFaqsUrl(
|
|
|
65
59
|
return buildSuggestionUrl('/api/faqs', { apiBaseUrl, entityType, entityId, count: minResults })
|
|
66
60
|
}
|
|
67
61
|
|
|
68
|
-
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
function
|
|
72
|
-
return
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
62
|
+
/** Stable, URL-safe anchor id for a category. Prefixed so it can't collide
|
|
63
|
+
* with other in-page ids, and so a bare numeric/blank section still yields a
|
|
64
|
+
* valid id. */
|
|
65
|
+
function sectionSlug(section: string): string {
|
|
66
|
+
return (
|
|
67
|
+
'faq-' +
|
|
68
|
+
section
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
71
|
+
.replace(/^-+|-+$/g, '')
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface FaqGroup {
|
|
76
|
+
/** null → the uncategorized bucket: no heading, no jump pill, rendered last. */
|
|
77
|
+
section: string | null
|
|
78
|
+
slug: string | null
|
|
79
|
+
items: FaqItem[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Group FAQs by `faq.section`, preserving the server's first-seen
|
|
83
|
+
* (display_order) order for BOTH the section order and the rows within each
|
|
84
|
+
* section. The uncategorized bucket (blank/missing section) sinks to the end
|
|
85
|
+
* since it renders without a heading. Items carry NO badge here — the `<h2>`
|
|
86
|
+
* IS the category, so a per-row chip would be redundant. */
|
|
87
|
+
function groupFaqsBySection(faqs: Faq[]): FaqGroup[] {
|
|
88
|
+
const order: string[] = []
|
|
89
|
+
const byName = new Map<string, FaqGroup>()
|
|
90
|
+
let uncategorized: FaqGroup | null = null
|
|
91
|
+
for (const faq of faqs) {
|
|
92
|
+
const item: FaqItem = { id: faq.id, question: faq.question, answer: faq.answer }
|
|
93
|
+
const name = faq.section?.trim()
|
|
94
|
+
if (!name) {
|
|
95
|
+
if (!uncategorized) uncategorized = { section: null, slug: null, items: [] }
|
|
96
|
+
uncategorized.items.push(item)
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
let group = byName.get(name)
|
|
100
|
+
if (!group) {
|
|
101
|
+
group = { section: name, slug: sectionSlug(name), items: [] }
|
|
102
|
+
byName.set(name, group)
|
|
103
|
+
order.push(name)
|
|
104
|
+
}
|
|
105
|
+
group.items.push(item)
|
|
106
|
+
}
|
|
107
|
+
const groups = order.map((name) => byName.get(name)!)
|
|
108
|
+
if (uncategorized) groups.push(uncategorized)
|
|
109
|
+
return groups
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The standard hub sticky-header height — same offset `useNavLink`'s hash
|
|
113
|
+
* scroll uses, so a category jump lands below the header, not under it. */
|
|
114
|
+
const FAQ_NAV_HEADER_OFFSET = 96
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Grouped FAQ layout: a category jump-nav above stacked `<h2>` category
|
|
118
|
+
* sections (each its own accordion). Isolated into its own component so the
|
|
119
|
+
* scroll-spy hooks only mount in grouped mode — `FaqSection`'s own hooks stay
|
|
120
|
+
* unconditional.
|
|
121
|
+
*
|
|
122
|
+
* The pills are real `<a href="#slug">` anchors (crawlable in-page links,
|
|
123
|
+
* deep-linkable, work without JS); the click handler upgrades the jump to the
|
|
124
|
+
* cancellation-proof `scrollElementIntoView` tween and syncs the URL hash.
|
|
125
|
+
*/
|
|
126
|
+
function GroupedFaqList({
|
|
127
|
+
groups,
|
|
128
|
+
categoryHeadingAs,
|
|
129
|
+
}: {
|
|
130
|
+
groups: FaqGroup[]
|
|
131
|
+
/** Heading tag for each category, so the document outline nests correctly
|
|
132
|
+
* under whatever owns the heading above this block: `h2` on the standalone
|
|
133
|
+
* page (the page owns the `<h1>`), `h3` beneath an embed's `<h2>` title.
|
|
134
|
+
* The VISUAL is `SECTION_HEADING_CLASS` either way, so categories look
|
|
135
|
+
* identical on every surface. */
|
|
136
|
+
categoryHeadingAs: 'h2' | 'h3'
|
|
137
|
+
}) {
|
|
138
|
+
const CategoryHeading = categoryHeadingAs
|
|
139
|
+
const navGroups = useMemo(() => groups.filter((g) => g.slug), [groups])
|
|
140
|
+
const [activeSlug, setActiveSlug] = useState<string | null>(navGroups[0]?.slug ?? null)
|
|
141
|
+
// Identity-stable key for the section set so the observer re-binds only when
|
|
142
|
+
// the categories actually change (not on every parent re-render).
|
|
143
|
+
const slugKey = navGroups.map((g) => g.slug).join('|')
|
|
144
|
+
|
|
145
|
+
// Scroll-spy: mark the pill for the category currently at the top of the
|
|
146
|
+
// viewport. rootMargin drops the trigger line just below the sticky header
|
|
147
|
+
// and ignores the bottom ~55% so "active" is the section being read, not the
|
|
148
|
+
// next one peeking in.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (navGroups.length < 2) return
|
|
151
|
+
const tops = new Map<string, number>()
|
|
152
|
+
const observer = new IntersectionObserver(
|
|
153
|
+
(entries) => {
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
const id = (entry.target as HTMLElement).id
|
|
156
|
+
if (entry.isIntersecting) tops.set(id, entry.boundingClientRect.top)
|
|
157
|
+
else tops.delete(id)
|
|
158
|
+
}
|
|
159
|
+
let bestId: string | null = null
|
|
160
|
+
let bestTop = Number.POSITIVE_INFINITY
|
|
161
|
+
for (const [id, top] of tops) {
|
|
162
|
+
if (top < bestTop) {
|
|
163
|
+
bestTop = top
|
|
164
|
+
bestId = id
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (bestId) setActiveSlug(bestId)
|
|
168
|
+
},
|
|
169
|
+
{ rootMargin: `-${FAQ_NAV_HEADER_OFFSET}px 0px -55% 0px`, threshold: 0 },
|
|
170
|
+
)
|
|
171
|
+
for (const group of navGroups) {
|
|
172
|
+
const el = group.slug ? document.getElementById(group.slug) : null
|
|
173
|
+
if (el) observer.observe(el)
|
|
174
|
+
}
|
|
175
|
+
return () => observer.disconnect()
|
|
176
|
+
// slugKey encodes the section set; re-observe only when it changes.
|
|
177
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
178
|
+
}, [slugKey])
|
|
179
|
+
|
|
180
|
+
const handleJump = useCallback(
|
|
181
|
+
(e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
|
|
182
|
+
e.preventDefault()
|
|
183
|
+
setActiveSlug(slug)
|
|
184
|
+
scrollElementIntoView(document.getElementById(slug), {
|
|
185
|
+
headerOffset: FAQ_NAV_HEADER_OFFSET,
|
|
186
|
+
})
|
|
187
|
+
if (typeof history !== 'undefined') history.replaceState(null, '', `#${slug}`)
|
|
188
|
+
},
|
|
189
|
+
[],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<div className="space-y-8">
|
|
194
|
+
{navGroups.length > 1 && (
|
|
195
|
+
<nav aria-label="FAQ categories" className="flex flex-wrap gap-2">
|
|
196
|
+
{navGroups.map((group) => {
|
|
197
|
+
const isActive = group.slug === activeSlug
|
|
198
|
+
return (
|
|
199
|
+
<a
|
|
200
|
+
key={group.slug}
|
|
201
|
+
href={`#${group.slug}`}
|
|
202
|
+
aria-current={isActive ? 'true' : undefined}
|
|
203
|
+
onClick={(e) => handleJump(e, group.slug as string)}
|
|
204
|
+
className={cn(
|
|
205
|
+
"rounded-full border px-4 py-2 text-sm font-medium font-['DM_Sans'] transition-colors",
|
|
206
|
+
isActive
|
|
207
|
+
? 'border-ods-text-primary bg-ods-card text-ods-text-primary'
|
|
208
|
+
: 'border-ods-border bg-ods-card text-ods-text-secondary hover:border-ods-text-secondary hover:text-ods-text-primary',
|
|
209
|
+
)}
|
|
210
|
+
>
|
|
211
|
+
{group.section}
|
|
212
|
+
</a>
|
|
213
|
+
)
|
|
214
|
+
})}
|
|
215
|
+
</nav>
|
|
216
|
+
)}
|
|
217
|
+
<div className="space-y-10">
|
|
218
|
+
{groups.map((group) => (
|
|
219
|
+
<section
|
|
220
|
+
key={group.slug ?? 'faq-uncategorized'}
|
|
221
|
+
id={group.slug ?? undefined}
|
|
222
|
+
className="scroll-mt-24 space-y-4"
|
|
223
|
+
>
|
|
224
|
+
{group.section && (
|
|
225
|
+
<CategoryHeading className={SECTION_HEADING_CLASS}>{group.section}</CategoryHeading>
|
|
226
|
+
)}
|
|
227
|
+
<FaqAccordion items={group.items} />
|
|
228
|
+
</section>
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
)
|
|
78
233
|
}
|
|
79
234
|
|
|
80
235
|
function FaqSkeleton() {
|
|
@@ -98,23 +253,27 @@ interface FaqsResponse {
|
|
|
98
253
|
}
|
|
99
254
|
|
|
100
255
|
/**
|
|
101
|
-
*
|
|
256
|
+
* The FAQ display surface — ONE rendering on every host: the list grouped by
|
|
257
|
+
* `faq.section` (each category a heading + its own accordion, with a category
|
|
258
|
+
* jump-nav once there are 2+ categories). There is no flat/ungrouped mode and
|
|
259
|
+
* no page-vs-embedded shell fork; the standalone /faqs page and every embed
|
|
260
|
+
* render through this single path, so they cannot drift.
|
|
102
261
|
*
|
|
103
|
-
* - Standalone /faqs page: pass `initialFaqs`
|
|
104
|
-
* `emitJsonLd` with `jsonLd` overrides for SEO.
|
|
262
|
+
* - Standalone /faqs page: pass `initialFaqs` (SSR) + `heading={null}` (the
|
|
263
|
+
* page owns the <h1>) + `emitJsonLd` with `jsonLd` overrides for SEO.
|
|
105
264
|
* - Per-entity embed: pass `entityType` + `entityId` (no `initialFaqs`); the
|
|
106
|
-
* hook self-fetches
|
|
265
|
+
* hook self-fetches `GET /api/faqs`, and `heading` is this block's own <h2>.
|
|
107
266
|
*
|
|
108
|
-
* CONTRACT: the consuming app MUST implement `GET /api/faqs`.
|
|
109
|
-
*
|
|
110
|
-
*
|
|
267
|
+
* CONTRACT: the consuming app MUST implement `GET /api/faqs`. On a fetch error
|
|
268
|
+
* (or zero FAQs) the component renders nothing so the host page isn't
|
|
269
|
+
* disfigured. The host always supplies the page shell — this renders a bare
|
|
270
|
+
* <section>.
|
|
111
271
|
*/
|
|
112
272
|
export function FaqSection({
|
|
113
273
|
initialFaqs,
|
|
114
274
|
entityType,
|
|
115
275
|
entityId,
|
|
116
276
|
heading,
|
|
117
|
-
variant,
|
|
118
277
|
emitJsonLd = false,
|
|
119
278
|
jsonLd,
|
|
120
279
|
className,
|
|
@@ -131,86 +290,35 @@ export function FaqSection({
|
|
|
131
290
|
const { data, isLoading, error } = useSelfFetch<FaqsResponse>(url, { initialData })
|
|
132
291
|
|
|
133
292
|
const faqs = data?.faqs ?? []
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
heading === undefined ? (
|
|
137
|
-
isEmbedded ? (
|
|
138
|
-
// Embedded default is an <h2> — a host page already owns the <h1>,
|
|
139
|
-
// and the schema/heading pair must read "Frequently Asked Questions"
|
|
140
|
-
// (one FAQ heading per page, never tag/section names).
|
|
141
|
-
<h2 className={SECTION_HEADING_CLASS}>{DEFAULT_HEADING_TEXT}</h2>
|
|
142
|
-
) : (
|
|
143
|
-
<h1 className={PAGE_HEADING_CLASS}>{DEFAULT_HEADING_TEXT}</h1>
|
|
144
|
-
)
|
|
145
|
-
) : (
|
|
146
|
-
heading
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
// Embedded mode: degrade silently on error.
|
|
150
|
-
if (error && isEmbedded) {
|
|
151
|
-
return null
|
|
152
|
-
}
|
|
293
|
+
// Grouped before the early returns so the hook order stays stable.
|
|
294
|
+
const groups = useMemo(() => (faqs.length > 0 ? groupFaqsBySection(faqs) : []), [faqs])
|
|
153
295
|
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
296
|
+
// `undefined` → default <h2> title; `null` → the host page owns the <h1>, so
|
|
297
|
+
// no title renders here. `heading === null` also makes the category headings
|
|
298
|
+
// <h2> (directly under the page <h1>); otherwise they nest as <h3>.
|
|
299
|
+
const headingNode =
|
|
300
|
+
heading === undefined ? <h2 className={SECTION_HEADING_CLASS}>{DEFAULT_HEADING_TEXT}</h2> : heading
|
|
160
301
|
|
|
302
|
+
// Degrade silently — never show an error banner or an empty section shell
|
|
303
|
+
// where FAQs would be (host pages and the standalone surface both rely on it).
|
|
304
|
+
if (error) return null
|
|
305
|
+
if (!isLoading && faqs.length === 0) return null
|
|
161
306
|
if (isLoading && faqs.length === 0) {
|
|
162
|
-
// Embedded mode renders skeletons WITHOUT the standalone page shell —
|
|
163
|
-
// a host detail page already provides <main> + gutters; nesting them
|
|
164
|
-
// here would emit invalid markup and double padding.
|
|
165
|
-
if (isEmbedded) {
|
|
166
|
-
return (
|
|
167
|
-
<div className={className}>
|
|
168
|
-
<FaqSkeleton />
|
|
169
|
-
</div>
|
|
170
|
-
)
|
|
171
|
-
}
|
|
172
|
-
return (
|
|
173
|
-
<main className={className ?? 'bg-ods-bg'}>
|
|
174
|
-
<div className="max-w-[1920px] px-6 md:px-20 py-6 md:py-10 mx-auto">
|
|
175
|
-
<FaqSkeleton />
|
|
176
|
-
</div>
|
|
177
|
-
</main>
|
|
178
|
-
)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (error && faqs.length === 0) {
|
|
182
307
|
return (
|
|
183
|
-
<
|
|
184
|
-
<
|
|
185
|
-
</
|
|
308
|
+
<div className={className}>
|
|
309
|
+
<FaqSkeleton />
|
|
310
|
+
</div>
|
|
186
311
|
)
|
|
187
312
|
}
|
|
188
313
|
|
|
189
314
|
const schema = emitJsonLd ? buildFaqJsonLdFromFaqs(faqs, jsonLd) : null
|
|
190
315
|
|
|
191
|
-
// ONE flat accordion on EVERY surface — `faq.section` becomes a per-row
|
|
192
|
-
// chip, NOT a heading, so the page outline stays heading + h3(questions).
|
|
193
|
-
// Order is the server's (post-specific first, then reused) — grouping by
|
|
194
|
-
// section here would destroy it. The variants differ ONLY in shell:
|
|
195
|
-
// standalone /faqs keeps its <main> + page gutters, embeds render bare.
|
|
196
|
-
const accordion = <FaqAccordion items={toAccordionItems(faqs)} />
|
|
197
|
-
const body = isEmbedded ? (
|
|
198
|
-
<section className={className ?? 'space-y-10'}>
|
|
199
|
-
{headingNode}
|
|
200
|
-
{accordion}
|
|
201
|
-
</section>
|
|
202
|
-
) : (
|
|
203
|
-
<main className={className ?? 'bg-ods-bg'}>
|
|
204
|
-
<div className="max-w-[1920px] px-6 md:px-20 py-6 md:py-10 mx-auto space-y-10">
|
|
205
|
-
{headingNode}
|
|
206
|
-
{accordion}
|
|
207
|
-
</div>
|
|
208
|
-
</main>
|
|
209
|
-
)
|
|
210
|
-
|
|
211
316
|
return (
|
|
212
317
|
<>
|
|
213
|
-
{
|
|
318
|
+
<section className={className ?? 'space-y-10'}>
|
|
319
|
+
{headingNode}
|
|
320
|
+
<GroupedFaqList groups={groups} categoryHeadingAs={heading === null ? 'h2' : 'h3'} />
|
|
321
|
+
</section>
|
|
214
322
|
{schema && (
|
|
215
323
|
<script
|
|
216
324
|
type="application/ld+json"
|
|
@@ -2,19 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useRef, useState, useEffect, useCallback } from 'react'
|
|
4
4
|
import { ChevronButton } from './ui/chevron-button'
|
|
5
|
-
import { StatusBadge } from './ui/status-badge'
|
|
6
|
-
import { TAG_BADGE_CLASS } from './features/entity-tag-badges'
|
|
7
5
|
import { cn } from "../utils/cn"
|
|
8
6
|
|
|
9
7
|
export interface FaqItem {
|
|
10
8
|
id: number | string
|
|
11
9
|
question: string
|
|
12
10
|
answer: string
|
|
13
|
-
/** Optional category label (faq.section = tag name) rendered under the
|
|
14
|
-
* question as the product's standard tag badge — the same StatusBadge
|
|
15
|
-
* card skin EntityTagBadges uses everywhere (never a heading, so the
|
|
16
|
-
* document outline stays h2/h3 only). */
|
|
17
|
-
badge?: string
|
|
18
11
|
}
|
|
19
12
|
|
|
20
13
|
interface FaqAccordionProps {
|
|
@@ -83,17 +76,10 @@ export function FaqAccordion({ items, defaultOpenIds = [] }: FaqAccordionProps)
|
|
|
83
76
|
aria-expanded={isOpen}
|
|
84
77
|
className="flex w-full items-center justify-between px-6 md:px-8 py-6 text-left focus:outline-none transition-colors cursor-pointer"
|
|
85
78
|
>
|
|
86
|
-
<div className="
|
|
79
|
+
<div className="min-w-0 pr-4">
|
|
87
80
|
<h3>
|
|
88
81
|
{item.question}
|
|
89
82
|
</h3>
|
|
90
|
-
{item.badge && (
|
|
91
|
-
<StatusBadge
|
|
92
|
-
text={item.badge.toUpperCase()}
|
|
93
|
-
variant="card"
|
|
94
|
-
className={TAG_BADGE_CLASS}
|
|
95
|
-
/>
|
|
96
|
-
)}
|
|
97
83
|
</div>
|
|
98
84
|
<div className="flex-shrink-0">
|
|
99
85
|
<ChevronButton
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-7VMEOFEA.cjs","../src/utils/common.ts","../src/utils/suggestion-url.ts","../src/components/ui/chevron-button.tsx"],"names":["cn"],"mappings":"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","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-7VMEOFEA.cjs","sourcesContent":[null,"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\n/**\n * Utility functions for common operations\n */\n\n/**\n * Merge class names with Tailwind CSS\n * @param inputs - Class names to merge\n * @returns Merged class names\n */\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n\n/**\n * Delay execution for a specified time\n * @param ms - Milliseconds to delay\n * @returns Promise that resolves after the delay\n */\nexport function delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\n/**\n * Generate a random string of specified length\n * @param length - Length of the string\n * @returns Random string\n */\nexport function generateRandomString(length = 8): string {\n const chars = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\"\n let result = \"\"\n for (let i = 0; i < length; i++) {\n result += chars.charAt(Math.floor(Math.random() * chars.length))\n }\n return result\n}\n\n/**\n * Truncate a string to a specified length\n * @param str - String to truncate\n * @param maxLength - Maximum length\n * @param suffix - Suffix to add to truncated string\n * @returns Truncated string\n */\nexport function truncateString(str: string, maxLength: number, suffix = \"...\"): string {\n if (str.length <= maxLength) return str\n return str.substring(0, maxLength - suffix.length) + suffix\n}\n\n/**\n * Serialize a JSON-LD schema for a `<script type=\"application/ld+json\">`\n * block — THE one home of the escape rule (the hub re-exports this from\n * lib/utils/breadcrumbs). JSON.stringify does NOT HTML-escape: a stored\n * \"</script>\" inside any admin/user-entered field would terminate the tag\n * early (stored XSS). Every \"<\" becomes the JSON escape sequence\n * backslash-u003c — still valid JSON, inert in HTML.\n */\nexport function serializeJsonLd(schema: unknown): string {\n return JSON.stringify(schema).replace(/</g, \"\\\\u003c\")\n}\n\n/**\n * Deep clone an object\n * @param obj - Object to clone\n * @returns Cloned object\n */\nexport function deepClone<T>(obj: T): T {\n return JSON.parse(JSON.stringify(obj))\n}\n\n/**\n * Get the Slack community join URL from environment variables\n * @returns Slack community join URL or fallback URL\n */\nexport function getSlackCommunityJoinUrl(): string {\n const url = process.env.NEXT_PUBLIC_SLACK_COMMUNITY_JOIN_URL\n if (!url) {\n console.warn('NEXT_PUBLIC_SLACK_COMMUNITY_JOIN_URL is not defined in environment variables')\n return 'https://join.slack.com/t/openmsp/shared_invite/zt-36bl7mx0h-3~U2nFH6nqHqoTPXMaHEHA'\n }\n return url\n}\n\n","/**\n * Shared fetch-URL composer for the two suggestion sections (FaqSection +\n * RelatedContentSection) — ONE owner of the entityType/entityId/count query\n * shape so the sections can never drift. Pure + server-safe.\n *\n * Byte-parity contract with the original `buildFaqsUrl`: when only\n * entityType/entityId are set, the param order is `entityType`, `entityId`;\n * with nothing set, the bare `path` is returned (no `?`).\n */\nexport interface SuggestionUrlOptions {\n /** Fetch-URL prefix for third-party embeds / reverse proxies\n * ('' = same-origin relative). */\n apiBaseUrl?: string\n /** Both required together for entity scope; partial → bare path. */\n entityType?: string\n entityId?: number | string\n /** Maps to the server's `count` param. Undefined → param not sent\n * (server default applies). */\n count?: number\n /** Extra query params appended after the shared trio (e.g. the\n * related-content `excludeTypes` deny-list). Empty/undefined values\n * are skipped. */\n extraParams?: Record<string, string | undefined>\n}\n\nexport function buildSuggestionUrl(path: string, opts: SuggestionUrlOptions = {}): string {\n const qs = new URLSearchParams()\n const { entityType, entityId } = opts\n if (entityType && entityId !== undefined && entityId !== null && entityId !== '') {\n qs.set('entityType', entityType)\n qs.set('entityId', String(entityId))\n }\n if (opts.count !== undefined) qs.set('count', String(opts.count))\n for (const [key, value] of Object.entries(opts.extraParams ?? {})) {\n if (value !== undefined && value !== '') qs.set(key, value)\n }\n const query = qs.toString()\n return `${opts.apiBaseUrl ?? ''}${path}${query ? `?${query}` : ''}`\n}\n","\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronUp, ChevronDown } from \"lucide-react\"\nimport { cn } from \"../../utils/cn\"\n\ninterface ChevronButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n direction?: 'up' | 'down';\n size?: 'sm' | 'md' | 'lg';\n isExpanded?: boolean;\n backgroundColor?: string;\n borderColor?: string;\n}\n\nconst ChevronButton = React.forwardRef<HTMLButtonElement, ChevronButtonProps>(\n ({ className, direction = 'down', size = 'md', isExpanded, backgroundColor, borderColor, ...props }, ref) => {\n const Icon = (isExpanded ? ChevronUp : ChevronDown) || (direction === 'up' ? ChevronUp : ChevronDown);\n const sizeClasses = {\n sm: 'h-4 w-4',\n md: 'h-5 w-5', \n lg: 'h-6 w-6'\n };\n\n return (\n <button\n className={cn(\n \"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50\",\n \"h-10 px-4 py-2\",\n className\n )}\n style={{\n backgroundColor: backgroundColor,\n borderColor: borderColor\n }}\n ref={ref}\n {...props}\n >\n <Icon className={sizeClasses[size]} />\n </button>\n )\n }\n)\nChevronButton.displayName = \"ChevronButton\"\n\nexport { ChevronButton }"]}
|