@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
@@ -0,0 +1,272 @@
1
+ 'use client';
2
+
3
+ import { useQueryClient } from '@tanstack/react-query';
4
+ import { Archive, ChevronRight, Inbox, Loader2 } from '@tuturuuu/icons';
5
+ import { Button } from '@tuturuuu/ui/button';
6
+ import {
7
+ dedupeNotifications,
8
+ useInfiniteNotifications,
9
+ useMarkAllAsRead,
10
+ useNotificationSubscription,
11
+ useUnreadCount,
12
+ useUpdateNotification,
13
+ } from '@tuturuuu/ui/hooks/use-notifications';
14
+ import { Popover, PopoverContent } from '@tuturuuu/ui/popover';
15
+ import { toast } from '@tuturuuu/ui/sonner';
16
+ import { cn } from '@tuturuuu/utils/format';
17
+ import dayjs from 'dayjs';
18
+ import relativeTime from 'dayjs/plugin/relativeTime';
19
+ import { useState } from 'react';
20
+ import { NotificationList } from './notification-list';
21
+ import { NotificationPopoverTriggerButton } from './notification-popover-trigger';
22
+ import { useNotificationRuntime } from './notification-runtime';
23
+
24
+ dayjs.extend(relativeTime);
25
+ type TabType = 'inbox' | 'archive';
26
+
27
+ export interface NotificationPopoverClientProps {
28
+ userId?: string;
29
+ noNotificationsText: string;
30
+ notificationsText: string;
31
+ viewAllText: string;
32
+ markAsReadText: string;
33
+ markAsUnreadText: string;
34
+ inboxText?: string;
35
+ archiveText?: string;
36
+ archiveAllText?: string;
37
+ emptyArchiveText?: string;
38
+ loadingMoreText?: string;
39
+ retryText?: string;
40
+ acceptText?: string;
41
+ declineText?: string;
42
+ acceptedText?: string;
43
+ declinedText?: string;
44
+ /** Base URL for external redirect (e.g. 'https://tuturuuu.com'). When set, "View All" links to {webAppUrl}/{wsId}/notifications. */
45
+ webAppUrl?: string;
46
+ }
47
+
48
+ // Workspace identifier validation regex
49
+ const UUID_REGEX =
50
+ /^[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}$/;
51
+ const SPECIAL_WORKSPACE_SLUGS = new Set(['personal', 'internal']);
52
+
53
+ function isValidWorkspaceIdentifier(value: unknown): value is string {
54
+ return (
55
+ typeof value === 'string' &&
56
+ (UUID_REGEX.test(value) || SPECIAL_WORKSPACE_SLUGS.has(value))
57
+ );
58
+ }
59
+
60
+ function isValidWorkspaceFilterId(value: unknown): value is string {
61
+ return typeof value === 'string' && UUID_REGEX.test(value);
62
+ }
63
+
64
+ export default function SatelliteNotificationPopover({
65
+ userId,
66
+ noNotificationsText,
67
+ notificationsText,
68
+ viewAllText,
69
+ markAsReadText,
70
+ markAsUnreadText,
71
+ inboxText = 'Inbox',
72
+ archiveText = 'Archive',
73
+ archiveAllText = 'Archive all',
74
+ emptyArchiveText = 'No archived notifications yet.',
75
+ loadingMoreText = 'Loading more...',
76
+ retryText = 'Retry',
77
+ acceptText = 'Accept',
78
+ declineText = 'Decline',
79
+ acceptedText = 'Joined',
80
+ declinedText = 'Declined',
81
+ webAppUrl,
82
+ }: NotificationPopoverClientProps) {
83
+ const [open, setOpen] = useState(false);
84
+ const [activeTab, setActiveTab] = useState<TabType>('inbox');
85
+ const { params, Link, realtime } = useNotificationRuntime();
86
+ const queryClient = useQueryClient();
87
+
88
+ const workspaceIdParam =
89
+ typeof params.wsId === 'string' ? params.wsId : undefined;
90
+ const wsIdForFiltering = isValidWorkspaceFilterId(workspaceIdParam)
91
+ ? workspaceIdParam
92
+ : undefined;
93
+ const wsIdForTaskContext = isValidWorkspaceIdentifier(workspaceIdParam)
94
+ ? workspaceIdParam
95
+ : undefined;
96
+
97
+ // Accurate unread count from dedicated endpoint
98
+ const { data: unreadCount = 0 } = useUnreadCount(wsIdForFiltering, {
99
+ cacheScope: userId,
100
+ enabled: Boolean(userId),
101
+ refetchInterval: realtime ? undefined : 30_000,
102
+ });
103
+
104
+ // Infinite scroll for inbox (unread) and archive (read)
105
+ const inboxQuery = useInfiniteNotifications({
106
+ cacheScope: userId,
107
+ wsId: wsIdForFiltering,
108
+ unreadOnly: true,
109
+ pageSize: 15,
110
+ refetchInterval: realtime ? undefined : 30_000,
111
+ enabled: Boolean(userId) && open && activeTab === 'inbox',
112
+ });
113
+
114
+ const archiveQuery = useInfiniteNotifications({
115
+ cacheScope: userId,
116
+ wsId: wsIdForFiltering,
117
+ readOnly: true,
118
+ pageSize: 15,
119
+ refetchInterval: realtime ? undefined : 30_000,
120
+ enabled: Boolean(userId) && open && activeTab === 'archive',
121
+ });
122
+
123
+ const markAllAsRead = useMarkAllAsRead();
124
+ const updateNotification = useUpdateNotification();
125
+
126
+ // Subscribe to realtime updates
127
+ useNotificationSubscription(
128
+ wsIdForFiltering || '',
129
+ realtime ? userId || '' : ''
130
+ );
131
+
132
+ const activeQuery = activeTab === 'inbox' ? inboxQuery : archiveQuery;
133
+ const allNotifications = dedupeNotifications(
134
+ activeQuery.data?.pages.flatMap((p) => p.notifications) ?? []
135
+ );
136
+ const hasNotifications = allNotifications.length > 0;
137
+
138
+ // When webAppUrl is set, link to the external web app's notifications page
139
+ const baseUrl = webAppUrl ?? '';
140
+ const notificationsPageUrl = params.wsId
141
+ ? `${baseUrl}/${params.wsId}/notifications`
142
+ : `${baseUrl}/notifications`;
143
+
144
+ const handleMarkAsRead = async (id: string, isUnread: boolean) => {
145
+ try {
146
+ await updateNotification.mutateAsync({ id, read: isUnread });
147
+ } catch (error) {
148
+ console.error('Failed to update notification:', error);
149
+ toast.error('Failed to update notification');
150
+ }
151
+ };
152
+
153
+ const handleArchiveAll = async () => {
154
+ try {
155
+ await markAllAsRead.mutateAsync(wsIdForFiltering);
156
+ toast.success('All notifications archived');
157
+ } catch (error) {
158
+ console.error('Failed to archive all:', error);
159
+ toast.error('Failed to archive notifications');
160
+ }
161
+ };
162
+
163
+ if (!userId) {
164
+ return null;
165
+ }
166
+
167
+ return (
168
+ <Popover open={open} onOpenChange={setOpen}>
169
+ <NotificationPopoverTriggerButton
170
+ notificationsText={notificationsText}
171
+ unreadCount={unreadCount}
172
+ />
173
+ <PopoverContent
174
+ className="w-100 max-w-[calc(100vw-1rem)] overflow-hidden rounded-xl border p-0 shadow-xl"
175
+ align="end"
176
+ >
177
+ {/* Header */}
178
+ <div className="flex items-center justify-between border-b bg-background px-4 py-3">
179
+ <h3 className="font-semibold text-base">{notificationsText}</h3>
180
+ <div className="flex items-center gap-2">
181
+ {unreadCount > 0 && (
182
+ <Button
183
+ variant="ghost"
184
+ size="sm"
185
+ className="h-7 gap-1 text-foreground/60 text-xs hover:text-foreground"
186
+ onClick={handleArchiveAll}
187
+ disabled={markAllAsRead.isPending}
188
+ >
189
+ {markAllAsRead.isPending ? (
190
+ <Loader2 className="h-3 w-3 animate-spin" />
191
+ ) : (
192
+ <Archive className="h-3 w-3" />
193
+ )}
194
+ {archiveAllText}
195
+ </Button>
196
+ )}
197
+ </div>
198
+ </div>
199
+
200
+ {/* Tabs */}
201
+ <div className="flex border-b bg-background">
202
+ <button
203
+ type="button"
204
+ onClick={() => setActiveTab('inbox')}
205
+ className={cn(
206
+ 'flex flex-1 items-center justify-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-colors',
207
+ activeTab === 'inbox'
208
+ ? 'border-primary border-b-2 text-primary'
209
+ : 'text-foreground/50 hover:text-foreground/80'
210
+ )}
211
+ >
212
+ <Inbox className="h-3.5 w-3.5" />
213
+ {inboxText}
214
+ {unreadCount > 0 && (
215
+ <span className="rounded-full bg-dynamic-red/10 px-1.5 py-0.5 font-semibold text-[10px] text-dynamic-red leading-none">
216
+ {unreadCount > 99 ? '99+' : unreadCount}
217
+ </span>
218
+ )}
219
+ </button>
220
+ <button
221
+ type="button"
222
+ onClick={() => setActiveTab('archive')}
223
+ className={cn(
224
+ 'flex flex-1 items-center justify-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-colors',
225
+ activeTab === 'archive'
226
+ ? 'border-primary border-b-2 text-primary'
227
+ : 'text-foreground/50 hover:text-foreground/80'
228
+ )}
229
+ >
230
+ <Archive className="h-3.5 w-3.5" />
231
+ {archiveText}
232
+ </button>
233
+ </div>
234
+
235
+ {/* Notification List with Infinite Scroll — key forces remount on tab switch to reset scroll */}
236
+ <NotificationList
237
+ key={activeTab}
238
+ query={activeQuery}
239
+ notifications={allNotifications}
240
+ hasNotifications={hasNotifications}
241
+ activeTab={activeTab}
242
+ wsId={wsIdForTaskContext}
243
+ noNotificationsText={noNotificationsText}
244
+ emptyArchiveText={emptyArchiveText}
245
+ loadingMoreText={loadingMoreText}
246
+ retryText={retryText}
247
+ acceptText={acceptText}
248
+ declineText={declineText}
249
+ acceptedText={acceptedText}
250
+ declinedText={declinedText}
251
+ markAsReadText={markAsReadText}
252
+ markAsUnreadText={markAsUnreadText}
253
+ onMarkAsRead={handleMarkAsRead}
254
+ queryClient={queryClient}
255
+ onActionComplete={() => setOpen(false)}
256
+ />
257
+
258
+ {/* Footer with View All */}
259
+ <div className="border-t bg-background">
260
+ <Link
261
+ href={notificationsPageUrl}
262
+ onClick={() => setOpen(false)}
263
+ 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"
264
+ >
265
+ {viewAllText}
266
+ <ChevronRight className="h-4 w-4" />
267
+ </Link>
268
+ </div>
269
+ </PopoverContent>
270
+ </Popover>
271
+ );
272
+ }
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+ import { MessageSquareText, Play, RotateCcw } from '@tuturuuu/icons';
3
+ import { Button } from '@tuturuuu/ui/button';
4
+ export function SatelliteOnboardingSettingsPanel({
5
+ t,
6
+ pending,
7
+ replayApp,
8
+ restartJourney,
9
+ shareFeedback,
10
+ }: {
11
+ t: (
12
+ key:
13
+ | 'feedback'
14
+ | 'feedback_description'
15
+ | 'replay_app'
16
+ | 'replay_app_description'
17
+ | 'restart_journey'
18
+ | 'restart_journey_description'
19
+ ) => string;
20
+ pending: 'replay' | 'restart' | null;
21
+ replayApp: () => void;
22
+ restartJourney: () => void;
23
+ shareFeedback: () => void;
24
+ }) {
25
+ const actions = [
26
+ {
27
+ description: t('replay_app_description'),
28
+ icon: Play,
29
+ label: t('replay_app'),
30
+ onClick: replayApp,
31
+ pending: pending === 'replay',
32
+ },
33
+ {
34
+ description: t('restart_journey_description'),
35
+ icon: RotateCcw,
36
+ label: t('restart_journey'),
37
+ onClick: restartJourney,
38
+ pending: pending === 'restart',
39
+ },
40
+ {
41
+ description: t('feedback_description'),
42
+ icon: MessageSquareText,
43
+ label: t('feedback'),
44
+ onClick: shareFeedback,
45
+ pending: false,
46
+ },
47
+ ];
48
+
49
+ return (
50
+ <div className="space-y-3">
51
+ {actions.map((action) => (
52
+ <div
53
+ key={action.label}
54
+ className="flex flex-col gap-4 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between"
55
+ >
56
+ <div className="flex gap-3">
57
+ <div className="grid size-9 shrink-0 place-items-center rounded-lg border bg-muted/40">
58
+ <action.icon className="size-4" />
59
+ </div>
60
+ <div>
61
+ <p className="font-medium text-sm">{action.label}</p>
62
+ <p className="mt-1 max-w-xl text-muted-foreground text-sm">
63
+ {action.description}
64
+ </p>
65
+ </div>
66
+ </div>
67
+ <Button
68
+ type="button"
69
+ variant="outline"
70
+ disabled={action.pending}
71
+ onClick={action.onClick}
72
+ >
73
+ <action.icon className="size-4" />
74
+ {action.label}
75
+ </Button>
76
+ </div>
77
+ ))}
78
+ </div>
79
+ );
80
+ }