@tuturuuu/ui 0.30.2 → 0.32.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/biome.json +1 -1
  3. package/package.json +95 -49
  4. package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
  5. package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
  7. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
  8. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
  9. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
  10. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
  11. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
  12. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
  13. package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
  14. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
  15. package/src/components/ui/chat/ai-message-markdown.tsx +17 -2
  16. package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
  17. package/src/components/ui/custom/nav-link.tsx +7 -7
  18. package/src/components/ui/custom/nav.tsx +15 -63
  19. package/src/components/ui/custom/notification-card.tsx +361 -0
  20. package/src/components/ui/custom/notification-list.tsx +178 -0
  21. package/src/components/ui/custom/notification-popover-client.tsx +12 -780
  22. package/src/components/ui/custom/notification-runtime.tsx +23 -0
  23. package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
  24. package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
  25. package/src/components/ui/custom/satellite-brand.tsx +122 -0
  26. package/src/components/ui/custom/satellite-content.tsx +148 -0
  27. package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
  28. package/src/components/ui/custom/satellite-language-item.tsx +26 -0
  29. package/src/components/ui/custom/satellite-navigation.tsx +86 -0
  30. package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
  31. package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
  32. package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
  33. package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
  34. package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
  35. package/src/components/ui/custom/satellite-shell.tsx +50 -0
  36. package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
  37. package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
  38. package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
  39. package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
  40. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
  41. package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
  42. package/src/components/ui/custom/sidebar-context.tsx +5 -14
  43. package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
  44. package/src/components/ui/custom/structure.tsx +14 -0
  45. package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
  46. package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
  47. package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
  48. package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
  49. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
  50. package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
  51. package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
  52. package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
  53. package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
  54. package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
  55. package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
  56. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
  57. package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
  58. package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
  59. package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
  60. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
  61. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
  62. package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
  63. package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
  64. package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
  65. package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
  66. package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
  67. package/src/components/ui/pwa-status.test.tsx +53 -0
  68. package/src/components/ui/pwa-status.tsx +113 -0
  69. package/src/components/ui/report-problem-dialog-content.tsx +350 -0
  70. package/src/components/ui/report-problem-dialog.tsx +6 -975
  71. package/src/components/ui/report-problem-model.ts +133 -0
  72. package/src/components/ui/report-problem-mutation.ts +122 -0
  73. package/src/components/ui/save-notification.test.tsx +36 -0
  74. package/src/components/ui/save-notification.tsx +17 -0
  75. package/src/components/ui/use-report-problem.ts +488 -0
  76. package/src/declarations.d.ts +2 -0
  77. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
  78. package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
  79. package/src/hooks/calendar-date-utils.ts +11 -0
  80. package/src/hooks/calendar-event-lookup.test.ts +77 -0
  81. package/src/hooks/calendar-event-lookup.ts +47 -0
  82. package/src/hooks/calendar-range-cache.ts +52 -0
  83. package/src/hooks/pwa-install-prompt.ts +44 -0
  84. package/src/hooks/use-calendar-sync.tsx +23 -100
  85. package/src/hooks/use-calendar.tsx +21 -77
  86. package/src/hooks/use-notifications.ts +9 -24
  87. package/src/hooks/use-open-initial-calendar-event.ts +27 -0
  88. package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
  89. package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
  90. package/src/hooks/use-workspace-presence.ts +0 -18
  91. package/src/lib/tasks-app-url.test.ts +25 -0
  92. package/src/lib/tasks-app-url.ts +8 -0
@@ -1,789 +1,21 @@
1
1
  'use client';
2
-
3
- import { useQueryClient } from '@tanstack/react-query';
4
- import {
5
- Archive,
6
- AtSign,
7
- Bell,
8
- Calendar,
9
- Check,
10
- CheckCircle2,
11
- ChevronRight,
12
- ClipboardList,
13
- Edit3,
14
- Eye,
15
- EyeOff,
16
- Inbox,
17
- Loader2,
18
- Mail,
19
- RotateCcw,
20
- Shield,
21
- UserPlus,
22
- X,
23
- XCircle,
24
- } from '@tuturuuu/icons';
25
- import { updateNotificationMetadata } from '@tuturuuu/internal-api';
26
- import {
27
- acceptWorkspaceInvite,
28
- declineWorkspaceInvite,
29
- } from '@tuturuuu/internal-api/workspaces';
30
- import { Button } from '@tuturuuu/ui/button';
31
- import {
32
- dedupeNotifications,
33
- type Notification,
34
- useInfiniteNotifications,
35
- useMarkAllAsRead,
36
- useNotificationSubscription,
37
- useUnreadCount,
38
- useUpdateNotification,
39
- } from '@tuturuuu/ui/hooks/use-notifications';
40
- import {
41
- dispatchRequestOpenTask,
42
- waitForTaskOpenResult,
43
- } from '@tuturuuu/ui/lib/task-open-events';
44
- import { Popover, PopoverContent } from '@tuturuuu/ui/popover';
45
- import { toast } from '@tuturuuu/ui/sonner';
46
- import { cn } from '@tuturuuu/utils/format';
47
- import dayjs from 'dayjs';
48
- import relativeTime from 'dayjs/plugin/relativeTime';
49
2
  import Link from 'next/link';
50
3
  import { useParams, usePathname, useRouter } from 'next/navigation';
51
- import { useCallback, useEffect, useRef, useState } from 'react';
52
- import { NotificationPopoverTriggerButton } from './notification-popover-trigger';
53
-
54
- dayjs.extend(relativeTime);
55
-
56
- type TabType = 'inbox' | 'archive';
57
-
58
- interface NotificationPopoverClientProps {
59
- userId?: string;
60
- noNotificationsText: string;
61
- notificationsText: string;
62
- viewAllText: string;
63
- markAsReadText: string;
64
- markAsUnreadText: string;
65
- inboxText?: string;
66
- archiveText?: string;
67
- archiveAllText?: string;
68
- emptyArchiveText?: string;
69
- loadingMoreText?: string;
70
- retryText?: string;
71
- acceptText?: string;
72
- declineText?: string;
73
- acceptedText?: string;
74
- declinedText?: string;
75
- /** Base URL for external redirect (e.g. 'https://tuturuuu.com'). When set, "View All" links to {webAppUrl}/{wsId}/notifications. */
76
- webAppUrl?: string;
77
- }
78
-
79
- // Workspace identifier validation regex
80
- const UUID_REGEX =
81
- /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/;
82
- const SPECIAL_WORKSPACE_SLUGS = new Set(['personal', 'internal']);
83
-
84
- function isValidWorkspaceIdentifier(value: unknown): value is string {
85
- return (
86
- typeof value === 'string' &&
87
- (UUID_REGEX.test(value) || SPECIAL_WORKSPACE_SLUGS.has(value))
88
- );
89
- }
90
-
91
- function isValidWorkspaceFilterId(value: unknown): value is string {
92
- return typeof value === 'string' && UUID_REGEX.test(value);
93
- }
94
-
95
- export default function NotificationPopoverClient({
96
- userId,
97
- noNotificationsText,
98
- notificationsText,
99
- viewAllText,
100
- markAsReadText,
101
- markAsUnreadText,
102
- inboxText = 'Inbox',
103
- archiveText = 'Archive',
104
- archiveAllText = 'Archive all',
105
- emptyArchiveText = 'No archived notifications yet.',
106
- loadingMoreText = 'Loading more...',
107
- retryText = 'Retry',
108
- acceptText = 'Accept',
109
- declineText = 'Decline',
110
- acceptedText = 'Joined',
111
- declinedText = 'Declined',
112
- webAppUrl,
113
- }: NotificationPopoverClientProps) {
114
- const [open, setOpen] = useState(false);
115
- const [activeTab, setActiveTab] = useState<TabType>('inbox');
116
- const params = useParams();
117
- const queryClient = useQueryClient();
118
-
119
- const workspaceIdParam =
120
- typeof params.wsId === 'string' ? params.wsId : undefined;
121
- const wsIdForFiltering = isValidWorkspaceFilterId(workspaceIdParam)
122
- ? workspaceIdParam
123
- : undefined;
124
- const wsIdForTaskContext = isValidWorkspaceIdentifier(workspaceIdParam)
125
- ? workspaceIdParam
126
- : undefined;
127
-
128
- // Accurate unread count from dedicated endpoint
129
- const { data: unreadCount = 0 } = useUnreadCount(wsIdForFiltering, {
130
- cacheScope: userId,
131
- enabled: Boolean(userId),
132
- });
133
-
134
- // Infinite scroll for inbox (unread) and archive (read)
135
- const inboxQuery = useInfiniteNotifications({
136
- cacheScope: userId,
137
- wsId: wsIdForFiltering,
138
- unreadOnly: true,
139
- pageSize: 15,
140
- enabled: Boolean(userId) && open && activeTab === 'inbox',
141
- });
142
-
143
- const archiveQuery = useInfiniteNotifications({
144
- cacheScope: userId,
145
- wsId: wsIdForFiltering,
146
- readOnly: true,
147
- pageSize: 15,
148
- enabled: Boolean(userId) && open && activeTab === 'archive',
149
- });
150
-
151
- const markAllAsRead = useMarkAllAsRead();
152
- const updateNotification = useUpdateNotification();
153
-
154
- // Subscribe to realtime updates
155
- useNotificationSubscription(wsIdForFiltering || '', userId || '');
156
-
157
- const activeQuery = activeTab === 'inbox' ? inboxQuery : archiveQuery;
158
- const allNotifications = dedupeNotifications(
159
- activeQuery.data?.pages.flatMap((p) => p.notifications) ?? []
160
- );
161
- const hasNotifications = allNotifications.length > 0;
162
-
163
- // When webAppUrl is set, link to the external web app's notifications page
164
- const baseUrl = webAppUrl ?? '';
165
- const notificationsPageUrl = params.wsId
166
- ? `${baseUrl}/${params.wsId}/notifications`
167
- : `${baseUrl}/notifications`;
168
-
169
- const handleMarkAsRead = async (id: string, isUnread: boolean) => {
170
- try {
171
- await updateNotification.mutateAsync({ id, read: isUnread });
172
- } catch (error) {
173
- console.error('Failed to update notification:', error);
174
- toast.error('Failed to update notification');
175
- }
176
- };
177
-
178
- const handleArchiveAll = async () => {
179
- try {
180
- await markAllAsRead.mutateAsync(wsIdForFiltering);
181
- toast.success('All notifications archived');
182
- } catch (error) {
183
- console.error('Failed to archive all:', error);
184
- toast.error('Failed to archive notifications');
185
- }
186
- };
187
-
188
- if (!userId) {
189
- return null;
190
- }
191
-
192
- return (
193
- <Popover open={open} onOpenChange={setOpen}>
194
- <NotificationPopoverTriggerButton
195
- notificationsText={notificationsText}
196
- unreadCount={unreadCount}
197
- />
198
- <PopoverContent
199
- className="w-100 overflow-hidden rounded-xl border p-0 shadow-xl"
200
- align="end"
201
- >
202
- {/* Header */}
203
- <div className="flex items-center justify-between border-b bg-background px-4 py-3">
204
- <h3 className="font-semibold text-base">{notificationsText}</h3>
205
- <div className="flex items-center gap-2">
206
- {unreadCount > 0 && (
207
- <Button
208
- variant="ghost"
209
- size="sm"
210
- className="h-7 gap-1 text-foreground/60 text-xs hover:text-foreground"
211
- onClick={handleArchiveAll}
212
- disabled={markAllAsRead.isPending}
213
- >
214
- {markAllAsRead.isPending ? (
215
- <Loader2 className="h-3 w-3 animate-spin" />
216
- ) : (
217
- <Archive className="h-3 w-3" />
218
- )}
219
- {archiveAllText}
220
- </Button>
221
- )}
222
- </div>
223
- </div>
224
-
225
- {/* Tabs */}
226
- <div className="flex border-b bg-background">
227
- <button
228
- type="button"
229
- onClick={() => setActiveTab('inbox')}
230
- className={cn(
231
- 'flex flex-1 items-center justify-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-colors',
232
- activeTab === 'inbox'
233
- ? 'border-primary border-b-2 text-primary'
234
- : 'text-foreground/50 hover:text-foreground/80'
235
- )}
236
- >
237
- <Inbox className="h-3.5 w-3.5" />
238
- {inboxText}
239
- {unreadCount > 0 && (
240
- <span className="rounded-full bg-dynamic-red/10 px-1.5 py-0.5 font-semibold text-[10px] text-dynamic-red leading-none">
241
- {unreadCount > 99 ? '99+' : unreadCount}
242
- </span>
243
- )}
244
- </button>
245
- <button
246
- type="button"
247
- onClick={() => setActiveTab('archive')}
248
- className={cn(
249
- 'flex flex-1 items-center justify-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-colors',
250
- activeTab === 'archive'
251
- ? 'border-primary border-b-2 text-primary'
252
- : 'text-foreground/50 hover:text-foreground/80'
253
- )}
254
- >
255
- <Archive className="h-3.5 w-3.5" />
256
- {archiveText}
257
- </button>
258
- </div>
259
-
260
- {/* Notification List with Infinite Scroll — key forces remount on tab switch to reset scroll */}
261
- <NotificationList
262
- key={activeTab}
263
- query={activeQuery}
264
- notifications={allNotifications}
265
- hasNotifications={hasNotifications}
266
- activeTab={activeTab}
267
- wsId={wsIdForTaskContext}
268
- noNotificationsText={noNotificationsText}
269
- emptyArchiveText={emptyArchiveText}
270
- loadingMoreText={loadingMoreText}
271
- retryText={retryText}
272
- acceptText={acceptText}
273
- declineText={declineText}
274
- acceptedText={acceptedText}
275
- declinedText={declinedText}
276
- markAsReadText={markAsReadText}
277
- markAsUnreadText={markAsUnreadText}
278
- onMarkAsRead={handleMarkAsRead}
279
- queryClient={queryClient}
280
- onActionComplete={() => setOpen(false)}
281
- />
282
-
283
- {/* Footer with View All */}
284
- <div className="border-t bg-background">
285
- <Link
286
- href={notificationsPageUrl}
287
- onClick={() => setOpen(false)}
288
- className="flex items-center justify-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-colors hover:rounded-b-xl hover:bg-foreground/5"
289
- >
290
- {viewAllText}
291
- <ChevronRight className="h-4 w-4" />
292
- </Link>
293
- </div>
294
- </PopoverContent>
295
- </Popover>
296
- );
297
- }
298
-
299
- function NotificationList({
300
- query,
301
- notifications,
302
- hasNotifications,
303
- activeTab,
304
- wsId,
305
- noNotificationsText,
306
- emptyArchiveText,
307
- loadingMoreText,
308
- retryText,
309
- acceptText,
310
- declineText,
311
- acceptedText,
312
- declinedText,
313
- markAsReadText,
314
- markAsUnreadText,
315
- onMarkAsRead,
316
- queryClient,
317
- onActionComplete,
318
- }: {
319
- query: ReturnType<typeof useInfiniteNotifications>;
320
- notifications: Notification[];
321
- hasNotifications: boolean;
322
- activeTab: TabType;
323
- wsId?: string;
324
- noNotificationsText: string;
325
- emptyArchiveText: string;
326
- loadingMoreText: string;
327
- retryText: string;
328
- acceptText: string;
329
- declineText: string;
330
- acceptedText: string;
331
- declinedText: string;
332
- markAsReadText: string;
333
- markAsUnreadText: string;
334
- onMarkAsRead: (id: string, isUnread: boolean) => void;
335
- queryClient: any;
336
- onActionComplete: () => void;
337
- }) {
338
- const scrollRef = useRef<HTMLDivElement>(null);
339
- const sentinelRef = useRef<HTMLDivElement>(null);
340
-
341
- // Infinite scroll via IntersectionObserver
342
- const handleIntersect = useCallback(
343
- (entries: IntersectionObserverEntry[]) => {
344
- const entry = entries[0];
345
- if (
346
- entry?.isIntersecting &&
347
- query.hasNextPage &&
348
- !query.isFetchingNextPage
349
- ) {
350
- query.fetchNextPage();
351
- }
352
- },
353
- [query]
354
- );
355
-
356
- useEffect(() => {
357
- const sentinel = sentinelRef.current;
358
- if (!sentinel) return;
359
-
360
- const observer = new IntersectionObserver(handleIntersect, {
361
- root: scrollRef.current,
362
- rootMargin: '100px',
363
- });
364
-
365
- observer.observe(sentinel);
366
- return () => observer.disconnect();
367
- }, [handleIntersect]);
368
-
369
- const emptyText =
370
- activeTab === 'inbox' ? noNotificationsText : emptyArchiveText;
371
-
372
- if (query.error) {
373
- return (
374
- <div className="flex h-90 flex-col items-center justify-center text-center">
375
- <Bell className="mb-3 h-10 w-10 text-dynamic-red/30" />
376
- <p className="text-foreground/60 text-sm">
377
- Failed to load notifications
378
- </p>
379
- <p className="mt-1 text-foreground/40 text-xs">
380
- {query.error instanceof Error ? query.error.message : 'Unknown error'}
381
- </p>
382
- <Button
383
- className="mt-3"
384
- disabled={query.isFetching}
385
- onClick={() => query.refetch()}
386
- size="sm"
387
- type="button"
388
- variant="outline"
389
- >
390
- {query.isFetching ? (
391
- <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
392
- ) : (
393
- <RotateCcw className="mr-1.5 h-3.5 w-3.5" />
394
- )}
395
- {retryText}
396
- </Button>
397
- </div>
398
- );
399
- }
400
-
401
- if (query.isLoading) {
402
- return (
403
- <div className="space-y-2 p-2">
404
- {[...Array(3)].map((_, i) => (
405
- <div key={i} className="rounded-xl border bg-foreground/2 p-3">
406
- <div className="flex items-start gap-3">
407
- <div className="h-8 w-8 flex-none animate-pulse rounded-full bg-foreground/10" />
408
- <div className="min-w-0 flex-1 space-y-2">
409
- <div className="h-3 w-24 animate-pulse rounded bg-foreground/10" />
410
- <div className="h-4 w-full animate-pulse rounded bg-foreground/10" />
411
- <div className="h-3 w-3/4 animate-pulse rounded bg-foreground/10" />
412
- </div>
413
- </div>
414
- </div>
415
- ))}
416
- </div>
417
- );
418
- }
419
-
420
- if (!hasNotifications) {
421
- return (
422
- <div className="flex h-90 flex-col items-center justify-center text-center">
423
- {activeTab === 'inbox' ? (
424
- <Inbox className="mb-3 h-10 w-10 text-foreground/20" />
425
- ) : (
426
- <Archive className="mb-3 h-10 w-10 text-foreground/20" />
427
- )}
428
- <p className="text-foreground/60 text-sm">{emptyText}</p>
429
- </div>
430
- );
431
- }
432
-
433
- return (
434
- <div ref={scrollRef} className="max-h-90 overflow-y-auto">
435
- <div className="space-y-1.5 p-2">
436
- {notifications.map((notification) => (
437
- <NotificationCard
438
- key={notification.id}
439
- notification={notification}
440
- wsId={wsId}
441
- onMarkAsRead={onMarkAsRead}
442
- markAsReadText={markAsReadText}
443
- markAsUnreadText={markAsUnreadText}
444
- queryClient={queryClient}
445
- onActionComplete={onActionComplete}
446
- acceptText={acceptText}
447
- declineText={declineText}
448
- acceptedText={acceptedText}
449
- declinedText={declinedText}
450
- />
451
- ))}
452
-
453
- {/* Sentinel for infinite scroll */}
454
- <div ref={sentinelRef} className="h-1" />
455
-
456
- {query.isFetchingNextPage && (
457
- <div className="flex items-center justify-center gap-2 py-3 text-foreground/40 text-xs">
458
- <Loader2 className="h-3 w-3 animate-spin" />
459
- {loadingMoreText}
460
- </div>
461
- )}
462
- </div>
463
- </div>
464
- );
465
- }
466
-
467
- interface NotificationCardProps {
468
- notification: Notification;
469
- wsId?: string;
470
- onMarkAsRead: (id: string, isUnread: boolean) => void;
471
- markAsReadText: string;
472
- markAsUnreadText: string;
473
- queryClient: any;
474
- onActionComplete?: () => void;
475
- acceptText: string;
476
- declineText: string;
477
- acceptedText: string;
478
- declinedText: string;
479
- }
480
-
481
- function getWorkspaceInviteWorkspaceId(notification: Notification) {
482
- const candidates = [
483
- notification.data?.workspace_id,
484
- notification.entity_id,
485
- notification.ws_id,
486
- ];
487
-
488
- return (
489
- candidates.find(
490
- (candidate): candidate is string =>
491
- typeof candidate === 'string' && candidate.length > 0
492
- ) ?? null
493
- );
494
- }
495
-
496
- function NotificationCard({
497
- notification,
498
- wsId,
499
- onMarkAsRead,
500
- markAsReadText,
501
- markAsUnreadText,
502
- queryClient,
503
- onActionComplete,
504
- acceptText,
505
- declineText,
506
- acceptedText,
507
- declinedText,
508
- }: NotificationCardProps) {
509
- const isUnread = !notification.read_at;
510
- const [processingAction, setProcessingAction] = useState<string | null>(null);
511
- const router = useRouter();
4
+ import { NotificationRuntime } from './notification-runtime';
5
+ import SatelliteNotificationPopover, {
6
+ type NotificationPopoverClientProps,
7
+ } from './satellite-notification-popover';
8
+ export default function NotificationPopoverClient(
9
+ props: NotificationPopoverClientProps
10
+ ) {
512
11
  const params = useParams();
513
12
  const pathname = usePathname();
514
-
515
- const handleAction = async (actionType: string, payload: any) => {
516
- setProcessingAction(actionType);
517
-
518
- try {
519
- switch (actionType) {
520
- case 'WORKSPACE_INVITE_ACCEPT':
521
- case 'WORKSPACE_INVITE_DECLINE': {
522
- const accept = actionType === 'WORKSPACE_INVITE_ACCEPT';
523
- const targetWsId = payload.wsId;
524
- if (!targetWsId) {
525
- toast.error('Failed to process invite');
526
- break;
527
- }
528
-
529
- await (accept
530
- ? acceptWorkspaceInvite(targetWsId)
531
- : declineWorkspaceInvite(targetWsId));
532
- await updateNotificationMetadata(notification.id, {
533
- action_taken: accept ? 'accepted' : 'declined',
534
- action_timestamp: new Date().toISOString(),
535
- });
536
-
537
- await Promise.all([
538
- queryClient.invalidateQueries({
539
- queryKey: ['workspaces'],
540
- refetchType: 'active',
541
- }),
542
- queryClient.invalidateQueries({
543
- queryKey: ['notifications'],
544
- refetchType: 'active',
545
- }),
546
- queryClient.refetchQueries({
547
- queryKey: ['notifications'],
548
- type: 'active',
549
- }),
550
- ]);
551
-
552
- toast.success(accept ? acceptedText : declinedText);
553
-
554
- onMarkAsRead(notification.id, true);
555
- router.refresh();
556
- onActionComplete?.();
557
- break;
558
- }
559
- default:
560
- break;
561
- }
562
- } catch (error) {
563
- console.error('Action error:', error);
564
- toast.error('An error occurred');
565
- } finally {
566
- setProcessingAction(null);
567
- }
568
- };
569
-
570
- const notificationWsId = notification.ws_id || wsId;
571
- const isTaskEntityNotification =
572
- notification.entity_type === 'task' && !!notification.entity_id;
573
- const entityLink =
574
- notification.entity_type === 'time_tracking_request' &&
575
- notification.entity_id &&
576
- notificationWsId
577
- ? `/${notificationWsId}/time-tracker/requests`
578
- : null;
579
-
13
+ const router = useRouter();
580
14
  return (
581
- <div
582
- className={cn(
583
- 'group relative rounded-xl border p-3 transition-all hover:border-foreground/20 hover:shadow-sm',
584
- isUnread
585
- ? 'border-dynamic-blue/40 bg-dynamic-blue/5'
586
- : 'bg-foreground/2 hover:bg-foreground/4'
587
- )}
15
+ <NotificationRuntime
16
+ value={{ params, pathname, router, Link, realtime: true }}
588
17
  >
589
- <div className="flex items-start gap-3">
590
- {/* Icon */}
591
- <div
592
- className={cn(
593
- 'flex h-8 w-8 flex-none items-center justify-center rounded-full transition-colors',
594
- isUnread
595
- ? 'bg-dynamic-blue/20 text-dynamic-blue'
596
- : 'bg-foreground/10 text-foreground/60'
597
- )}
598
- >
599
- {getNotificationIcon(notification.type)}
600
- </div>
601
-
602
- {/* Content */}
603
- <div className="min-w-0 flex-1">
604
- <div className="mb-1 font-medium text-sm leading-snug">
605
- {notification.title}
606
- </div>
607
-
608
- {notification.description && (
609
- <div className="mb-1.5 line-clamp-2 text-foreground/70 text-xs leading-relaxed">
610
- {notification.description}
611
- </div>
612
- )}
613
-
614
- <div className="mb-2 text-foreground/40 text-xs">
615
- {dayjs(notification.created_at).fromNow()}
616
- </div>
617
-
618
- {/* Action buttons or status */}
619
- {notification.type === 'workspace_invite' &&
620
- notification.data?.action_taken ? (
621
- <div className="mt-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
622
- {notification.data.action_taken === 'accepted' ? (
623
- <>
624
- <CheckCircle2 className="h-3 w-3 text-dynamic-green" />
625
- <span className="font-medium text-dynamic-green">
626
- {acceptedText}
627
- </span>
628
- </>
629
- ) : (
630
- <>
631
- <XCircle className="h-3 w-3 text-foreground/40" />
632
- <span className="font-medium text-foreground/60">
633
- {declinedText}
634
- </span>
635
- </>
636
- )}
637
- </div>
638
- ) : notification.type === 'workspace_invite' &&
639
- !notification.data?.action_taken ? (
640
- <div className="mt-2 flex items-center gap-1.5">
641
- <Button
642
- variant="outline"
643
- size="sm"
644
- onClick={() =>
645
- handleAction('WORKSPACE_INVITE_DECLINE', {
646
- wsId: getWorkspaceInviteWorkspaceId(notification),
647
- })
648
- }
649
- disabled={!!processingAction}
650
- className="h-7 gap-1 text-xs"
651
- >
652
- {processingAction === 'WORKSPACE_INVITE_DECLINE' ? (
653
- <Loader2 className="h-3 w-3 animate-spin" />
654
- ) : (
655
- <X className="h-3 w-3" />
656
- )}
657
- {declineText}
658
- </Button>
659
- <Button
660
- size="sm"
661
- onClick={() =>
662
- handleAction('WORKSPACE_INVITE_ACCEPT', {
663
- wsId: getWorkspaceInviteWorkspaceId(notification),
664
- })
665
- }
666
- disabled={!!processingAction}
667
- className="h-7 gap-1 text-xs"
668
- >
669
- {processingAction === 'WORKSPACE_INVITE_ACCEPT' ? (
670
- <Loader2 className="h-3 w-3 animate-spin" />
671
- ) : (
672
- <Check className="h-3 w-3" />
673
- )}
674
- {acceptText}
675
- </Button>
676
- </div>
677
- ) : isTaskEntityNotification ? (
678
- <Button
679
- variant="link"
680
- size="sm"
681
- className="h-auto p-0 text-xs hover:text-dynamic-blue"
682
- disabled={!!processingAction}
683
- onClick={async () => {
684
- if (!notification.entity_id) return;
685
- setProcessingAction('OPEN_TASK');
686
- try {
687
- const { handled, requestId } = dispatchRequestOpenTask({
688
- taskId: notification.entity_id,
689
- wsId: notificationWsId,
690
- });
691
-
692
- const opened = handled
693
- ? await waitForTaskOpenResult(requestId, 6000)
694
- : false;
695
-
696
- if (!opened && notificationWsId) {
697
- const locale =
698
- typeof params?.locale === 'string' ? params.locale : null;
699
- const targetWorkspacePath = locale
700
- ? `/${locale}/${notificationWsId}`
701
- : `/${notificationWsId}`;
702
- const isAlreadyInTargetWorkspace =
703
- pathname === targetWorkspacePath ||
704
- pathname.startsWith(`${targetWorkspacePath}/`);
705
-
706
- const openTaskParam = `openTaskId=${encodeURIComponent(notification.entity_id)}`;
707
- const fallbackUrl = isAlreadyInTargetWorkspace
708
- ? `${pathname}?${openTaskParam}`
709
- : `${targetWorkspacePath}?${openTaskParam}`;
710
-
711
- router.push(fallbackUrl);
712
- }
713
- onActionComplete?.();
714
- } finally {
715
- setProcessingAction(null);
716
- }
717
- }}
718
- >
719
- {processingAction === 'OPEN_TASK' ? (
720
- <>
721
- <Loader2 className="h-3 w-3 animate-spin" />
722
- Opening...
723
- </>
724
- ) : (
725
- 'View details →'
726
- )}
727
- </Button>
728
- ) : entityLink ? (
729
- <Link href={entityLink} onClick={onActionComplete}>
730
- <Button
731
- variant="link"
732
- size="sm"
733
- className="h-auto p-0 text-xs hover:text-dynamic-blue"
734
- >
735
- View details →
736
- </Button>
737
- </Link>
738
- ) : null}
739
- </div>
740
-
741
- {/* Mark as read/unread button */}
742
- <Button
743
- variant="ghost"
744
- size="icon"
745
- className={cn(
746
- 'h-7 w-7 flex-none transition-opacity hover:bg-foreground/10',
747
- 'opacity-0 group-hover:opacity-100'
748
- )}
749
- onClick={() => onMarkAsRead(notification.id, isUnread)}
750
- title={isUnread ? markAsReadText : markAsUnreadText}
751
- >
752
- {isUnread ? (
753
- <EyeOff className="h-4 w-4" />
754
- ) : (
755
- <Eye className="h-4 w-4" />
756
- )}
757
- </Button>
758
- </div>
759
- </div>
18
+ <SatelliteNotificationPopover {...props} />
19
+ </NotificationRuntime>
760
20
  );
761
21
  }
762
-
763
- function getNotificationIcon(type: string) {
764
- const iconClassName = 'h-4 w-4';
765
-
766
- switch (type) {
767
- case 'task_assigned':
768
- return <ClipboardList className={iconClassName} />;
769
- case 'task_updated':
770
- return <Edit3 className={iconClassName} />;
771
- case 'task_completed':
772
- return <CheckCircle2 className={iconClassName} />;
773
- case 'task_reopened':
774
- return <RotateCcw className={iconClassName} />;
775
- case 'task_mention':
776
- return <AtSign className={iconClassName} />;
777
- case 'task_due_date_changed':
778
- case 'task_start_date_changed':
779
- return <Calendar className={iconClassName} />;
780
- case 'workspace_invite':
781
- return <Mail className={iconClassName} />;
782
- case 'account_update':
783
- return <UserPlus className={iconClassName} />;
784
- case 'security_alert':
785
- return <Shield className={iconClassName} />;
786
- default:
787
- return <Bell className={iconClassName} />;
788
- }
789
- }