@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 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
+ }