@checkstack/notification-frontend 0.0.2

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.
@@ -0,0 +1,311 @@
1
+ import { useState } from "react";
2
+ import {
3
+ Link2,
4
+ Link2Off,
5
+ Check,
6
+ X,
7
+ Loader2,
8
+ ChevronDown,
9
+ ChevronUp,
10
+ Send,
11
+ } from "lucide-react";
12
+ import {
13
+ Card,
14
+ Button,
15
+ Badge,
16
+ DynamicForm,
17
+ cn,
18
+ DynamicIcon,
19
+ MarkdownBlock,
20
+ type LucideIconName,
21
+ } from "@checkstack/ui";
22
+
23
+ /**
24
+ * User channel data from getUserDeliveryChannels endpoint
25
+ */
26
+ export interface UserDeliveryChannel {
27
+ strategyId: string;
28
+ displayName: string;
29
+ description?: string;
30
+ icon?: LucideIconName;
31
+ contactResolution: {
32
+ type: "auth-email" | "auth-provider" | "user-config" | "oauth-link";
33
+ };
34
+ enabled: boolean;
35
+ isConfigured: boolean;
36
+ linkedAt?: Date;
37
+ userConfigSchema?: Record<string, unknown>;
38
+ userConfig?: Record<string, unknown>;
39
+ /** Markdown instructions for users (connection guides, etc.) */
40
+ userInstructions?: string;
41
+ }
42
+
43
+ export interface UserChannelCardProps {
44
+ channel: UserDeliveryChannel;
45
+ onToggle: (strategyId: string, enabled: boolean) => Promise<void>;
46
+ onConnect: (strategyId: string) => Promise<void>;
47
+ onDisconnect: (strategyId: string) => Promise<void>;
48
+ onSaveConfig: (
49
+ strategyId: string,
50
+ config: Record<string, unknown>
51
+ ) => Promise<void>;
52
+ onTest: (strategyId: string) => Promise<{ success: boolean; error?: string }>;
53
+ saving?: boolean;
54
+ connecting?: boolean;
55
+ testing?: boolean;
56
+ }
57
+
58
+ /**
59
+ * User card for managing their notification channel preferences.
60
+ * Shows enable/disable, OAuth connect/disconnect, and user config form.
61
+ */
62
+ export function UserChannelCard({
63
+ channel,
64
+ onToggle,
65
+ onConnect,
66
+ onDisconnect,
67
+ onSaveConfig,
68
+ onTest,
69
+ saving,
70
+ connecting,
71
+ testing,
72
+ }: UserChannelCardProps) {
73
+ const [expanded, setExpanded] = useState(false);
74
+ const [userConfig, setUserConfig] = useState<Record<string, unknown>>(
75
+ channel.userConfig ?? {}
76
+ );
77
+ const [localEnabled, setLocalEnabled] = useState(channel.enabled);
78
+ const [configValid, setConfigValid] = useState(true); // Start true since existing config is valid
79
+
80
+ const requiresOAuth = channel.contactResolution.type === "oauth-link";
81
+ const requiresUserConfig = channel.contactResolution.type === "user-config";
82
+ const isLinked = !!channel.linkedAt;
83
+ const hasUserConfigSchema =
84
+ channel.userConfigSchema &&
85
+ Object.keys(channel.userConfigSchema).length > 0;
86
+
87
+ // Determine if channel can be enabled
88
+ const canEnable = () => {
89
+ if (requiresOAuth && !isLinked) return false;
90
+ if (requiresUserConfig && !channel.isConfigured) return false;
91
+ return true;
92
+ };
93
+
94
+ const handleToggle = async () => {
95
+ const newEnabled = !localEnabled;
96
+ if (!canEnable() && newEnabled) {
97
+ // Can't enable - missing requirements
98
+ return;
99
+ }
100
+ setLocalEnabled(newEnabled);
101
+ await onToggle(channel.strategyId, newEnabled);
102
+ };
103
+
104
+ const handleConnect = async () => {
105
+ await onConnect(channel.strategyId);
106
+ };
107
+
108
+ const handleDisconnect = async () => {
109
+ await onDisconnect(channel.strategyId);
110
+ setLocalEnabled(false);
111
+ };
112
+
113
+ const handleSaveConfig = async () => {
114
+ await onSaveConfig(channel.strategyId, userConfig);
115
+ };
116
+
117
+ // Get status badge
118
+ const getStatusBadge = () => {
119
+ if (requiresOAuth && !isLinked) {
120
+ return (
121
+ <Badge variant="outline" className="text-amber-600 border-amber-600">
122
+ Not Connected
123
+ </Badge>
124
+ );
125
+ }
126
+ if (requiresUserConfig && !channel.isConfigured) {
127
+ return (
128
+ <Badge variant="outline" className="text-amber-600 border-amber-600">
129
+ Setup Required
130
+ </Badge>
131
+ );
132
+ }
133
+ if (localEnabled) {
134
+ return (
135
+ <Badge variant="secondary" className="text-green-600 border-green-600">
136
+ Active
137
+ </Badge>
138
+ );
139
+ }
140
+ return <Badge variant="outline">Disabled</Badge>;
141
+ };
142
+
143
+ return (
144
+ <Card
145
+ className={cn(
146
+ "overflow-hidden transition-all",
147
+ localEnabled && channel.isConfigured ? "border-green-500/30" : ""
148
+ )}
149
+ >
150
+ {/* Header */}
151
+ <div className="flex items-center justify-between p-4">
152
+ <div className="flex items-center gap-3">
153
+ <DynamicIcon
154
+ name={channel.icon}
155
+ className="h-5 w-5 text-muted-foreground"
156
+ />
157
+ <div>
158
+ <div className="flex items-center gap-2">
159
+ <span className="font-medium">{channel.displayName}</span>
160
+ {getStatusBadge()}
161
+ </div>
162
+ {channel.description && (
163
+ <p className="text-sm text-muted-foreground mt-0.5">
164
+ {channel.description}
165
+ </p>
166
+ )}
167
+ {isLinked && channel.linkedAt && (
168
+ <p className="text-xs text-muted-foreground mt-1">
169
+ Connected {new Date(channel.linkedAt).toLocaleDateString()}
170
+ </p>
171
+ )}
172
+ {/* Warning for OAuth strategies about shared targets */}
173
+ {requiresOAuth && isLinked && (
174
+ <p className="text-xs text-amber-600 mt-1">
175
+ ⚠️ Avoid shared targets (group chats) — transactional messages
176
+ (e.g., password resets) may also be sent here.
177
+ </p>
178
+ )}
179
+ </div>
180
+ </div>
181
+
182
+ <div className="flex items-center gap-2">
183
+ {/* OAuth Connect/Disconnect */}
184
+ {requiresOAuth &&
185
+ (isLinked ? (
186
+ <Button
187
+ variant="outline"
188
+ size="sm"
189
+ onClick={() => void handleDisconnect()}
190
+ disabled={saving || connecting}
191
+ className="text-destructive hover:text-destructive"
192
+ >
193
+ {connecting ? (
194
+ <Loader2 className="h-4 w-4 animate-spin" />
195
+ ) : (
196
+ <Link2Off className="h-4 w-4 mr-1" />
197
+ )}
198
+ Disconnect
199
+ </Button>
200
+ ) : (
201
+ <Button
202
+ variant="primary"
203
+ size="sm"
204
+ onClick={() => void handleConnect()}
205
+ disabled={saving || connecting}
206
+ >
207
+ {connecting ? (
208
+ <Loader2 className="h-4 w-4 animate-spin" />
209
+ ) : (
210
+ <Link2 className="h-4 w-4 mr-1" />
211
+ )}
212
+ Connect
213
+ </Button>
214
+ ))}
215
+
216
+ {/* Enable/Disable toggle */}
217
+ {canEnable() && (
218
+ <Button
219
+ variant={localEnabled ? "primary" : "outline"}
220
+ size="sm"
221
+ onClick={() => void handleToggle()}
222
+ disabled={saving}
223
+ className="min-w-[90px]"
224
+ >
225
+ {localEnabled ? (
226
+ <>
227
+ <Check className="h-4 w-4 mr-1" />
228
+ Enabled
229
+ </>
230
+ ) : (
231
+ <>
232
+ <X className="h-4 w-4 mr-1" />
233
+ Disabled
234
+ </>
235
+ )}
236
+ </Button>
237
+ )}
238
+
239
+ {/* Test notification button - only show when channel is configured */}
240
+ {channel.isConfigured && (
241
+ <Button
242
+ variant="ghost"
243
+ size="sm"
244
+ onClick={() => void onTest(channel.strategyId)}
245
+ disabled={testing || saving}
246
+ title="Send test notification"
247
+ >
248
+ {testing ? (
249
+ <Loader2 className="h-4 w-4 animate-spin" />
250
+ ) : (
251
+ <Send className="h-4 w-4" />
252
+ )}
253
+ </Button>
254
+ )}
255
+
256
+ {/* Expand for user config */}
257
+ {hasUserConfigSchema && (
258
+ <Button
259
+ variant="ghost"
260
+ size="sm"
261
+ onClick={() => setExpanded(!expanded)}
262
+ >
263
+ {expanded ? (
264
+ <ChevronUp className="h-4 w-4" />
265
+ ) : (
266
+ <ChevronDown className="h-4 w-4" />
267
+ )}
268
+ </Button>
269
+ )}
270
+ </div>
271
+ </div>
272
+
273
+ {/* User config form */}
274
+ {expanded && hasUserConfigSchema && channel.userConfigSchema && (
275
+ <div className="border-t p-4 bg-muted/30 space-y-4">
276
+ {/* User instructions block */}
277
+ {channel.userInstructions && (
278
+ <div className="p-4 bg-muted/50 rounded-lg border border-border/50">
279
+ <MarkdownBlock size="sm">
280
+ {channel.userInstructions}
281
+ </MarkdownBlock>
282
+ </div>
283
+ )}
284
+
285
+ <DynamicForm
286
+ schema={channel.userConfigSchema}
287
+ value={userConfig}
288
+ onChange={setUserConfig}
289
+ onValidChange={setConfigValid}
290
+ />
291
+ <div className="mt-4 flex justify-end">
292
+ <Button
293
+ onClick={() => void handleSaveConfig()}
294
+ disabled={saving || !configValid}
295
+ size="sm"
296
+ >
297
+ {saving ? "Saving..." : "Save Settings"}
298
+ </Button>
299
+ </div>
300
+ </div>
301
+ )}
302
+
303
+ {/* User instructions when not connected (for oauth-link channels) */}
304
+ {!isLinked && channel.userInstructions && requiresOAuth && (
305
+ <div className="border-t p-4 bg-muted/30">
306
+ <MarkdownBlock size="sm">{channel.userInstructions}</MarkdownBlock>
307
+ </div>
308
+ )}
309
+ </Card>
310
+ );
311
+ }
@@ -0,0 +1,16 @@
1
+ import { Link } from "react-router-dom";
2
+ import { Bell } from "lucide-react";
3
+ import type { UserMenuItemsContext } from "@checkstack/frontend-api";
4
+ import { DropdownMenuItem } from "@checkstack/ui";
5
+ import { resolveRoute } from "@checkstack/common";
6
+ import { notificationRoutes } from "@checkstack/notification-common";
7
+
8
+ export const NotificationUserMenuItems = (_props: UserMenuItemsContext) => {
9
+ return (
10
+ <Link to={resolveRoute(notificationRoutes.routes.settings)}>
11
+ <DropdownMenuItem icon={<Bell className="h-4 w-4" />}>
12
+ Notification Settings
13
+ </DropdownMenuItem>
14
+ </Link>
15
+ );
16
+ };
package/src/index.tsx ADDED
@@ -0,0 +1,39 @@
1
+ import {
2
+ createFrontendPlugin,
3
+ createSlotExtension,
4
+ NavbarRightSlot,
5
+ UserMenuItemsSlot,
6
+ } from "@checkstack/frontend-api";
7
+ import {
8
+ notificationRoutes,
9
+ pluginMetadata,
10
+ } from "@checkstack/notification-common";
11
+ import { NotificationBell } from "./components/NotificationBell";
12
+ import { NotificationsPage } from "./pages/NotificationsPage";
13
+ import { NotificationSettingsPage } from "./pages/NotificationSettingsPage";
14
+ import { NotificationUserMenuItems } from "./components/UserMenuItems";
15
+
16
+ export const notificationPlugin = createFrontendPlugin({
17
+ metadata: pluginMetadata,
18
+ routes: [
19
+ {
20
+ route: notificationRoutes.routes.home,
21
+ element: <NotificationsPage />,
22
+ },
23
+ {
24
+ route: notificationRoutes.routes.settings,
25
+ element: <NotificationSettingsPage />,
26
+ },
27
+ ],
28
+ extensions: [
29
+ {
30
+ id: "notification.navbar.bell",
31
+ slot: NavbarRightSlot,
32
+ component: NotificationBell,
33
+ },
34
+ createSlotExtension(UserMenuItemsSlot, {
35
+ id: "notification.user.setting",
36
+ component: NotificationUserMenuItems,
37
+ }),
38
+ ],
39
+ });