@flamingo-stack/openframe-frontend-core 0.0.404-snapshot.20260711053526 → 0.0.404
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-F3YLM5PO.js → chunk-3I4IZYHM.js} +3 -3
- package/dist/{chunk-QYTGHN2T.js → chunk-5Z5GXXRU.js} +4 -4
- package/dist/{chunk-DHIYBC5Z.cjs → chunk-6MO5HF6P.cjs} +32 -32
- package/dist/{chunk-DHIYBC5Z.cjs.map → chunk-6MO5HF6P.cjs.map} +1 -1
- package/dist/{chunk-UIAQXIA6.js → chunk-A636Y7GU.js} +5 -5
- package/dist/{chunk-BK7QCB5J.js → chunk-AMPQKNAX.js} +6 -6
- package/dist/{chunk-ACJ4CM6H.cjs → chunk-CRUPO3MR.cjs} +93 -93
- package/dist/{chunk-ACJ4CM6H.cjs.map → chunk-CRUPO3MR.cjs.map} +1 -1
- package/dist/{chunk-E6YHDT3D.js → chunk-DH6HTYOV.js} +2 -6
- package/dist/chunk-DH6HTYOV.js.map +1 -0
- package/dist/{chunk-VFWEFR3Q.cjs → chunk-FFWIUNOT.cjs} +40 -40
- package/dist/{chunk-VFWEFR3Q.cjs.map → chunk-FFWIUNOT.cjs.map} +1 -1
- package/dist/{chunk-FFYEDIAG.cjs → chunk-G3QYCV63.cjs} +441 -499
- package/dist/chunk-G3QYCV63.cjs.map +1 -0
- package/dist/{chunk-ELLTTMPE.cjs → chunk-G4YLKA7O.cjs} +5 -5
- package/dist/{chunk-ELLTTMPE.cjs.map → chunk-G4YLKA7O.cjs.map} +1 -1
- package/dist/{chunk-BX3R44MR.cjs → chunk-GA5AN24T.cjs} +2 -2
- package/dist/chunk-GA5AN24T.cjs.map +1 -0
- package/dist/{chunk-GT7AJXKH.js → chunk-GYFUGMNM.js} +2 -2
- package/dist/{chunk-5XZEY34X.js → chunk-HBO2DMX4.js} +3 -3
- package/dist/{chunk-WJRPMULP.js → chunk-HGKWTKW7.js} +2 -2
- package/dist/chunk-HGKWTKW7.js.map +1 -0
- package/dist/{chunk-CCIBZ7TO.cjs → chunk-J4XR6YYZ.cjs} +6 -10
- package/dist/chunk-J4XR6YYZ.cjs.map +1 -0
- package/dist/{chunk-V4MOB3AD.cjs → chunk-KVB7SHXY.cjs} +12 -12
- package/dist/{chunk-V4MOB3AD.cjs.map → chunk-KVB7SHXY.cjs.map} +1 -1
- package/dist/{chunk-6OQ7YOX5.cjs → chunk-M7LIGEAX.cjs} +33 -33
- package/dist/{chunk-6OQ7YOX5.cjs.map → chunk-M7LIGEAX.cjs.map} +1 -1
- package/dist/{chunk-PNJ5LMVK.js → chunk-MTBFE3JF.js} +2 -16
- package/dist/chunk-MTBFE3JF.js.map +1 -0
- package/dist/{chunk-3XCNFTFV.js → chunk-NOWXUQWF.js} +3 -3
- package/dist/{chunk-WEZIVLCO.cjs → chunk-OJ4HFM3I.cjs} +18 -18
- package/dist/{chunk-WEZIVLCO.cjs.map → chunk-OJ4HFM3I.cjs.map} +1 -1
- package/dist/{chunk-M6BGJVDY.cjs → chunk-PFQQ5H6G.cjs} +19 -19
- package/dist/{chunk-M6BGJVDY.cjs.map → chunk-PFQQ5H6G.cjs.map} +1 -1
- package/dist/{chunk-QAFRLO5N.cjs → chunk-Q3AO2QWK.cjs} +4 -18
- package/dist/chunk-Q3AO2QWK.cjs.map +1 -0
- package/dist/{chunk-LREN3G7R.cjs → chunk-QXAHXMPD.cjs} +12 -12
- package/dist/{chunk-LREN3G7R.cjs.map → chunk-QXAHXMPD.cjs.map} +1 -1
- package/dist/{chunk-RC35ITSR.js → chunk-R6SMQ2CO.js} +6 -6
- package/dist/{chunk-RIGB7LP7.js → chunk-RNKXG55J.js} +4 -4
- package/dist/{chunk-FOJRR647.cjs → chunk-S4ZHBKGS.cjs} +73 -73
- package/dist/{chunk-FOJRR647.cjs.map → chunk-S4ZHBKGS.cjs.map} +1 -1
- package/dist/{chunk-BS6O4HLJ.cjs → chunk-TBXE5S6P.cjs} +40 -40
- package/dist/{chunk-BS6O4HLJ.cjs.map → chunk-TBXE5S6P.cjs.map} +1 -1
- package/dist/{chunk-ICJRSNBM.js → chunk-TTUQSOBL.js} +3 -61
- package/dist/chunk-TTUQSOBL.js.map +1 -0
- package/dist/{chunk-UN2BJ7XW.js → chunk-YORONSXG.js} +3 -3
- package/dist/{chunk-Q2KKEE6W.js → chunk-YXOWUHGQ.js} +6 -6
- package/dist/components/announcement-bar.d.ts +1 -27
- 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/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 +299 -274
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +135 -110
- 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 +4 -0
- 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 +0 -7
- package/dist/hooks/use-self-fetch.d.ts.map +1 -1
- package/dist/index.cjs +4 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +3 -19
- package/dist/types/announcement.cjs +0 -5
- package/dist/types/announcement.cjs.map +1 -1
- package/dist/types/announcement.d.ts +7 -37
- package/dist/types/announcement.d.ts.map +1 -1
- package/dist/types/announcement.js +1 -5
- package/dist/types/announcement.js.map +1 -1
- package/dist/types/index.cjs +0 -5
- package/dist/types/index.cjs.map +1 -1
- package/dist/types/index.js +1 -5
- package/dist/types/index.js.map +1 -1
- package/dist/utils/announcement-storage.d.ts +7 -39
- 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 +0 -13
- package/dist/utils/color-analysis.d.ts.map +1 -1
- package/dist/utils/index.cjs +1 -58
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +0 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -52
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/.announcement-bar.md +30 -32
- package/src/components/announcement-bar.tsx +182 -227
- package/src/components/ui/button/button.tsx +1 -1
- package/src/hooks/.api-hooks.md +4 -1
- package/src/hooks/api-hooks.ts +7 -0
- package/src/hooks/use-self-fetch.ts +1 -29
- package/src/stories/AnnouncementBar.stories.tsx +152 -188
- package/src/types/.announcement.md +2 -2
- package/src/types/announcement.ts +7 -37
- package/src/utils/.announcement-storage.md +24 -19
- package/src/utils/announcement-storage.ts +28 -74
- package/src/utils/app-config.ts +1 -10
- package/src/utils/color-analysis.ts +1 -16
- package/src/utils/index.ts +0 -3
- package/dist/chunk-BX3R44MR.cjs.map +0 -1
- package/dist/chunk-CCIBZ7TO.cjs.map +0 -1
- package/dist/chunk-E6YHDT3D.js.map +0 -1
- package/dist/chunk-FFYEDIAG.cjs.map +0 -1
- package/dist/chunk-ICJRSNBM.js.map +0 -1
- package/dist/chunk-PNJ5LMVK.js.map +0 -1
- package/dist/chunk-QAFRLO5N.cjs.map +0 -1
- package/dist/chunk-WJRPMULP.js.map +0 -1
- /package/dist/{chunk-F3YLM5PO.js.map → chunk-3I4IZYHM.js.map} +0 -0
- /package/dist/{chunk-QYTGHN2T.js.map → chunk-5Z5GXXRU.js.map} +0 -0
- /package/dist/{chunk-UIAQXIA6.js.map → chunk-A636Y7GU.js.map} +0 -0
- /package/dist/{chunk-BK7QCB5J.js.map → chunk-AMPQKNAX.js.map} +0 -0
- /package/dist/{chunk-GT7AJXKH.js.map → chunk-GYFUGMNM.js.map} +0 -0
- /package/dist/{chunk-5XZEY34X.js.map → chunk-HBO2DMX4.js.map} +0 -0
- /package/dist/{chunk-3XCNFTFV.js.map → chunk-NOWXUQWF.js.map} +0 -0
- /package/dist/{chunk-RC35ITSR.js.map → chunk-R6SMQ2CO.js.map} +0 -0
- /package/dist/{chunk-RIGB7LP7.js.map → chunk-RNKXG55J.js.map} +0 -0
- /package/dist/{chunk-UN2BJ7XW.js.map → chunk-YORONSXG.js.map} +0 -0
- /package/dist/{chunk-Q2KKEE6W.js.map → chunk-YXOWUHGQ.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,42 +1,40 @@
|
|
|
1
|
-
<!-- source-hash:
|
|
2
|
-
A
|
|
1
|
+
<!-- source-hash: cbe120bea34edef14f05fff077b0b285 -->
|
|
2
|
+
A client-side React component that renders a dismissible announcement banner, fetching active announcements from a configured API endpoint with localStorage-based caching and per-announcement dismissal persistence.
|
|
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
|
-
|
|
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()`.
|
|
8
|
+
| `AnnouncementBar` | Main component — renders the announcement banner or `null` if hidden/dismissed |
|
|
9
|
+
| `fetchActiveAnnouncement` | Fetches the active announcement from `announcementsUrl`, updates state and localStorage cache |
|
|
10
|
+
| `handleDismiss` | Marks the current announcement as dismissed in localStorage and hides the bar |
|
|
11
|
+
| `handleCtaClick` | Navigates to the CTA URL, respecting `_blank` target for new-tab behavior |
|
|
12
|
+
| `renderIcon` | Renders either a PNG `<Image>` or an SVG icon based on `announcement.icon_type` |
|
|
13
|
+
| `getSvgIcon` | Thin wrapper around `renderSvgIcon` applying size-appropriate Tailwind classes |
|
|
16
14
|
|
|
17
15
|
## Behavior Notes
|
|
18
16
|
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
17
|
+
- **Instant paint**: On mount, the cached announcement from localStorage is applied synchronously before the API response arrives.
|
|
18
|
+
- **Polling**: Refreshes every 5 minutes via `setInterval`; interval restarts if `announcementsUrl` changes.
|
|
19
|
+
- **No provider fallback**: When `useEndpointsRuntime` returns no URL, polling is skipped entirely — cached data still renders.
|
|
20
|
+
- **Platform-scoped keys**: localStorage keys are prefixed with the platform type (via `getAppType()`) to prevent key collisions across apps.
|
|
21
|
+
- **Responsive CTA**: The CTA button is desktop-only; on mobile, tapping the content area triggers `handleCtaClick` directly.
|
|
22
|
+
|
|
23
|
+
## Usage Example
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
// Wrap your app with the endpoints provider, then drop in the bar:
|
|
27
|
+
import { AnnouncementBar } from './announcement-bar';
|
|
28
|
+
import { EndpointsRuntimeProvider } from '../contexts/endpoints-runtime-context';
|
|
29
|
+
|
|
30
|
+
function AppShell() {
|
|
31
|
+
return (
|
|
32
|
+
<EndpointsRuntimeProvider announcementsUrl="/api/announcements/active">
|
|
33
|
+
<AnnouncementBar />
|
|
34
|
+
<main>{/* app content */}</main>
|
|
35
|
+
</EndpointsRuntimeProvider>
|
|
36
|
+
);
|
|
36
37
|
}
|
|
37
|
-
|
|
38
|
-
<EndpointsRuntimeContext.Provider value={endpointsRuntime}>
|
|
39
|
-
<AnnouncementBar />
|
|
40
|
-
<main>{/* app content */}</main>
|
|
41
|
-
</EndpointsRuntimeContext.Provider>
|
|
42
38
|
```
|
|
39
|
+
|
|
40
|
+
> **Note:** The component renders nothing (`null`) when no active announcement exists, when the fetch URL is unconfigured, or when the user has previously dismissed the current announcement.
|
|
@@ -1,266 +1,221 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useState, useEffect } 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
|
+
setStoredAnnouncement,
|
|
9
|
+
getStoredAnnouncement,
|
|
10
|
+
clearStoredAnnouncement,
|
|
11
11
|
} from '../utils/announcement-storage';
|
|
12
|
-
import
|
|
12
|
+
import { Announcement } 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';
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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.
|
|
16
|
+
export function AnnouncementBar() {
|
|
17
|
+
const [announcement, setAnnouncement] = useState<Announcement | null>(null);
|
|
18
|
+
const [isVisible, setIsVisible] = useState<boolean>(false);
|
|
19
|
+
|
|
20
|
+
// Get the platform type for platform-specific localStorage keys
|
|
53
21
|
const platform = getAppType();
|
|
54
22
|
|
|
55
|
-
// Optional endpoint runtime: no provider
|
|
23
|
+
// Optional endpoint runtime: when no provider is mounted (e.g. on a
|
|
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.
|
|
56
29
|
const endpoints = useEndpointsRuntime();
|
|
57
|
-
const
|
|
30
|
+
const announcementsUrl = endpoints?.announcementsUrl;
|
|
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
|
+
};
|
|
58
84
|
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
85
|
+
// Initial load: use cached announcement synchronously for instant paint
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const cached = getStoredAnnouncement(getCacheKey());
|
|
88
|
+
if (cached) {
|
|
89
|
+
const isDismissed = localStorage.getItem(getDismissKey(cached.id));
|
|
90
|
+
setAnnouncement(cached);
|
|
91
|
+
setIsVisible(!isDismissed);
|
|
92
|
+
}
|
|
67
93
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
94
|
+
// No provider mounted → no URL → no fetch / no polling. Cached
|
|
95
|
+
// announcement still painted above. Skip scheduling the 5-min
|
|
96
|
+
// interval entirely to avoid an idle timer + repeated short-circuit
|
|
97
|
+
// calls.
|
|
98
|
+
if (!announcementsUrl) return;
|
|
73
99
|
|
|
74
|
-
|
|
75
|
-
|
|
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;
|
|
100
|
+
// Always fetch latest on mount
|
|
101
|
+
fetchActiveAnnouncement();
|
|
79
102
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
103
|
+
// Schedule refresh every 5 minutes. When announcementsUrl flips
|
|
104
|
+
// (e.g. provider value swap), the effect re-runs and restarts the
|
|
105
|
+
// interval against the new URL — no stale captured fetch.
|
|
106
|
+
const interval = setInterval(fetchActiveAnnouncement, 300_000);
|
|
107
|
+
return () => clearInterval(interval);
|
|
84
108
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
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]);
|
|
109
|
+
}, [announcementsUrl]);
|
|
108
110
|
|
|
111
|
+
// helpers
|
|
109
112
|
const handleDismiss = () => {
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
+
if (!announcement) return;
|
|
114
|
+
localStorage.setItem(getDismissKey(announcement.id), 'true');
|
|
115
|
+
setIsVisible(false);
|
|
113
116
|
};
|
|
114
117
|
|
|
115
118
|
const handleCtaClick = () => {
|
|
116
|
-
if (
|
|
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
|
-
}
|
|
119
|
+
if (!announcement?.cta_url) return;
|
|
128
120
|
announcement.cta_target === '_blank'
|
|
129
|
-
? window.open(
|
|
130
|
-
: (window.location.href =
|
|
121
|
+
? window.open(announcement.cta_url, '_blank', 'noopener,noreferrer')
|
|
122
|
+
: (window.location.href = announcement.cta_url);
|
|
131
123
|
};
|
|
132
124
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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)]';
|
|
125
|
+
const renderIcon = () => {
|
|
126
|
+
if (!announcement) return null;
|
|
127
|
+
// ONE unified display path (shared with the chat): uploaded image URL wins,
|
|
128
|
+
// else a library glyph by name (+ props), via <EntityIcon>.
|
|
129
|
+
return (
|
|
130
|
+
<EntityIcon
|
|
131
|
+
icon={{
|
|
132
|
+
name: announcement.icon_name || 'openframe-logo',
|
|
133
|
+
url: announcement.icon_url,
|
|
134
|
+
props: announcement.icon_props,
|
|
135
|
+
}}
|
|
136
|
+
size={32}
|
|
137
|
+
className="relative shrink-0 w-6 h-6 md:w-8 md:h-8"
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
154
141
|
|
|
155
|
-
|
|
142
|
+
// If no announcement or dismissed => render nothing
|
|
143
|
+
if (!announcement || !isVisible) return null;
|
|
156
144
|
|
|
157
145
|
return (
|
|
158
146
|
<div
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
aria-hidden={!expanded}
|
|
147
|
+
className="relative w-full z-50"
|
|
148
|
+
style={{ backgroundColor: announcement.background_color }}
|
|
162
149
|
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' }}
|
|
165
150
|
>
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
)}
|
|
151
|
+
<div className="flex items-center w-full max-w-full">
|
|
152
|
+
{/* Mobile: Clickable content area, Desktop: Regular content */}
|
|
153
|
+
<div
|
|
154
|
+
className={`flex flex-row gap-2 md:gap-4 items-center pl-4 md:pl-6 py-1.5 md:py-2 flex-1 min-w-0 ${
|
|
155
|
+
announcement.cta_enabled && announcement.cta_url ? 'md:cursor-default cursor-pointer' : ''
|
|
156
|
+
}`}
|
|
157
|
+
onClick={(e) => {
|
|
158
|
+
// Only handle click on mobile (< 768px) and if CTA is enabled
|
|
159
|
+
if (window.innerWidth < 768 && announcement.cta_enabled && announcement.cta_url) {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
handleCtaClick();
|
|
162
|
+
}
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
{renderIcon()}
|
|
166
|
+
|
|
167
|
+
<div className="flex-1 min-w-0 max-w-full">
|
|
168
|
+
<p className="font-body font-bold text-[14px] md:text-[18px] leading-tight tracking-tight mb-0 text-[#1A1A1A] truncate">
|
|
169
|
+
{announcement.title}
|
|
170
|
+
</p>
|
|
171
|
+
<p className="font-body text-[12px] md:text-[18px] leading-tight hidden md:block text-[#1A1A1A] truncate">
|
|
172
|
+
{announcement.description}
|
|
209
173
|
</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
|
-
)}
|
|
241
174
|
</div>
|
|
242
175
|
|
|
243
|
-
{/*
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
176
|
+
{/* CTA Button - Hidden on mobile, shown on desktop */}
|
|
177
|
+
{announcement.cta_enabled && announcement.cta_text && announcement.cta_url && (
|
|
178
|
+
<div className="hidden md:flex flex-shrink-0 ml-1 md:ml-2">
|
|
179
|
+
<Button
|
|
180
|
+
onClick={handleCtaClick}
|
|
181
|
+
variant="outline"
|
|
182
|
+
size="small-legacy"
|
|
183
|
+
leftIcon={
|
|
184
|
+
announcement.cta_show_icon && announcement.cta_icon_name
|
|
185
|
+
? (
|
|
186
|
+
<EntityIcon
|
|
187
|
+
icon={{ name: announcement.cta_icon_name, props: announcement.cta_icon_props }}
|
|
188
|
+
size={16}
|
|
189
|
+
className="w-3 h-3 md:w-4 md:h-4"
|
|
190
|
+
/>
|
|
191
|
+
)
|
|
192
|
+
: undefined
|
|
193
|
+
}
|
|
194
|
+
className="transition-opacity hover:opacity-90 text-xs md:text-sm whitespace-nowrap"
|
|
195
|
+
style={{
|
|
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
|
+
)}
|
|
263
205
|
</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>
|
|
264
219
|
</div>
|
|
265
220
|
</div>
|
|
266
221
|
);
|
|
@@ -29,7 +29,7 @@ const buttonVariants = cva(
|
|
|
29
29
|
size: {
|
|
30
30
|
default: "py-[var(--spacing-system-sf)] px-[var(--spacing-system-m)] text-h3 md:h-12 h-10",
|
|
31
31
|
small: "p-[var(--spacing-system-xs)] text-h5 h-6 md:h-8",
|
|
32
|
-
"small-legacy": "py-[var(--spacing-system-xs)] px-[var(--spacing-system-m)] h-10 text-[14px] font-bold", // Temporary alias for "small"
|
|
32
|
+
"small-legacy": "py-[var(--spacing-system-xs)] px-[var(--spacing-system-m)] h-10 text-[14px] font-bold", // Temporary alias for "small" to avoid breaking changes in AnnouncementBar's CTA button; will be removed in the future
|
|
33
33
|
icon: "p-[var(--spacing-system-sf)] h-11 w-11 md:h-12 md:w-12 [&_svg]:h-4 [&_svg]:w-4 md:[&_svg]:h-6 md:[&_svg]:w-6",
|
|
34
34
|
// Quiet 32px icon target with a 16px glyph, fixed across breakpoints
|
|
35
35
|
// (Carbon ghost sm / Primer medium / shadcn icon-sm all pin 32px;
|
package/src/hooks/.api-hooks.md
CHANGED
|
@@ -5,16 +5,18 @@ This file provides stub React hooks for API data fetching, offering consistent i
|
|
|
5
5
|
|
|
6
6
|
- **`useCategories()`** - Hook for fetching and managing category data with loading/error states
|
|
7
7
|
- **`useVendors()`** - Hook for fetching and managing vendor data with loading/error states
|
|
8
|
+
- **`useAnnouncements()`** - Hook for fetching announcement data with loading state
|
|
8
9
|
- **`useSubcategoryCountByCategory()`** - Hook that returns empty subcategory count data
|
|
9
10
|
|
|
10
11
|
## Usage Example
|
|
11
12
|
|
|
12
13
|
```typescript
|
|
13
|
-
import { useCategories, useVendors } from './api-hooks';
|
|
14
|
+
import { useCategories, useVendors, useAnnouncements } from './api-hooks';
|
|
14
15
|
|
|
15
16
|
function MyComponent() {
|
|
16
17
|
const { categories, loading: categoriesLoading, error: categoriesError } = useCategories();
|
|
17
18
|
const { vendors, loading: vendorsLoading, error: vendorsError } = useVendors();
|
|
19
|
+
const { announcements, loading: announcementsLoading } = useAnnouncements();
|
|
18
20
|
|
|
19
21
|
if (categoriesLoading || vendorsLoading) {
|
|
20
22
|
return <div>Loading...</div>;
|
|
@@ -28,6 +30,7 @@ function MyComponent() {
|
|
|
28
30
|
<div>
|
|
29
31
|
<h2>Categories: {categories.length}</h2>
|
|
30
32
|
<h2>Vendors: {vendors.length}</h2>
|
|
33
|
+
<h2>Announcements: {announcements.length}</h2>
|
|
31
34
|
</div>
|
|
32
35
|
);
|
|
33
36
|
}
|
package/src/hooks/api-hooks.ts
CHANGED
|
@@ -17,6 +17,13 @@ export function useVendors() {
|
|
|
17
17
|
return { vendors, loading, error };
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export function useAnnouncements() {
|
|
21
|
+
const [announcements, setAnnouncements] = useState([]);
|
|
22
|
+
const [loading, setLoading] = useState(false);
|
|
23
|
+
|
|
24
|
+
return { announcements, loading };
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
export function useSubcategoryCountByCategory() {
|
|
21
28
|
return { data: {}, loading: false };
|
|
22
29
|
}
|