@flamingo-stack/openframe-frontend-core 0.0.738 → 0.0.740
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/build/export-map.json +1 -1
- package/dist/{chunk-7GETNFZ5.cjs → chunk-2Y5Y4FLM.cjs} +38 -38
- package/dist/{chunk-2UZS3ZH7.cjs → chunk-3ZIQOYNX.cjs} +5 -5
- package/dist/{chunk-KQ4IKALJ.cjs → chunk-4RLGLB45.cjs} +35 -35
- package/dist/{chunk-5BKAUXLR.cjs → chunk-5SVOGLNK.cjs} +4 -4
- package/dist/{chunk-XGHZIYQN.cjs → chunk-7UIQPMBJ.cjs} +19 -19
- package/dist/{chunk-FTTLBMHU.cjs → chunk-7ZFCR7UY.cjs} +2 -2
- package/dist/{chunk-EG545WGV.cjs → chunk-B3XBJCOD.cjs} +9 -9
- package/dist/{chunk-XMNLPQ75.cjs → chunk-BDA5F6RM.cjs} +12 -12
- package/dist/{chunk-BRKTN6UR.cjs → chunk-BV5WZVOP.cjs} +199 -199
- package/dist/{chunk-4T6ETBL4.cjs → chunk-CWRVTQUO.cjs} +91 -83
- package/dist/{chunk-GQZOUVXC.cjs → chunk-D5KEWWLW.cjs} +34 -34
- package/dist/{chunk-7LTG6CJ3.cjs → chunk-DSVNFRY5.cjs} +35 -35
- package/dist/{chunk-2SBHPHLS.cjs → chunk-EKGL5K5K.cjs} +4 -4
- package/dist/{chunk-N2CISBJJ.cjs → chunk-GABDE3FM.cjs} +10 -10
- package/dist/{chunk-MYBTNOS6.cjs → chunk-GSWQZX23.cjs} +2 -2
- package/dist/{chunk-JZNESLJX.cjs → chunk-HF7Z2E4W.cjs} +2 -2
- package/dist/chunk-HOTM4UM6.cjs +84 -0
- package/dist/{chunk-HNYLTEIS.cjs → chunk-HYBMADYO.cjs} +13 -13
- package/dist/{chunk-EAY6ZUES.cjs → chunk-I5MSCD57.cjs} +2 -2
- package/dist/{chunk-3BBOAMWJ.cjs → chunk-KLI4OSRA.cjs} +2 -2
- package/dist/{chunk-R36NZTUJ.cjs → chunk-LLAC2UZ3.cjs} +24 -24
- package/dist/{chunk-FG7HTGT3.cjs → chunk-O67V3HJR.cjs} +14 -14
- package/dist/{chunk-UMO55S55.cjs → chunk-PQ2M7P2U.cjs} +5 -5
- package/dist/{chunk-MLKL2YVE.cjs → chunk-SFY4UYPY.cjs} +44 -44
- package/dist/{chunk-66R4NGGU.cjs → chunk-SWR22DOZ.cjs} +176 -88
- package/dist/{chunk-MTNNYSP3.cjs → chunk-UW3YXDC4.cjs} +20 -20
- package/dist/{chunk-U5BRSRXA.cjs → chunk-VC4IV3AY.cjs} +644 -621
- package/dist/{chunk-4YQNJJWM.cjs → chunk-VU4CKUJW.cjs} +90 -90
- package/dist/{chunk-GAF4L3JW.cjs → chunk-WBANGORJ.cjs} +3 -3
- package/dist/{chunk-LY5VOKV7.cjs → chunk-YRE3O65E.cjs} +12 -12
- package/dist/{chunk-7F3V45AH.cjs → chunk-YXKB5WRE.cjs} +66 -75
- package/dist/{chunk-N5NDPULP.cjs → chunk-ZLTCQKTD.cjs} +6 -6
- package/dist/{cloud-tenants-FTQGWXHS.cjs → cloud-tenants-PVXDI5MU.cjs} +15 -15
- package/dist/components/case-studies/index.cjs +27 -27
- package/dist/components/chat/index.cjs +11 -11
- package/dist/components/contact/index.cjs +16 -16
- package/dist/components/docs/index.cjs +19 -19
- package/dist/components/embeds/index.cjs +16 -16
- package/dist/components/faq/index.cjs +17 -17
- package/dist/components/features/index.cjs +15 -15
- package/dist/components/features/walkthrough-video.d.ts +3 -0
- package/dist/components/features/walkthrough-video.d.ts.map +1 -1
- package/dist/components/features/walkthrough-video.js +36 -13
- package/dist/components/help-center-pages/index.cjs +118 -118
- package/dist/components/index.cjs +86 -80
- package/dist/components/meeting-scheduler/index.cjs +57 -57
- package/dist/components/navigation/index.cjs +18 -18
- package/dist/components/onboarding-guides/index.cjs +18 -18
- package/dist/components/product-demo/index.cjs +8 -8
- package/dist/components/related-content/index.cjs +9 -9
- package/dist/components/shared/legal-document/index.d.ts +2 -1
- package/dist/components/shared/legal-document/index.d.ts.map +1 -1
- package/dist/components/shared/legal-document/index.js +10 -2
- package/dist/components/shared/legal-document/legal-document-page.d.ts +35 -23
- package/dist/components/shared/legal-document/legal-document-page.d.ts.map +1 -1
- package/dist/components/shared/legal-document/legal-document-page.js +27 -10
- package/dist/components/shared/legal-document/use-legal-docs.d.ts +7 -3
- package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -1
- package/dist/components/shared/legal-document/use-legal-docs.js +7 -0
- package/dist/components/shared/legal-document/use-legal-documents.d.ts +31 -0
- package/dist/components/shared/legal-document/use-legal-documents.d.ts.map +1 -0
- package/dist/components/shared/legal-document/use-legal-documents.js +67 -0
- package/dist/components/tickets/index.cjs +21 -21
- package/dist/components/ui/index.cjs +15 -15
- package/dist/{devices-MPI5HUQS.cjs → devices-TWEA7XOR.cjs} +16 -16
- package/dist/embed-shims/index.cjs +2 -2
- package/dist/embed-shims/next-image.cjs +2 -2
- package/dist/embed-shims/next-image.d.ts.map +1 -1
- package/dist/embed-shims/next-image.js +6 -2
- package/dist/{embeddable-chat-7WLUDBGK.cjs → embeddable-chat-6JKKFQEB.cjs} +82 -82
- package/dist/hooks/index.cjs +3 -3
- package/dist/index.cjs +26 -21
- package/dist/{knowledge-BS663XXL.cjs → knowledge-4GQDBNQB.cjs} +16 -16
- package/dist/{logs-KS2A2VN5.cjs → logs-EVU6GJHZ.cjs} +23 -23
- package/dist/{policies-H3OK6H4Q.cjs → policies-OZUEA5YS.cjs} +19 -19
- package/dist/{remote-session-EHKXU4HI.cjs → remote-session-DNFZ5OLY.cjs} +8 -8
- package/dist/{software-update-HMMMWIL3.cjs → software-update-BLIVYOY2.cjs} +19 -19
- package/dist/{tickets-board-3YJKOOZE.cjs → tickets-board-CKZRB6YV.cjs} +9 -9
- package/dist/utils/index.cjs +8 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/legal-documents.d.ts +18 -0
- package/dist/utils/legal-documents.d.ts.map +1 -0
- package/dist/utils/legal-documents.js +8 -0
- package/package.json +1 -1
- package/src/components/features/walkthrough-video.tsx +59 -18
- package/src/components/shared/legal-document/index.ts +8 -0
- package/src/components/shared/legal-document/legal-document-page.tsx +83 -44
- package/src/components/shared/legal-document/use-legal-docs.ts +18 -3
- package/src/components/shared/legal-document/use-legal-documents.ts +113 -0
- package/src/components/ui/dropdown-button.tsx +1 -1
- package/src/embed-shims/next-image.tsx +15 -4
- package/src/utils/index.ts +1 -0
- package/src/utils/legal-documents.ts +21 -0
- package/dist/chunk-UR4K2NSL.cjs +0 -69
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"legal-documents.d.ts","sourceRoot":"","sources":["../../src/utils/legal-documents.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB,iEAAkE,CAAC;AAEpG,MAAM,MAAM,iBAAiB,GAAG,CAAC,OAAO,oBAAoB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEtE,mFAAmF;AACnF,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,iBAAiB,CAE7E"}
|
package/package.json
CHANGED
|
@@ -106,6 +106,13 @@ export interface WalkthroughVideoData {
|
|
|
106
106
|
position?: 'left' | 'right' | null;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* What ends Largest Contentful Paint: the browser reports no new candidate after
|
|
111
|
+
* the visitor's first scroll, tap or key press. The floating card renders only
|
|
112
|
+
* after one of these.
|
|
113
|
+
*/
|
|
114
|
+
const FIRST_INTERACTION_EVENTS = ['scroll', 'pointerdown', 'keydown'] as const;
|
|
115
|
+
|
|
109
116
|
/** Everything the two placements share — the widget's actual contract. */
|
|
110
117
|
interface WalkthroughVideoBaseProps {
|
|
111
118
|
video: WalkthroughVideoData | null | undefined;
|
|
@@ -148,6 +155,9 @@ interface WalkthroughVideoBaseProps {
|
|
|
148
155
|
* not in the base.
|
|
149
156
|
*/
|
|
150
157
|
export interface FloatingWalkthroughVideoProps extends WalkthroughVideoBaseProps {
|
|
158
|
+
/** The card appears no sooner than this after load, and only once the visitor
|
|
159
|
+
* has scrolled, tapped or pressed a key (so it can never be the page's
|
|
160
|
+
* Largest Contentful Paint). Default 3000. */
|
|
151
161
|
appearDelayMs?: number;
|
|
152
162
|
/** Cookie-based dismissal (id-match, mirrors the announcement bar). `false`
|
|
153
163
|
* disables the X entirely. `storageKey` is the per-platform cookie name. */
|
|
@@ -208,9 +218,10 @@ function WalkthroughVideo({
|
|
|
208
218
|
autoPlayCard = false,
|
|
209
219
|
label = 'Play Demo Video',
|
|
210
220
|
placement,
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
221
|
+
// The FLOATING card never appears sooner than this (and never before the
|
|
222
|
+
// visitor's first interaction: see the mount gate). The inline preset passes
|
|
223
|
+
// 0: an in-page block is part of a page the visitor is already looking at,
|
|
224
|
+
// so staggering it in would only jump the layout.
|
|
214
225
|
appearDelayMs = 3000,
|
|
215
226
|
dismissal = {},
|
|
216
227
|
hideNearSelector = 'footer',
|
|
@@ -224,7 +235,16 @@ function WalkthroughVideo({
|
|
|
224
235
|
// (embedder) → a stable presence marker so dismissal still works.
|
|
225
236
|
const dismissalId = video?.id != null ? String(video.id) : 'dismissed';
|
|
226
237
|
|
|
227
|
-
// --- mount gate (
|
|
238
|
+
// --- mount gate (cookie read happens post-delay, no hydration mismatch) ---
|
|
239
|
+
// The FLOATING card waits for two things: the appear delay AND the visitor's
|
|
240
|
+
// first scroll, tap or key press. The delay alone never kept it out of Largest
|
|
241
|
+
// Contentful Paint: the metric takes candidates until the first interaction,
|
|
242
|
+
// so a late card that was the largest thing in a short window became the
|
|
243
|
+
// page's LCP (blog post, 1440x215: the heading at 3.2 s, then the card's
|
|
244
|
+
// poster at 7.1 s). No attribute opts an element out, and a timer does not
|
|
245
|
+
// either; rendering only after the first interaction is the documented way,
|
|
246
|
+
// because the metric is final by then. The inline block is part of the page's
|
|
247
|
+
// own content and waits for neither.
|
|
228
248
|
const [mounted, setMounted] = useState(false);
|
|
229
249
|
const [dismissed, setDismissed] = useState(false);
|
|
230
250
|
const hasVideo = video != null;
|
|
@@ -235,31 +255,52 @@ function WalkthroughVideo({
|
|
|
235
255
|
// dismissal id was 'dismissed' on the first pass and a dismissed card
|
|
236
256
|
// flashed for the length of a second appear-delay before hiding.
|
|
237
257
|
if (!hasVideo) return undefined; // wait for data — see the id-based dep below
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
258
|
+
let delayElapsed = false;
|
|
259
|
+
let interacted = inline;
|
|
260
|
+
const reveal = () => {
|
|
261
|
+
if (dismissEnabled && isWalkthroughDismissed(storageKey, dismissalId)) {
|
|
262
|
+
setDismissed(true);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// Cleared on the way through: a client-only embedder can swap to a NEW
|
|
266
|
+
// video id in-session (the cookie is id-matched, so the new one isn't
|
|
267
|
+
// dismissed), and a sticky `dismissed` kept the card hidden until a
|
|
268
|
+
// full reload — contradicting the id-match behaviour documented above.
|
|
269
|
+
setDismissed(false);
|
|
270
|
+
setMounted(true);
|
|
271
|
+
};
|
|
272
|
+
const revealWhenReady = () => {
|
|
273
|
+
if (!delayElapsed || !interacted) return;
|
|
251
274
|
const ric = (window as unknown as { requestIdleCallback?: (cb: () => void) => number }).requestIdleCallback;
|
|
252
275
|
if (typeof ric === 'function') idleHandle = ric(reveal);
|
|
253
276
|
else reveal();
|
|
277
|
+
};
|
|
278
|
+
const stopListening = () => {
|
|
279
|
+
for (const type of FIRST_INTERACTION_EVENTS) window.removeEventListener(type, onInteraction, true);
|
|
280
|
+
};
|
|
281
|
+
function onInteraction() {
|
|
282
|
+
interacted = true;
|
|
283
|
+
stopListening();
|
|
284
|
+
revealWhenReady();
|
|
285
|
+
}
|
|
286
|
+
if (!interacted) {
|
|
287
|
+
for (const type of FIRST_INTERACTION_EVENTS) {
|
|
288
|
+
window.addEventListener(type, onInteraction, { capture: true, passive: true });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const timer = setTimeout(() => {
|
|
292
|
+
delayElapsed = true;
|
|
293
|
+
revealWhenReady();
|
|
254
294
|
}, appearDelayMs);
|
|
255
295
|
return () => {
|
|
256
296
|
clearTimeout(timer);
|
|
297
|
+
stopListening();
|
|
257
298
|
const cic = (window as unknown as { cancelIdleCallback?: (h: number) => void }).cancelIdleCallback;
|
|
258
299
|
if (idleHandle !== null && typeof cic === 'function') cic(idleHandle);
|
|
259
300
|
};
|
|
260
301
|
// `videoId`, not the object: a new identity (embedder refetch, re-seeded
|
|
261
302
|
// RSC payload) would otherwise restart the appear delay.
|
|
262
|
-
}, [appearDelayMs, dismissEnabled, storageKey, dismissalId, hasVideo, videoId]);
|
|
303
|
+
}, [appearDelayMs, dismissEnabled, storageKey, dismissalId, hasVideo, videoId, inline]);
|
|
263
304
|
|
|
264
305
|
// --- environment preferences (read after hydration; no SSR access) ---
|
|
265
306
|
// Derived, not stored: both inputs are plain reads of the environment, and
|
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
|
|
9
9
|
export {
|
|
10
10
|
LegalDocumentPage,
|
|
11
|
+
legalDocumentStampLabel,
|
|
11
12
|
type LegalDocumentPageProps,
|
|
12
13
|
type LegalDocumentMarkdownRendererProps,
|
|
13
14
|
} from './legal-document-page';
|
|
14
15
|
export { useLegalDocs, type LegalDocument, type UseLegalDocsOptions, type UseLegalDocsReturn } from './use-legal-docs';
|
|
16
|
+
export {
|
|
17
|
+
useLegalDocuments,
|
|
18
|
+
toLegalDocumentSummaries,
|
|
19
|
+
type LegalDocumentSummary,
|
|
20
|
+
type UseLegalDocumentsOptions,
|
|
21
|
+
type UseLegalDocumentsReturn,
|
|
22
|
+
} from './use-legal-documents';
|
|
@@ -25,6 +25,7 @@ import { FaqSection, pageFaqProps, type PageFaqSlot } from '../../faq/faq-sectio
|
|
|
25
25
|
import { PageShell, PageLayout } from '../../ui';
|
|
26
26
|
import { RichMarkdownRenderer } from '../../ui/markdown';
|
|
27
27
|
import { useLegalDocs, type LegalDocument } from './use-legal-docs';
|
|
28
|
+
import { useLegalDocuments } from './use-legal-documents';
|
|
28
29
|
|
|
29
30
|
export interface LegalDocumentMarkdownRendererProps {
|
|
30
31
|
content: string;
|
|
@@ -34,25 +35,24 @@ export interface LegalDocumentMarkdownRendererProps {
|
|
|
34
35
|
|
|
35
36
|
export interface LegalDocumentPageProps {
|
|
36
37
|
/** Document type identifier — drives the default API endpoint
|
|
37
|
-
* `/api/legal/<docType>` AND the error-log prefix.
|
|
38
|
-
*
|
|
38
|
+
* `/api/legal/<docType>` AND the error-log prefix. One of the types the
|
|
39
|
+
* host's list answers (`useLegalDocuments`). */
|
|
39
40
|
docType: string;
|
|
40
|
-
/** Heading text
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
fallbackDescription
|
|
45
|
-
/** Email shown in the error + empty-state copy
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
emptyStateMessage: string;
|
|
41
|
+
/** Heading text. Default: the title the served document carries, so a host
|
|
42
|
+
* that shows whatever the list holds states no per-document copy. */
|
|
43
|
+
title?: string;
|
|
44
|
+
/** Subtitle shown when the document states neither a version nor a date. */
|
|
45
|
+
fallbackDescription?: string;
|
|
46
|
+
/** Email shown in the error + empty-state copy. Absent: no contact line. */
|
|
47
|
+
contactEmail?: string;
|
|
48
|
+
/** Prompt shown above the contact link in the error state.
|
|
49
|
+
* Default "For questions about this document, please contact:". */
|
|
50
|
+
errorContactPrompt?: string;
|
|
51
|
+
/** Title for the error block. Default "Unable to load <title>". */
|
|
52
|
+
errorTitle?: string;
|
|
53
|
+
/** Sentence shown when the API returns no document.
|
|
54
|
+
* Default "<title> is not available at this time.". */
|
|
55
|
+
emptyStateMessage?: string;
|
|
56
56
|
/** SSR-prepared document, if available. */
|
|
57
57
|
initialData?: LegalDocument | null;
|
|
58
58
|
/** SSR-prepared formatted "Last Updated" label. Stable across hydration. */
|
|
@@ -66,29 +66,52 @@ export interface LegalDocumentPageProps {
|
|
|
66
66
|
* to hide. Default `{ label: 'Back to home', href: '/' }`. */
|
|
67
67
|
backButton?: { label?: string; href?: string } | false;
|
|
68
68
|
/**
|
|
69
|
-
* The
|
|
70
|
-
*
|
|
71
|
-
*
|
|
69
|
+
* The hub's list of legal documents (`GET /api/legal`). Given it, the page
|
|
70
|
+
* takes the document's title and FAQ topic from the list, so a host passes
|
|
71
|
+
* only the type and its endpoints and states nothing per document; the title
|
|
72
|
+
* then names the document even when its text fails to load.
|
|
72
73
|
*/
|
|
73
|
-
|
|
74
|
+
documentsEndpoint?: string;
|
|
75
|
+
/**
|
|
76
|
+
* The page's own FAQ block: the topic the page is about and where the host
|
|
77
|
+
* serves the FAQs. Rendered after the page's content, inside its container.
|
|
78
|
+
* Without a `topic`, the topic is the one the list (`documentsEndpoint`)
|
|
79
|
+
* gives the document. Absent, or no topic known: no FAQ block.
|
|
80
|
+
*/
|
|
81
|
+
faq?: PageFaqSlot | Omit<PageFaqSlot, 'topic'>;
|
|
74
82
|
/** Render the standalone `<PageShell>`. Default true. Pass false when the host
|
|
75
83
|
* layout already provides the page container — only the padding box renders,
|
|
76
84
|
* avoiding a nested `<main>`. */
|
|
77
85
|
shell?: boolean;
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
/**
|
|
89
|
+
* "Version 1.0 · Effective October 14, 2026" for a document that states them;
|
|
90
|
+
* null for one that states neither. One wording for every legal surface.
|
|
91
|
+
*/
|
|
92
|
+
export function legalDocumentStampLabel(
|
|
93
|
+
doc: Pick<LegalDocument, 'version' | 'effectiveDate'> | null | undefined,
|
|
94
|
+
): string | null {
|
|
95
|
+
const parts = [
|
|
96
|
+
doc?.version ? `Version ${doc.version}` : null,
|
|
97
|
+
doc?.effectiveDate ? `Effective ${doc.effectiveDate}` : null,
|
|
98
|
+
].filter((part): part is string => part !== null);
|
|
99
|
+
return parts.length > 0 ? parts.join(' \u00b7 ') : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
80
102
|
export function LegalDocumentPage({
|
|
81
103
|
docType,
|
|
82
|
-
title,
|
|
83
|
-
fallbackDescription,
|
|
104
|
+
title: titleProp,
|
|
105
|
+
fallbackDescription = '',
|
|
84
106
|
contactEmail,
|
|
85
|
-
errorContactPrompt,
|
|
86
|
-
errorTitle,
|
|
87
|
-
emptyStateMessage,
|
|
107
|
+
errorContactPrompt = 'For questions about this document, please contact:',
|
|
108
|
+
errorTitle: errorTitleProp,
|
|
109
|
+
emptyStateMessage: emptyStateMessageProp,
|
|
88
110
|
initialData = null,
|
|
89
111
|
initialLastUpdatedLabel = null,
|
|
90
112
|
apiEndpoint,
|
|
91
113
|
MarkdownRenderer = RichMarkdownRenderer,
|
|
114
|
+
documentsEndpoint,
|
|
92
115
|
backButton,
|
|
93
116
|
faq,
|
|
94
117
|
shell = true,
|
|
@@ -107,13 +130,25 @@ export function LegalDocumentPage({
|
|
|
107
130
|
onClick: () => router.push(backButton?.href ?? '/'),
|
|
108
131
|
};
|
|
109
132
|
|
|
133
|
+
const { documents } = useLegalDocuments({ apiEndpoint: documentsEndpoint, enabled: Boolean(documentsEndpoint) });
|
|
134
|
+
const listed = documents.find(doc => doc.docType === docType);
|
|
135
|
+
const title = titleProp ?? data?.title ?? listed?.title ?? 'Legal document';
|
|
136
|
+
const faqTopic = faq && 'topic' in faq ? faq.topic : listed?.faqTopic;
|
|
137
|
+
const faqSlot = faq && faqTopic ? { ...faq, topic: faqTopic } : null;
|
|
138
|
+
const errorTitle = errorTitleProp ?? `Unable to load ${title}`;
|
|
139
|
+
const emptyStateMessage = emptyStateMessageProp ?? `${title} is not available at this time.`;
|
|
140
|
+
|
|
110
141
|
const fallbackLastUpdatedLabel = data?.lastSynced != null ? formatLegalDate(data.lastSynced) : null;
|
|
111
142
|
const effectiveLastUpdatedLabel = initialLastUpdatedLabel ?? fallbackLastUpdatedLabel;
|
|
112
143
|
|
|
113
144
|
// Subtitle routes through the frozen `PageLayout` `TitleBlock` (text-h2 title
|
|
114
|
-
// + subtitle) — unified header across all help-center pages.
|
|
115
|
-
//
|
|
116
|
-
|
|
145
|
+
// + subtitle) — unified header across all help-center pages. A document that
|
|
146
|
+
// states its own version and effective date shows those (they are what a
|
|
147
|
+
// reader agreed to); one that states neither shows when it was last read
|
|
148
|
+
// from its source, else the fallback description.
|
|
149
|
+
const subtitle =
|
|
150
|
+
legalDocumentStampLabel(data) ??
|
|
151
|
+
(effectiveLastUpdatedLabel ? `Last Updated: ${effectiveLastUpdatedLabel}` : fallbackDescription);
|
|
117
152
|
|
|
118
153
|
const inner = (
|
|
119
154
|
<PageLayout title={title} subtitle={subtitle} backButton={backCfg} titleSize="h1" titleWrap>
|
|
@@ -144,32 +179,36 @@ export function LegalDocumentPage({
|
|
|
144
179
|
<p className="mb-2 text-ods-error">{errorTitle}</p>
|
|
145
180
|
<p className="text-ods-error text-h6">{error}</p>
|
|
146
181
|
</div>
|
|
147
|
-
|
|
148
|
-
<
|
|
149
|
-
|
|
150
|
-
{contactEmail}
|
|
151
|
-
|
|
152
|
-
|
|
182
|
+
{contactEmail && (
|
|
183
|
+
<div className="text-ods-text-secondary">
|
|
184
|
+
<p>{errorContactPrompt}</p>
|
|
185
|
+
<Link href={`mailto:${contactEmail}`} className="text-ods-accent hover:underline">
|
|
186
|
+
{contactEmail}
|
|
187
|
+
</Link>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
153
190
|
</div>
|
|
154
191
|
) : data ? (
|
|
155
192
|
<MarkdownRenderer content={data.content} sectionIds={data.sections || []} demoteMarkdownH1ToH2 />
|
|
156
193
|
) : (
|
|
157
194
|
<div className="py-16 text-center text-ods-text-secondary">
|
|
158
195
|
<p className="text-h4">{emptyStateMessage}</p>
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
{contactEmail}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
196
|
+
{contactEmail && (
|
|
197
|
+
<p className="mt-2">
|
|
198
|
+
Please contact{' '}
|
|
199
|
+
<Link href={`mailto:${contactEmail}`} className="text-ods-accent hover:underline">
|
|
200
|
+
{contactEmail}
|
|
201
|
+
</Link>{' '}
|
|
202
|
+
for more information.
|
|
203
|
+
</p>
|
|
204
|
+
)}
|
|
166
205
|
</div>
|
|
167
206
|
)}
|
|
168
207
|
</article>
|
|
169
208
|
</div>
|
|
170
209
|
</div>
|
|
171
210
|
</div>
|
|
172
|
-
{
|
|
211
|
+
{faqSlot && <FaqSection {...pageFaqProps(faqSlot)} />}
|
|
173
212
|
</PageLayout>
|
|
174
213
|
);
|
|
175
214
|
|
|
@@ -24,6 +24,10 @@ export interface LegalDocument {
|
|
|
24
24
|
githubSha: string | null;
|
|
25
25
|
sections: Array<{ id: string; title: string; level: number }>;
|
|
26
26
|
docType: string;
|
|
27
|
+
/** The version the document states for itself ("1.0"); null when it states none. */
|
|
28
|
+
version: string | null;
|
|
29
|
+
/** The day that version takes effect, as the document words it; null when it states none. */
|
|
30
|
+
effectiveDate: string | null;
|
|
27
31
|
meta: {
|
|
28
32
|
sectionsCount: number;
|
|
29
33
|
contentLength: number;
|
|
@@ -111,6 +115,8 @@ function toLegalDocument(payload: unknown, docType: string): LegalDocument | nul
|
|
|
111
115
|
githubSha: typeof githubSha === 'string' ? githubSha : null,
|
|
112
116
|
sections,
|
|
113
117
|
docType: readString(payload, 'docType') ?? docType,
|
|
118
|
+
version: readString(payload, 'version') ?? null,
|
|
119
|
+
effectiveDate: readString(payload, 'effectiveDate') ?? null,
|
|
114
120
|
meta: {
|
|
115
121
|
sectionsCount: typeof sectionsCount === 'number' ? sectionsCount : sections.length,
|
|
116
122
|
contentLength: typeof contentLength === 'number' ? contentLength : content.length,
|
|
@@ -122,9 +128,9 @@ function toLegalDocument(payload: unknown, docType: string): LegalDocument | nul
|
|
|
122
128
|
/**
|
|
123
129
|
* Hook to fetch a legal document.
|
|
124
130
|
* @param docType — short identifier for the document (drives the
|
|
125
|
-
* default endpoint path AND the error-log prefix).
|
|
126
|
-
*
|
|
127
|
-
*
|
|
131
|
+
* default endpoint path AND the error-log prefix). The valid values are
|
|
132
|
+
* the ones the host's list endpoint answers (`useLegalDocuments`); the
|
|
133
|
+
* hook treats it as opaque.
|
|
128
134
|
*/
|
|
129
135
|
export function useLegalDocs(docType: string, options: UseLegalDocsOptions = {}): UseLegalDocsReturn {
|
|
130
136
|
const { initialData = null, apiEndpoint } = options;
|
|
@@ -183,6 +189,15 @@ export function useLegalDocs(docType: string, options: UseLegalDocsOptions = {})
|
|
|
183
189
|
try {
|
|
184
190
|
const response = await contentFetch(effectiveEndpoint);
|
|
185
191
|
|
|
192
|
+
// 404: the hub has no published text for this document yet (a legal
|
|
193
|
+
// file is published only once it states its version and effective
|
|
194
|
+
// date). That is "not available", not a failure: no data, no error.
|
|
195
|
+
if (response.status === 404) {
|
|
196
|
+
if (!isCurrent()) return;
|
|
197
|
+
setData(null);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
186
201
|
if (!response.ok) {
|
|
187
202
|
throw new Error(`Failed to fetch ${docType} document: ${response.status} ${response.statusText}`);
|
|
188
203
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useLegalDocuments — the list of legal documents a hub publishes (privacy
|
|
5
|
+
* policy, terms of service, payment terms, license, security policy, and
|
|
6
|
+
* whatever it adds later), each with its page and the version it states.
|
|
7
|
+
*
|
|
8
|
+
* A host that lists legal pages reads this list, shows one entry per published
|
|
9
|
+
* document, each leading to that document's own page, and names no document
|
|
10
|
+
* itself: a document the hub adds appears with no change here.
|
|
11
|
+
*
|
|
12
|
+
* Endpoint: default `/api/legal`; an embedder passes its proxied path.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useEffect, useState } from 'react';
|
|
16
|
+
import { contentFetch } from '../../../utils/embed-content-fetch';
|
|
17
|
+
|
|
18
|
+
export interface LegalDocumentSummary {
|
|
19
|
+
/** The identifier `useLegalDocs` and `<LegalDocumentPage>` take. */
|
|
20
|
+
docType: string;
|
|
21
|
+
title: string;
|
|
22
|
+
/** One line saying what the document is, for a card that lists it. */
|
|
23
|
+
summary: string;
|
|
24
|
+
/** The document's page on the hub's own site (a path). */
|
|
25
|
+
route: string;
|
|
26
|
+
sourceFile: string;
|
|
27
|
+
/** The topic the document's FAQs are written for, when the hub has one. */
|
|
28
|
+
faqTopic: string | null;
|
|
29
|
+
version: string | null;
|
|
30
|
+
effectiveDate: string | null;
|
|
31
|
+
/** False while the hub has no content for it yet. */
|
|
32
|
+
published: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface UseLegalDocumentsOptions {
|
|
36
|
+
/** Full GET endpoint URL. Default `/api/legal`. */
|
|
37
|
+
apiEndpoint?: string;
|
|
38
|
+
/** False: read nothing and answer an empty list. Default true. */
|
|
39
|
+
enabled?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UseLegalDocumentsReturn {
|
|
43
|
+
documents: LegalDocumentSummary[];
|
|
44
|
+
isLoading: boolean;
|
|
45
|
+
error: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return typeof value === 'object' && value !== null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function text(source: Record<string, unknown>, key: string): string | null {
|
|
53
|
+
const value = source[key];
|
|
54
|
+
return typeof value === 'string' && value ? value : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Decode the endpoint's answer; a row without a type and a title is left out. */
|
|
58
|
+
export function toLegalDocumentSummaries(payload: unknown): LegalDocumentSummary[] {
|
|
59
|
+
const rows: unknown = isRecord(payload) ? payload.documents : null;
|
|
60
|
+
if (!Array.isArray(rows)) return [];
|
|
61
|
+
const documents: LegalDocumentSummary[] = [];
|
|
62
|
+
for (const row of rows as readonly unknown[]) {
|
|
63
|
+
if (!isRecord(row)) continue;
|
|
64
|
+
const docType = text(row, 'docType');
|
|
65
|
+
const title = text(row, 'title');
|
|
66
|
+
if (!docType || !title) continue;
|
|
67
|
+
documents.push({
|
|
68
|
+
docType,
|
|
69
|
+
title,
|
|
70
|
+
summary: text(row, 'summary') ?? '',
|
|
71
|
+
route: text(row, 'route') ?? '',
|
|
72
|
+
sourceFile: text(row, 'sourceFile') ?? '',
|
|
73
|
+
faqTopic: text(row, 'faqTopic'),
|
|
74
|
+
version: text(row, 'version'),
|
|
75
|
+
effectiveDate: text(row, 'effectiveDate'),
|
|
76
|
+
published: row.published === true,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return documents;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function useLegalDocuments(options: UseLegalDocumentsOptions = {}): UseLegalDocumentsReturn {
|
|
83
|
+
const endpoint = options.apiEndpoint ?? '/api/legal';
|
|
84
|
+
const enabled = options.enabled ?? true;
|
|
85
|
+
const [state, setState] = useState<UseLegalDocumentsReturn>({ documents: [], isLoading: enabled, error: null });
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
let current = enabled;
|
|
89
|
+
const load = async () => {
|
|
90
|
+
try {
|
|
91
|
+
const response = await contentFetch(endpoint);
|
|
92
|
+
if (!response.ok) throw new Error(`Failed to fetch legal documents: ${response.status} ${response.statusText}`);
|
|
93
|
+
const payload: unknown = await response.json();
|
|
94
|
+
if (current) setState({ documents: toLegalDocumentSummaries(payload), isLoading: false, error: null });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error('Error fetching legal documents:', err);
|
|
97
|
+
if (current) {
|
|
98
|
+
setState({
|
|
99
|
+
documents: [],
|
|
100
|
+
isLoading: false,
|
|
101
|
+
error: err instanceof Error ? err.message : 'Unknown error occurred',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
if (enabled) void load();
|
|
107
|
+
return () => {
|
|
108
|
+
current = false;
|
|
109
|
+
};
|
|
110
|
+
}, [endpoint, enabled]);
|
|
111
|
+
|
|
112
|
+
return state;
|
|
113
|
+
}
|
|
@@ -144,7 +144,7 @@ export function DropdownButton({
|
|
|
144
144
|
href={action.href}
|
|
145
145
|
download={action.download}
|
|
146
146
|
openInNewTab={action.openInNewTab}
|
|
147
|
-
onClick={action.onClick
|
|
147
|
+
onClick={action.onClick}
|
|
148
148
|
disabled={disabled}
|
|
149
149
|
loading={loading}
|
|
150
150
|
leftIcon={icon}
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
'use client';
|
|
23
23
|
import { forwardRef, type ComponentType, type ImgHTMLAttributes, type Ref } from 'react';
|
|
24
24
|
|
|
25
|
+
import { fetchPriorityProp } from '../utils/fetch-priority';
|
|
26
|
+
|
|
25
27
|
type ImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'placeholder'> & {
|
|
26
28
|
src?: string | { src: string };
|
|
27
29
|
alt?: string;
|
|
@@ -91,12 +93,18 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function NextImageShim({
|
|
|
91
93
|
optimizedWidth && resizer && typeof given.src === 'string'
|
|
92
94
|
? { ...given, src: resizer(given.src, optimizedWidth), unoptimized: true }
|
|
93
95
|
: given;
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
96
|
+
// `priority` means "this is the picture the page is waiting for", so its
|
|
97
|
+
// request is ranked high on EVERY host. Stated here, once: since Next 16
|
|
98
|
+
// `priority` only preloads and no longer ranks the request, and the plain
|
|
99
|
+
// <img> below dropped the prop altogether, so an embedder's hero went out at
|
|
100
|
+
// the default priority. A caller that names its own `fetchPriority` keeps it.
|
|
101
|
+
const fetchPriority = props.fetchPriority ?? (props.priority ? 'high' : undefined);
|
|
102
|
+
|
|
103
|
+
// Real impl path — registered by the host. Every Next-specific prop
|
|
104
|
+
// (priority, placeholder, sizes, loader…) reaches the real component intact.
|
|
97
105
|
if (impl) {
|
|
98
106
|
const Real = impl;
|
|
99
|
-
return <Real ref={ref} {...props} />;
|
|
107
|
+
return <Real ref={ref} {...props} fetchPriority={fetchPriority} />;
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
// Fallback path — plain <img>. Drops Next-only props and reduces
|
|
@@ -115,6 +123,7 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function NextImageShim({
|
|
|
115
123
|
unoptimized: _unoptimized,
|
|
116
124
|
onLoadingComplete,
|
|
117
125
|
loader: _loader,
|
|
126
|
+
fetchPriority: _fetchPriority,
|
|
118
127
|
style,
|
|
119
128
|
...rest
|
|
120
129
|
} = props;
|
|
@@ -147,6 +156,8 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function NextImageShim({
|
|
|
147
156
|
style={finalStyle}
|
|
148
157
|
onLoad={onLoadingComplete ? e => onLoadingComplete(e.currentTarget) : undefined}
|
|
149
158
|
{...rest}
|
|
159
|
+
// The attribute's spelling depends on the host's React major (`fetchPriorityProp`).
|
|
160
|
+
{...(fetchPriority ? fetchPriorityProp(fetchPriority) : {})}
|
|
150
161
|
/>
|
|
151
162
|
);
|
|
152
163
|
});
|
package/src/utils/index.ts
CHANGED
|
@@ -415,6 +415,7 @@ export * from './humanity-signals';
|
|
|
415
415
|
// so the host's draft endpoint re-applies the same filter the browser hook runs.
|
|
416
416
|
// Also exported via the granular subpath `./utils/form-rescue`.
|
|
417
417
|
export * from './form-rescue';
|
|
418
|
+
export * from './legal-documents';
|
|
418
419
|
|
|
419
420
|
// Doc-source viewer utilities (path parsing, tree building, section extraction,
|
|
420
421
|
// embed-URL conversion) — single home for all doc-viewer pure helpers across
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The types of legal document a hub can publish, each with a page of its own
|
|
3
|
+
* (`/api/legal/<type>`, `<LegalDocumentPage docType>`).
|
|
4
|
+
*
|
|
5
|
+
* WHICH of them are published, and each one's title, version and effective
|
|
6
|
+
* date, is the hub's answer at run time (`GET /api/legal`, `useLegalDocuments`).
|
|
7
|
+
* This list exists for a host that must know every possible page before it
|
|
8
|
+
* runs: a statically exported app prerenders one legal page per type here and
|
|
9
|
+
* links only the ones the hub's list says are published.
|
|
10
|
+
*
|
|
11
|
+
* Pure and server-safe. The hub's own list of legal documents carries exactly
|
|
12
|
+
* these types.
|
|
13
|
+
*/
|
|
14
|
+
export const LEGAL_DOCUMENT_TYPES = ['privacy', 'terms', 'payment', 'license', 'security'] as const;
|
|
15
|
+
|
|
16
|
+
export type LegalDocumentType = (typeof LEGAL_DOCUMENT_TYPES)[number];
|
|
17
|
+
|
|
18
|
+
/** True for a type a host has a page for (one of {@link LEGAL_DOCUMENT_TYPES}). */
|
|
19
|
+
export function isLegalDocumentType(value: string): value is LegalDocumentType {
|
|
20
|
+
return (LEGAL_DOCUMENT_TYPES as readonly string[]).includes(value);
|
|
21
|
+
}
|