@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,488 @@
1
+ 'use client';
2
+
3
+ import type { Product, SupportType } from '@tuturuuu/types';
4
+ import { toast } from '@tuturuuu/ui/sonner';
5
+ import imageCompression from 'browser-image-compression';
6
+ import type React from 'react';
7
+ import { useEffect, useId, useRef, useState } from 'react';
8
+ import {
9
+ ALLOWED_IMAGE_TYPES,
10
+ ALLOWED_VIDEO_TYPES,
11
+ DEFAULT_PRODUCTS,
12
+ LOCAL_STORAGE_KEY,
13
+ MAX_FILES,
14
+ MAX_MEDIA_SIZE,
15
+ type ReportProblemDialogProps,
16
+ type ReportProblemFormData,
17
+ reportProblemSchema,
18
+ } from './report-problem-model';
19
+ import { useSubmitReportMutation } from './report-problem-mutation';
20
+ export function useReportProblem({
21
+ open,
22
+ onOpenChange,
23
+ t,
24
+ apiOptions,
25
+ }: ReportProblemDialogProps) {
26
+ const suggestionId = useId();
27
+ const mediaUploadId = useId();
28
+
29
+ // Internal state for uncontrolled mode
30
+ const [internalOpen, setInternalOpen] = useState(false);
31
+
32
+ // Use controlled or uncontrolled mode
33
+ const isControlled = open !== undefined;
34
+ const dialogOpen = isControlled ? open : internalOpen;
35
+
36
+ const [formData, setFormData] = useState<ReportProblemFormData>({
37
+ product: '',
38
+ type: '',
39
+ suggestion: '',
40
+ media: [],
41
+ });
42
+ const [mediaPreviews, setMediaPreviews] = useState<string[]>([]);
43
+ const [isCompressing, setIsCompressing] = useState(false);
44
+ const [validationErrors, setValidationErrors] = useState<{
45
+ product?: string;
46
+ type?: string;
47
+ suggestion?: string;
48
+ media?: string;
49
+ }>({});
50
+ const [isDragOver, setIsDragOver] = useState(false);
51
+ const fileInputRef = useRef<HTMLInputElement>(null);
52
+ const mediaPreviewsRef = useRef<string[]>([]);
53
+ const submitReportMutation = useSubmitReportMutation(apiOptions);
54
+ const isSubmitting = submitReportMutation.isPending;
55
+
56
+ const validateForm = () => {
57
+ const result = reportProblemSchema.safeParse(formData);
58
+ if (result.success) {
59
+ setValidationErrors({});
60
+ return true;
61
+ }
62
+
63
+ const fieldErrors: {
64
+ product?: string;
65
+ type?: string;
66
+ suggestion?: string;
67
+ media?: string;
68
+ } = {};
69
+ for (const issue of result.error.issues) {
70
+ if (issue.path[0] === 'product' && !fieldErrors.product) {
71
+ fieldErrors.product = issue.message;
72
+ }
73
+ if (issue.path[0] === 'type' && !fieldErrors.type) {
74
+ fieldErrors.type = issue.message;
75
+ }
76
+ if (issue.path[0] === 'suggestion' && !fieldErrors.suggestion) {
77
+ fieldErrors.suggestion = issue.message;
78
+ }
79
+ if (issue.path[0] === 'media' && !fieldErrors.media) {
80
+ fieldErrors.media = issue.message;
81
+ }
82
+ }
83
+ setValidationErrors(fieldErrors);
84
+ return false;
85
+ };
86
+
87
+ const handleProductChange = (value: string) => {
88
+ setFormData((prev) => ({ ...prev, product: value as Product }));
89
+ if (validationErrors.product) {
90
+ setValidationErrors((prev) => ({ ...prev, product: undefined }));
91
+ }
92
+ };
93
+
94
+ const handleTypeChange = (value: string) => {
95
+ setFormData((prev) => ({ ...prev, type: value as SupportType }));
96
+ if (validationErrors.type) {
97
+ setValidationErrors((prev) => ({ ...prev, type: undefined }));
98
+ }
99
+ };
100
+
101
+ const handleSuggestionChange = (
102
+ event: React.ChangeEvent<HTMLTextAreaElement>
103
+ ) => {
104
+ setFormData((prev) => ({ ...prev, suggestion: event.target.value }));
105
+ if (validationErrors.suggestion && event.target.value.trim()) {
106
+ setValidationErrors((prev) => ({ ...prev, suggestion: undefined }));
107
+ }
108
+ };
109
+
110
+ const handleDragOver = (event: React.DragEvent) => {
111
+ event.preventDefault();
112
+ if (event.dataTransfer) {
113
+ event.dataTransfer.dropEffect = 'copy';
114
+ }
115
+ setIsDragOver(true);
116
+ };
117
+ const handleDragLeave = (event: React.DragEvent) => {
118
+ event.preventDefault();
119
+ setIsDragOver(false);
120
+ };
121
+
122
+ const handleDrop = (event: React.DragEvent) => {
123
+ event.preventDefault();
124
+ setIsDragOver(false);
125
+
126
+ const files = Array.from(event.dataTransfer.files).filter(
127
+ (file) =>
128
+ ALLOWED_IMAGE_TYPES.includes(file.type) ||
129
+ ALLOWED_VIDEO_TYPES.includes(file.type)
130
+ );
131
+
132
+ if (files.length > 0) {
133
+ processMediaFiles(files);
134
+ }
135
+ };
136
+
137
+ const compressImage = async (file: File): Promise<File> => {
138
+ try {
139
+ const options = {
140
+ maxSizeMB: 5,
141
+ maxWidthOrHeight: 1920,
142
+ useWebWorker: true,
143
+ initialQuality: 0.8,
144
+ };
145
+ const compressedBlob = await imageCompression(file, options);
146
+
147
+ // Convert Blob back to File with original name and type
148
+ const compressedFile = new File([compressedBlob], file.name, {
149
+ type: compressedBlob.type || file.type,
150
+ lastModified: Date.now(),
151
+ });
152
+
153
+ return compressedFile;
154
+ } catch (error) {
155
+ console.error('Image compression failed:', error);
156
+ return file; // Return original if compression fails
157
+ }
158
+ };
159
+
160
+ const compressVideo = async (file: File): Promise<File> => {
161
+ try {
162
+ // Create video element to load the file
163
+ const video = document.createElement('video');
164
+ video.preload = 'metadata';
165
+ video.muted = true;
166
+
167
+ const videoUrl = URL.createObjectURL(file);
168
+ video.src = videoUrl;
169
+
170
+ // Wait for video to load metadata
171
+ await new Promise((resolve, reject) => {
172
+ video.onloadedmetadata = resolve;
173
+ video.onerror = reject;
174
+ });
175
+
176
+ // Calculate target dimensions (max 1280x720 to reduce file size)
177
+ let targetWidth = video.videoWidth;
178
+ let targetHeight = video.videoHeight;
179
+ const maxWidth = 1280;
180
+ const maxHeight = 720;
181
+
182
+ if (targetWidth > maxWidth || targetHeight > maxHeight) {
183
+ const aspectRatio = targetWidth / targetHeight;
184
+ if (aspectRatio > maxWidth / maxHeight) {
185
+ targetWidth = maxWidth;
186
+ targetHeight = Math.round(maxWidth / aspectRatio);
187
+ } else {
188
+ targetHeight = maxHeight;
189
+ targetWidth = Math.round(maxHeight * aspectRatio);
190
+ }
191
+ }
192
+
193
+ // Create canvas for video processing
194
+ const canvas = document.createElement('canvas');
195
+ canvas.width = targetWidth;
196
+ canvas.height = targetHeight;
197
+ const ctx = canvas.getContext('2d');
198
+
199
+ if (!ctx) {
200
+ throw new Error('Could not get canvas context');
201
+ }
202
+
203
+ // Set up MediaRecorder with compression settings
204
+ const stream = canvas.captureStream(30); // 30 FPS
205
+ const mimeType = MediaRecorder.isTypeSupported('video/webm; codecs=vp9')
206
+ ? 'video/webm; codecs=vp9'
207
+ : MediaRecorder.isTypeSupported('video/webm; codecs=vp8')
208
+ ? 'video/webm; codecs=vp8'
209
+ : 'video/webm';
210
+
211
+ const mediaRecorder = new MediaRecorder(stream, {
212
+ mimeType,
213
+ videoBitsPerSecond: 1000000, // 1 Mbps for good quality with compression
214
+ });
215
+
216
+ const chunks: Blob[] = [];
217
+ mediaRecorder.ondataavailable = (e) => {
218
+ if (e.data.size > 0) {
219
+ chunks.push(e.data);
220
+ }
221
+ };
222
+
223
+ // Start recording
224
+ mediaRecorder.start();
225
+
226
+ // Play video and draw frames to canvas
227
+ video.currentTime = 0;
228
+ await video.play();
229
+
230
+ const drawFrame = () => {
231
+ if (video.ended || video.paused) {
232
+ return;
233
+ }
234
+ ctx.drawImage(video, 0, 0, targetWidth, targetHeight);
235
+ requestAnimationFrame(drawFrame);
236
+ };
237
+
238
+ drawFrame();
239
+
240
+ // Wait for video to finish
241
+ await new Promise((resolve) => {
242
+ video.onended = resolve;
243
+ });
244
+
245
+ // Stop recording
246
+ mediaRecorder.stop();
247
+
248
+ // Wait for final data
249
+ const compressedBlob = await new Promise<Blob>((resolve) => {
250
+ mediaRecorder.onstop = () => {
251
+ resolve(new Blob(chunks, { type: mimeType }));
252
+ };
253
+ });
254
+
255
+ // Clean up
256
+ URL.revokeObjectURL(videoUrl);
257
+ video.remove();
258
+ canvas.remove();
259
+
260
+ // Convert to File
261
+ const fileExtension = mimeType.includes('webm') ? 'webm' : 'mp4';
262
+ const fileName = file.name.replace(/\.[^/.]+$/, `.${fileExtension}`);
263
+ const compressedFile = new File([compressedBlob], fileName, {
264
+ type: mimeType,
265
+ lastModified: Date.now(),
266
+ });
267
+
268
+ // Only return compressed version if it's actually smaller
269
+ return compressedFile.size < file.size ? compressedFile : file;
270
+ } catch (error) {
271
+ console.error('Video compression failed:', error);
272
+ return file; // Return original if compression fails
273
+ }
274
+ };
275
+
276
+ const processMediaFiles = async (files: File[]) => {
277
+ // First filter by type only
278
+ const validTypeFiles = files.filter(
279
+ (file) =>
280
+ ALLOWED_IMAGE_TYPES.includes(file.type) ||
281
+ ALLOWED_VIDEO_TYPES.includes(file.type)
282
+ );
283
+
284
+ const invalidTypeCount = files.length - validTypeFiles.length;
285
+ if (invalidTypeCount > 0) {
286
+ setValidationErrors((prev) => ({
287
+ ...prev,
288
+ media: `${invalidTypeCount} file(s) rejected: only images (PNG, JPEG, WebP, GIF) and videos (MP4, WebM, MOV) are allowed.`,
289
+ }));
290
+ }
291
+
292
+ const currentMediaCount = formData.media.length;
293
+ const availableSlots = MAX_FILES - currentMediaCount;
294
+ const filesToProcess = validTypeFiles.slice(0, availableSlots);
295
+ const overflow = validTypeFiles.length - filesToProcess.length;
296
+
297
+ if (overflow > 0) {
298
+ setValidationErrors((prev) => ({
299
+ ...prev,
300
+ media: `You can upload up to ${MAX_FILES} files. ${overflow} file(s) were rejected.`,
301
+ }));
302
+ }
303
+
304
+ if (filesToProcess.length > 0) {
305
+ setIsCompressing(true);
306
+ try {
307
+ // Compress images and videos first, then check size
308
+ const processedWithSize = await Promise.all(
309
+ filesToProcess.map(async (file) => {
310
+ let processedFile = file;
311
+
312
+ // Compress images before size check
313
+ if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
314
+ processedFile = await compressImage(file);
315
+ }
316
+ // Compress videos before size check
317
+ else if (ALLOWED_VIDEO_TYPES.includes(file.type)) {
318
+ processedFile = await compressVideo(file);
319
+ }
320
+
321
+ return {
322
+ file: processedFile,
323
+ isValid: processedFile.size <= MAX_MEDIA_SIZE,
324
+ originalName: file.name,
325
+ };
326
+ })
327
+ );
328
+
329
+ // Filter out files that are still too large after compression
330
+ const validFiles = processedWithSize.filter((item) => item.isValid);
331
+ const rejectedBySize = processedWithSize.filter(
332
+ (item) => !item.isValid
333
+ );
334
+
335
+ if (rejectedBySize.length > 0) {
336
+ const rejectedNames = rejectedBySize
337
+ .map((item) => item.originalName)
338
+ .join(', ');
339
+ setValidationErrors((prev) => ({
340
+ ...prev,
341
+ media: `${rejectedBySize.length} file(s) rejected (exceeded 5MB even after compression): ${rejectedNames}`,
342
+ }));
343
+ } else if (validationErrors.media && invalidTypeCount === 0) {
344
+ setValidationErrors((prev) => ({ ...prev, media: undefined }));
345
+ }
346
+
347
+ if (validFiles.length > 0) {
348
+ const acceptedFiles = validFiles.map((item) => item.file);
349
+ const newMedia = [...formData.media, ...acceptedFiles];
350
+ setFormData((prev) => ({ ...prev, media: newMedia }));
351
+
352
+ // Create preview URLs
353
+ const newPreviews = acceptedFiles.map((file) =>
354
+ URL.createObjectURL(file)
355
+ );
356
+ setMediaPreviews((prev) => {
357
+ const updated = [...prev, ...newPreviews];
358
+ mediaPreviewsRef.current = updated;
359
+ return updated;
360
+ });
361
+ }
362
+ } finally {
363
+ setIsCompressing(false);
364
+ }
365
+ }
366
+ };
367
+
368
+ const handleMediaUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
369
+ const files = Array.from(event.target.files || []);
370
+ processMediaFiles(files);
371
+
372
+ // Reset file input
373
+ if (fileInputRef.current) {
374
+ fileInputRef.current.value = '';
375
+ }
376
+ };
377
+
378
+ const removeMedia = (index: number) => {
379
+ // Revoke the object URL to free memory
380
+ if (mediaPreviews[index]) {
381
+ URL.revokeObjectURL(mediaPreviews[index]);
382
+ }
383
+
384
+ const newMedia = formData.media.filter((_, i) => i !== index);
385
+ const newPreviews = mediaPreviews.filter((_, i) => i !== index);
386
+
387
+ setFormData((prev) => ({ ...prev, media: newMedia }));
388
+ setMediaPreviews(newPreviews);
389
+ mediaPreviewsRef.current = newPreviews;
390
+ };
391
+
392
+ const handleSubmit = async (event: React.FormEvent) => {
393
+ event.preventDefault();
394
+
395
+ if (!validateForm()) {
396
+ return;
397
+ }
398
+
399
+ try {
400
+ const validatedForm = reportProblemSchema.parse(formData);
401
+
402
+ // Generate a subject based on the type and product
403
+ const subject = `${validatedForm.type === 'bug' ? 'Bug Report' : 'Feature Request'} - ${
404
+ DEFAULT_PRODUCTS.find((p) => p.value === validatedForm.product)
405
+ ?.label || validatedForm.product
406
+ }`;
407
+
408
+ await submitReportMutation.mutateAsync({
409
+ product: validatedForm.product,
410
+ type: validatedForm.type,
411
+ suggestion: validatedForm.suggestion,
412
+ subject,
413
+ media: validatedForm.media,
414
+ });
415
+ toast.success(t('report-submitted-success'));
416
+
417
+ // Close dialog (which will also trigger cleanup)
418
+ handleOpenChange(false);
419
+ } catch (error) {
420
+ console.error('Failed to submit report:', error);
421
+ toast.error('Failed to submit report. Please try again.');
422
+ }
423
+ };
424
+
425
+ const handleOpenChange = (newOpen: boolean) => {
426
+ // Update internal state for uncontrolled mode
427
+ if (!isControlled) {
428
+ setInternalOpen(newOpen);
429
+ }
430
+
431
+ // Notify parent if handler exists (controlled mode)
432
+ if (onOpenChange) {
433
+ onOpenChange(newOpen);
434
+ }
435
+
436
+ // Clean up and reset everything when dialog closes
437
+ if (!newOpen) {
438
+ // Revoke all object URLs to free memory
439
+ mediaPreviews.forEach((url) => {
440
+ URL.revokeObjectURL(url);
441
+ });
442
+
443
+ // Clear all form state
444
+ setFormData({ product: '', type: '', suggestion: '', media: [] });
445
+ setMediaPreviews([]);
446
+ mediaPreviewsRef.current = [];
447
+ setValidationErrors({});
448
+ setIsDragOver(false);
449
+ setIsCompressing(false);
450
+
451
+ // Clear localStorage
452
+ localStorage.removeItem(LOCAL_STORAGE_KEY);
453
+ }
454
+ };
455
+
456
+ // Clean up object URLs on unmount
457
+ useEffect(() => {
458
+ return () => {
459
+ mediaPreviewsRef.current.forEach((url) => {
460
+ URL.revokeObjectURL(url);
461
+ });
462
+ };
463
+ }, []);
464
+
465
+ return {
466
+ dialogOpen,
467
+ handleOpenChange,
468
+ handleSubmit,
469
+ formData,
470
+ setFormData,
471
+ validationErrors,
472
+ isSubmitting,
473
+ isCompressing,
474
+ suggestionId,
475
+ mediaUploadId,
476
+ isDragOver,
477
+ fileInputRef,
478
+ handleDragOver,
479
+ handleDragLeave,
480
+ handleDrop,
481
+ handleProductChange,
482
+ handleTypeChange,
483
+ handleSuggestionChange,
484
+ handleMediaUpload,
485
+ mediaPreviews,
486
+ removeMedia,
487
+ };
488
+ }
@@ -1,3 +1,5 @@
1
+ /// <reference types="@testing-library/jest-dom/vitest" />
2
+
1
3
  declare module '*.css';
2
4
  declare module '*.layer.css';
3
5
  declare module 'moment/locale/vi';
@@ -1,4 +1,5 @@
1
1
  import { act, renderHook } from '@testing-library/react';
2
+ import type { Workspace } from '@tuturuuu/types';
2
3
  import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
3
4
  import type { ReactNode } from 'react';
4
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -16,6 +17,8 @@ const internalApiMocks = vi.hoisted(() => ({
16
17
  updateWorkspaceCalendarEvent: vi.fn(),
17
18
  }));
18
19
 
20
+ const workspace = { id: 'workspace-1', name: 'Workspace' } as Workspace;
21
+
19
22
  vi.mock('@tuturuuu/internal-api', () => ({
20
23
  createWorkspaceCalendarEvent: internalApiMocks.createWorkspaceCalendarEvent,
21
24
  deleteWorkspaceCalendarEvent: internalApiMocks.deleteWorkspaceCalendarEvent,
@@ -66,7 +69,7 @@ describe('CalendarProvider Read-Only Mode', () => {
66
69
  function Wrapper({ children }: { children: ReactNode }) {
67
70
  return (
68
71
  <CalendarProvider
69
- ws={{ id: 'workspace-1', name: 'Workspace' } as any}
72
+ ws={workspace}
70
73
  useQuery={mockUseQuery}
71
74
  useQueryClient={mockUseQueryClient}
72
75
  readOnly={readOnly}
@@ -84,6 +87,45 @@ describe('CalendarProvider Read-Only Mode', () => {
84
87
  ws_id: 'workspace-1',
85
88
  };
86
89
 
90
+ it('opens a deep-linked event after it is loaded', async () => {
91
+ calendarMockState.events = [baseEvent];
92
+ const wrapper = ({ children }: { children: ReactNode }) => (
93
+ <CalendarProvider
94
+ initialEventId="event-1"
95
+ useQuery={mockUseQuery}
96
+ useQueryClient={mockUseQueryClient}
97
+ ws={workspace}
98
+ >
99
+ {children}
100
+ </CalendarProvider>
101
+ );
102
+
103
+ const { result } = renderHook(() => useCalendar(), { wrapper });
104
+ await act(async () => undefined);
105
+ expect(result.current.activeEvent?.id).toBe('event-1');
106
+ expect(result.current.isModalOpen).toBe(true);
107
+ });
108
+
109
+ it('routes a deep-linked event through a custom event adapter', async () => {
110
+ calendarMockState.events = [baseEvent];
111
+ const onOpen = vi.fn();
112
+ const wrapper = ({ children }: { children: ReactNode }) => (
113
+ <CalendarProvider
114
+ eventAdapter={{ disableBuiltInEventUi: true, onOpen }}
115
+ initialEventId="event-1"
116
+ useQuery={mockUseQuery}
117
+ useQueryClient={mockUseQueryClient}
118
+ ws={workspace}
119
+ >
120
+ {children}
121
+ </CalendarProvider>
122
+ );
123
+
124
+ renderHook(() => useCalendar(), { wrapper });
125
+ await act(async () => undefined);
126
+ expect(onOpen).toHaveBeenCalledWith('event-1', baseEvent);
127
+ });
128
+
87
129
  it('should have readOnly set to true when passed as prop', () => {
88
130
  const wrapper = ({ children }: { children: ReactNode }) => (
89
131
  <CalendarProvider
@@ -115,7 +157,7 @@ describe('CalendarProvider Read-Only Mode', () => {
115
157
  title: 'Test',
116
158
  start_at: new Date().toISOString(),
117
159
  end_at: new Date().toISOString(),
118
- } as any);
160
+ });
119
161
  expect(event).toBeUndefined();
120
162
  });
121
163
 
@@ -165,7 +207,7 @@ describe('CalendarProvider Read-Only Mode', () => {
165
207
 
166
208
  const wrapper = ({ children }: { children: ReactNode }) => (
167
209
  <CalendarProvider
168
- ws={{ id: 'workspace-1', name: 'Workspace' } as any}
210
+ ws={workspace}
169
211
  useQuery={mockUseQuery}
170
212
  useQueryClient={mockUseQueryClient}
171
213
  >
@@ -195,7 +237,7 @@ describe('CalendarProvider Read-Only Mode', () => {
195
237
  it('opens the editor directly for create flows', () => {
196
238
  const wrapper = ({ children }: { children: ReactNode }) => (
197
239
  <CalendarProvider
198
- ws={{ id: 'workspace-1', name: 'Workspace' } as any}
240
+ ws={workspace}
199
241
  useQuery={mockUseQuery}
200
242
  useQueryClient={mockUseQueryClient}
201
243
  >
@@ -154,6 +154,30 @@ describe('CalendarSyncProvider optimistic visible events', () => {
154
154
  vi.stubGlobal('fetch', vi.fn());
155
155
  });
156
156
 
157
+ it('reports a partial HTTP 200 sync as an error without a success callback', async () => {
158
+ vi.stubGlobal(
159
+ 'fetch',
160
+ vi.fn(async () =>
161
+ Response.json({
162
+ ok: false,
163
+ partialFailure: true,
164
+ error: 'One calendar failed',
165
+ })
166
+ )
167
+ );
168
+ const { result } = renderCalendarSync({
169
+ externalEvents: [createEvent('saved')],
170
+ });
171
+ const progress = vi.fn();
172
+ await act(async () => {
173
+ await result.current.syncToTuturuuu(progress, { skipCooldown: true });
174
+ });
175
+ expect(result.current.syncStatus.state).toBe('error');
176
+ expect(result.current.error?.message).toBe('One calendar failed');
177
+ expect(progress).not.toHaveBeenCalled();
178
+ expect(result.current.events[0]?.id).toBe('saved');
179
+ });
180
+
157
181
  it('keeps last successful events visible while a refetch is pending', async () => {
158
182
  const initialEvent = createEvent('event-1');
159
183
  let databaseCalls = 0;
@@ -0,0 +1,11 @@
1
+ export function roundToNearest15Minutes(date: Date): Date {
2
+ const minutes = date.getMinutes();
3
+ const remainder = minutes % 15;
4
+ const roundedMinutes =
5
+ remainder < 8 ? minutes - remainder : minutes + (15 - remainder);
6
+ const roundedDate = new Date(date);
7
+ roundedDate.setMinutes(roundedMinutes);
8
+ roundedDate.setSeconds(0);
9
+ roundedDate.setMilliseconds(0);
10
+ return roundedDate;
11
+ }