@flamingo-stack/openframe-frontend-core 0.0.403 → 0.0.404-snapshot.20260711053526
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-NOWXUQWF.js → chunk-3XCNFTFV.js} +3 -3
- package/dist/{chunk-HBO2DMX4.js → chunk-5XZEY34X.js} +3 -3
- package/dist/{chunk-M7LIGEAX.cjs → chunk-6OQ7YOX5.cjs} +33 -33
- package/dist/{chunk-M7LIGEAX.cjs.map → chunk-6OQ7YOX5.cjs.map} +1 -1
- package/dist/{chunk-CRUPO3MR.cjs → chunk-ACJ4CM6H.cjs} +93 -93
- package/dist/{chunk-CRUPO3MR.cjs.map → chunk-ACJ4CM6H.cjs.map} +1 -1
- package/dist/{chunk-AMPQKNAX.js → chunk-BK7QCB5J.js} +6 -6
- package/dist/{chunk-TBXE5S6P.cjs → chunk-BS6O4HLJ.cjs} +40 -40
- package/dist/{chunk-TBXE5S6P.cjs.map → chunk-BS6O4HLJ.cjs.map} +1 -1
- package/dist/{chunk-GA5AN24T.cjs → chunk-BX3R44MR.cjs} +2 -2
- package/dist/chunk-BX3R44MR.cjs.map +1 -0
- package/dist/{chunk-J4XR6YYZ.cjs → chunk-CCIBZ7TO.cjs} +10 -6
- package/dist/chunk-CCIBZ7TO.cjs.map +1 -0
- package/dist/{chunk-6MO5HF6P.cjs → chunk-DHIYBC5Z.cjs} +32 -32
- package/dist/{chunk-6MO5HF6P.cjs.map → chunk-DHIYBC5Z.cjs.map} +1 -1
- package/dist/{chunk-DH6HTYOV.js → chunk-E6YHDT3D.js} +6 -2
- package/dist/chunk-E6YHDT3D.js.map +1 -0
- package/dist/{chunk-G4YLKA7O.cjs → chunk-ELLTTMPE.cjs} +5 -5
- package/dist/{chunk-G4YLKA7O.cjs.map → chunk-ELLTTMPE.cjs.map} +1 -1
- package/dist/{chunk-3I4IZYHM.js → chunk-F3YLM5PO.js} +3 -3
- package/dist/{chunk-G3QYCV63.cjs → chunk-FFYEDIAG.cjs} +499 -441
- package/dist/chunk-FFYEDIAG.cjs.map +1 -0
- package/dist/{chunk-S4ZHBKGS.cjs → chunk-FOJRR647.cjs} +73 -73
- package/dist/{chunk-S4ZHBKGS.cjs.map → chunk-FOJRR647.cjs.map} +1 -1
- package/dist/{chunk-GYFUGMNM.js → chunk-GT7AJXKH.js} +2 -2
- package/dist/{chunk-TTUQSOBL.js → chunk-ICJRSNBM.js} +61 -3
- package/dist/chunk-ICJRSNBM.js.map +1 -0
- package/dist/{chunk-QXAHXMPD.cjs → chunk-LREN3G7R.cjs} +12 -12
- package/dist/{chunk-QXAHXMPD.cjs.map → chunk-LREN3G7R.cjs.map} +1 -1
- package/dist/{chunk-PFQQ5H6G.cjs → chunk-M6BGJVDY.cjs} +19 -19
- package/dist/{chunk-PFQQ5H6G.cjs.map → chunk-M6BGJVDY.cjs.map} +1 -1
- package/dist/{chunk-MTBFE3JF.js → chunk-PNJ5LMVK.js} +16 -2
- package/dist/chunk-PNJ5LMVK.js.map +1 -0
- package/dist/{chunk-YXOWUHGQ.js → chunk-Q2KKEE6W.js} +6 -6
- package/dist/{chunk-Q3AO2QWK.cjs → chunk-QAFRLO5N.cjs} +18 -4
- package/dist/chunk-QAFRLO5N.cjs.map +1 -0
- package/dist/{chunk-GYC5KXNX.js → chunk-QYTGHN2T.js} +24 -21
- package/dist/chunk-QYTGHN2T.js.map +1 -0
- package/dist/{chunk-R6SMQ2CO.js → chunk-RC35ITSR.js} +6 -6
- package/dist/{chunk-RNKXG55J.js → chunk-RIGB7LP7.js} +4 -4
- package/dist/{chunk-A636Y7GU.js → chunk-UIAQXIA6.js} +5 -5
- package/dist/{chunk-YORONSXG.js → chunk-UN2BJ7XW.js} +3 -3
- package/dist/{chunk-KVB7SHXY.cjs → chunk-V4MOB3AD.cjs} +12 -12
- package/dist/{chunk-KVB7SHXY.cjs.map → chunk-V4MOB3AD.cjs.map} +1 -1
- package/dist/{chunk-FFWIUNOT.cjs → chunk-VFWEFR3Q.cjs} +40 -40
- package/dist/{chunk-FFWIUNOT.cjs.map → chunk-VFWEFR3Q.cjs.map} +1 -1
- package/dist/{chunk-UXKVKB33.cjs → chunk-WEZIVLCO.cjs} +37 -34
- package/dist/chunk-WEZIVLCO.cjs.map +1 -0
- package/dist/{chunk-HGKWTKW7.js → chunk-WJRPMULP.js} +2 -2
- package/dist/chunk-WJRPMULP.js.map +1 -0
- package/dist/components/announcement-bar.d.ts +27 -1
- package/dist/components/announcement-bar.d.ts.map +1 -1
- package/dist/components/case-studies/index.cjs +10 -10
- package/dist/components/case-studies/index.js +4 -4
- package/dist/components/chat/index.cjs +4 -4
- package/dist/components/chat/index.js +3 -3
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/contact/index.js +4 -4
- package/dist/components/docs/index.cjs +7 -7
- package/dist/components/docs/index.js +6 -6
- package/dist/components/embeds/index.cjs +5 -5
- package/dist/components/embeds/index.js +4 -4
- package/dist/components/faq/index.cjs +6 -6
- package/dist/components/faq/index.js +5 -5
- package/dist/components/faq-accordion.d.ts.map +1 -1
- package/dist/components/features/index.cjs +4 -4
- package/dist/components/features/index.js +3 -3
- package/dist/components/help-center-pages/index.cjs +23 -23
- package/dist/components/help-center-pages/index.js +14 -14
- package/dist/components/index.cjs +274 -299
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +110 -135
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +4 -4
- package/dist/components/navigation/index.js +3 -3
- package/dist/components/onboarding-guides/index.cjs +8 -8
- package/dist/components/onboarding-guides/index.js +7 -7
- package/dist/components/related-content/index.cjs +6 -6
- package/dist/components/related-content/index.js +5 -5
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/file-manager/index.cjs +50 -50
- package/dist/components/ui/file-manager/index.js +1 -1
- package/dist/components/ui/index.cjs +4 -4
- package/dist/components/ui/index.js +3 -3
- package/dist/hooks/api-hooks.d.ts +0 -4
- package/dist/hooks/api-hooks.d.ts.map +1 -1
- package/dist/hooks/index.cjs +2 -2
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/use-self-fetch.d.ts +7 -0
- package/dist/hooks/use-self-fetch.d.ts.map +1 -1
- package/dist/index.cjs +20 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +19 -3
- package/dist/types/announcement.cjs +5 -0
- package/dist/types/announcement.cjs.map +1 -1
- package/dist/types/announcement.d.ts +37 -7
- package/dist/types/announcement.d.ts.map +1 -1
- package/dist/types/announcement.js +5 -1
- package/dist/types/announcement.js.map +1 -1
- package/dist/types/index.cjs +5 -0
- package/dist/types/index.cjs.map +1 -1
- package/dist/types/index.js +5 -1
- package/dist/types/index.js.map +1 -1
- package/dist/utils/announcement-storage.d.ts +39 -7
- package/dist/utils/announcement-storage.d.ts.map +1 -1
- package/dist/utils/app-config.d.ts.map +1 -1
- package/dist/utils/color-analysis.d.ts +13 -0
- package/dist/utils/color-analysis.d.ts.map +1 -1
- package/dist/utils/index.cjs +58 -1
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +52 -2
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/.announcement-bar.md +32 -30
- package/src/components/announcement-bar.tsx +227 -182
- package/src/components/faq/faq-section.tsx +3 -3
- package/src/components/faq-accordion.tsx +17 -20
- package/src/components/ui/button/button.tsx +1 -1
- package/src/hooks/.api-hooks.md +1 -4
- package/src/hooks/api-hooks.ts +0 -7
- package/src/hooks/use-self-fetch.ts +29 -1
- package/src/stories/AnnouncementBar.stories.tsx +188 -152
- package/src/types/.announcement.md +2 -2
- package/src/types/announcement.ts +37 -7
- package/src/utils/.announcement-storage.md +19 -24
- package/src/utils/announcement-storage.ts +74 -28
- package/src/utils/app-config.ts +10 -1
- package/src/utils/color-analysis.ts +16 -1
- package/src/utils/index.ts +3 -0
- package/dist/chunk-DH6HTYOV.js.map +0 -1
- package/dist/chunk-G3QYCV63.cjs.map +0 -1
- package/dist/chunk-GA5AN24T.cjs.map +0 -1
- package/dist/chunk-GYC5KXNX.js.map +0 -1
- package/dist/chunk-HGKWTKW7.js.map +0 -1
- package/dist/chunk-J4XR6YYZ.cjs.map +0 -1
- package/dist/chunk-MTBFE3JF.js.map +0 -1
- package/dist/chunk-Q3AO2QWK.cjs.map +0 -1
- package/dist/chunk-TTUQSOBL.js.map +0 -1
- package/dist/chunk-UXKVKB33.cjs.map +0 -1
- /package/dist/{chunk-NOWXUQWF.js.map → chunk-3XCNFTFV.js.map} +0 -0
- /package/dist/{chunk-HBO2DMX4.js.map → chunk-5XZEY34X.js.map} +0 -0
- /package/dist/{chunk-AMPQKNAX.js.map → chunk-BK7QCB5J.js.map} +0 -0
- /package/dist/{chunk-3I4IZYHM.js.map → chunk-F3YLM5PO.js.map} +0 -0
- /package/dist/{chunk-GYFUGMNM.js.map → chunk-GT7AJXKH.js.map} +0 -0
- /package/dist/{chunk-YXOWUHGQ.js.map → chunk-Q2KKEE6W.js.map} +0 -0
- /package/dist/{chunk-R6SMQ2CO.js.map → chunk-RC35ITSR.js.map} +0 -0
- /package/dist/{chunk-RNKXG55J.js.map → chunk-RIGB7LP7.js.map} +0 -0
- /package/dist/{chunk-A636Y7GU.js.map → chunk-UIAQXIA6.js.map} +0 -0
- /package/dist/{chunk-YORONSXG.js.map → chunk-UN2BJ7XW.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,40 +1,42 @@
|
|
|
1
|
-
<!-- source-hash:
|
|
2
|
-
A
|
|
1
|
+
<!-- source-hash: pending-regeneration -->
|
|
2
|
+
A dismissible platform announcement bar with DUAL MODE support: SSR-seeded (Next hosts) and client-only (bare React apps without SSR). Single-line 44px strip, contrast handled by scoping content with the ODS theme classes, event-driven freshness (no polling).
|
|
3
3
|
|
|
4
4
|
## Key Components
|
|
5
5
|
|
|
6
6
|
| Export/Function | Description |
|
|
7
7
|
|----------------|-------------|
|
|
8
|
-
| `AnnouncementBar` | Main component — renders the
|
|
9
|
-
| `
|
|
10
|
-
| `
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
| `AnnouncementBar` | Main component — renders the bar or `null` when there is nothing to show |
|
|
9
|
+
| `initialAnnouncement` prop | SSR mode seed (nullable): server resolved announcement + dismissal cookie; skips the mount fetch |
|
|
10
|
+
| `previewMode` prop | Render-only (admin live preview): no fetch, inert dismiss, no storage side effects |
|
|
11
|
+
|
|
12
|
+
## Modes
|
|
13
|
+
|
|
14
|
+
- **SSR mode** (hub / any Next host): the server passes `initialAnnouncement` (already dismissal-filtered via the cookie helpers in `announcement-storage.ts`). The bar is in the first HTML byte — zero layout shift, no flash for dismissed users, zero mount fetch.
|
|
15
|
+
- **Client-only mode** (Vite/CRA embeds, no SSR — the react-embedding-example structure): mount the bar PROP-LESS inside an EndpointsRuntime provider; `announcementsUrl` is a fixed suffix under the embedder's one content base (`${CONTENT}/announcements/active`). No provider → renders nothing, never fetches. Appearance animates in (grid `0fr -> 1fr`). Deliberately NO per-component URL or platform knobs: the platform is never sent as a parameter — each server returns its own announcement via `currentPlatform()`.
|
|
14
16
|
|
|
15
17
|
## Behavior Notes
|
|
16
18
|
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<main>{/* app content */}</main>
|
|
35
|
-
</EndpointsRuntimeProvider>
|
|
36
|
-
);
|
|
19
|
+
- **No polling**: freshness is visibility-driven — `useSelfFetch`'s `revalidateOnVisibleAfterMs` refetches on tab refocus when held data is older than 60s. Idle tabs make zero requests.
|
|
20
|
+
- **Dismissal**: cookie is the SSOT (`<platform>-announcement-dismissed=<id>`, id-match); localStorage is read-only legacy. Storage reads happen only in effects (hydration safety).
|
|
21
|
+
- **Contrast**: the admin background's tone selects `.theme-light`/`.theme-dark` around the content; all colors resolve from the active ODS theme's Tier-1 primitives.
|
|
22
|
+
- **Responsive**: single truncating line (description hidden < sm); CTA is desktop-only, the whole bar is the tap target on mobile.
|
|
23
|
+
|
|
24
|
+
## Usage Examples
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
// SSR mode (Next server component resolves the seed):
|
|
28
|
+
<AnnouncementBar initialAnnouncement={announcementOrNull} />
|
|
29
|
+
|
|
30
|
+
// Client-only mode (react-embedding-example structure): one content base,
|
|
31
|
+
// endpoints built once, bar mounted prop-less inside the provider.
|
|
32
|
+
const endpointsRuntime: EndpointsRuntime = {
|
|
33
|
+
announcementsUrl: `${CONTENT}/announcements/active`,
|
|
34
|
+
accessCode: { validateUrl: `${CONTENT}/validate-access-code`, consumeUrl: `${CONTENT}/consume-access-code` },
|
|
35
|
+
contactUrl: `${CONTENT}/contact`,
|
|
37
36
|
}
|
|
38
|
-
```
|
|
39
37
|
|
|
40
|
-
>
|
|
38
|
+
<EndpointsRuntimeContext.Provider value={endpointsRuntime}>
|
|
39
|
+
<AnnouncementBar />
|
|
40
|
+
<main>{/* app content */}</main>
|
|
41
|
+
</EndpointsRuntimeContext.Provider>
|
|
42
|
+
```
|
|
@@ -1,221 +1,266 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
4
4
|
import { X } from 'lucide-react';
|
|
5
5
|
import { Button } from './ui/button';
|
|
6
6
|
import { EntityIcon } from './icon-display';
|
|
7
7
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
dismissAnnouncement,
|
|
9
|
+
isAnnouncementDismissed,
|
|
10
|
+
clearLegacyAnnouncementCache,
|
|
11
11
|
} from '../utils/announcement-storage';
|
|
12
|
-
import {
|
|
12
|
+
import type { AnnouncementBarProps, AnnouncementResponse } from '../types/announcement';
|
|
13
13
|
import { getAppType } from '../utils/app-config';
|
|
14
14
|
import { useEndpointsRuntime } from '../contexts/endpoints-runtime-context';
|
|
15
|
+
import { useSelfFetch } from '../hooks/use-self-fetch';
|
|
16
|
+
import { pickReadableTextColor } from '../utils/color-analysis';
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Platform announcement bar — DUAL MODE, works with or without SSR.
|
|
20
|
+
*
|
|
21
|
+
* SSR mode (Next hosts, e.g. the hub): the server resolves the announcement
|
|
22
|
+
* and the dismissal cookie, then passes `initialAnnouncement` (nullable).
|
|
23
|
+
* The bar renders in the first HTML byte (zero layout shift, no flash for
|
|
24
|
+
* dismissed users) and skips the mount fetch.
|
|
25
|
+
*
|
|
26
|
+
* Client-only mode (bare React apps without SSR: Vite/CRA embeds — see
|
|
27
|
+
* react-embedding-example): omit `initialAnnouncement` and mount the bar
|
|
28
|
+
* PROP-LESS inside an EndpointsRuntime provider; it self-fetches from the
|
|
29
|
+
* provider's `announcementsUrl` (a fixed suffix under the embedder's one
|
|
30
|
+
* content base). No provider → renders nothing, never fetches (silent
|
|
31
|
+
* no-op). Appearance animates in (grid 0fr→1fr) so there is no hard layout
|
|
32
|
+
* jump. Deliberately NO per-component URL or platform knobs: the embedder
|
|
33
|
+
* is platform-agnostic — each server returns ITS OWN announcement via
|
|
34
|
+
* currentPlatform(); the platform is never sent as a parameter.
|
|
35
|
+
*
|
|
36
|
+
* Both modes: freshness is event-driven, no polling — `useSelfFetch`'s
|
|
37
|
+
* visibility revalidation re-fetches on tab refocus when the held data is
|
|
38
|
+
* >60s old (matching the server cache TTL by convention); idle tabs make
|
|
39
|
+
* zero requests. Dismissal cookie is the SSOT (`announcement-storage.ts`);
|
|
40
|
+
* storage reads happen ONLY in effects (a render-time read would desync
|
|
41
|
+
* hydration in SSR mode).
|
|
42
|
+
*/
|
|
43
|
+
export function AnnouncementBar({
|
|
44
|
+
initialAnnouncement,
|
|
45
|
+
previewMode = false,
|
|
46
|
+
className,
|
|
47
|
+
}: AnnouncementBarProps = {}) {
|
|
48
|
+
// Namespace for the dismissal cookie/legacy keys. Next hosts inline
|
|
49
|
+
// NEXT_PUBLIC_APP_TYPE (matching the server's currentPlatform(), which the
|
|
50
|
+
// hub layout uses for the SSR cookie read); platform-agnostic embeds get
|
|
51
|
+
// the fallback — fine, cookies are domain-scoped and an embed domain
|
|
52
|
+
// serves one platform's announcements.
|
|
21
53
|
const platform = getAppType();
|
|
22
54
|
|
|
23
|
-
// Optional endpoint runtime:
|
|
24
|
-
// bare React-tree page that doesn't wrap with HubRuntimeProvider),
|
|
25
|
-
// the bar silently skips its fetch instead of throwing. Apps that DO
|
|
26
|
-
// mount the provider get the configured URL — typically
|
|
27
|
-
// '/api/announcements/active' in the hub, or a proxied path in an
|
|
28
|
-
// embedded host.
|
|
55
|
+
// Optional endpoint runtime: no provider → no URL → fetching disabled.
|
|
29
56
|
const endpoints = useEndpointsRuntime();
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
// Helper to determine dismissal key for localStorage
|
|
33
|
-
const getDismissKey = (id: string) => `${platform}-announcement-${id}-dismissed`;
|
|
34
|
-
|
|
35
|
-
// Helper to get platform-specific cache key
|
|
36
|
-
const getCacheKey = () => `${platform}-announcement-cache`;
|
|
37
|
-
|
|
38
|
-
// Fetch active announcement from API and update state + LS
|
|
39
|
-
const fetchActiveAnnouncement = async () => {
|
|
40
|
-
// No provider mounted → no URL configured → skip fetch silently.
|
|
41
|
-
// Cached announcement from previous sessions still renders if present.
|
|
42
|
-
if (!announcementsUrl) return;
|
|
43
|
-
try {
|
|
44
|
-
// Server-side platform injection - no URL parameter needed
|
|
45
|
-
const response = await fetch(announcementsUrl);
|
|
46
|
-
|
|
47
|
-
if (response.ok) {
|
|
48
|
-
const data = await response.json();
|
|
49
|
-
if (data.announcement) {
|
|
50
|
-
setAnnouncement(data.announcement);
|
|
51
|
-
|
|
52
|
-
// persist latest announcement for quick future loads with platform-specific key
|
|
53
|
-
setStoredAnnouncement(getCacheKey(), data.announcement);
|
|
54
|
-
|
|
55
|
-
// Check if this specific announcement was dismissed
|
|
56
|
-
const isDismissed = localStorage.getItem(getDismissKey(data.announcement.id));
|
|
57
|
-
setIsVisible(!isDismissed);
|
|
58
|
-
} else {
|
|
59
|
-
// No announcement available - clean up localStorage and hide bar
|
|
60
|
-
setAnnouncement(null);
|
|
61
|
-
setIsVisible(false);
|
|
62
|
-
|
|
63
|
-
// Use utility function to properly clear platform-specific announcement data
|
|
64
|
-
clearStoredAnnouncement(getCacheKey());
|
|
65
|
-
}
|
|
66
|
-
} else {
|
|
67
|
-
// Network or other error - hide announcement and clean up
|
|
68
|
-
console.error(`❌ [${platform.toUpperCase()}] Error fetching announcement: ${response.status}`);
|
|
69
|
-
setAnnouncement(null);
|
|
70
|
-
setIsVisible(false);
|
|
71
|
-
|
|
72
|
-
// Clear stale data on network errors too
|
|
73
|
-
clearStoredAnnouncement(getCacheKey());
|
|
74
|
-
}
|
|
75
|
-
} catch (error) {
|
|
76
|
-
console.error('Error fetching active announcement:', error);
|
|
77
|
-
setAnnouncement(null);
|
|
78
|
-
setIsVisible(false);
|
|
79
|
-
|
|
80
|
-
// Clear stale data on exceptions too
|
|
81
|
-
clearStoredAnnouncement(getCacheKey());
|
|
82
|
-
}
|
|
83
|
-
};
|
|
57
|
+
const url = previewMode ? null : endpoints?.announcementsUrl ?? null;
|
|
84
58
|
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
59
|
+
// MUST be memoized (the hook re-syncs on [initialData] identity — an inline
|
|
60
|
+
// literal per render would setData-loop) and strict-undefined-mapped:
|
|
61
|
+
// `null` (hub: dismissed / no active announcement) still seeds the hook and
|
|
62
|
+
// skips the mount fetch; only an ABSENT prop (embeds) enables self-fetch.
|
|
63
|
+
const initialData = useMemo<AnnouncementResponse | undefined>(
|
|
64
|
+
() => (initialAnnouncement === undefined ? undefined : { announcement: initialAnnouncement }),
|
|
65
|
+
[initialAnnouncement],
|
|
66
|
+
);
|
|
93
67
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
68
|
+
const { data } = useSelfFetch<AnnouncementResponse>(url, {
|
|
69
|
+
initialData,
|
|
70
|
+
revalidateOnVisibleAfterMs: 60_000,
|
|
71
|
+
});
|
|
72
|
+
const announcement = data?.announcement ?? null;
|
|
99
73
|
|
|
100
|
-
|
|
101
|
-
|
|
74
|
+
// Expanded (height) state — initial value is a pure function of props so
|
|
75
|
+
// the SSR HTML and the hydration render agree for every cohort.
|
|
76
|
+
const [expandedState, setExpandedState] = useState<boolean>(() => initialAnnouncement != null);
|
|
77
|
+
// Preview always mirrors the draft directly (effects are disabled there).
|
|
78
|
+
const expanded = previewMode ? announcement != null : expandedState;
|
|
102
79
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return () => clearInterval(interval);
|
|
80
|
+
// One-time cleanup of the pre-refactor localStorage announcement cache.
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (previewMode) return;
|
|
83
|
+
clearLegacyAnnouncementCache(platform);
|
|
108
84
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
109
|
-
}, [
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
// Visibility reconciliation — runs on the seed and after EVERY completed
|
|
88
|
+
// fetch (keyed on `data` identity, not announcement id, so a refocus
|
|
89
|
+
// revalidation that returns the same announcement still re-checks the
|
|
90
|
+
// dismissal store: this is what keeps a bar dismissed seconds earlier in
|
|
91
|
+
// another tab from resurrecting). Also the legacy-migration point: an
|
|
92
|
+
// LS-only dismissal (no cookie — server couldn't see it) collapses once,
|
|
93
|
+
// animated, and backfills the cookie so the next SSR skips the bar.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (previewMode) return;
|
|
96
|
+
if (!announcement) {
|
|
97
|
+
setExpandedState(false);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (isAnnouncementDismissed(platform, announcement.id)) {
|
|
101
|
+
dismissAnnouncement(platform, announcement.id); // idempotent cookie backfill
|
|
102
|
+
setExpandedState(false);
|
|
103
|
+
} else {
|
|
104
|
+
setExpandedState(true);
|
|
105
|
+
}
|
|
106
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
107
|
+
}, [data]);
|
|
110
108
|
|
|
111
|
-
// helpers
|
|
112
109
|
const handleDismiss = () => {
|
|
113
|
-
if (!announcement) return;
|
|
114
|
-
|
|
115
|
-
|
|
110
|
+
if (previewMode || !announcement) return;
|
|
111
|
+
dismissAnnouncement(platform, announcement.id);
|
|
112
|
+
setExpandedState(false);
|
|
116
113
|
};
|
|
117
114
|
|
|
118
115
|
const handleCtaClick = () => {
|
|
119
|
-
if (!announcement?.cta_url) return;
|
|
116
|
+
if (previewMode || !announcement?.cta_url) return;
|
|
117
|
+
// Scheme guard: cta_url is admin-entered data — only http(s) (absolute or
|
|
118
|
+
// relative) may navigate. A `javascript:` value would otherwise execute
|
|
119
|
+
// via location.href / window.open (stored XSS).
|
|
120
|
+
let safeUrl: string;
|
|
121
|
+
try {
|
|
122
|
+
const parsed = new URL(announcement.cta_url, window.location.origin);
|
|
123
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
|
|
124
|
+
safeUrl = parsed.href;
|
|
125
|
+
} catch {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
120
128
|
announcement.cta_target === '_blank'
|
|
121
|
-
? window.open(
|
|
122
|
-
: (window.location.href =
|
|
129
|
+
? window.open(safeUrl, '_blank', 'noopener,noreferrer')
|
|
130
|
+
: (window.location.href = safeUrl);
|
|
123
131
|
};
|
|
124
132
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
133
|
+
// Nothing to show (and nothing to animate around): render nothing. The
|
|
134
|
+
// dismissed/deactivated case keeps the element mounted at 0fr so the
|
|
135
|
+
// collapse animates.
|
|
136
|
+
if (!announcement) return null;
|
|
137
|
+
|
|
138
|
+
// Contrast is solved with the ODS THEME system, not color literals: the
|
|
139
|
+
// admin background's tone selects which ODS theme scopes the bar's content
|
|
140
|
+
// (light-toned background needs dark-on-light = the light theme's values;
|
|
141
|
+
// dark-toned needs the dark theme's). `.theme-light` / `.theme-dark` are
|
|
142
|
+
// the design system's own scoping classes (ods-colors.css) and flip every
|
|
143
|
+
// Tier-1 `--ods-*` primitive for descendants.
|
|
144
|
+
const themeScope = pickReadableTextColor(announcement.background_color) === 'dark' ? 'theme-light' : 'theme-dark';
|
|
145
|
+
|
|
146
|
+
// Inside the scope, colors reference Tier-1 theme primitives directly
|
|
147
|
+
// (Tier-2 `--color-*` aliases resolve once at :root and do NOT re-resolve
|
|
148
|
+
// in a nested theme scope — verified). `--ods-system-greys-white` is the
|
|
149
|
+
// theme's primary-text primitive; `--ods-system-greys-black-hover/-action`
|
|
150
|
+
// are its quiet hover/active surfaces. Every value comes from the active
|
|
151
|
+
// ODS theme; nothing is hardcoded.
|
|
152
|
+
const barButtonClasses =
|
|
153
|
+
'text-[color:var(--ods-system-greys-white)] hover:bg-[var(--ods-system-greys-black-hover)] active:bg-[var(--ods-system-greys-black-action)]';
|
|
141
154
|
|
|
142
|
-
|
|
143
|
-
if (!announcement || !isVisible) return null;
|
|
155
|
+
const hasCta = Boolean(announcement.cta_enabled && announcement.cta_url);
|
|
144
156
|
|
|
145
157
|
return (
|
|
146
158
|
<div
|
|
147
|
-
|
|
148
|
-
|
|
159
|
+
role="region"
|
|
160
|
+
aria-label="Announcement"
|
|
161
|
+
aria-hidden={!expanded}
|
|
149
162
|
data-announcement-bar
|
|
163
|
+
className={`relative w-full z-50 grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none ${className ?? ''}`}
|
|
164
|
+
style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
|
|
150
165
|
>
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
166
|
+
{/*
|
|
167
|
+
Bar anatomy follows the announcement-bar industry standard: ONE line of
|
|
168
|
+
text at 13-14px inside a 44px-tall strip (guides converge on 40-60px
|
|
169
|
+
with a single sentence; two stacked 18px rows blew past that), title +
|
|
170
|
+
description merged inline (description hidden on small screens), ONE
|
|
171
|
+
compact CTA on the right, and a ghost-icon dismiss (design-system
|
|
172
|
+
icon-sm: 32px target, >= the 24px WCAG 2.2 SC 2.5.8 AA floor).
|
|
173
|
+
*/}
|
|
174
|
+
<div className={`min-h-0 overflow-hidden ${themeScope}`} style={{ backgroundColor: announcement.background_color }}>
|
|
175
|
+
<div className="flex items-center w-full max-w-full min-h-11 text-[color:var(--ods-system-greys-white)]">
|
|
176
|
+
{/* Mobile: Clickable content area, Desktop: Regular content */}
|
|
177
|
+
<div
|
|
178
|
+
className={`flex flex-row gap-2 md:gap-3 items-center pl-4 md:pl-6 py-1.5 flex-1 min-w-0 ${
|
|
179
|
+
hasCta ? 'md:cursor-default cursor-pointer' : ''
|
|
180
|
+
}`}
|
|
181
|
+
onClick={(e) => {
|
|
182
|
+
// Only handle click on mobile (< 768px) and if CTA is enabled
|
|
183
|
+
if (window.innerWidth < 768 && hasCta) {
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
handleCtaClick();
|
|
186
|
+
}
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
{/* ONE unified icon path (shared with the chat): uploaded image URL
|
|
190
|
+
wins, else a library glyph by name (+ props), via <EntityIcon>. */}
|
|
191
|
+
<EntityIcon
|
|
192
|
+
icon={{
|
|
193
|
+
name: announcement.icon_name || 'openframe-logo',
|
|
194
|
+
url: announcement.icon_url,
|
|
195
|
+
props: announcement.icon_props,
|
|
196
|
+
}}
|
|
197
|
+
size={24}
|
|
198
|
+
className="relative shrink-0 w-5 h-5 md:w-6 md:h-6"
|
|
199
|
+
/>
|
|
200
|
+
|
|
201
|
+
{/* Single-line message: bold title + regular description inline,
|
|
202
|
+
truncating as one unit. Separator is a middot (house rule: no
|
|
203
|
+
en/em dashes in copy). */}
|
|
204
|
+
<p className="font-body flex-1 min-w-0 max-w-full text-[13px] md:text-sm leading-snug truncate mb-0">
|
|
205
|
+
<span className="font-semibold">{announcement.title}</span>
|
|
206
|
+
{announcement.description && (
|
|
207
|
+
<span className="hidden sm:inline opacity-80"> · {announcement.description}</span>
|
|
208
|
+
)}
|
|
173
209
|
</p>
|
|
210
|
+
|
|
211
|
+
{/* CTA - the common Button in the bar's quiet treatment: ghost
|
|
212
|
+
surface, computed-foreground text, translucent fg-tint hover
|
|
213
|
+
(barButtonClasses) so nothing renders as a dark slab on the
|
|
214
|
+
admin color. The admin cta_button_* colors are NOT applied:
|
|
215
|
+
they were designed for the legacy bespoke treatment. Hidden
|
|
216
|
+
on mobile, where the whole bar is the tap target. */}
|
|
217
|
+
{hasCta && announcement.cta_text && (
|
|
218
|
+
<div className="hidden md:flex flex-shrink-0 ml-1">
|
|
219
|
+
<Button
|
|
220
|
+
onClick={handleCtaClick}
|
|
221
|
+
variant="transparent"
|
|
222
|
+
size="small"
|
|
223
|
+
className={barButtonClasses}
|
|
224
|
+
tabIndex={expanded ? 0 : -1}
|
|
225
|
+
leftIcon={
|
|
226
|
+
announcement.cta_show_icon && announcement.cta_icon_name
|
|
227
|
+
? (
|
|
228
|
+
<EntityIcon
|
|
229
|
+
icon={{ name: announcement.cta_icon_name, props: announcement.cta_icon_props }}
|
|
230
|
+
size={14}
|
|
231
|
+
className="w-3.5 h-3.5"
|
|
232
|
+
/>
|
|
233
|
+
)
|
|
234
|
+
: undefined
|
|
235
|
+
}
|
|
236
|
+
>
|
|
237
|
+
{announcement.cta_text}
|
|
238
|
+
</Button>
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
174
241
|
</div>
|
|
175
242
|
|
|
176
|
-
{/*
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
backgroundColor: announcement.cta_button_background_color || undefined,
|
|
197
|
-
color: announcement.cta_button_text_color || undefined,
|
|
198
|
-
borderColor: announcement.cta_button_background_color || undefined,
|
|
199
|
-
}}
|
|
200
|
-
>
|
|
201
|
-
{announcement.cta_text}
|
|
202
|
-
</Button>
|
|
203
|
-
</div>
|
|
204
|
-
)}
|
|
243
|
+
{/* Dismiss - the common Button in its ghost-icon treatment
|
|
244
|
+
(size="icon-sm": 32px target, >= the 24px WCAG 2.5.8 AA floor,
|
|
245
|
+
16px glyph) with the bar's quiet tint hover. Inert in
|
|
246
|
+
previewMode. */}
|
|
247
|
+
<div className="flex-shrink-0 mr-1 md:mr-3">
|
|
248
|
+
<Button
|
|
249
|
+
onClick={(e) => {
|
|
250
|
+
e.stopPropagation(); // Prevent triggering the mobile CTA click
|
|
251
|
+
handleDismiss();
|
|
252
|
+
}}
|
|
253
|
+
variant="transparent"
|
|
254
|
+
size="icon-sm"
|
|
255
|
+
className={barButtonClasses}
|
|
256
|
+
aria-label="Dismiss announcement"
|
|
257
|
+
type="button"
|
|
258
|
+
tabIndex={expanded ? 0 : -1}
|
|
259
|
+
>
|
|
260
|
+
<X strokeWidth={2} />
|
|
261
|
+
</Button>
|
|
262
|
+
</div>
|
|
205
263
|
</div>
|
|
206
|
-
|
|
207
|
-
{/* Dismiss button - always visible */}
|
|
208
|
-
<button
|
|
209
|
-
onClick={(e) => {
|
|
210
|
-
e.stopPropagation(); // Prevent triggering the mobile CTA click
|
|
211
|
-
handleDismiss();
|
|
212
|
-
}}
|
|
213
|
-
className="flex-shrink-0 w-8 h-8 md:w-10 md:h-10 flex items-center justify-center hover:bg-[#1A1A1A]/10 focus:outline-none focus:ring-2 focus:ring-[#1A1A1A] mr-2 md:mr-4"
|
|
214
|
-
aria-label="Dismiss announcement"
|
|
215
|
-
type="button"
|
|
216
|
-
>
|
|
217
|
-
<X className="w-4 h-4 text-[#1A1A1A]" strokeWidth={2} />
|
|
218
|
-
</button>
|
|
219
264
|
</div>
|
|
220
265
|
</div>
|
|
221
266
|
);
|
|
@@ -316,11 +316,11 @@ function FaqSkeleton() {
|
|
|
316
316
|
return (
|
|
317
317
|
<div className="space-y-8 animate-pulse">
|
|
318
318
|
<div className="h-12 md:h-14 w-2/3 rounded bg-ods-border" />
|
|
319
|
-
<div className="rounded-
|
|
319
|
+
<div className="rounded-md border border-ods-border overflow-hidden bg-transparent divide-y divide-ods-border w-full">
|
|
320
320
|
{Array.from({ length: 8 }).map((_, idx) => (
|
|
321
|
-
<div key={idx} className="flex items-center justify-between
|
|
321
|
+
<div key={idx} className="flex items-center justify-between gap-6 md:gap-10 px-6 py-4">
|
|
322
322
|
<div className="h-6 w-5/6 rounded bg-ods-border" />
|
|
323
|
-
<div className="
|
|
323
|
+
<div className="size-6 rounded bg-ods-border" />
|
|
324
324
|
</div>
|
|
325
325
|
))}
|
|
326
326
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import React, { useRef, useState, useEffect, useCallback } from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import { Chevron02DownIcon } from './icons-v2-generated/arrows/chevron-02-down-icon'
|
|
5
5
|
import { cn } from "../utils/cn"
|
|
6
6
|
import { faqItemAnchor } from "../utils/faq-anchor"
|
|
7
7
|
|
|
@@ -53,7 +53,7 @@ export function FaqAccordion({ items, defaultOpenIds = [] }: FaqAccordionProps)
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
return (
|
|
56
|
-
<div className="rounded-
|
|
56
|
+
<div className="rounded-md border border-ods-border divide-y divide-ods-border bg-transparent overflow-hidden">
|
|
57
57
|
{items.map(item => {
|
|
58
58
|
const isOpen = openSet.has(item.id)
|
|
59
59
|
const { ref, maxHeight } = useMeasuredHeight(isOpen)
|
|
@@ -67,7 +67,7 @@ export function FaqAccordion({ items, defaultOpenIds = [] }: FaqAccordionProps)
|
|
|
67
67
|
// sticky nav offset (matches `<section>`'s scroll-margin for
|
|
68
68
|
// category anchors).
|
|
69
69
|
id={faqItemAnchor(item.id)}
|
|
70
|
-
className=
|
|
70
|
+
className="scroll-mt-24 transition-colors hover:bg-ods-bg-hover"
|
|
71
71
|
>
|
|
72
72
|
{/* Header */}
|
|
73
73
|
<div
|
|
@@ -81,33 +81,30 @@ export function FaqAccordion({ items, defaultOpenIds = [] }: FaqAccordionProps)
|
|
|
81
81
|
}
|
|
82
82
|
}}
|
|
83
83
|
aria-expanded={isOpen}
|
|
84
|
-
className="flex w-full items-center
|
|
84
|
+
className="flex w-full items-center gap-6 md:gap-10 px-6 py-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-focus transition-colors cursor-pointer"
|
|
85
85
|
>
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
borderColor="#3A3A3A"
|
|
98
|
-
/>
|
|
99
|
-
</div>
|
|
86
|
+
<h3 className="flex-1 min-w-0 break-words">
|
|
87
|
+
{item.question}
|
|
88
|
+
</h3>
|
|
89
|
+
<Chevron02DownIcon
|
|
90
|
+
aria-hidden="true"
|
|
91
|
+
size={24}
|
|
92
|
+
className={cn(
|
|
93
|
+
'shrink-0 text-ods-text-primary transition-transform duration-300',
|
|
94
|
+
isOpen && 'rotate-180',
|
|
95
|
+
)}
|
|
96
|
+
/>
|
|
100
97
|
</div>
|
|
101
98
|
{/* Content wrapper with max-height animation */}
|
|
102
99
|
<div
|
|
103
100
|
style={{ maxHeight, transition: 'max-height 0.35s ease-in-out, opacity 0.35s ease-in-out', opacity: isOpen ? 1 : 0 }}
|
|
104
|
-
className="overflow-hidden
|
|
101
|
+
className="overflow-hidden"
|
|
105
102
|
>
|
|
106
103
|
{/* break-words: FAQ answers render as plain text, so a long URL or
|
|
107
104
|
token has no wrap opportunity — and the parent is overflow-hidden,
|
|
108
105
|
which would CLIP it past the viewport on mobile. Mirrors the
|
|
109
106
|
markdown-renderer overflow-wrap fix. */}
|
|
110
|
-
<div ref={ref} className="px-6
|
|
107
|
+
<div ref={ref} className="px-6 pb-4 text-ods-text-primary text-h4 break-words">
|
|
111
108
|
{item.answer}
|
|
112
109
|
</div>
|
|
113
110
|
</div>
|