@checkstack/tips-frontend 0.2.0
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/CHANGELOG.md +76 -0
- package/package.json +32 -0
- package/src/components/Tip.tsx +164 -0
- package/src/components/TipBanner.tsx +106 -0
- package/src/components/TipsSynchronizer.tsx +43 -0
- package/src/hooks/useLocalDismissals.ts +74 -0
- package/src/hooks/useTipState.ts +126 -0
- package/src/index.tsx +28 -0
- package/tsconfig.json +26 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# @checkstack/tips-frontend
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3547670: Redesign `<Tip>` to be user-triggered instead of auto-opening.
|
|
8
|
+
|
|
9
|
+
A small lightbulb icon is now rendered immediately after the wrapped
|
|
10
|
+
element. The popover only opens when the user clicks the lightbulb.
|
|
11
|
+
Once the user explicitly dismisses the tip (X, "Got it", or the action
|
|
12
|
+
button), the lightbulb disappears for that user (per-user when signed
|
|
13
|
+
in, per-browser when anonymous) and only the underlying element is
|
|
14
|
+
rendered.
|
|
15
|
+
|
|
16
|
+
This replaces the previous auto-open behaviour, which was racing with
|
|
17
|
+
focus management whenever multiple tips on a page mounted at once
|
|
18
|
+
(e.g. the Catalog "Add System" + "Add Group" tips would flash open and
|
|
19
|
+
instantly self-close as Radix's outside-focus handler fired). It also
|
|
20
|
+
fixes the bug where clicking the anchored button would silently dismiss
|
|
21
|
+
the tip — the lightbulb model has no implicit dismissal at all.
|
|
22
|
+
|
|
23
|
+
The default `align` for the popover changed from `"start"` to `"end"`
|
|
24
|
+
so the popover hangs off the lightbulb rather than the larger anchor
|
|
25
|
+
to its left. New optional `triggerClassName` prop on `<TipProps>` lets
|
|
26
|
+
callers restyle the lightbulb when needed.
|
|
27
|
+
|
|
28
|
+
- 3547670: Add `@checkstack/tips-*` — first-run tip and onboarding infrastructure for
|
|
29
|
+
the frontends.
|
|
30
|
+
|
|
31
|
+
Three new packages:
|
|
32
|
+
|
|
33
|
+
- `@checkstack/tips-common` — RPC contract (`tipsContract`), `TipsApi`
|
|
34
|
+
client definition, and zod schemas. Fully-qualified tip IDs have shape
|
|
35
|
+
`<pluginId>.<localTipId>` and are produced exclusively by
|
|
36
|
+
`qualifyTipId(plugin, localId)` — plugins never write the namespace
|
|
37
|
+
themselves, and a local id with a leading or trailing `.` is rejected,
|
|
38
|
+
so one plugin cannot forge or dismiss a tip in another plugin's
|
|
39
|
+
namespace.
|
|
40
|
+
- `@checkstack/tips-backend` — Postgres-backed dismissal store
|
|
41
|
+
(`user_tip_dismissal` with composite PK on `(user_id, tip_id)`),
|
|
42
|
+
`listDismissed` / `dismiss` / `reset` endpoints scoped to the
|
|
43
|
+
requesting user via the auto-auth middleware, and a
|
|
44
|
+
`auth.userDeleted` hook that cleans up dismissals when a user is
|
|
45
|
+
deleted.
|
|
46
|
+
- `@checkstack/tips-frontend` — `<Tip>` (anchored popover) and
|
|
47
|
+
`<TipBanner>` (inline callout) components plus the `useTipState`
|
|
48
|
+
hook. All three accept `{ plugin, id }` (where `plugin` is the
|
|
49
|
+
caller's `pluginMetadata`) and route through `qualifyTipId` so the
|
|
50
|
+
namespace prefix is enforced at the boundary. Persists per-user on
|
|
51
|
+
the server when logged in, and per-browser in `localStorage`
|
|
52
|
+
(`checkstack.tips.dismissed`) when anonymous, with cross-tab sync via
|
|
53
|
+
the `storage` event.
|
|
54
|
+
|
|
55
|
+
`@checkstack/ui`'s `<EmptyState>` gains optional `steps` and `actions`
|
|
56
|
+
props for richer empty-state coaching (numbered onboarding lists +
|
|
57
|
+
primary CTA), and accepts `ReactNode` for `description`. Existing
|
|
58
|
+
callers continue to work unchanged.
|
|
59
|
+
|
|
60
|
+
`@checkstack/test-utils-backend`'s `createMockDb` now also mocks
|
|
61
|
+
`insert().values().onConflictDoNothing()` so routers using upsert-or-skip
|
|
62
|
+
semantics can be unit-tested.
|
|
63
|
+
|
|
64
|
+
### Patch Changes
|
|
65
|
+
|
|
66
|
+
- Updated dependencies [42abfff]
|
|
67
|
+
- Updated dependencies [3547670]
|
|
68
|
+
- Updated dependencies [1ef2e79]
|
|
69
|
+
- Updated dependencies [aa89bc5]
|
|
70
|
+
- Updated dependencies [950d6ec]
|
|
71
|
+
- Updated dependencies [3547670]
|
|
72
|
+
- @checkstack/common@0.9.0
|
|
73
|
+
- @checkstack/ui@1.8.0
|
|
74
|
+
- @checkstack/frontend-api@0.5.0
|
|
75
|
+
- @checkstack/auth-frontend@0.6.0
|
|
76
|
+
- @checkstack/tips-common@0.2.0
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/tips-frontend",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.tsx",
|
|
7
|
+
"checkstack": {
|
|
8
|
+
"type": "frontend"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "tsgo -b",
|
|
12
|
+
"lint": "bun run lint:code",
|
|
13
|
+
"lint:code": "eslint . --max-warnings 0",
|
|
14
|
+
"test": "bun test"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@checkstack/tips-common": "0.1.0",
|
|
18
|
+
"@checkstack/frontend-api": "0.4.2",
|
|
19
|
+
"@checkstack/auth-frontend": "0.5.33",
|
|
20
|
+
"@checkstack/common": "0.8.0",
|
|
21
|
+
"@checkstack/ui": "1.7.1",
|
|
22
|
+
"react": "^18.2.0",
|
|
23
|
+
"lucide-react": "^0.344.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"typescript": "^5.0.0",
|
|
27
|
+
"@types/react": "^18.2.0",
|
|
28
|
+
"@checkstack/tsconfig": "0.0.7",
|
|
29
|
+
"@checkstack/scripts": "0.3.0",
|
|
30
|
+
"@checkstack/test-utils-frontend": "0.0.5"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Popover,
|
|
4
|
+
PopoverContent,
|
|
5
|
+
PopoverTrigger,
|
|
6
|
+
Button,
|
|
7
|
+
cn,
|
|
8
|
+
} from "@checkstack/ui";
|
|
9
|
+
import type { PluginMetadata } from "@checkstack/common";
|
|
10
|
+
import { Lightbulb, X } from "lucide-react";
|
|
11
|
+
import { useTipState } from "../hooks/useTipState";
|
|
12
|
+
|
|
13
|
+
export interface TipProps {
|
|
14
|
+
/**
|
|
15
|
+
* The calling plugin's metadata. The plugin's `pluginId` is automatically
|
|
16
|
+
* prepended to `id` to produce the fully-qualified tip identifier —
|
|
17
|
+
* plugins never write the namespace themselves.
|
|
18
|
+
*/
|
|
19
|
+
plugin: Pick<PluginMetadata, "pluginId">;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Local tip identifier — the part *after* the plugin's namespace.
|
|
23
|
+
*
|
|
24
|
+
* Must not start or end with `.` and must not include the plugin
|
|
25
|
+
* separator. Two `<Tip>` instances with the same `(plugin, id)` share
|
|
26
|
+
* dismissal state — useful when the same hint is pinned to multiple
|
|
27
|
+
* equivalent affordances.
|
|
28
|
+
*/
|
|
29
|
+
id: string;
|
|
30
|
+
|
|
31
|
+
/** Headline shown bold at the top of the popover. */
|
|
32
|
+
title: string;
|
|
33
|
+
|
|
34
|
+
/** Optional explanatory body. Plain text or a node (e.g. for inline links). */
|
|
35
|
+
description?: React.ReactNode;
|
|
36
|
+
|
|
37
|
+
/** Optional CTA shown alongside "Got it". Triggering it also dismisses the tip. */
|
|
38
|
+
action?: {
|
|
39
|
+
label: string;
|
|
40
|
+
onClick: () => void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Where the popover opens relative to the lightbulb. Defaults to "bottom". */
|
|
44
|
+
side?: "top" | "right" | "bottom" | "left";
|
|
45
|
+
|
|
46
|
+
/** Alignment of the popover along the chosen side. Defaults to "end". */
|
|
47
|
+
align?: "start" | "center" | "end";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The element a tip is configured for. Rendered as-is. The lightbulb
|
|
51
|
+
* trigger is rendered immediately after, in a shared inline-flex
|
|
52
|
+
* container so both stay visually grouped without disturbing the
|
|
53
|
+
* surrounding layout.
|
|
54
|
+
*/
|
|
55
|
+
children: React.ReactNode;
|
|
56
|
+
|
|
57
|
+
/** Optional className applied to the popover content. */
|
|
58
|
+
contentClassName?: string;
|
|
59
|
+
|
|
60
|
+
/** Optional className applied to the lightbulb button. */
|
|
61
|
+
triggerClassName?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Anchored, user-triggered hint.
|
|
66
|
+
*
|
|
67
|
+
* Renders `children` (typically a button, an icon, or any UI element you
|
|
68
|
+
* want to explain) followed by a small lightbulb that, when clicked,
|
|
69
|
+
* opens a popover with the tip text.
|
|
70
|
+
*
|
|
71
|
+
* The popover never auto-opens — first-time users see the lightbulb as a
|
|
72
|
+
* subtle "more info available" affordance. Once they read the tip and
|
|
73
|
+
* confirm with "Got it", the X icon, or the action button, the lightbulb
|
|
74
|
+
* disappears for that user (per-user when signed in, per-browser when
|
|
75
|
+
* anonymous) and the underlying element is rendered alone.
|
|
76
|
+
*
|
|
77
|
+
* Soft closes (clicking outside the popover, Escape) just close the
|
|
78
|
+
* popover for now — the lightbulb remains so the user can re-open it.
|
|
79
|
+
*/
|
|
80
|
+
export const Tip: React.FC<TipProps> = ({
|
|
81
|
+
plugin,
|
|
82
|
+
id,
|
|
83
|
+
title,
|
|
84
|
+
description,
|
|
85
|
+
action,
|
|
86
|
+
side = "bottom",
|
|
87
|
+
align = "end",
|
|
88
|
+
children,
|
|
89
|
+
contentClassName,
|
|
90
|
+
triggerClassName,
|
|
91
|
+
}) => {
|
|
92
|
+
const { isDismissed, isLoading, dismiss } = useTipState({ plugin, id });
|
|
93
|
+
const [open, setOpen] = useState(false);
|
|
94
|
+
|
|
95
|
+
const handleDismiss = () => {
|
|
96
|
+
setOpen(false);
|
|
97
|
+
dismiss();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// While we don't yet know whether the tip has been dismissed, render
|
|
101
|
+
// only the underlying element to avoid a visible lightbulb flicker.
|
|
102
|
+
if (isLoading || isDismissed) {
|
|
103
|
+
return <>{children}</>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<span className="inline-flex items-center gap-1.5">
|
|
108
|
+
{children}
|
|
109
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
110
|
+
<PopoverTrigger asChild>
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
aria-label={`Show tip: ${title}`}
|
|
114
|
+
className={cn(
|
|
115
|
+
"inline-flex size-6 shrink-0 items-center justify-center rounded-full text-amber-500 hover:bg-amber-500/10 hover:text-amber-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/40 transition-colors",
|
|
116
|
+
triggerClassName,
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
<Lightbulb className="size-4" />
|
|
120
|
+
</button>
|
|
121
|
+
</PopoverTrigger>
|
|
122
|
+
<PopoverContent
|
|
123
|
+
side={side}
|
|
124
|
+
align={align}
|
|
125
|
+
className={cn("w-80 p-4", contentClassName)}
|
|
126
|
+
>
|
|
127
|
+
<div className="flex items-start justify-between gap-2">
|
|
128
|
+
<p className="text-sm font-semibold text-foreground">{title}</p>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={handleDismiss}
|
|
132
|
+
aria-label="Don't show this tip again"
|
|
133
|
+
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
134
|
+
>
|
|
135
|
+
<X className="size-4" />
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
{description && (
|
|
139
|
+
<div className="mt-2 text-sm text-muted-foreground">
|
|
140
|
+
{description}
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
<div className="mt-3 flex items-center justify-end gap-2">
|
|
144
|
+
{action && (
|
|
145
|
+
<Button
|
|
146
|
+
size="sm"
|
|
147
|
+
variant="primary"
|
|
148
|
+
onClick={() => {
|
|
149
|
+
action.onClick();
|
|
150
|
+
handleDismiss();
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
{action.label}
|
|
154
|
+
</Button>
|
|
155
|
+
)}
|
|
156
|
+
<Button size="sm" variant="ghost" onClick={handleDismiss}>
|
|
157
|
+
Got it
|
|
158
|
+
</Button>
|
|
159
|
+
</div>
|
|
160
|
+
</PopoverContent>
|
|
161
|
+
</Popover>
|
|
162
|
+
</span>
|
|
163
|
+
);
|
|
164
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Card, CardContent, Button, cn } from "@checkstack/ui";
|
|
3
|
+
import type { PluginMetadata } from "@checkstack/common";
|
|
4
|
+
import { Lightbulb, X } from "lucide-react";
|
|
5
|
+
import { useTipState } from "../hooks/useTipState";
|
|
6
|
+
|
|
7
|
+
export interface TipBannerProps {
|
|
8
|
+
/**
|
|
9
|
+
* The calling plugin's metadata. The plugin's `pluginId` is automatically
|
|
10
|
+
* prepended to `id` to produce the fully-qualified tip identifier —
|
|
11
|
+
* plugins never write the namespace themselves.
|
|
12
|
+
*/
|
|
13
|
+
plugin: Pick<PluginMetadata, "pluginId">;
|
|
14
|
+
/** Local tip identifier — the part *after* the plugin's namespace. */
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
description?: React.ReactNode;
|
|
18
|
+
action?: {
|
|
19
|
+
label: string;
|
|
20
|
+
onClick: () => void;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Optional hint content rendered immediately to the right of the action
|
|
24
|
+
* button on the same row — useful for short notes that relate to the
|
|
25
|
+
* primary CTA (e.g. "Look for the lightbulb icons elsewhere in the UI").
|
|
26
|
+
* Wraps below the button on narrow viewports.
|
|
27
|
+
*/
|
|
28
|
+
actionHint?: React.ReactNode;
|
|
29
|
+
icon?: React.ReactNode;
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Inline, dismissable callout for use at the top of a page or alongside
|
|
35
|
+
* an empty state. Disappears entirely once dismissed (no anchor remains),
|
|
36
|
+
* which makes it appropriate for "first-time on this page" coaching where
|
|
37
|
+
* there's no ongoing UI element to attach to.
|
|
38
|
+
*/
|
|
39
|
+
export const TipBanner: React.FC<TipBannerProps> = ({
|
|
40
|
+
plugin,
|
|
41
|
+
id,
|
|
42
|
+
title,
|
|
43
|
+
description,
|
|
44
|
+
action,
|
|
45
|
+
actionHint,
|
|
46
|
+
icon,
|
|
47
|
+
className,
|
|
48
|
+
}) => {
|
|
49
|
+
const { isDismissed, isLoading, dismiss } = useTipState({ plugin, id });
|
|
50
|
+
|
|
51
|
+
if (isDismissed || isLoading) return null;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Card
|
|
55
|
+
className={cn(
|
|
56
|
+
"border border-primary/30 bg-primary/5",
|
|
57
|
+
className,
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
<CardContent className="flex items-start gap-3 py-4">
|
|
61
|
+
<div className="text-primary mt-0.5">
|
|
62
|
+
{icon ?? <Lightbulb className="size-5" />}
|
|
63
|
+
</div>
|
|
64
|
+
<div className="flex-1">
|
|
65
|
+
<p className="text-sm font-semibold text-foreground">{title}</p>
|
|
66
|
+
{description && (
|
|
67
|
+
<div className="mt-1 text-sm text-muted-foreground">
|
|
68
|
+
{description}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
{(action || actionHint) && (
|
|
72
|
+
<div className="mt-3 flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
|
|
73
|
+
{action ? (
|
|
74
|
+
<Button
|
|
75
|
+
size="sm"
|
|
76
|
+
variant="primary"
|
|
77
|
+
onClick={() => {
|
|
78
|
+
action.onClick();
|
|
79
|
+
dismiss();
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
{action.label}
|
|
83
|
+
</Button>
|
|
84
|
+
) : (
|
|
85
|
+
<span />
|
|
86
|
+
)}
|
|
87
|
+
{actionHint && (
|
|
88
|
+
<div className="text-xs text-muted-foreground sm:text-right ml-auto">
|
|
89
|
+
{actionHint}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={dismiss}
|
|
98
|
+
aria-label="Dismiss tip"
|
|
99
|
+
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
100
|
+
>
|
|
101
|
+
<X className="size-4" />
|
|
102
|
+
</button>
|
|
103
|
+
</CardContent>
|
|
104
|
+
</Card>
|
|
105
|
+
);
|
|
106
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useApi, usePluginClient } from "@checkstack/frontend-api";
|
|
3
|
+
import { authApiRef } from "@checkstack/auth-frontend/api";
|
|
4
|
+
import { TipsApi } from "@checkstack/tips-common";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Headless extension that prefetches the user's dismissed-tips list as
|
|
8
|
+
* soon as the session is known. Without this, the very first <Tip> on
|
|
9
|
+
* the page would have to issue its own request — visible as a brief
|
|
10
|
+
* flash of an already-dismissed popover.
|
|
11
|
+
*
|
|
12
|
+
* Renders nothing; mounted into NavbarRightSlot so it's present on every
|
|
13
|
+
* page that uses the standard layout.
|
|
14
|
+
*/
|
|
15
|
+
export const TipsSynchronizer = () => {
|
|
16
|
+
const authApi = useApi(authApiRef);
|
|
17
|
+
const tipsClient = usePluginClient(TipsApi);
|
|
18
|
+
const { data: session, isPending } = authApi.useSession();
|
|
19
|
+
const lastUserIdRef = useRef<string | undefined>(undefined);
|
|
20
|
+
|
|
21
|
+
// The query is keyed by react-query under the plugin's key; using
|
|
22
|
+
// `useQuery` here is enough to populate the cache for any later
|
|
23
|
+
// `useTipState` consumer.
|
|
24
|
+
const dismissedQuery = tipsClient.listDismissed.useQuery(undefined, {
|
|
25
|
+
enabled: !!session?.user && !isPending,
|
|
26
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const currentUserId = session?.user?.id ?? undefined;
|
|
31
|
+
if (currentUserId !== lastUserIdRef.current) {
|
|
32
|
+
lastUserIdRef.current = currentUserId;
|
|
33
|
+
// Force a refetch when the user changes (login/logout/switch).
|
|
34
|
+
// staleTime is Infinity, so without this we'd keep the previous
|
|
35
|
+
// user's dismissals in the cache.
|
|
36
|
+
if (currentUserId) {
|
|
37
|
+
void dismissedQuery.refetch();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}, [session?.user?.id, dismissedQuery]);
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = "checkstack.tips.dismissed";
|
|
4
|
+
|
|
5
|
+
const readStorage = (): Set<string> => {
|
|
6
|
+
if (globalThis.window === undefined) return new Set();
|
|
7
|
+
try {
|
|
8
|
+
const raw = globalThis.localStorage.getItem(STORAGE_KEY);
|
|
9
|
+
if (!raw) return new Set();
|
|
10
|
+
const parsed = JSON.parse(raw);
|
|
11
|
+
if (!Array.isArray(parsed)) return new Set();
|
|
12
|
+
return new Set(parsed.filter((v): v is string => typeof v === "string"));
|
|
13
|
+
} catch {
|
|
14
|
+
return new Set();
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const writeStorage = (ids: Set<string>) => {
|
|
19
|
+
if (globalThis.window === undefined) return;
|
|
20
|
+
try {
|
|
21
|
+
globalThis.localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]));
|
|
22
|
+
} catch {
|
|
23
|
+
// Storage may be unavailable (private mode, quota); the in-memory copy
|
|
24
|
+
// still keeps the dismissal active for the current page load.
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Local-only dismissal store for anonymous (logged-out) users and as a
|
|
30
|
+
* synchronous fallback while the server query is loading.
|
|
31
|
+
*
|
|
32
|
+
* Synchronizes across tabs of the same browser via the `storage` event so
|
|
33
|
+
* dismissing a tip in one tab hides it in another.
|
|
34
|
+
*/
|
|
35
|
+
export const useLocalDismissals = () => {
|
|
36
|
+
const [ids, setIds] = useState<Set<string>>(() => readStorage());
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (globalThis.window === undefined) return;
|
|
40
|
+
|
|
41
|
+
const onStorage = (event: StorageEvent) => {
|
|
42
|
+
if (event.key !== STORAGE_KEY) return;
|
|
43
|
+
setIds(readStorage());
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
globalThis.addEventListener("storage", onStorage);
|
|
47
|
+
return () => globalThis.removeEventListener("storage", onStorage);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const dismiss = useCallback((tipId: string) => {
|
|
51
|
+
setIds((prev) => {
|
|
52
|
+
if (prev.has(tipId)) return prev;
|
|
53
|
+
const next = new Set(prev);
|
|
54
|
+
next.add(tipId);
|
|
55
|
+
writeStorage(next);
|
|
56
|
+
return next;
|
|
57
|
+
});
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
const reset = useCallback((tipIds?: string[]) => {
|
|
61
|
+
setIds((prev) => {
|
|
62
|
+
if (!tipIds || tipIds.length === 0) {
|
|
63
|
+
writeStorage(new Set());
|
|
64
|
+
return new Set();
|
|
65
|
+
}
|
|
66
|
+
const next = new Set(prev);
|
|
67
|
+
for (const id of tipIds) next.delete(id);
|
|
68
|
+
writeStorage(next);
|
|
69
|
+
return next;
|
|
70
|
+
});
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
return { ids, dismiss, reset };
|
|
74
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import { useApi, usePluginClient } from "@checkstack/frontend-api";
|
|
3
|
+
import { authApiRef } from "@checkstack/auth-frontend/api";
|
|
4
|
+
import { qualifyTipId, TipsApi } from "@checkstack/tips-common";
|
|
5
|
+
import type { PluginMetadata } from "@checkstack/common";
|
|
6
|
+
import { useLocalDismissals } from "./useLocalDismissals";
|
|
7
|
+
|
|
8
|
+
export interface UseTipStateOptions {
|
|
9
|
+
/**
|
|
10
|
+
* The calling plugin's metadata. The plugin's `pluginId` is automatically
|
|
11
|
+
* prepended to `id` to produce the fully-qualified tip identifier — plugins
|
|
12
|
+
* never construct that string themselves, which prevents one plugin from
|
|
13
|
+
* dismissing or forging tips in another plugin's namespace.
|
|
14
|
+
*/
|
|
15
|
+
plugin: Pick<PluginMetadata, "pluginId">;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Local tip identifier — the part *after* the plugin's namespace.
|
|
19
|
+
*
|
|
20
|
+
* Must not start or end with a `.` and must not contain the plugin
|
|
21
|
+
* separator manually. Examples: `"systems.create"`, `"first-run"`.
|
|
22
|
+
*/
|
|
23
|
+
id: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UseTipStateResult {
|
|
27
|
+
/** Fully-qualified tip ID (`<pluginId>.<id>`) — useful for logs / analytics. */
|
|
28
|
+
tipId: string;
|
|
29
|
+
/** True once we know the tip should not be shown to this user. */
|
|
30
|
+
isDismissed: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* True while the dismissal list is being fetched for the first time.
|
|
33
|
+
* Consumers should typically render nothing while loading — showing a tip
|
|
34
|
+
* and then hiding it would be flicker.
|
|
35
|
+
*/
|
|
36
|
+
isLoading: boolean;
|
|
37
|
+
/** Mark this tip dismissed. Idempotent and safe to call repeatedly. */
|
|
38
|
+
dismiss: () => void;
|
|
39
|
+
/** Restore this tip so it shows again. Useful for "replay onboarding". */
|
|
40
|
+
reset: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Hook for reading and updating the dismissal state of a single tip.
|
|
45
|
+
*
|
|
46
|
+
* Persistence model:
|
|
47
|
+
* - Logged-in users: state lives on the server in `user_tip_dismissal`; fetched
|
|
48
|
+
* once per session and kept in sync via react-query. Mutations optimistically
|
|
49
|
+
* update the cache.
|
|
50
|
+
* - Anonymous users: state lives in `localStorage` under
|
|
51
|
+
* `checkstack.tips.dismissed` and syncs across tabs via the `storage` event.
|
|
52
|
+
*
|
|
53
|
+
* Both stores are consulted on read, so a tip dismissed locally while logged
|
|
54
|
+
* out stays dismissed after sign-in (the server copy then takes over once
|
|
55
|
+
* the user dismisses it again).
|
|
56
|
+
*/
|
|
57
|
+
export const useTipState = ({
|
|
58
|
+
plugin,
|
|
59
|
+
id,
|
|
60
|
+
}: UseTipStateOptions): UseTipStateResult => {
|
|
61
|
+
const tipId = useMemo(() => qualifyTipId(plugin, id), [plugin, id]);
|
|
62
|
+
|
|
63
|
+
const authApi = useApi(authApiRef);
|
|
64
|
+
const tipsClient = usePluginClient(TipsApi);
|
|
65
|
+
const { data: session, isPending: sessionPending } = authApi.useSession();
|
|
66
|
+
const local = useLocalDismissals();
|
|
67
|
+
|
|
68
|
+
const isLoggedIn = !!session?.user;
|
|
69
|
+
|
|
70
|
+
const dismissedQuery = tipsClient.listDismissed.useQuery(undefined, {
|
|
71
|
+
enabled: isLoggedIn && !sessionPending,
|
|
72
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Pull only the stable `mutate` references so we don't capture the entire
|
|
76
|
+
// mutation result object in dependency arrays — that object is recreated
|
|
77
|
+
// every render and would re-run our callbacks unnecessarily.
|
|
78
|
+
const { mutate: dismissMutate } = tipsClient.dismiss.useMutation();
|
|
79
|
+
const { mutate: resetMutate } = tipsClient.reset.useMutation();
|
|
80
|
+
|
|
81
|
+
const refetch = dismissedQuery.refetch;
|
|
82
|
+
|
|
83
|
+
const isDismissed = useMemo(() => {
|
|
84
|
+
if (local.ids.has(tipId)) return true;
|
|
85
|
+
if (!isLoggedIn) return false;
|
|
86
|
+
if (!dismissedQuery.data) return false;
|
|
87
|
+
return dismissedQuery.data.dismissed.some((d) => d.tipId === tipId);
|
|
88
|
+
}, [tipId, isLoggedIn, dismissedQuery.data, local.ids]);
|
|
89
|
+
|
|
90
|
+
const dismiss = useCallback(() => {
|
|
91
|
+
// Always reflect locally for instant feedback and offline / anonymous use.
|
|
92
|
+
local.dismiss(tipId);
|
|
93
|
+
if (isLoggedIn) {
|
|
94
|
+
dismissMutate(
|
|
95
|
+
{ tipId },
|
|
96
|
+
{
|
|
97
|
+
onSuccess: () => {
|
|
98
|
+
void refetch();
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}, [tipId, isLoggedIn, dismissMutate, refetch, local]);
|
|
104
|
+
|
|
105
|
+
const reset = useCallback(() => {
|
|
106
|
+
local.reset([tipId]);
|
|
107
|
+
if (isLoggedIn) {
|
|
108
|
+
resetMutate(
|
|
109
|
+
{ tipIds: [tipId] },
|
|
110
|
+
{
|
|
111
|
+
onSuccess: () => {
|
|
112
|
+
void refetch();
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}, [tipId, isLoggedIn, resetMutate, refetch, local]);
|
|
118
|
+
|
|
119
|
+
// While we don't yet know the server answer for a logged-in user, we
|
|
120
|
+
// consider the state "loading" — the consumer can decide whether to
|
|
121
|
+
// suppress rendering to avoid flicker.
|
|
122
|
+
const isLoading =
|
|
123
|
+
sessionPending || (isLoggedIn && dismissedQuery.isPending);
|
|
124
|
+
|
|
125
|
+
return { tipId, isDismissed, isLoading, dismiss, reset };
|
|
126
|
+
};
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFrontendPlugin,
|
|
3
|
+
NavbarRightSlot,
|
|
4
|
+
} from "@checkstack/frontend-api";
|
|
5
|
+
import { pluginMetadata } from "@checkstack/tips-common";
|
|
6
|
+
import { TipsSynchronizer } from "./components/TipsSynchronizer";
|
|
7
|
+
|
|
8
|
+
export { Tip } from "./components/Tip";
|
|
9
|
+
export type { TipProps } from "./components/Tip";
|
|
10
|
+
export { TipBanner } from "./components/TipBanner";
|
|
11
|
+
export type { TipBannerProps } from "./components/TipBanner";
|
|
12
|
+
export { useTipState } from "./hooks/useTipState";
|
|
13
|
+
export type {
|
|
14
|
+
UseTipStateOptions,
|
|
15
|
+
UseTipStateResult,
|
|
16
|
+
} from "./hooks/useTipState";
|
|
17
|
+
|
|
18
|
+
export const tipsPlugin = createFrontendPlugin({
|
|
19
|
+
metadata: pluginMetadata,
|
|
20
|
+
routes: [],
|
|
21
|
+
extensions: [
|
|
22
|
+
{
|
|
23
|
+
id: "tips.navbar.synchronizer",
|
|
24
|
+
slot: NavbarRightSlot,
|
|
25
|
+
component: TipsSynchronizer,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@checkstack/tsconfig/frontend.json",
|
|
3
|
+
"include": [
|
|
4
|
+
"src"
|
|
5
|
+
],
|
|
6
|
+
"references": [
|
|
7
|
+
{
|
|
8
|
+
"path": "../auth-frontend"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"path": "../common"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"path": "../frontend-api"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"path": "../test-utils-frontend"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "../tips-common"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "../ui"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|