@flamingo-stack/openframe-frontend-core 0.0.582-1967.5390.1 → 0.0.582
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-2EIGO6ZS.js +1055 -0
- package/dist/chunk-2EIGO6ZS.js.map +1 -0
- package/dist/{chunk-PDK6KVU6.js → chunk-53ZVUW2M.js} +4 -4
- package/dist/{chunk-BTXJQPGE.js → chunk-5U2FU5LM.js} +2 -2
- package/dist/{chunk-VG2SRECA.cjs → chunk-7WE6FGGK.cjs} +49 -49
- package/dist/{chunk-VG2SRECA.cjs.map → chunk-7WE6FGGK.cjs.map} +1 -1
- package/dist/{chunk-EXBNO6FB.js → chunk-CJW6KZD3.js} +2 -2
- package/dist/{chunk-UQXQCM3F.cjs → chunk-E4TK2BBD.cjs} +3 -3
- package/dist/{chunk-UQXQCM3F.cjs.map → chunk-E4TK2BBD.cjs.map} +1 -1
- package/dist/{chunk-NOO3QOJI.js → chunk-EULY3BXB.js} +2 -2
- package/dist/{chunk-ML22BIKH.js → chunk-GAL6EXUY.js} +3 -3
- package/dist/{chunk-YTZK2ORN.js → chunk-H7K76TPX.js} +4 -4
- package/dist/{chunk-RH7EY7YN.cjs → chunk-HMEEW73F.cjs} +1978 -237
- package/dist/chunk-HMEEW73F.cjs.map +1 -0
- package/dist/{chunk-KNQZZJYJ.js → chunk-IB23CRL2.js} +2 -2
- package/dist/{chunk-GHYWIVSU.cjs → chunk-IVJ6TBVW.cjs} +34 -34
- package/dist/{chunk-GHYWIVSU.cjs.map → chunk-IVJ6TBVW.cjs.map} +1 -1
- package/dist/{chunk-XJ3Y4OOF.js → chunk-IZGXIZWF.js} +2 -2
- package/dist/chunk-JMWLF2GL.cjs +1055 -0
- package/dist/chunk-JMWLF2GL.cjs.map +1 -0
- package/dist/{chunk-MCFETAH7.js → chunk-JUXCEHK2.js} +2 -2
- package/dist/{chunk-KX4F4O26.cjs → chunk-KS7NKONC.cjs} +3 -3
- package/dist/{chunk-KX4F4O26.cjs.map → chunk-KS7NKONC.cjs.map} +1 -1
- package/dist/{chunk-VYNYJVSE.cjs → chunk-LHQ6RVFE.cjs} +17 -17
- package/dist/{chunk-VYNYJVSE.cjs.map → chunk-LHQ6RVFE.cjs.map} +1 -1
- package/dist/{chunk-LGHMGCUG.cjs → chunk-M3ERSHPM.cjs} +4 -4
- package/dist/{chunk-LGHMGCUG.cjs.map → chunk-M3ERSHPM.cjs.map} +1 -1
- package/dist/{chunk-Q7X74UZF.cjs → chunk-RBMVTCRM.cjs} +3 -3
- package/dist/{chunk-Q7X74UZF.cjs.map → chunk-RBMVTCRM.cjs.map} +1 -1
- package/dist/{chunk-LYU3IZOA.cjs → chunk-RILGQ2OY.cjs} +10 -10
- package/dist/{chunk-LYU3IZOA.cjs.map → chunk-RILGQ2OY.cjs.map} +1 -1
- package/dist/{chunk-T6DRWYLM.cjs → chunk-RQMS2CF5.cjs} +17 -17
- package/dist/{chunk-T6DRWYLM.cjs.map → chunk-RQMS2CF5.cjs.map} +1 -1
- package/dist/{chunk-TC47NAJ3.cjs → chunk-S2ZXUQLR.cjs} +16 -16
- package/dist/{chunk-TC47NAJ3.cjs.map → chunk-S2ZXUQLR.cjs.map} +1 -1
- package/dist/{chunk-G3ZMLSKJ.js → chunk-SM6JAZ4C.js} +2 -2
- package/dist/{chunk-LENIASJW.js → chunk-T4DIAKIK.js} +2 -2
- package/dist/{chunk-ODOFQLSP.cjs → chunk-TMNFC24G.cjs} +13 -13
- package/dist/{chunk-ODOFQLSP.cjs.map → chunk-TMNFC24G.cjs.map} +1 -1
- package/dist/{chunk-FVF2MVIT.js → chunk-U6GFESVF.js} +2 -2
- package/dist/{chunk-2T7GXHRQ.js → chunk-XA5UPYWE.js} +2745 -1004
- package/dist/chunk-XA5UPYWE.js.map +1 -0
- package/dist/{chunk-TVLQVTZ6.cjs → chunk-XNSEZVV4.cjs} +4 -4
- package/dist/{chunk-TVLQVTZ6.cjs.map → chunk-XNSEZVV4.cjs.map} +1 -1
- package/dist/{chunk-XJJ2D64Q.cjs → chunk-YU4QZ6I6.cjs} +3 -3
- package/dist/{chunk-XJJ2D64Q.cjs.map → chunk-YU4QZ6I6.cjs.map} +1 -1
- package/dist/{chunk-LFG5JG2D.js → chunk-ZSENCE55.js} +5 -5
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/features/push-button-selector.d.ts +29 -1
- package/dist/components/features/push-button-selector.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +61 -892
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +244 -1075
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +18 -18
- package/dist/components/meeting-scheduler/index.js +1 -1
- package/dist/components/navigation/index.cjs +5 -5
- package/dist/components/navigation/index.js +4 -4
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +6 -6
- package/dist/index.js +5 -5
- package/dist/utils/announcement-storage.d.ts +16 -25
- package/dist/utils/announcement-storage.d.ts.map +1 -1
- package/dist/utils/dismiss-cookie.d.ts +8 -27
- package/dist/utils/dismiss-cookie.d.ts.map +1 -1
- package/dist/utils/dismissal-storage.d.ts +2 -2
- package/dist/utils/dismissal-storage.d.ts.map +1 -1
- package/dist/utils/index.cjs +53 -70
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js +53 -70
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/features/.push-button-selector.md +1 -1
- package/src/components/features/push-button-selector.tsx +142 -17
- package/src/utils/announcement-storage.ts +16 -25
- package/src/utils/dismiss-cookie.ts +9 -86
- package/src/utils/dismissal-storage.ts +2 -2
- package/dist/chunk-2T7GXHRQ.js.map +0 -1
- package/dist/chunk-FWNIZC6A.js +0 -1867
- package/dist/chunk-FWNIZC6A.js.map +0 -1
- package/dist/chunk-IHFTLVDF.cjs +0 -1867
- package/dist/chunk-IHFTLVDF.cjs.map +0 -1
- package/dist/chunk-RH7EY7YN.cjs.map +0 -1
- package/src/utils/__tests__/dismiss-cookie.test.ts +0 -133
- /package/dist/{chunk-PDK6KVU6.js.map → chunk-53ZVUW2M.js.map} +0 -0
- /package/dist/{chunk-BTXJQPGE.js.map → chunk-5U2FU5LM.js.map} +0 -0
- /package/dist/{chunk-EXBNO6FB.js.map → chunk-CJW6KZD3.js.map} +0 -0
- /package/dist/{chunk-NOO3QOJI.js.map → chunk-EULY3BXB.js.map} +0 -0
- /package/dist/{chunk-ML22BIKH.js.map → chunk-GAL6EXUY.js.map} +0 -0
- /package/dist/{chunk-YTZK2ORN.js.map → chunk-H7K76TPX.js.map} +0 -0
- /package/dist/{chunk-KNQZZJYJ.js.map → chunk-IB23CRL2.js.map} +0 -0
- /package/dist/{chunk-XJ3Y4OOF.js.map → chunk-IZGXIZWF.js.map} +0 -0
- /package/dist/{chunk-MCFETAH7.js.map → chunk-JUXCEHK2.js.map} +0 -0
- /package/dist/{chunk-G3ZMLSKJ.js.map → chunk-SM6JAZ4C.js.map} +0 -0
- /package/dist/{chunk-LENIASJW.js.map → chunk-T4DIAKIK.js.map} +0 -0
- /package/dist/{chunk-FVF2MVIT.js.map → chunk-U6GFESVF.js.map} +0 -0
- /package/dist/{chunk-LFG5JG2D.js.map → chunk-ZSENCE55.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- source-hash:
|
|
1
|
+
<!-- source-hash: 0ccfb311d1bb254bbfb9ffdb994a9af1 -->
|
|
2
2
|
A client-side React component that renders a list of toggleable push-button options with support for single/multi-select, section grouping, disabled states with tooltips, loading skeletons, and error handling.
|
|
3
3
|
|
|
4
4
|
## Key Components
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { Check } from 'lucide-react';
|
|
4
4
|
import type React from 'react';
|
|
5
|
-
import {
|
|
5
|
+
import { useEffect, useRef } from 'react';
|
|
6
|
+
import { UnifiedSkeleton } from '../loading';
|
|
7
|
+
import { Badge, Input } from '../ui';
|
|
6
8
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
|
7
9
|
|
|
8
10
|
export interface SelectableOption {
|
|
@@ -27,6 +29,28 @@ export interface SectionDefinition {
|
|
|
27
29
|
description?: string; // Optional description shown under section label
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/** SERVER-SIDE search: the selector renders the input; the PARENT owns the
|
|
33
|
+
* query state and re-fetches `options` (never a client-side .filter of a
|
|
34
|
+
* pre-fetched list). While `isLoading`, the input stays mounted (typing
|
|
35
|
+
* must not lose focus to a skeleton swap). */
|
|
36
|
+
export interface PushButtonSelectorSearch {
|
|
37
|
+
value: string;
|
|
38
|
+
onChange: (value: string) => void;
|
|
39
|
+
placeholder?: string;
|
|
40
|
+
ariaLabel?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Lazy paging: the option list becomes a FIXED-HEIGHT scroll viewport with
|
|
44
|
+
* an IntersectionObserver sentinel near the bottom — the selector calls
|
|
45
|
+
* `onLoadMore` as the user scrolls; the parent appends to `options`. */
|
|
46
|
+
export interface PushButtonSelectorLazyLoad {
|
|
47
|
+
hasMore: boolean;
|
|
48
|
+
isFetchingMore: boolean;
|
|
49
|
+
onLoadMore: () => void;
|
|
50
|
+
/** CSS height of the scroll viewport (default '336px' ≈ 6 rows). */
|
|
51
|
+
maxHeight?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
30
54
|
interface PushButtonSelectorProps {
|
|
31
55
|
options: SelectableOption[];
|
|
32
56
|
selectedIds: string[];
|
|
@@ -41,25 +65,37 @@ interface PushButtonSelectorProps {
|
|
|
41
65
|
error?: string | null;
|
|
42
66
|
skeletonCount?: number;
|
|
43
67
|
sections?: SectionDefinition[]; // Optional sections for grouping options
|
|
68
|
+
/** Built-in server-side search input (see PushButtonSelectorSearch). */
|
|
69
|
+
search?: PushButtonSelectorSearch;
|
|
70
|
+
/** Built-in fixed-height scroll + infinite paging (see PushButtonSelectorLazyLoad). */
|
|
71
|
+
lazyLoad?: PushButtonSelectorLazyLoad;
|
|
72
|
+
/** Rendered inside the list when `options` is empty and not loading. */
|
|
73
|
+
emptyMessage?: React.ReactNode;
|
|
74
|
+
/** Rendered under the list (result counts, hints). */
|
|
75
|
+
footer?: React.ReactNode;
|
|
44
76
|
}
|
|
45
77
|
|
|
46
|
-
//
|
|
78
|
+
// Composed from the app-wide UnifiedSkeleton system (components/loading) —
|
|
79
|
+
// the same base every other loading surface uses (bg-ods-skeleton pulse,
|
|
80
|
+
// prefers-reduced-motion support, role="status" a11y). Each skeleton row
|
|
81
|
+
// mirrors a LOADED option row exactly (bg-ods-bg card, p-4, icon + two
|
|
82
|
+
// 20px-tall text lines + selection box) so the list never jumps on load.
|
|
47
83
|
function PushButtonSelectorSkeleton({ count = 3, hasTitle }: { count?: number; hasTitle?: boolean }) {
|
|
48
84
|
return (
|
|
49
|
-
<div className="space-y-3">
|
|
50
|
-
{hasTitle && <
|
|
85
|
+
<div className="space-y-3" role="status" aria-label="Loading options">
|
|
86
|
+
{hasTitle && <UnifiedSkeleton className="h-5 w-20" aria-label="Loading title" />}
|
|
51
87
|
<div className="space-y-3">
|
|
52
88
|
{Array.from({ length: count }, (_, i) => (
|
|
53
|
-
<div key={i} className="
|
|
54
|
-
<div className="flex items-center justify-between">
|
|
89
|
+
<div key={i} className="rounded-lg border border-ods-border bg-ods-bg p-4">
|
|
90
|
+
<div className="flex items-center justify-between gap-4">
|
|
55
91
|
<div className="flex items-center gap-3">
|
|
56
|
-
<
|
|
92
|
+
<UnifiedSkeleton className="h-8 w-8" aria-label="Loading icon" />
|
|
57
93
|
<div>
|
|
58
|
-
<
|
|
59
|
-
<
|
|
94
|
+
<UnifiedSkeleton variant="text" className="mb-2 h-4 w-24" aria-label="Loading name" />
|
|
95
|
+
<UnifiedSkeleton variant="text" className="h-4 w-36" aria-label="Loading description" />
|
|
60
96
|
</div>
|
|
61
97
|
</div>
|
|
62
|
-
<
|
|
98
|
+
<UnifiedSkeleton className="h-6 w-6" aria-label="Loading selection state" />
|
|
63
99
|
</div>
|
|
64
100
|
</div>
|
|
65
101
|
))}
|
|
@@ -94,18 +130,68 @@ export function PushButtonSelector({
|
|
|
94
130
|
error = null,
|
|
95
131
|
skeletonCount = 3,
|
|
96
132
|
sections,
|
|
133
|
+
search,
|
|
134
|
+
lazyLoad,
|
|
135
|
+
emptyMessage,
|
|
136
|
+
footer,
|
|
97
137
|
}: PushButtonSelectorProps) {
|
|
98
|
-
//
|
|
99
|
-
|
|
138
|
+
// Lazy-load sentinel: observe within the scroll viewport so onLoadMore
|
|
139
|
+
// fires as the user approaches the bottom. Hooks run unconditionally
|
|
140
|
+
// (before the early returns) per the rules of hooks. Pass a STABLE
|
|
141
|
+
// onLoadMore (react-query's fetchNextPage is) — an inline arrow re-arms
|
|
142
|
+
// the observer each render (harmless, just wasteful).
|
|
143
|
+
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
144
|
+
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
145
|
+
const lazyHasMore = lazyLoad?.hasMore ?? false;
|
|
146
|
+
const lazyFetching = lazyLoad?.isFetchingMore ?? false;
|
|
147
|
+
const onLoadMore = lazyLoad?.onLoadMore;
|
|
148
|
+
const optionCount = options.length;
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const sentinel = sentinelRef.current;
|
|
151
|
+
const root = scrollRef.current;
|
|
152
|
+
if (!sentinel || !root || !onLoadMore || !lazyHasMore) return undefined;
|
|
153
|
+
const observer = new IntersectionObserver(
|
|
154
|
+
entries => {
|
|
155
|
+
if (entries.some(e => e.isIntersecting) && !lazyFetching) {
|
|
156
|
+
onLoadMore();
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
{ root, rootMargin: '80px' },
|
|
160
|
+
);
|
|
161
|
+
observer.observe(sentinel);
|
|
162
|
+
return () => {
|
|
163
|
+
observer.disconnect();
|
|
164
|
+
};
|
|
165
|
+
// optionCount: re-arm when the list grows (the sentinel moves).
|
|
166
|
+
}, [lazyHasMore, lazyFetching, onLoadMore, optionCount]);
|
|
167
|
+
|
|
168
|
+
const searchInput = search ? (
|
|
169
|
+
<Input
|
|
170
|
+
value={search.value}
|
|
171
|
+
onChange={e => search.onChange(e.target.value)}
|
|
172
|
+
placeholder={search.placeholder}
|
|
173
|
+
aria-label={search.ariaLabel ?? search.placeholder ?? 'Search'}
|
|
174
|
+
/>
|
|
175
|
+
) : null;
|
|
176
|
+
|
|
177
|
+
// Framed mode (search and/or lazyLoad): loading and error are NOT early
|
|
178
|
+
// returns — the frame (title, search input, fixed-height viewport, footer)
|
|
179
|
+
// must stay pixel-identical across loading/loaded/empty/error, with only
|
|
180
|
+
// the viewport CONTENT swapping. An early-return skeleton dropped the
|
|
181
|
+
// bordered viewport + footer and rendered shorter rows, so the whole
|
|
182
|
+
// section collapsed and jumped on every debounced search.
|
|
183
|
+
const framed = !!search || !!lazyLoad;
|
|
184
|
+
|
|
185
|
+
// LOADING / ERROR STATES — legacy (unframed) consumers keep the historical
|
|
186
|
+
// whole-component swap, byte-identical.
|
|
187
|
+
if (isLoading && !framed) {
|
|
100
188
|
return (
|
|
101
189
|
<div className={className}>
|
|
102
190
|
<PushButtonSelectorSkeleton count={skeletonCount} hasTitle={!!title} />
|
|
103
191
|
</div>
|
|
104
192
|
);
|
|
105
193
|
}
|
|
106
|
-
|
|
107
|
-
// ERROR STATE
|
|
108
|
-
if (error) {
|
|
194
|
+
if (error && !framed) {
|
|
109
195
|
return (
|
|
110
196
|
<div className={className}>
|
|
111
197
|
<PushButtonSelectorError message={error} title={title} />
|
|
@@ -252,15 +338,54 @@ export function PushButtonSelector({
|
|
|
252
338
|
);
|
|
253
339
|
};
|
|
254
340
|
|
|
341
|
+
// ~82px per row: enough skeleton rows to fill the fixed viewport, so the
|
|
342
|
+
// loading state occupies the SAME height as the loaded list.
|
|
343
|
+
const framedSkeletonCount = lazyLoad ? Math.max(skeletonCount, 4) : skeletonCount;
|
|
344
|
+
const listContent = isLoading ? (
|
|
345
|
+
<PushButtonSelectorSkeleton count={framedSkeletonCount} />
|
|
346
|
+
) : error ? (
|
|
347
|
+
<PushButtonSelectorError message={error} />
|
|
348
|
+
) : (
|
|
349
|
+
<>
|
|
350
|
+
{options.length === 0 && emptyMessage !== undefined ? (
|
|
351
|
+
<div className="text-ods-text-secondary text-h6">{emptyMessage}</div>
|
|
352
|
+
) : (
|
|
353
|
+
renderOptionsContent()
|
|
354
|
+
)}
|
|
355
|
+
{lazyLoad ? (
|
|
356
|
+
<>
|
|
357
|
+
<div ref={sentinelRef} />
|
|
358
|
+
{lazyLoad.isFetchingMore ? (
|
|
359
|
+
<div className="py-2 text-center text-ods-text-secondary text-h6">Loading more…</div>
|
|
360
|
+
) : null}
|
|
361
|
+
</>
|
|
362
|
+
) : null}
|
|
363
|
+
</>
|
|
364
|
+
);
|
|
365
|
+
|
|
255
366
|
return (
|
|
256
367
|
<TooltipProvider delayDuration={150}>
|
|
257
368
|
<div className={`space-y-4 ${className}`}>
|
|
258
369
|
{title && <h3 className="text-ods-text-primary text-h5">{title}</h3>}
|
|
259
370
|
|
|
260
|
-
{
|
|
371
|
+
{searchInput}
|
|
372
|
+
|
|
373
|
+
{lazyLoad ? (
|
|
374
|
+
<div
|
|
375
|
+
ref={scrollRef}
|
|
376
|
+
className="space-y-4 overflow-y-auto rounded-lg border border-ods-border p-2"
|
|
377
|
+
style={{ height: lazyLoad.maxHeight ?? '336px' }}
|
|
378
|
+
>
|
|
379
|
+
{listContent}
|
|
380
|
+
</div>
|
|
381
|
+
) : (
|
|
382
|
+
listContent
|
|
383
|
+
)}
|
|
384
|
+
|
|
385
|
+
{footer}
|
|
261
386
|
|
|
262
387
|
{/* Selection Summary */}
|
|
263
|
-
{selectionSummary && validSelectedIds.length > 0 && (
|
|
388
|
+
{!isLoading && !error && selectionSummary && validSelectedIds.length > 0 && (
|
|
264
389
|
<div className="rounded-lg border border-ods-success bg-ods-success-secondary p-4">
|
|
265
390
|
<div className="mb-2 flex items-center gap-2">
|
|
266
391
|
<div className="h-2 w-2 rounded-full bg-ods-success"></div>
|
|
@@ -1,24 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Announcement dismissal store.
|
|
3
3
|
*
|
|
4
|
-
* The dismissal COOKIE is the source of truth
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* On a cookie-averse document (see `utils/dismiss-cookie.ts`) the primitive
|
|
13
|
-
* writes `<platform>-announcement-dismissed` to localStorage instead. Do not
|
|
14
|
-
* mistake it for the legacy key below and delete the write: it is the ONLY
|
|
15
|
-
* thing making a dismissal persist in a shell. `clearAnnouncementDismissals`' prefix sweep does match it, which
|
|
16
|
-
* is right — a full reset should take both.
|
|
4
|
+
* The dismissal COOKIE is the single source of truth: the hub's root layout
|
|
5
|
+
* reads it server-side to decide whether to SSR-seed the bar (no flash for
|
|
6
|
+
* dismissed users), and the client reads it via `isAnnouncementDismissed`.
|
|
7
|
+
* One cookie per platform holding the LAST dismissed announcement id —
|
|
8
|
+
* dismissal is an id-match, never mere cookie presence, so a new announcement
|
|
9
|
+
* always shows after an old one was dismissed, and re-activating an old
|
|
10
|
+
* announcement intentionally re-shows it.
|
|
17
11
|
*
|
|
18
12
|
* The legacy per-id localStorage key (`<platform>-announcement-<id>-dismissed`)
|
|
19
13
|
* is READ-ONLY legacy: consulted only when no cookie exists for the platform,
|
|
20
14
|
* purely as the migration trigger (the bar's mount effect backfills the
|
|
21
|
-
* cookie).
|
|
15
|
+
* cookie). New dismissals write the cookie only, so the second store dies out.
|
|
22
16
|
*
|
|
23
17
|
* No `"use client"` directive on purpose — `announcementDismissCookieName` and
|
|
24
18
|
* `isDismissedCookieValue` are pure/isomorphic and imported by the hub's
|
|
@@ -42,21 +36,18 @@ export { isDismissedCookieValue } from './dismiss-cookie';
|
|
|
42
36
|
|
|
43
37
|
const legacyDismissKey = (platform: string, id: string) => `${platform}-announcement-${id}-dismissed`;
|
|
44
38
|
|
|
45
|
-
/** Persist a dismissal (1 year). The SSR layout sees it on the
|
|
46
|
-
*
|
|
47
|
-
* there is no SSR layout to see it anyway. The LEGACY per-id key is still never
|
|
48
|
-
* written. */
|
|
39
|
+
/** Persist a dismissal: cookie only (1 year). The SSR layout sees it on the
|
|
40
|
+
* next request; localStorage is intentionally NOT written (read-only legacy). */
|
|
49
41
|
export function dismissAnnouncement(platform: string, id: string): void {
|
|
50
42
|
if (typeof document === 'undefined') return;
|
|
51
43
|
writeDismissCookie(announcementDismissCookieName(platform), id);
|
|
52
44
|
}
|
|
53
45
|
|
|
54
46
|
/**
|
|
55
|
-
* Client-side dismissal check —
|
|
56
|
-
*
|
|
57
|
-
* storage, so callers must invoke it
|
|
58
|
-
*
|
|
59
|
-
* from the SSR HTML).
|
|
47
|
+
* Client-side dismissal check — cookie-first: the legacy localStorage key is
|
|
48
|
+
* consulted ONLY when no cookie exists for the platform. Reads browser
|
|
49
|
+
* storage, so callers must invoke it from effects, never during render
|
|
50
|
+
* (a render-time read would desync hydration from the SSR HTML).
|
|
60
51
|
*/
|
|
61
52
|
export function isAnnouncementDismissed(platform: string, id: string): boolean {
|
|
62
53
|
if (typeof document === 'undefined') return false;
|
|
@@ -69,8 +60,8 @@ export function isAnnouncementDismissed(platform: string, id: string): boolean {
|
|
|
69
60
|
}
|
|
70
61
|
}
|
|
71
62
|
|
|
72
|
-
/** Remove ALL dismissal state for a platform
|
|
73
|
-
*
|
|
63
|
+
/** Remove ALL dismissal state for a platform (cookie + legacy localStorage
|
|
64
|
+
* keys) — test/story helper so callers never restate the key encoding. */
|
|
74
65
|
export function clearAnnouncementDismissals(platform: string): void {
|
|
75
66
|
if (typeof document === 'undefined') return;
|
|
76
67
|
clearDismissCookie(announcementDismissCookieName(platform));
|
|
@@ -3,92 +3,31 @@
|
|
|
3
3
|
* dismissed", shared by every dismissible surface (announcement bar, floating
|
|
4
4
|
* walkthrough video, and anything added later).
|
|
5
5
|
*
|
|
6
|
-
* The dismissal rule is an ID MATCH, never mere cookie presence: the
|
|
7
|
-
*
|
|
6
|
+
* The dismissal rule is an ID MATCH, never mere cookie presence: the cookie
|
|
7
|
+
* stores the id of the LAST dismissed item, so publishing a NEW item re-shows
|
|
8
8
|
* the surface while the old dismissal still suppresses the old item.
|
|
9
9
|
*
|
|
10
|
-
* TWO TIERS. The cookie is primary wherever cookies work; on a cookie-averse
|
|
11
|
-
* document `localStorage` takes over under the same key. See `canUseCookies`
|
|
12
|
-
* for when that is, and `fallbackStore` for why it is a fallback and not a
|
|
13
|
-
* mirror.
|
|
14
|
-
*
|
|
15
10
|
* No `"use client"` on purpose — `isDismissedCookieValue` is pure and is used by
|
|
16
11
|
* server code (the hub's root layout reads the cookie during SSR to avoid a
|
|
17
12
|
* flash); the DOM-touching helpers guard on `typeof document`.
|
|
18
13
|
*/
|
|
19
14
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
/** One year — the COOKIE tier's horizon. The fallback tier has no expiry, so a
|
|
23
|
-
* dismissal in a native shell is permanent; adding an expiry envelope there
|
|
24
|
-
* would be inventing a second encoding to answer a question nobody has asked. */
|
|
15
|
+
/** One year, the standard dismissal horizon for these surfaces. */
|
|
25
16
|
const DISMISS_COOKIE_MAX_AGE_SECONDS = 31_536_000;
|
|
26
17
|
|
|
27
|
-
/**
|
|
28
|
-
* Whether `document.cookie` actually stores anything here.
|
|
29
|
-
*
|
|
30
|
-
* A Document whose URL scheme is not http(s) is COOKIE-AVERSE — the HTML spec's
|
|
31
|
-
* term for one where the `document.cookie` setter is a silent no-op and the
|
|
32
|
-
* getter returns ''. That is where the custom-scheme shells run: Tauri serves
|
|
33
|
-
* the bundle from `tauri://localhost` on macOS/Linux, Capacitor from
|
|
34
|
-
* `capacitor://localhost` on iOS. Every dismissal there was dropped on write
|
|
35
|
-
* and unreadable on the next mount, so the surface came back in every new
|
|
36
|
-
* window and on every launch. Tauri on Windows and Capacitor on Android serve
|
|
37
|
-
* http(s), were never affected, and stay on the cookie tier.
|
|
38
|
-
*
|
|
39
|
-
* The scheme is read directly rather than probed with a throwaway write: a lib
|
|
40
|
-
* whose consumers are consent-regulated marketing sites should not set a
|
|
41
|
-
* cookie, however briefly, to answer a question the URL already answers.
|
|
42
|
-
* Deliberately NOT covered: cookies blocked by user or enterprise policy on an
|
|
43
|
-
* http(s) origin — the surface reappears there exactly as it always has, which
|
|
44
|
-
* is what someone who blocked cookies asked for.
|
|
45
|
-
*
|
|
46
|
-
* Read off `document.URL`, not `window.location`: averseness is a property of
|
|
47
|
-
* the DOCUMENT's URL, and this file must stay usable wherever `document` is the
|
|
48
|
-
* only thing the other helpers here already require. `about:blank` and
|
|
49
|
-
* `about:srcdoc` are the one miss: they carry their creator's origin and are
|
|
50
|
-
* served its cookies, yet land on the fallback tier here. Accepted — nothing
|
|
51
|
-
* hosts this lib in one, and the cost is a re-show, not a broken surface.
|
|
52
|
-
*/
|
|
53
|
-
function canUseCookies(): boolean {
|
|
54
|
-
if (typeof document === 'undefined') return false;
|
|
55
|
-
return document.URL.startsWith('http:') || document.URL.startsWith('https:');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* The fallback tier for cookie-averse documents, keyed identically to the
|
|
60
|
-
* cookie so a host needs no second key.
|
|
61
|
-
*
|
|
62
|
-
* A FALLBACK, never a mirror: cookies stay primary wherever they work, because
|
|
63
|
-
* they are the only tier a server can read — which is what lets the hub
|
|
64
|
-
* suppress a dismissed surface during SSR instead of flashing it — and writing
|
|
65
|
-
* both would mean clearing cookies no longer clears a dismissal.
|
|
66
|
-
*/
|
|
67
|
-
function fallbackStore(name: string) {
|
|
68
|
-
return createLocalStorageAdapter<string>({
|
|
69
|
-
key: name,
|
|
70
|
-
logTag: '[dismiss-cookie]',
|
|
71
|
-
validate: (parsed): parsed is string => typeof parsed === 'string',
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
18
|
/** THE dismissal match rule. `undefined` id (nothing active) → not dismissed. */
|
|
76
19
|
export function isDismissedCookieValue(cookieValue: string | undefined, id: string | undefined): boolean {
|
|
77
20
|
return !!id && cookieValue === id;
|
|
78
21
|
}
|
|
79
22
|
|
|
80
23
|
/**
|
|
81
|
-
* Read a dismissal. Returns `undefined` when absent OR
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* Callers run this inside reveal paths where an uncaught throw would hide the
|
|
86
|
-
* surface permanently, so failing open — treated as not-dismissed — is always
|
|
87
|
-
* the safe side.
|
|
24
|
+
* Read a dismissal cookie. Returns `undefined` when absent OR malformed:
|
|
25
|
+
* `decodeURIComponent` throws `URIError` on bad percent-encoding, and callers
|
|
26
|
+
* run this inside reveal paths where an uncaught throw would hide the surface
|
|
27
|
+
* permanently. Failing open (treated as not-dismissed) is always the safe side.
|
|
88
28
|
*/
|
|
89
29
|
export function readDismissCookie(name: string): string | undefined {
|
|
90
30
|
if (typeof document === 'undefined') return undefined;
|
|
91
|
-
if (!canUseCookies()) return fallbackStore(name).load() ?? undefined;
|
|
92
31
|
const match = document.cookie.split('; ').find(row => row.startsWith(`${name}=`));
|
|
93
32
|
if (!match) return undefined;
|
|
94
33
|
try {
|
|
@@ -98,30 +37,14 @@ export function readDismissCookie(name: string): string | undefined {
|
|
|
98
37
|
}
|
|
99
38
|
}
|
|
100
39
|
|
|
101
|
-
/** Persist a dismissal. */
|
|
40
|
+
/** Persist a dismissal (cookie only, 1 year). */
|
|
102
41
|
export function writeDismissCookie(name: string, id: string): void {
|
|
103
42
|
if (typeof document === 'undefined') return;
|
|
104
|
-
if (!canUseCookies()) {
|
|
105
|
-
fallbackStore(name).save(id);
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
43
|
document.cookie = `${name}=${encodeURIComponent(id)}; path=/; max-age=${DISMISS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
|
|
109
44
|
}
|
|
110
45
|
|
|
111
|
-
/**
|
|
112
|
-
* Clear a dismissal (test/story helper).
|
|
113
|
-
*
|
|
114
|
-
* BOTH tiers, unconditionally — the tier check governs writes, not removal.
|
|
115
|
-
* Clearing writes nothing and cannot resurrect a dismissal, so it carries none
|
|
116
|
-
* of the reasons the write path commits to one tier, and a clear that leaves
|
|
117
|
-
* the other tier holding a value is a lie.
|
|
118
|
-
*
|
|
119
|
-
* NOT for a shell that changes the scheme it serves under: that is an origin
|
|
120
|
-
* change, so the old tier moves to a storage bucket no code in the new origin
|
|
121
|
-
* can reach — `clear` included.
|
|
122
|
-
*/
|
|
46
|
+
/** Clear a dismissal (test/story helper). */
|
|
123
47
|
export function clearDismissCookie(name: string): void {
|
|
124
48
|
if (typeof document === 'undefined') return;
|
|
125
|
-
fallbackStore(name).clear();
|
|
126
49
|
document.cookie = `${name}=; path=/; max-age=0`;
|
|
127
50
|
}
|
|
@@ -22,8 +22,8 @@ export function walkthroughDismissCookieName(platform: string): string {
|
|
|
22
22
|
return `${platform}-${WALKTHROUGH_VIDEO_DISMISS_KEY}`;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/** Client-side dismissal check (id-match). Reads
|
|
26
|
-
*
|
|
25
|
+
/** Client-side dismissal check (id-match). Reads a cookie, so call it from an
|
|
26
|
+
* effect, never during render. */
|
|
27
27
|
export function isWalkthroughDismissed(key: string, id: string | undefined): boolean {
|
|
28
28
|
return isDismissedCookieValue(readDismissCookie(key), id);
|
|
29
29
|
}
|