@flamingo-stack/openframe-frontend-core 0.0.404 → 0.0.405-snapshot.20260711081601

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.
Files changed (148) hide show
  1. package/dist/{chunk-HBO2DMX4.js → chunk-3KZXOLFK.js} +3 -3
  2. package/dist/{chunk-RNKXG55J.js → chunk-6WMVBCNA.js} +4 -4
  3. package/dist/{chunk-A636Y7GU.js → chunk-7RVSGGO6.js} +5 -5
  4. package/dist/{chunk-YXOWUHGQ.js → chunk-AYFWYOL6.js} +6 -6
  5. package/dist/{chunk-GA5AN24T.cjs → chunk-BX3R44MR.cjs} +2 -2
  6. package/dist/chunk-BX3R44MR.cjs.map +1 -0
  7. package/dist/{chunk-GYFUGMNM.js → chunk-C3NPMZBI.js} +2 -2
  8. package/dist/{chunk-J4XR6YYZ.cjs → chunk-CCIBZ7TO.cjs} +10 -6
  9. package/dist/chunk-CCIBZ7TO.cjs.map +1 -0
  10. package/dist/{chunk-6MO5HF6P.cjs → chunk-CF3LBCNI.cjs} +32 -32
  11. package/dist/{chunk-6MO5HF6P.cjs.map → chunk-CF3LBCNI.cjs.map} +1 -1
  12. package/dist/{chunk-S4ZHBKGS.cjs → chunk-COERBDG4.cjs} +73 -73
  13. package/dist/{chunk-S4ZHBKGS.cjs.map → chunk-COERBDG4.cjs.map} +1 -1
  14. package/dist/{chunk-DH6HTYOV.js → chunk-E6YHDT3D.js} +6 -2
  15. package/dist/chunk-E6YHDT3D.js.map +1 -0
  16. package/dist/{chunk-OJ4HFM3I.cjs → chunk-GNS65HNC.cjs} +18 -18
  17. package/dist/{chunk-OJ4HFM3I.cjs.map → chunk-GNS65HNC.cjs.map} +1 -1
  18. package/dist/{chunk-G4YLKA7O.cjs → chunk-HJJXNMM7.cjs} +5 -5
  19. package/dist/{chunk-G4YLKA7O.cjs.map → chunk-HJJXNMM7.cjs.map} +1 -1
  20. package/dist/{chunk-TBXE5S6P.cjs → chunk-ISYVNPRT.cjs} +40 -40
  21. package/dist/{chunk-TBXE5S6P.cjs.map → chunk-ISYVNPRT.cjs.map} +1 -1
  22. package/dist/{chunk-CRUPO3MR.cjs → chunk-JAPP2F4M.cjs} +93 -93
  23. package/dist/{chunk-CRUPO3MR.cjs.map → chunk-JAPP2F4M.cjs.map} +1 -1
  24. package/dist/{chunk-G3QYCV63.cjs → chunk-LL4QQSAD.cjs} +500 -441
  25. package/dist/chunk-LL4QQSAD.cjs.map +1 -0
  26. package/dist/{chunk-YORONSXG.js → chunk-LSPBPUQM.js} +3 -3
  27. package/dist/{chunk-AMPQKNAX.js → chunk-OATWTTCI.js} +6 -6
  28. package/dist/{chunk-FFWIUNOT.cjs → chunk-P43ZHPM6.cjs} +40 -40
  29. package/dist/{chunk-FFWIUNOT.cjs.map → chunk-P43ZHPM6.cjs.map} +1 -1
  30. package/dist/{chunk-MTBFE3JF.js → chunk-PNJ5LMVK.js} +16 -2
  31. package/dist/chunk-PNJ5LMVK.js.map +1 -0
  32. package/dist/{chunk-R6SMQ2CO.js → chunk-PQN5R5II.js} +6 -6
  33. package/dist/{chunk-Q3AO2QWK.cjs → chunk-QAFRLO5N.cjs} +18 -4
  34. package/dist/chunk-QAFRLO5N.cjs.map +1 -0
  35. package/dist/{chunk-PFQQ5H6G.cjs → chunk-SF4G7M6U.cjs} +19 -19
  36. package/dist/{chunk-PFQQ5H6G.cjs.map → chunk-SF4G7M6U.cjs.map} +1 -1
  37. package/dist/{chunk-TTUQSOBL.js → chunk-SOK32D6C.js} +62 -3
  38. package/dist/chunk-SOK32D6C.js.map +1 -0
  39. package/dist/{chunk-5Z5GXXRU.js → chunk-TFDNJTBX.js} +4 -4
  40. package/dist/{chunk-QXAHXMPD.cjs → chunk-VANFD4FX.cjs} +12 -12
  41. package/dist/{chunk-QXAHXMPD.cjs.map → chunk-VANFD4FX.cjs.map} +1 -1
  42. package/dist/{chunk-KVB7SHXY.cjs → chunk-VIB4SGCD.cjs} +12 -12
  43. package/dist/{chunk-KVB7SHXY.cjs.map → chunk-VIB4SGCD.cjs.map} +1 -1
  44. package/dist/{chunk-M7LIGEAX.cjs → chunk-VZGU4BV6.cjs} +33 -33
  45. package/dist/{chunk-M7LIGEAX.cjs.map → chunk-VZGU4BV6.cjs.map} +1 -1
  46. package/dist/{chunk-HGKWTKW7.js → chunk-WJRPMULP.js} +2 -2
  47. package/dist/chunk-WJRPMULP.js.map +1 -0
  48. package/dist/{chunk-NOWXUQWF.js → chunk-XXG2Z2JT.js} +3 -3
  49. package/dist/{chunk-3I4IZYHM.js → chunk-ZVUR3A33.js} +3 -3
  50. package/dist/components/announcement-bar.d.ts +27 -1
  51. package/dist/components/announcement-bar.d.ts.map +1 -1
  52. package/dist/components/case-studies/index.cjs +10 -10
  53. package/dist/components/case-studies/index.js +4 -4
  54. package/dist/components/chat/index.cjs +4 -4
  55. package/dist/components/chat/index.js +3 -3
  56. package/dist/components/contact/index.cjs +5 -5
  57. package/dist/components/contact/index.js +4 -4
  58. package/dist/components/docs/index.cjs +7 -7
  59. package/dist/components/docs/index.js +6 -6
  60. package/dist/components/embeds/index.cjs +5 -5
  61. package/dist/components/embeds/index.js +4 -4
  62. package/dist/components/faq/index.cjs +6 -6
  63. package/dist/components/faq/index.js +5 -5
  64. package/dist/components/features/index.cjs +4 -4
  65. package/dist/components/features/index.js +3 -3
  66. package/dist/components/help-center-pages/index.cjs +23 -23
  67. package/dist/components/help-center-pages/index.js +14 -14
  68. package/dist/components/index.cjs +274 -299
  69. package/dist/components/index.cjs.map +1 -1
  70. package/dist/components/index.js +110 -135
  71. package/dist/components/index.js.map +1 -1
  72. package/dist/components/navigation/index.cjs +4 -4
  73. package/dist/components/navigation/index.js +3 -3
  74. package/dist/components/onboarding-guides/index.cjs +8 -8
  75. package/dist/components/onboarding-guides/index.js +7 -7
  76. package/dist/components/related-content/index.cjs +6 -6
  77. package/dist/components/related-content/index.js +5 -5
  78. package/dist/components/tickets/index.cjs +7 -7
  79. package/dist/components/tickets/index.js +6 -6
  80. package/dist/components/ui/file-manager/index.cjs +50 -50
  81. package/dist/components/ui/file-manager/index.js +1 -1
  82. package/dist/components/ui/index.cjs +4 -4
  83. package/dist/components/ui/index.js +3 -3
  84. package/dist/hooks/api-hooks.d.ts +0 -4
  85. package/dist/hooks/api-hooks.d.ts.map +1 -1
  86. package/dist/hooks/index.cjs +2 -2
  87. package/dist/hooks/index.js +1 -1
  88. package/dist/hooks/use-self-fetch.d.ts +7 -0
  89. package/dist/hooks/use-self-fetch.d.ts.map +1 -1
  90. package/dist/index.cjs +20 -4
  91. package/dist/index.cjs.map +1 -1
  92. package/dist/index.js +19 -3
  93. package/dist/types/announcement.cjs +5 -0
  94. package/dist/types/announcement.cjs.map +1 -1
  95. package/dist/types/announcement.d.ts +37 -7
  96. package/dist/types/announcement.d.ts.map +1 -1
  97. package/dist/types/announcement.js +5 -1
  98. package/dist/types/announcement.js.map +1 -1
  99. package/dist/types/index.cjs +5 -0
  100. package/dist/types/index.cjs.map +1 -1
  101. package/dist/types/index.js +5 -1
  102. package/dist/types/index.js.map +1 -1
  103. package/dist/utils/announcement-storage.d.ts +39 -7
  104. package/dist/utils/announcement-storage.d.ts.map +1 -1
  105. package/dist/utils/app-config.d.ts.map +1 -1
  106. package/dist/utils/color-analysis.d.ts +13 -0
  107. package/dist/utils/color-analysis.d.ts.map +1 -1
  108. package/dist/utils/index.cjs +58 -1
  109. package/dist/utils/index.cjs.map +1 -1
  110. package/dist/utils/index.d.ts +1 -0
  111. package/dist/utils/index.d.ts.map +1 -1
  112. package/dist/utils/index.js +52 -2
  113. package/dist/utils/index.js.map +1 -1
  114. package/package.json +1 -1
  115. package/src/components/.announcement-bar.md +32 -30
  116. package/src/components/announcement-bar.tsx +227 -182
  117. package/src/components/features/video.tsx +8 -0
  118. package/src/components/ui/button/button.tsx +1 -1
  119. package/src/hooks/.api-hooks.md +1 -4
  120. package/src/hooks/api-hooks.ts +0 -7
  121. package/src/hooks/use-self-fetch.ts +29 -1
  122. package/src/stories/AnnouncementBar.stories.tsx +188 -152
  123. package/src/types/.announcement.md +2 -2
  124. package/src/types/announcement.ts +37 -7
  125. package/src/utils/.announcement-storage.md +19 -24
  126. package/src/utils/announcement-storage.ts +74 -28
  127. package/src/utils/app-config.ts +10 -1
  128. package/src/utils/color-analysis.ts +16 -1
  129. package/src/utils/index.ts +3 -0
  130. package/dist/chunk-DH6HTYOV.js.map +0 -1
  131. package/dist/chunk-G3QYCV63.cjs.map +0 -1
  132. package/dist/chunk-GA5AN24T.cjs.map +0 -1
  133. package/dist/chunk-HGKWTKW7.js.map +0 -1
  134. package/dist/chunk-J4XR6YYZ.cjs.map +0 -1
  135. package/dist/chunk-MTBFE3JF.js.map +0 -1
  136. package/dist/chunk-Q3AO2QWK.cjs.map +0 -1
  137. package/dist/chunk-TTUQSOBL.js.map +0 -1
  138. /package/dist/{chunk-HBO2DMX4.js.map → chunk-3KZXOLFK.js.map} +0 -0
  139. /package/dist/{chunk-RNKXG55J.js.map → chunk-6WMVBCNA.js.map} +0 -0
  140. /package/dist/{chunk-A636Y7GU.js.map → chunk-7RVSGGO6.js.map} +0 -0
  141. /package/dist/{chunk-YXOWUHGQ.js.map → chunk-AYFWYOL6.js.map} +0 -0
  142. /package/dist/{chunk-GYFUGMNM.js.map → chunk-C3NPMZBI.js.map} +0 -0
  143. /package/dist/{chunk-YORONSXG.js.map → chunk-LSPBPUQM.js.map} +0 -0
  144. /package/dist/{chunk-AMPQKNAX.js.map → chunk-OATWTTCI.js.map} +0 -0
  145. /package/dist/{chunk-R6SMQ2CO.js.map → chunk-PQN5R5II.js.map} +0 -0
  146. /package/dist/{chunk-5Z5GXXRU.js.map → chunk-TFDNJTBX.js.map} +0 -0
  147. /package/dist/{chunk-NOWXUQWF.js.map → chunk-XXG2Z2JT.js.map} +0 -0
  148. /package/dist/{chunk-3I4IZYHM.js.map → chunk-ZVUR3A33.js.map} +0 -0
@@ -31,19 +31,30 @@ export interface UseSelfFetchResult<T> {
31
31
  * - a `cancelled` guard ensures an in-flight response from a STALE `url`
32
32
  * (e.g. a fast pagination / section toggle) can't overwrite a newer one.
33
33
  * - `reload()` bumps an internal key to retry after an error.
34
+ * - `revalidateOnVisibleAfterMs` (opt-in) → re-fetch when the tab becomes
35
+ * visible AND the held data is older than the threshold. Event-driven
36
+ * freshness with ZERO background requests — the replacement for
37
+ * setInterval polling. Data age counts from the last completed fetch, or
38
+ * from mount when `initialData` seeded it (an SSR seed is milliseconds
39
+ * old — without that, the first refocus would always refire).
34
40
  *
35
41
  * Re-fetches whenever `url` changes, so callers fold all query params INTO the
36
42
  * url string (the url IS the cache key).
37
43
  */
38
44
  export function useSelfFetch<T>(
39
45
  url: string | null,
40
- options?: { initialData?: T },
46
+ options?: { initialData?: T; revalidateOnVisibleAfterMs?: number },
41
47
  ): UseSelfFetchResult<T> {
42
48
  const initialData = options?.initialData
49
+ const revalidateOnVisibleAfterMs = options?.revalidateOnVisibleAfterMs
43
50
  const [data, setData] = useState<T | null>(initialData ?? null)
44
51
  const [isLoading, setIsLoading] = useState(initialData === undefined && url !== null)
45
52
  const [error, setError] = useState(false)
46
53
  const [reloadKey, setReloadKey] = useState(0)
54
+ // When the held data was obtained: seeded at mount for SSR-hydrated data,
55
+ // stamped after each completed fetch. Drives the visibility revalidation age
56
+ // check. 0 = nothing held yet (first visible event may fetch).
57
+ const dataAtRef = useRef<number>(initialData !== undefined ? Date.now() : 0)
47
58
  // The url whose data we currently hold — seeded from SSR `initialData`, then set after
48
59
  // each completed fetch. The effect skips the fetch when this equals `url` (so server-
49
60
  // rendered data isn't re-fetched on mount). A VALUE compare (not a one-shot flag) so the
@@ -78,6 +89,7 @@ export function useSelfFetch<T>(
78
89
  if (!cancelled) {
79
90
  setData(json)
80
91
  dataUrlRef.current = url // remember the url we now hold data for
92
+ dataAtRef.current = Date.now()
81
93
  }
82
94
  } catch (err) {
83
95
  // AbortError on cleanup (unmount / stale-url change / React StrictMode's dev
@@ -100,6 +112,22 @@ export function useSelfFetch<T>(
100
112
  // `url` folds in every query param; `reloadKey` drives retry.
101
113
  }, [url, reloadKey])
102
114
 
115
+ // Opt-in visibility-driven revalidation. Registered only when the option is
116
+ // set and fetching is enabled; SSR-safe (effects don't run on the server)
117
+ // and StrictMode-safe (idempotent listener, cleaned up per mount cycle).
118
+ useEffect(() => {
119
+ if (!revalidateOnVisibleAfterMs || url === null) return
120
+ const onVisible = () => {
121
+ if (document.visibilityState !== 'visible') return
122
+ if (Date.now() - dataAtRef.current < revalidateOnVisibleAfterMs) return
123
+ // Same mechanics as reload(): clear the held-url skip, bump the key.
124
+ dataUrlRef.current = null
125
+ setReloadKey((k) => k + 1)
126
+ }
127
+ document.addEventListener('visibilitychange', onVisible)
128
+ return () => document.removeEventListener('visibilitychange', onVisible)
129
+ }, [url, revalidateOnVisibleAfterMs])
130
+
103
131
  return {
104
132
  data,
105
133
  setData,
@@ -1,36 +1,24 @@
1
1
  import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2
2
  import type { Announcement } from '../types/announcement'
3
3
  import { AnnouncementBar } from '../components/announcement-bar'
4
+ import { announcementDismissCookieName } from '../utils/announcement-storage'
5
+ import { getAppType } from '../utils/app-config'
6
+ import { EndpointsRuntimeContext, type EndpointsRuntime } from '../contexts/endpoints-runtime-context'
4
7
 
5
8
  /**
6
- * Mock fetch so the component receives announcement data without a real API.
7
- * Also seeds localStorage so the bar isn't marked as dismissed.
9
+ * Stories drive the bar through its real `initialAnnouncement` prop (the hub's
10
+ * SSR-seed path) no fetch mock needed; with no EndpointsRuntime provider
11
+ * mounted the bar never fetches. The decorator clears any dismissal state left
12
+ * by a previous interaction so every story starts visible.
8
13
  */
9
- function mockAnnouncement(announcement: Announcement | null) {
10
- // Clear any previous dismiss flags
11
- if (announcement) {
12
- Object.keys(localStorage).forEach((key) => {
13
- if (key.includes(`announcement-${announcement.id}-dismissed`)) {
14
- localStorage.removeItem(key)
15
- }
16
- })
17
- }
18
-
19
- const originalFetch = window.fetch
20
- window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
21
- const url = typeof input === 'string' ? input : input.toString()
22
- if (url.includes('/api/announcements/active')) {
23
- return new Response(JSON.stringify({ announcement }), {
24
- status: 200,
25
- headers: { 'Content-Type': 'application/json' },
26
- })
14
+ function clearDismissals() {
15
+ const platform = getAppType()
16
+ document.cookie = `${announcementDismissCookieName(platform)}=; path=/; max-age=0`
17
+ Object.keys(localStorage).forEach((key) => {
18
+ if (key.includes('-announcement-') && key.endsWith('-dismissed')) {
19
+ localStorage.removeItem(key)
27
20
  }
28
- return originalFetch(input, init)
29
- }) as typeof window.fetch
30
-
31
- return () => {
32
- window.fetch = originalFetch
33
- }
21
+ })
34
22
  }
35
23
 
36
24
  const now = new Date().toISOString()
@@ -41,7 +29,6 @@ const baseAnnouncement: Announcement = {
41
29
  description: 'Check out our latest updates and improvements to the platform.',
42
30
  background_color: '#FFD951',
43
31
  icon_name: 'rocket',
44
- platform_id: 'openframe',
45
32
  is_active: true,
46
33
  created_at: now,
47
34
  updated_at: now,
@@ -55,10 +42,16 @@ const meta = {
55
42
  docs: {
56
43
  description: {
57
44
  component:
58
- 'A top-of-page announcement bar that fetches the active announcement from the API. Supports SVG/PNG icons, CTA buttons, dismiss functionality, and responsive mobile layout.',
45
+ 'A top-of-page announcement bar. The host seeds it with `initialAnnouncement` (SSR path) or lets it self-fetch via the endpoints runtime. Supports SVG/PNG icons, CTA buttons, contrast-aware text, animated dismiss, and responsive mobile layout.',
59
46
  },
60
47
  },
61
48
  },
49
+ decorators: [
50
+ (Story) => {
51
+ clearDismissals()
52
+ return <Story />
53
+ },
54
+ ],
62
55
  tags: ['autodocs'],
63
56
  } satisfies Meta<typeof AnnouncementBar>
64
57
 
@@ -69,189 +62,232 @@ type Story = StoryObj<typeof meta>
69
62
  * Default announcement with an icon and description.
70
63
  */
71
64
  export const Default: Story = {
72
- decorators: [
73
- (Story) => {
74
- const cleanup = mockAnnouncement(baseAnnouncement)
75
- return (
76
- <>
77
- <Story />
78
- {/* cleanup on unmount is handled by storybook re-renders */}
79
- </>
80
- )
81
- },
82
- ],
65
+ args: { initialAnnouncement: baseAnnouncement },
83
66
  }
84
67
 
85
68
  /**
86
69
  * Announcement with a CTA button.
87
70
  */
88
71
  export const WithCTA: Story = {
89
- decorators: [
90
- (Story) => {
91
- mockAnnouncement({
92
- ...baseAnnouncement,
93
- id: 'story-cta',
94
- title: 'OpenFrame v2.0 is here!',
95
- description: 'Explore the new dashboard, improved device management, and more.',
96
- background_color: '#FFD951',
97
- icon_name: 'star',
98
- cta_enabled: true,
99
- cta_text: 'Learn More',
100
- cta_url: 'https://example.com',
101
- cta_target: '_blank',
102
- cta_icon_name: 'rocket',
103
- cta_show_icon: true,
104
- cta_button_background_color: '#1A1A1A',
105
- cta_button_text_color: '#FFFFFF',
106
- })
107
- return <Story />
72
+ args: {
73
+ initialAnnouncement: {
74
+ ...baseAnnouncement,
75
+ id: 'story-cta',
76
+ title: 'OpenFrame v2.0 is here!',
77
+ description: 'Explore the new dashboard, improved device management, and more.',
78
+ background_color: '#FFD951',
79
+ icon_name: 'star',
80
+ cta_enabled: true,
81
+ cta_text: 'Learn More',
82
+ cta_url: 'https://example.com',
83
+ cta_target: '_blank',
84
+ cta_icon_name: 'rocket',
85
+ cta_show_icon: true,
86
+ cta_button_background_color: '#1A1A1A',
87
+ cta_button_text_color: '#FFFFFF',
108
88
  },
109
- ],
89
+ },
110
90
  }
111
91
 
112
92
  /**
113
93
  * Announcement with a CTA button that opens in the same tab.
114
94
  */
115
95
  export const WithCTASameTab: Story = {
116
- decorators: [
117
- (Story) => {
118
- mockAnnouncement({
119
- ...baseAnnouncement,
120
- id: 'story-cta-same',
121
- title: 'Scheduled Maintenance',
122
- description: 'We will perform maintenance on Saturday from 2:00 AM to 4:00 AM UTC.',
123
- background_color: '#FFE082',
124
- icon_name: 'info',
125
- cta_enabled: true,
126
- cta_text: 'View Status',
127
- cta_url: '/status',
128
- cta_target: '_self',
129
- })
130
- return <Story />
96
+ args: {
97
+ initialAnnouncement: {
98
+ ...baseAnnouncement,
99
+ id: 'story-cta-same',
100
+ title: 'Scheduled Maintenance',
101
+ description: 'We will perform maintenance on Saturday from 2:00 AM to 4:00 AM UTC.',
102
+ background_color: '#FFE082',
103
+ icon_name: 'info',
104
+ cta_enabled: true,
105
+ cta_text: 'View Status',
106
+ cta_url: '/status',
107
+ cta_target: '_self',
131
108
  },
132
- ],
109
+ },
110
+ }
111
+
112
+ /**
113
+ * Dark background — text flips to the light shade via pickReadableTextColor.
114
+ */
115
+ export const DarkBackground: Story = {
116
+ args: {
117
+ initialAnnouncement: {
118
+ ...baseAnnouncement,
119
+ id: 'story-dark',
120
+ title: 'Contrast-aware text',
121
+ description: 'On a dark admin-chosen background the foreground goes light automatically.',
122
+ background_color: '#1F2937',
123
+ icon_name: 'megaphone',
124
+ },
125
+ },
133
126
  }
134
127
 
135
128
  /**
136
129
  * Announcement with a blue background.
137
130
  */
138
131
  export const BlueBackground: Story = {
139
- decorators: [
140
- (Story) => {
141
- mockAnnouncement({
142
- ...baseAnnouncement,
143
- id: 'story-blue',
144
- title: 'Join our Community',
145
- description: 'Connect with other MSPs and share best practices.',
146
- background_color: '#60A5FA',
147
- icon_name: 'megaphone',
148
- })
149
- return <Story />
132
+ args: {
133
+ initialAnnouncement: {
134
+ ...baseAnnouncement,
135
+ id: 'story-blue',
136
+ title: 'Join our Community',
137
+ description: 'Connect with other MSPs and share best practices.',
138
+ background_color: '#60A5FA',
139
+ icon_name: 'megaphone',
150
140
  },
151
- ],
141
+ },
152
142
  }
153
143
 
154
144
  /**
155
145
  * Announcement with a green background.
156
146
  */
157
147
  export const GreenBackground: Story = {
158
- decorators: [
159
- (Story) => {
160
- mockAnnouncement({
161
- ...baseAnnouncement,
162
- id: 'story-green',
163
- title: 'All Systems Operational',
164
- description: 'Everything is running smoothly. No issues detected.',
165
- background_color: '#86EFAC',
166
- icon_name: 'bell',
167
- })
168
- return <Story />
148
+ args: {
149
+ initialAnnouncement: {
150
+ ...baseAnnouncement,
151
+ id: 'story-green',
152
+ title: 'All Systems Operational',
153
+ description: 'Everything is running smoothly. No issues detected.',
154
+ background_color: '#86EFAC',
155
+ icon_name: 'bell',
169
156
  },
170
- ],
157
+ },
171
158
  }
172
159
 
173
160
  /**
174
161
  * Announcement with OpenFrame logo icon.
175
162
  */
176
163
  export const WithOpenFrameLogo: Story = {
177
- decorators: [
178
- (Story) => {
179
- mockAnnouncement({
180
- ...baseAnnouncement,
181
- id: 'story-logo',
182
- title: 'Welcome to OpenFrame',
183
- description: 'Your open-source IT infrastructure management platform.',
184
- background_color: '#FFD951',
185
- icon_name: 'openframe-logo',
186
- cta_enabled: true,
187
- cta_text: 'Get Started',
188
- cta_url: '/getting-started',
189
- cta_target: '_self',
190
- cta_button_background_color: '#000000',
191
- cta_button_text_color: '#FFD951',
192
- })
193
- return <Story />
164
+ args: {
165
+ initialAnnouncement: {
166
+ ...baseAnnouncement,
167
+ id: 'story-logo',
168
+ title: 'Welcome to OpenFrame',
169
+ description: 'Your open-source IT infrastructure management platform.',
170
+ background_color: '#FFD951',
171
+ icon_name: 'openframe-logo',
172
+ cta_enabled: true,
173
+ cta_text: 'Get Started',
174
+ cta_url: '/getting-started',
175
+ cta_target: '_self',
176
+ cta_button_background_color: '#000000',
177
+ cta_button_text_color: '#FFD951',
194
178
  },
195
- ],
179
+ },
196
180
  }
197
181
 
198
182
  /**
199
183
  * Announcement with a PNG icon.
200
184
  */
201
185
  export const WithPNGIcon: Story = {
202
- decorators: [
203
- (Story) => {
204
- mockAnnouncement({
205
- ...baseAnnouncement,
206
- id: 'story-png',
207
- title: 'Partner Program Launch',
208
- description: 'Become an OpenFrame certified partner and grow your business.',
209
- background_color: '#C4B5FD',
210
- icon_url: 'https://placehold.co/32x32/1a1a1a/white?text=OF',
211
- })
212
- return <Story />
186
+ args: {
187
+ initialAnnouncement: {
188
+ ...baseAnnouncement,
189
+ id: 'story-png',
190
+ title: 'Partner Program Launch',
191
+ description: 'Become an OpenFrame certified partner and grow your business.',
192
+ background_color: '#C4B5FD',
193
+ icon_url: 'https://placehold.co/32x32/1a1a1a/white?text=OF',
213
194
  },
214
- ],
195
+ },
215
196
  }
216
197
 
217
198
  /**
218
199
  * Long title and description to test truncation.
219
200
  */
220
201
  export const LongContent: Story = {
221
- decorators: [
222
- (Story) => {
223
- mockAnnouncement({
224
- ...baseAnnouncement,
225
- id: 'story-long',
226
- title: 'This is a very long announcement title that should be truncated on smaller screens to prevent layout issues',
227
- description:
228
- 'This is an extremely long description that goes into great detail about the announcement. It should be truncated on the desktop view and hidden on mobile view to keep the bar compact and readable.',
229
- background_color: '#FCA5A5',
230
- icon_name: 'info',
231
- cta_enabled: true,
232
- cta_text: 'Read Full Announcement',
233
- cta_url: '/announcements/long',
234
- cta_target: '_self',
235
- })
236
- return <Story />
202
+ args: {
203
+ initialAnnouncement: {
204
+ ...baseAnnouncement,
205
+ id: 'story-long',
206
+ title: 'This is a very long announcement title that should be truncated on smaller screens to prevent layout issues',
207
+ description:
208
+ 'This is an extremely long description that goes into great detail about the announcement. It should be truncated on the desktop view and hidden on mobile view to keep the bar compact and readable.',
209
+ background_color: '#FCA5A5',
210
+ icon_name: 'info',
211
+ cta_enabled: true,
212
+ cta_text: 'Read Full Announcement',
213
+ cta_url: '/announcements/long',
214
+ cta_target: '_self',
237
215
  },
238
- ],
216
+ },
217
+ }
218
+
219
+ /**
220
+ * previewMode — the admin live-preview path: render-only, inert dismiss,
221
+ * no fetch and no storage side effects.
222
+ */
223
+ export const PreviewMode: Story = {
224
+ args: {
225
+ initialAnnouncement: {
226
+ ...baseAnnouncement,
227
+ id: 'story-preview',
228
+ title: 'Admin preview',
229
+ description: 'Exactly the live bar, but inert: dismiss does nothing.',
230
+ },
231
+ previewMode: true,
232
+ },
239
233
  }
240
234
 
241
235
  /**
242
236
  * No active announcement — the bar renders nothing.
243
237
  */
244
238
  export const NoAnnouncement: Story = {
239
+ args: { initialAnnouncement: null },
240
+ decorators: [
241
+ (Story) => (
242
+ <div>
243
+ <Story />
244
+ <p className="p-4 text-sm text-gray-500">
245
+ No announcement is active — the bar renders nothing above this text.
246
+ </p>
247
+ </div>
248
+ ),
249
+ ],
250
+ }
251
+
252
+ /**
253
+ * Client-only mode (no SSR) — the react-embedding-example structure: the
254
+ * embedder mounts an EndpointsRuntime provider (announcementsUrl is a fixed
255
+ * suffix under its one content base) and drops in a PROP-LESS bar. No
256
+ * platform knob anywhere on the client: the proxied server resolves its own
257
+ * platform via currentPlatform(). The decorator mocks the fetch.
258
+ */
259
+ const embedEndpoints: EndpointsRuntime = {
260
+ announcementsUrl: '/content/api/announcements/active',
261
+ accessCode: { validateUrl: '/content/api/validate-access-code', consumeUrl: '/content/api/consume-access-code' },
262
+ contactUrl: '/content/api/contact',
263
+ }
264
+
265
+ export const ClientOnlyEmbed: Story = {
266
+ args: {},
245
267
  decorators: [
246
268
  (Story) => {
247
- mockAnnouncement(null)
269
+ const originalFetch = window.fetch
270
+ window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
271
+ const requestUrl = typeof input === 'string' ? input : input.toString()
272
+ if (requestUrl.includes('/content/api/announcements/active')) {
273
+ return new Response(
274
+ JSON.stringify({
275
+ announcement: {
276
+ ...baseAnnouncement,
277
+ id: 'story-embed',
278
+ title: 'Client-only embed',
279
+ description: 'Fetched on mount through the EndpointsRuntime provider, no SSR.',
280
+ },
281
+ }),
282
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
283
+ )
284
+ }
285
+ return originalFetch(input, init)
286
+ }) as typeof window.fetch
248
287
  return (
249
- <div>
288
+ <EndpointsRuntimeContext.Provider value={embedEndpoints}>
250
289
  <Story />
251
- <p className="p-4 text-sm text-gray-500">
252
- No announcement is active — the bar renders nothing above this text.
253
- </p>
254
- </div>
290
+ </EndpointsRuntimeContext.Provider>
255
291
  )
256
292
  },
257
293
  ],
@@ -4,7 +4,7 @@ TypeScript type definitions for the announcement system, providing comprehensive
4
4
  ## Key Components
5
5
 
6
6
  ### Core Database Models
7
- - **`Announcement`** - Main announcement entity with title, description, platform targeting, and visual customization
7
+ - **`Announcement`** - Main announcement entity; platform targeting via hydrated `announcement_platforms?: EntityPlatformAssoc[]` (no singular platform_id/platform)
8
8
  - **`AnnouncementAsset`** - File attachments linked to announcements (images, documents, etc.)
9
9
 
10
10
  ### API Types
@@ -30,7 +30,7 @@ const newAnnouncement: CreateAnnouncementData = {
30
30
  background_color: "#FFC008",
31
31
  icon_type: "svg",
32
32
  icon_svg_name: "rocket",
33
- platform_id: "openframe-platform-id",
33
+ platforms: ["openframe-platform-id"],
34
34
  is_active: true,
35
35
  cta_enabled: true,
36
36
  cta_text: "Learn More",
@@ -1,7 +1,7 @@
1
1
  // Announcement system TypeScript types
2
- import type { LegacyPlatform, PlatformFilter, PlatformRecord } from './platform';
2
+ import type { LegacyPlatform, PlatformFilter } from './platform';
3
+ import type { EntityPlatformAssoc } from './entity-platform';
3
4
 
4
- // For backward compatibility, maintain the old Platform type temporarily
5
5
  // Database Models
6
6
  export interface Announcement {
7
7
  id: string;
@@ -12,8 +12,9 @@ export interface Announcement {
12
12
  icon_url?: string;
13
13
  /** Extra props for the main bar/icon */
14
14
  icon_props?: Record<string, any>;
15
- platform_id: string; // Foreign key to platforms table
16
- platform?: PlatformRecord; // Joined platform data
15
+ /** Hydrated entity_platforms associations (featured-first) what the hub
16
+ * DAL's attachPlatformsToRow(s) emits. No singular platform/platform_id. */
17
+ announcement_platforms?: EntityPlatformAssoc[];
17
18
  is_active: boolean;
18
19
  // CTA (Call-To-Action) fields
19
20
  cta_enabled?: boolean;
@@ -109,10 +110,25 @@ export interface AnnouncementListResponse {
109
110
  };
110
111
  }
111
112
 
113
+ /**
114
+ * Wire shape of GET /api/announcements/active — the single typed home for the
115
+ * `{ announcement }` envelope (hub route return + AnnouncementBar's
116
+ * useSelfFetch generic). `null` when no active announcement exists.
117
+ */
112
118
  export interface AnnouncementResponse {
113
- announcement: Announcement;
119
+ announcement: Announcement | null;
114
120
  }
115
121
 
122
+ /**
123
+ * Default CTA button colors — one home for the pair the DAL create/update
124
+ * defaults and the admin form both reference. Announcement colors are
125
+ * admin-chosen data (not ODS-token surfaces) by design.
126
+ */
127
+ export const ANNOUNCEMENT_CTA_DEFAULTS = {
128
+ background: '#1A1A1A',
129
+ text: '#FAFAFA',
130
+ } as const;
131
+
116
132
  // Form Data Types
117
133
  export interface AnnouncementFormData {
118
134
  title: string;
@@ -214,8 +230,22 @@ export interface AnnouncementStats {
214
230
 
215
231
  // Component Props Types
216
232
  export interface AnnouncementBarProps {
217
- announcement?: Announcement;
218
- onDismiss?: (announcementId: string) => void;
233
+ /**
234
+ * SSR mode seed. `null` = the server resolved "nothing to show" (no active
235
+ * announcement, or dismissed via cookie) — seeds the fetch hook and skips
236
+ * the mount fetch. Absent (`undefined`) = client-only mode: the bar
237
+ * self-fetches on mount from the EndpointsRuntime provider's
238
+ * `announcementsUrl` (see react-embedding-example — no per-component URL
239
+ * or platform knobs; the server resolves its own platform via
240
+ * currentPlatform()).
241
+ */
242
+ initialAnnouncement?: Announcement | null;
243
+ /**
244
+ * Render-only mode for the admin live preview: no fetch/revalidation, inert
245
+ * dismiss button, and no storage side effects (never writes dismissal
246
+ * cookies or touches localStorage).
247
+ */
248
+ previewMode?: boolean;
219
249
  className?: string;
220
250
  }
221
251
 
@@ -1,36 +1,31 @@
1
- <!-- source-hash: eb9e1e114c43007debee28971d977a02 -->
2
- A utility module for managing announcement data persistence in browser localStorage with safe server-side rendering support.
1
+ <!-- source-hash: pending-regeneration -->
2
+ Announcement dismissal store. The dismissal COOKIE is the single source of truth the hub's root layout reads it server-side to decide whether to SSR-seed the AnnouncementBar (no flash for dismissed users); the legacy per-id localStorage key is read-only legacy consulted only when no cookie exists (migration trigger).
3
3
 
4
4
  ## Key Components
5
5
 
6
- - **`AnnouncementStorageOptions`** - Interface defining storage configuration with key and optional default value
7
- - **`getStoredAnnouncement(key)`** - Retrieves and parses announcement data from localStorage
8
- - **`setStoredAnnouncement(key, value)`** - Stores announcement data as JSON in localStorage
9
- - **`clearStoredAnnouncement(key?)`** - Removes announcement data from localStorage (defaults to 'announcement' key)
6
+ - **`announcementDismissCookieName(platform)`** - The ONE home for the cookie-name encoding (`<platform>-announcement-dismissed`), shared by the client writer and the hub's SSR reader
7
+ - **`isDismissedCookieValue(cookieValue, id)`** - THE dismissal match rule (id-match, never mere cookie presence), pure and isomorphic shared across the server/client boundary
8
+ - **`dismissAnnouncement(platform, id)`** - Persists a dismissal (cookie only, 1 year, SameSite=Lax); localStorage is intentionally not written
9
+ - **`isAnnouncementDismissed(platform, id)`** - Client-side check, cookie-first with legacy-localStorage fallback; reads browser storage so it must be called from effects, never during render
10
+ - **`clearLegacyAnnouncementCache(platform)`** - One-time cleanup of the pre-refactor localStorage "instant paint" cache blob
10
11
 
11
12
  ## Usage Example
12
13
 
13
14
  ```typescript
14
- import {
15
- getStoredAnnouncement,
16
- setStoredAnnouncement,
17
- clearStoredAnnouncement
15
+ import {
16
+ dismissAnnouncement,
17
+ isAnnouncementDismissed,
18
+ announcementDismissCookieName,
19
+ isDismissedCookieValue,
18
20
  } from './announcement-storage';
19
21
 
20
- // Store announcement dismissal state
21
- setStoredAnnouncement('banner-dismissed', {
22
- dismissed: true,
23
- timestamp: Date.now()
24
- });
22
+ // Client (in an effect):
23
+ if (isAnnouncementDismissed('openframe', announcement.id)) { /* collapse */ }
24
+ dismissAnnouncement('openframe', announcement.id);
25
25
 
26
- // Check if announcement was dismissed
27
- const dismissalState = getStoredAnnouncement('banner-dismissed');
28
- if (dismissalState?.dismissed) {
29
- console.log('Banner was dismissed');
30
- }
31
-
32
- // Clear announcement data
33
- clearStoredAnnouncement('banner-dismissed');
26
+ // Server (hub root layout):
27
+ const cookieValue = (await cookies()).get(announcementDismissCookieName(platform))?.value;
28
+ const dismissed = isDismissedCookieValue(cookieValue, announcement?.id);
34
29
  ```
35
30
 
36
- All functions include browser environment checks and error handling to prevent issues during server-side rendering or when localStorage is unavailable.
31
+ No `"use client"` directive on purpose the name/match helpers are imported by the hub's server layout; DOM-touching helpers guard on `typeof document`.