@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,25 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+ import { expect, it, vi } from 'vitest';
3
+ import { saveCalendarEventDrafts } from './save-calendar-event-drafts';
4
+
5
+ it('retains rejected or unconfirmed drafts and retries only those events', async () => {
6
+ const drafts = [
7
+ { title: 'Saved' },
8
+ { title: 'Rejected', description: 'Keep this content' },
9
+ { title: 'Unconfirmed' },
10
+ ];
11
+ const addEvent = vi
12
+ .fn()
13
+ .mockResolvedValueOnce({ id: 'event-1' } as CalendarEvent)
14
+ .mockRejectedValueOnce(new Error('Offline'))
15
+ .mockResolvedValueOnce(undefined);
16
+ const result = await saveCalendarEventDrafts(drafts, addEvent, undefined);
17
+ expect(result.savedEvents).toEqual([{ id: 'event-1' }]);
18
+ expect(result.failedEvents).toEqual(drafts.slice(1));
19
+ addEvent.mockResolvedValue({ id: 'retried-event' });
20
+ await saveCalendarEventDrafts(result.failedEvents, addEvent, undefined);
21
+ expect(addEvent.mock.calls.slice(3).map(([event]) => event.title)).toEqual([
22
+ 'Rejected',
23
+ 'Unconfirmed',
24
+ ]);
25
+ });
@@ -0,0 +1,32 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+
3
+ /** Keep only unconfirmed drafts after a batch so retry never resends saved events. */
4
+ export async function saveCalendarEventDrafts(
5
+ drafts: Partial<CalendarEvent>[],
6
+ addEvent: (
7
+ event: Omit<CalendarEvent, 'id'>
8
+ ) => Promise<CalendarEvent | undefined>,
9
+ source: CalendarEvent['source']
10
+ ) {
11
+ const savedEvents: CalendarEvent[] = [];
12
+ const failedEvents: Partial<CalendarEvent>[] = [];
13
+ for (const draft of drafts) {
14
+ try {
15
+ const saved = await addEvent({
16
+ title: draft.title || 'New Event',
17
+ description: draft.description || '',
18
+ start_at: draft.start_at || '',
19
+ end_at: draft.end_at || '',
20
+ color: draft.color || 'BLUE',
21
+ location: draft.location || '',
22
+ locked: draft.locked || false,
23
+ source,
24
+ });
25
+ if (saved?.id) savedEvents.push(saved);
26
+ else failedEvents.push(draft);
27
+ } catch {
28
+ failedEvents.push(draft);
29
+ }
30
+ }
31
+ return { savedEvents, failedEvents };
32
+ }
@@ -27,6 +27,7 @@ export const SmartCalendar = ({
27
27
  disabled,
28
28
  enableHeader = true,
29
29
  experimentalGoogleToken,
30
+ initialEventId,
30
31
  externalState,
31
32
  extras,
32
33
  overlay,
@@ -46,6 +47,7 @@ export const SmartCalendar = ({
46
47
  disabled?: boolean;
47
48
  enableHeader?: boolean;
48
49
  experimentalGoogleToken?: WorkspaceCalendarGoogleTokenClient | null;
50
+ initialEventId?: string;
49
51
  externalState?: {
50
52
  date: Date;
51
53
  setDate: React.Dispatch<React.SetStateAction<Date>>;
@@ -84,6 +86,7 @@ export const SmartCalendar = ({
84
86
  useQuery={useQuery}
85
87
  useQueryClient={useQueryClient}
86
88
  experimentalGoogleToken={experimentalGoogleToken}
89
+ initialEventId={initialEventId}
87
90
  eventAdapter={eventAdapter}
88
91
  readOnly={disabled}
89
92
  >
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+
3
+ import { isPageShortcutBlocked } from '@tuturuuu/utils/keyboard-shortcuts';
4
+ import { useEffect, useEffectEvent } from 'react';
5
+ import type { CalendarView } from '../../../../hooks/use-view-transition';
6
+
7
+ export const CALENDAR_VIEW_KEYS: Record<string, CalendarView> = {
8
+ d: 'day',
9
+ '4': '4-days',
10
+ w: 'week',
11
+ m: 'month',
12
+ y: 'year',
13
+ a: 'agenda',
14
+ };
15
+
16
+ export function calendarShortcutView(
17
+ event: KeyboardEvent
18
+ ): CalendarView | null {
19
+ if (
20
+ isPageShortcutBlocked(event) ||
21
+ event.metaKey ||
22
+ event.ctrlKey ||
23
+ event.altKey ||
24
+ event.shiftKey
25
+ )
26
+ return null;
27
+ return CALENDAR_VIEW_KEYS[event.key.toLowerCase()] ?? null;
28
+ }
29
+
30
+ export function useCalendarViewShortcuts(
31
+ actions: Record<CalendarView, () => void> & {
32
+ enabled: boolean;
33
+ availableViews?: { value: string; disabled?: boolean }[];
34
+ }
35
+ ) {
36
+ const onKeyDown = useEffectEvent((event: KeyboardEvent) => {
37
+ const view = calendarShortcutView(event);
38
+ if (!view) return;
39
+ const available = actions.availableViews;
40
+ if (
41
+ available?.length &&
42
+ !available.some((item) => item.value === view && !item.disabled)
43
+ )
44
+ return;
45
+ event.preventDefault();
46
+ actions[view]();
47
+ });
48
+ useEffect(() => {
49
+ if (!actions.enabled) return;
50
+ const listener = (event: KeyboardEvent) => onKeyDown(event);
51
+ window.addEventListener('keydown', listener);
52
+ return () => window.removeEventListener('keydown', listener);
53
+ }, [actions.enabled]);
54
+ }
@@ -0,0 +1,59 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { useState } from 'react';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { useEventDraftSession } from './use-event-draft-session';
5
+
6
+ describe('calendar draft session', () => {
7
+ it('preserves typed content through background refreshes and initializes on reopening or switching events', () => {
8
+ const { result, rerender } = renderHook(
9
+ ({ open, eventId, workspaceId, title }) => {
10
+ const [draft, setDraft] = useState('');
11
+ useEventDraftSession({
12
+ isOpen: open,
13
+ eventId,
14
+ workspaceId,
15
+ initialize: () => setDraft(title),
16
+ });
17
+ return { draft, setDraft };
18
+ },
19
+ {
20
+ initialProps: {
21
+ open: true,
22
+ eventId: 'new',
23
+ workspaceId: 'ws-1',
24
+ title: '',
25
+ },
26
+ }
27
+ );
28
+ act(() => result.current.setDraft('Unsaved meeting details'));
29
+ rerender({
30
+ open: true,
31
+ eventId: 'new',
32
+ workspaceId: 'ws-1',
33
+ title: 'Refetched server state',
34
+ });
35
+ expect(result.current.draft).toBe('Unsaved meeting details');
36
+ rerender({ open: false, eventId: 'new', workspaceId: 'ws-1', title: '' });
37
+ rerender({
38
+ open: true,
39
+ eventId: 'new',
40
+ workspaceId: 'ws-1',
41
+ title: 'Next draft',
42
+ });
43
+ expect(result.current.draft).toBe('Next draft');
44
+ rerender({
45
+ open: true,
46
+ eventId: 'event-2',
47
+ workspaceId: 'ws-1',
48
+ title: 'Existing meeting',
49
+ });
50
+ expect(result.current.draft).toBe('Existing meeting');
51
+ rerender({
52
+ open: true,
53
+ eventId: 'event-2',
54
+ workspaceId: 'ws-2',
55
+ title: 'Other workspace',
56
+ });
57
+ expect(result.current.draft).toBe('Other workspace');
58
+ });
59
+ });
@@ -0,0 +1,20 @@
1
+ import { useEffect, useEffectEvent } from 'react';
2
+
3
+ /** Initialize a draft only on opening or switching the selected event/workspace. */
4
+ export function useEventDraftSession({
5
+ isOpen,
6
+ eventId,
7
+ workspaceId,
8
+ initialize,
9
+ }: {
10
+ isOpen: boolean;
11
+ eventId?: string;
12
+ workspaceId?: string;
13
+ initialize: () => void;
14
+ }) {
15
+ const initializeDraft = useEffectEvent(initialize);
16
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Event and workspace identity define a new editing session.
17
+ useEffect(() => {
18
+ if (isOpen) initializeDraft();
19
+ }, [isOpen, eventId, workspaceId]);
20
+ }
@@ -7,11 +7,11 @@ import { cn } from '@tuturuuu/utils/format';
7
7
  import {
8
8
  eachDayOfInterval,
9
9
  endOfMonth,
10
- format,
11
10
  getDay,
12
11
  isToday,
13
12
  startOfMonth,
14
13
  } from 'date-fns';
14
+ import { useTranslations } from 'next-intl';
15
15
  import { useMemo } from 'react';
16
16
  import {
17
17
  formatLunarDay,
@@ -57,9 +57,12 @@ function getEventDotColor(color: string): string {
57
57
 
58
58
  function getWeekdayLabels(
59
59
  firstDayOfWeek: number,
60
- showWeekends: boolean
60
+ showWeekends: boolean,
61
+ locale: string
61
62
  ): string[] {
62
- const days = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
63
+ const days = Array.from({ length: 7 }, (_, day) =>
64
+ new Date(2026, 0, 4 + day).toLocaleDateString(locale, { weekday: 'narrow' })
65
+ );
63
66
 
64
67
  const reordered: { label: string; dayIndex: number }[] = [];
65
68
  for (let i = 0; i < 7; i++) {
@@ -93,14 +96,15 @@ function MiniMonth({
93
96
  onDayClick?: (date: Date) => void;
94
97
  onMonthClick?: (date: Date) => void;
95
98
  }) {
99
+ const t = useTranslations('calendar');
96
100
  const { getCurrentEvents } = useCalendar();
97
101
  const monthStart = startOfMonth(monthDate);
98
102
  const monthEnd = endOfMonth(monthDate);
99
103
  const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
100
104
 
101
105
  const weekdayLabels = useMemo(
102
- () => getWeekdayLabels(firstDayOfWeek, showWeekends),
103
- [firstDayOfWeek, showWeekends]
106
+ () => getWeekdayLabels(firstDayOfWeek, showWeekends, locale),
107
+ [firstDayOfWeek, showWeekends, locale]
104
108
  );
105
109
 
106
110
  const numCols = showWeekends ? 7 : 5;
@@ -157,17 +161,25 @@ function MiniMonth({
157
161
  }, [days, getCurrentEvents]);
158
162
 
159
163
  return (
160
- <div className="flex flex-col gap-1 rounded-xl border bg-background/50 p-3">
164
+ <section
165
+ className={cn(
166
+ 'flex flex-col gap-2 rounded-xl border p-4 transition-colors hover:bg-muted/20',
167
+ isCurrentMonth ? 'border-primary/35 bg-primary/5' : 'bg-background'
168
+ )}
169
+ >
161
170
  <button
162
171
  type="button"
163
172
  onClick={() => onMonthClick?.(monthDate)}
164
173
  className={cn(
165
174
  'rounded-md px-1 py-1 text-left font-semibold text-sm transition-colors',
166
- 'hover:bg-foreground/5',
175
+ 'hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring',
167
176
  isCurrentMonth && 'text-primary'
168
177
  )}
169
178
  >
170
- {format(monthDate, 'MMMM')}
179
+ <span>{monthDate.toLocaleDateString(locale, { month: 'long' })}</span>
180
+ <span className="ml-2 font-normal text-muted-foreground text-xs">
181
+ {t('views.busy_days', { count: dayEventData.size })}
182
+ </span>
171
183
  </button>
172
184
 
173
185
  <div
@@ -199,7 +211,7 @@ function MiniMonth({
199
211
  />
200
212
  ))}
201
213
  </div>
202
- </div>
214
+ </section>
203
215
  );
204
216
  }
205
217
 
@@ -216,8 +228,9 @@ function DayCell({
216
228
  onDayClick?: (date: Date) => void;
217
229
  dayEventData?: { count: number; dots: string[] };
218
230
  }) {
231
+ const t = useTranslations('calendar');
219
232
  if (!day) {
220
- return <div className={cn('h-6', showLunar && 'h-9')} />;
233
+ return <div className={cn('h-9', showLunar && 'h-11')} />;
221
234
  }
222
235
 
223
236
  const today = isToday(day);
@@ -233,10 +246,13 @@ function DayCell({
233
246
  <button
234
247
  type="button"
235
248
  onClick={() => onDayClick?.(day)}
249
+ aria-label={`${day.toLocaleDateString(locale, { dateStyle: 'full' })}, ${t('agenda_event_count', { count: dayEventData?.count ?? 0 })}`}
250
+ aria-current={today ? 'date' : undefined}
236
251
  className={cn(
237
252
  'group relative flex flex-col items-center justify-start rounded-md transition-colors',
238
- showLunar ? 'h-9 py-0.5' : 'h-6',
239
- 'hover:bg-foreground/5',
253
+ showLunar ? 'h-11 py-1' : 'h-9 py-1',
254
+ 'hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring',
255
+ hasEvents && 'bg-primary/5',
240
256
  today && 'font-bold'
241
257
  )}
242
258
  >
@@ -294,8 +310,7 @@ function DayCell({
294
310
  )}
295
311
  {hasEvents && dayEventData && (
296
312
  <div className="text-muted-foreground">
297
- {dayEventData.count} event
298
- {dayEventData.count !== 1 ? 's' : ''}
313
+ {t('agenda_event_count', { count: dayEventData.count })}
299
314
  </div>
300
315
  )}
301
316
  </div>
@@ -325,8 +340,11 @@ export function YearCalendar({
325
340
  }, [year]);
326
341
 
327
342
  return (
328
- <div className="h-full overflow-auto p-4">
329
- <div className="mx-auto grid max-w-7xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
343
+ <div
344
+ className="h-full overflow-auto bg-muted/10 p-4 sm:p-6"
345
+ data-calendar-view="year"
346
+ >
347
+ <div className="mx-auto grid max-w-7xl grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
330
348
  {months.map((monthDate) => (
331
349
  <MiniMonth
332
350
  key={monthDate.getMonth()}
@@ -0,0 +1,53 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ clearPwaInstallPrompt,
5
+ initializePwaInstall,
6
+ } from '../../hooks/pwa-install-prompt';
7
+ import { PwaStatus } from './pwa-status';
8
+
9
+ const labels = {
10
+ offline_status: 'offline_status',
11
+ install: 'install',
12
+ install_help: 'install_help',
13
+ cache_help: 'cache_help',
14
+ };
15
+ beforeEach(() => {
16
+ initializePwaInstall();
17
+ clearPwaInstallPrompt();
18
+ vi.stubGlobal('matchMedia', () => ({ matches: false }));
19
+ });
20
+ afterEach(() => {
21
+ vi.unstubAllGlobals();
22
+ vi.restoreAllMocks();
23
+ });
24
+ describe('PWA status and installation', () => {
25
+ it('shows offline limitations without offering an unavailable install', () => {
26
+ vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
27
+ render(<PwaStatus labels={labels} />);
28
+ expect(screen.getByRole('status')).toHaveTextContent('offline_status');
29
+ expect(screen.queryByRole('button', { name: 'install' })).toBeNull();
30
+ });
31
+ it('provides Home Screen instructions when a browser has no install prompt', () => {
32
+ render(<PwaStatus labels={labels} />);
33
+ fireEvent.click(screen.getByRole('button', { name: 'install' }));
34
+ expect(screen.getByRole('dialog')).toHaveTextContent('install_help');
35
+ });
36
+ it('only invokes the native install prompt after an explicit click', async () => {
37
+ const prompt = vi.fn(async () => {});
38
+ const event = Object.assign(
39
+ new Event('beforeinstallprompt', { cancelable: true }),
40
+ { prompt, userChoice: Promise.resolve({ outcome: 'accepted' }) }
41
+ );
42
+ act(() => {
43
+ window.dispatchEvent(event);
44
+ });
45
+ render(<PwaStatus labels={labels} />);
46
+ expect(prompt).not.toHaveBeenCalled();
47
+ await act(async () => {
48
+ fireEvent.click(screen.getByRole('button', { name: 'install' }));
49
+ });
50
+ expect(prompt).toHaveBeenCalledOnce();
51
+ expect(screen.queryByRole('button', { name: 'install' })).toBeNull();
52
+ });
53
+ });
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+ import { Download, WifiOff } from '@tuturuuu/icons';
3
+ import { useEffect, useState, useSyncExternalStore } from 'react';
4
+ import {
5
+ clearPwaInstallPrompt,
6
+ getPwaInstallPrompt,
7
+ initializePwaInstall,
8
+ subscribePwaInstall,
9
+ } from '../../hooks/pwa-install-prompt';
10
+ import { Button } from './button';
11
+ import {
12
+ Dialog,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ } from './dialog';
18
+
19
+ function subscribeOnline(callback: () => void) {
20
+ window.addEventListener('online', callback);
21
+ window.addEventListener('offline', callback);
22
+ return () => {
23
+ window.removeEventListener('online', callback);
24
+ window.removeEventListener('offline', callback);
25
+ };
26
+ }
27
+ export function PwaStatus({
28
+ labels,
29
+ }: {
30
+ labels: {
31
+ offline_status: string;
32
+ install: string;
33
+ install_help: string;
34
+ cache_help: string;
35
+ };
36
+ }) {
37
+ const t = (key: keyof typeof labels) => labels[key];
38
+ const online = useSyncExternalStore(
39
+ subscribeOnline,
40
+ () => navigator.onLine,
41
+ () => true
42
+ );
43
+ const installPrompt = useSyncExternalStore(
44
+ subscribePwaInstall,
45
+ getPwaInstallPrompt,
46
+ () => null
47
+ );
48
+ const [installed, setInstalled] = useState(false);
49
+ const [showHelp, setShowHelp] = useState(false);
50
+ useEffect(() => {
51
+ setInstalled(
52
+ window.matchMedia('(display-mode: standalone)').matches ||
53
+ ('standalone' in navigator && navigator.standalone === true)
54
+ );
55
+ initializePwaInstall();
56
+ const onInstalled = () => {
57
+ setInstalled(true);
58
+ clearPwaInstallPrompt();
59
+ };
60
+ window.addEventListener('appinstalled', onInstalled);
61
+ return () => {
62
+ window.removeEventListener('appinstalled', onInstalled);
63
+ };
64
+ }, []);
65
+ const install = async () => {
66
+ if (!installPrompt) {
67
+ setShowHelp(true);
68
+ return;
69
+ }
70
+ try {
71
+ await installPrompt.prompt();
72
+ const choice = await installPrompt.userChoice;
73
+ if (choice.outcome === 'accepted') setInstalled(true);
74
+ } catch {
75
+ setShowHelp(true);
76
+ } finally {
77
+ clearPwaInstallPrompt();
78
+ }
79
+ };
80
+ return (
81
+ <>
82
+ {!online && (
83
+ <div
84
+ role="status"
85
+ className="fixed inset-x-3 bottom-3 z-50 mx-auto flex max-w-xl items-start gap-2 rounded-lg border bg-background p-3 text-sm shadow-lg"
86
+ >
87
+ <WifiOff className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
88
+ <p>{t('offline_status')}</p>
89
+ </div>
90
+ )}
91
+ {online && !installed && (
92
+ <Button
93
+ variant="outline"
94
+ size="sm"
95
+ className="fixed right-3 bottom-3 z-30 gap-2 rounded-full bg-background shadow-sm"
96
+ onClick={() => void install()}
97
+ >
98
+ <Download className="size-4" />
99
+ {t('install')}
100
+ </Button>
101
+ )}
102
+ <Dialog open={showHelp} onOpenChange={setShowHelp}>
103
+ <DialogContent className="max-w-md">
104
+ <DialogHeader>
105
+ <DialogTitle>{t('install')}</DialogTitle>
106
+ <DialogDescription>{t('install_help')}</DialogDescription>
107
+ </DialogHeader>
108
+ <p className="text-muted-foreground text-sm">{t('cache_help')}</p>
109
+ </DialogContent>
110
+ </Dialog>
111
+ </>
112
+ );
113
+ }