@tuturuuu/ui 0.23.0 → 0.24.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 (66) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/biome.json +1 -1
  3. package/package.json +78 -78
  4. package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.test.tsx +121 -0
  5. package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.tsx +37 -4
  6. package/src/components/ui/chat/ai-message-markdown.tsx +6 -1
  7. package/src/components/ui/chat/chat-sidebar-groups.test.ts +33 -0
  8. package/src/components/ui/chat/chat-sidebar-items.tsx +10 -2
  9. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
  10. package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
  11. package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
  12. package/src/components/ui/chat/chat-utils.test.ts +107 -0
  13. package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
  14. package/src/components/ui/chat/chat-workspace.tsx +66 -24
  15. package/src/components/ui/chat/hooks-conversations.ts +9 -1
  16. package/src/components/ui/chat/hooks-messages.ts +1 -0
  17. package/src/components/ui/chat/message-bubble.tsx +49 -4
  18. package/src/components/ui/chat/message-composer.tsx +29 -23
  19. package/src/components/ui/chat/message-markdown.tsx +4 -1
  20. package/src/components/ui/chat/query-keys.ts +7 -1
  21. package/src/components/ui/chat/utils.ts +85 -3
  22. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
  23. package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
  24. package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
  25. package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
  26. package/src/components/ui/custom/workspace-select.tsx +37 -38
  27. package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
  28. package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
  29. package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
  30. package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
  31. package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
  32. package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
  33. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
  34. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
  35. package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
  36. package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
  37. package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
  38. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
  39. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
  40. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
  41. package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
  42. package/src/components/ui/legacy/meet/form.tsx +25 -1
  43. package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
  44. package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
  45. package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
  46. package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
  47. package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
  48. package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
  49. package/src/components/ui/legacy/meet/page.tsx +1 -0
  50. package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
  51. package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
  52. package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
  53. package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
  54. package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
  55. package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
  56. package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
  57. package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
  58. package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
  59. package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
  60. package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
  61. package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
  62. package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
  63. package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
  64. package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
  65. package/src/components/ui/nuqs-adapter.tsx +1 -0
  66. package/src/hooks/time-blocking-provider.tsx +127 -211
@@ -1,22 +1,31 @@
1
1
  'use client';
2
2
 
3
- import { CircleQuestionMark } from '@tuturuuu/icons';
3
+ import {
4
+ Activity,
5
+ CalendarClock,
6
+ CircleQuestionMark,
7
+ Users,
8
+ } from '@tuturuuu/icons';
4
9
  import type {
10
+ MeetFinalizedTimeframe,
5
11
  MeetTogetherPlan,
6
12
  PlanUser,
7
13
  } from '@tuturuuu/types/primitives/MeetTogetherPlan';
8
14
  import type { GetPollsForPlanResult } from '@tuturuuu/types/primitives/Poll';
9
15
  import type { Timeblock } from '@tuturuuu/types/primitives/Timeblock';
16
+ import { Badge } from '@tuturuuu/ui/badge';
10
17
  import { useTimeBlocking } from '@tuturuuu/ui/hooks/time-blocking-provider';
11
18
  import { Label } from '@tuturuuu/ui/label';
12
19
  import EditPlanDialog from '@tuturuuu/ui/legacy/meet/edit-plan-dialog';
13
- import { Separator } from '@tuturuuu/ui/separator';
14
20
  import { Switch } from '@tuturuuu/ui/switch';
21
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@tuturuuu/ui/tabs';
15
22
  import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
16
- import { cn } from '@tuturuuu/utils/format';
23
+ import { useTranslations } from 'next-intl';
17
24
  import { useTheme } from 'next-themes';
18
- import { useCallback, useEffect, useState } from 'react';
25
+ import { useCallback, useState } from 'react';
19
26
  import AgendaDetails from './agenda-details';
27
+ import { MeetInsightsPanel } from './meet-insights-panel';
28
+ import { MeetSuggestionsPanel } from './meet-suggestions-panel';
20
29
  import PlanLogin from './plan-login';
21
30
  import SidebarDisplay from './sidebar-display';
22
31
  import StickyBottomIndicator from './sticky-bottom-indicator';
@@ -26,179 +35,184 @@ import UtilityButtons from './utility-buttons';
26
35
  interface PlanDetailsClientProps {
27
36
  plan: MeetTogetherPlan;
28
37
  polls: GetPollsForPlanResult | null;
29
- // platformUser: User | null;
30
- // isCreator: boolean;
31
38
  users: PlanUser[];
32
39
  timeblocks: Timeblock[];
40
+ finalizedTimeframes: MeetFinalizedTimeframe[];
41
+ isCreator: boolean;
42
+ isRefreshing: boolean;
33
43
  baseUrl: string;
34
44
  }
35
45
 
36
46
  export default function PlanDetailsClient({
37
47
  plan,
38
- // platformUser,
39
- // isCreator,
40
48
  users,
41
49
  polls,
42
50
  timeblocks,
51
+ finalizedTimeframes,
52
+ isCreator,
53
+ isRefreshing,
43
54
  baseUrl,
44
55
  }: PlanDetailsClientProps) {
45
56
  const { resolvedTheme } = useTheme();
57
+ const t = useTranslations('meet-together-plan-details');
46
58
  const [showBestTimes, setShowBestTimes] = useState(false);
47
59
  const { filteredUserIds, isDirty, resetLocalTimeblocks, user } =
48
60
  useTimeBlocking();
49
-
50
- // If user filter is active, force best times off
51
- const isUserFilterActive = filteredUserIds && filteredUserIds.length > 0;
52
-
53
- useEffect(() => {
54
- if (isUserFilterActive && showBestTimes) {
55
- setShowBestTimes(false);
56
- }
57
- }, [isUserFilterActive, showBestTimes]);
58
-
59
- // Best times status state
60
- const [bestTimesStatusByDate, setBestTimesStatusByDate] = useState<
61
- Record<string, boolean>
62
- >({});
63
- const allDates = plan.dates || [];
64
- const noBestTimesFound =
65
- showBestTimes &&
66
- allDates.length > 0 &&
67
- allDates.every((d) => bestTimesStatusByDate[d] === false);
61
+ const isUserFilterActive = filteredUserIds.length > 0;
68
62
 
69
63
  const downloadAsPNG = useCallback(async () => {
70
64
  const element = document.getElementById('plan-ref');
71
65
  if (!element) throw new Error('Plan element not found');
72
-
73
- const backgroundColor = resolvedTheme === 'dark' ? '#0a0a0a' : '#ffffff';
74
-
75
- try {
76
- const html2canvas = (await import('html2canvas-pro')).default;
77
- const canvas = await html2canvas(element, {
78
- useCORS: true,
79
- allowTaint: true,
80
- backgroundColor,
81
- scale: 1,
82
- logging: false,
83
- onclone: (clonedDoc: Document) => {
84
- Array.from(clonedDoc.getElementsByTagName('link')).forEach(
85
- (link: HTMLLinkElement) => {
86
- link.removeAttribute('integrity');
87
- link.removeAttribute('crossorigin');
88
- }
89
- );
90
- },
91
- });
92
-
93
- const link = document.createElement('a');
94
- link.download = `plan-${plan.id}.png`;
95
- link.href = canvas.toDataURL('image/png', 2.0);
96
- link.click();
97
- } catch (error) {
98
- console.error('Error generating PNG:', error);
99
- throw error;
100
- }
66
+ const html2canvas = (await import('html2canvas-pro')).default;
67
+ const canvas = await html2canvas(element, {
68
+ useCORS: true,
69
+ allowTaint: true,
70
+ backgroundColor: resolvedTheme === 'dark' ? '#0a0a0a' : '#ffffff',
71
+ scale: 1,
72
+ logging: false,
73
+ });
74
+ const link = document.createElement('a');
75
+ link.download = `tuturuuu-meet-${plan.id}.png`;
76
+ link.href = canvas.toDataURL('image/png', 2);
77
+ link.click();
101
78
  }, [plan.id, resolvedTheme]);
102
79
 
103
80
  return (
104
81
  <>
105
- <div className="flex w-full max-w-7xl flex-col gap-6 p-4 text-foreground md:px-6 lg:gap-14 lg:px-10">
106
- <div className="flex w-full flex-col items-center">
107
- <UtilityButtons plan={plan} handlePNG={downloadAsPNG} />
108
-
109
- <div id="plan-ref" className="flex w-full flex-col items-center">
110
- <p className="my-4 flex max-w-xl items-center gap-2 text-balance text-center font-semibold text-2xl leading-tight! md:mb-4 lg:text-3xl">
111
- {plan.name}{' '}
112
- {user?.id === plan.creator_id ? (
113
- <EditPlanDialog
114
- plan={plan}
115
- onSuccess={() => {
116
- resetLocalTimeblocks();
117
- }}
118
- />
119
- ) : null}
120
- </p>
121
-
122
- {/* Show Only Best Times Toggle - Back to original centered position */}
123
- <div className="mb-4 flex flex-col items-center justify-center gap-2">
124
- <div className="flex items-center justify-center gap-2">
125
- <Label
126
- htmlFor="show-best-times-toggle"
127
- className="flex cursor-pointer items-center gap-1 text-sm"
128
- >
129
- Show Only Best Times
130
- </Label>
131
- <Tooltip>
132
- <TooltipTrigger asChild>
133
- <span className="ml-1 inline-flex cursor-pointer items-center justify-center">
134
- <CircleQuestionMark size={16} />
135
- </span>
136
- </TooltipTrigger>
137
- <TooltipContent side="top">
138
- <div>
82
+ <main className="relative min-h-dvh w-full overflow-hidden bg-background text-foreground">
83
+ <div className="pointer-events-none absolute inset-x-0 top-0 -z-0 h-72 bg-[radial-gradient(circle_at_top_left,hsl(var(--primary)/0.10),transparent_52%)]" />
84
+ <div className="relative mx-auto flex w-full max-w-[1560px] flex-col gap-5 px-3 py-4 sm:px-5 lg:px-8">
85
+ <header className="rounded-2xl border bg-background/90 p-4 shadow-sm backdrop-blur-xl sm:p-5">
86
+ <div className="flex flex-col justify-between gap-5 xl:flex-row xl:items-start">
87
+ <div className="min-w-0">
88
+ <div className="mb-3 flex flex-wrap items-center gap-2">
89
+ <Badge variant={plan.is_confirmed ? 'default' : 'secondary'}>
90
+ {plan.is_confirmed
91
+ ? t('finalized')
92
+ : t('collecting_availability')}
93
+ </Badge>
94
+ <Badge variant="outline" className="tabular-nums">
95
+ {t('minutes_short', { count: plan.duration_minutes ?? 60 })}
96
+ </Badge>
97
+ <Badge variant="outline">
98
+ {plan.timezone || t('legacy_fixed_offset')}
99
+ </Badge>
100
+ <span className="flex items-center gap-1.5 text-muted-foreground text-xs">
101
+ <span
102
+ className={
103
+ isRefreshing
104
+ ? 'h-1.5 w-1.5 animate-pulse rounded-full bg-primary'
105
+ : 'h-1.5 w-1.5 rounded-full bg-muted-foreground/40'
106
+ }
107
+ />
108
+ {isRefreshing ? t('refreshing') : t('up_to_date')}
109
+ </span>
110
+ </div>
111
+ <div className="flex items-center gap-2">
112
+ <h1 className="text-balance font-semibold text-2xl tracking-tight sm:text-3xl">
113
+ {plan.name || t('untitled_meeting')}
114
+ </h1>
115
+ {user?.id === plan.creator_id && !plan.is_confirmed ? (
116
+ <EditPlanDialog
117
+ plan={plan}
118
+ onSuccess={resetLocalTimeblocks}
119
+ />
120
+ ) : null}
121
+ </div>
122
+ <p className="mt-2 max-w-3xl text-pretty text-muted-foreground text-sm">
123
+ {plan.description || t('default_plan_description')}
124
+ </p>
125
+ </div>
126
+ <UtilityButtons plan={plan} handlePNG={downloadAsPNG} />
127
+ </div>
128
+ </header>
129
+
130
+ <div id="plan-ref">
131
+ <Tabs defaultValue="availability" className="space-y-5">
132
+ <TabsList className="grid h-auto w-full grid-cols-3 rounded-xl border bg-muted/30 p-1 sm:w-fit sm:min-w-[28rem]">
133
+ <TabsTrigger value="availability" className="gap-2 py-2.5">
134
+ <CalendarClock className="h-4 w-4" />
135
+ <span className="hidden sm:inline">{t('availability')}</span>
136
+ <span className="sm:hidden">{t('times')}</span>
137
+ </TabsTrigger>
138
+ <TabsTrigger value="suggestions" className="gap-2 py-2.5">
139
+ <Activity className="h-4 w-4" /> {t('suggestions')}
140
+ </TabsTrigger>
141
+ <TabsTrigger value="insights" className="gap-2 py-2.5">
142
+ <Users className="h-4 w-4" /> {t('insights')}
143
+ </TabsTrigger>
144
+ </TabsList>
145
+
146
+ <TabsContent value="availability" className="mt-0">
147
+ <div className="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_22rem]">
148
+ <section className="min-w-0 rounded-2xl border bg-background/80 p-3 sm:p-5">
149
+ <div className="mb-4 flex flex-wrap items-center justify-between gap-3 border-b pb-4">
139
150
  <div>
140
- <b>Show Only Best Times</b> highlights the time slots
141
- that work best for the most people.
151
+ <h2 className="font-semibold">
152
+ {t('availability_canvas')}
153
+ </h2>
154
+ <p className="text-muted-foreground text-xs">
155
+ {t('availability_canvas_description')}
156
+ </p>
142
157
  </div>
143
- <ul className="mt-2 list-disc pl-4 text-xs">
144
- <li>
145
- Only time slots where <b>2 or more people</b> are
146
- available are highlighted.
147
- </li>
148
- <li>
149
- If you filter by user, this feature is <b>disabled</b>{' '}
150
- (since &quot;best time&quot; only makes sense for
151
- groups).
152
- </li>
153
- </ul>
154
- <div className="mt-2 text-muted-foreground text-xs">
155
- We&apos;re always tweaking this feature for clarity and
156
- usefulness. Let us know if you have feedback!
158
+ <div className="flex items-center gap-2">
159
+ <Label htmlFor="show-best-times" className="text-xs">
160
+ {t('peak_only')}
161
+ </Label>
162
+ <Tooltip>
163
+ <TooltipTrigger asChild>
164
+ <CircleQuestionMark className="h-3.5 w-3.5 text-muted-foreground" />
165
+ </TooltipTrigger>
166
+ <TooltipContent>
167
+ {t('peak_only_description')}
168
+ </TooltipContent>
169
+ </Tooltip>
170
+ <Switch
171
+ id="show-best-times"
172
+ checked={showBestTimes}
173
+ onCheckedChange={setShowBestTimes}
174
+ disabled={isUserFilterActive}
175
+ />
157
176
  </div>
158
177
  </div>
159
- </TooltipContent>
160
- </Tooltip>
161
- <Switch
162
- id="show-best-times-toggle"
163
- checked={showBestTimes}
164
- onCheckedChange={() => setShowBestTimes((v) => !v)}
165
- disabled={isUserFilterActive}
166
- />
167
- </div>
168
- {noBestTimesFound && (
169
- <div className="mt-2 w-full max-w-xl rounded bg-yellow-100 p-2 text-center text-xs text-yellow-900 dark:bg-yellow-900 dark:text-yellow-100">
170
- <strong>No best times were found!</strong>
171
- <br />
172
- Encourage your group to sync up or adjust their availability.
178
+ <UnifiedAvailability
179
+ plan={plan}
180
+ timeblocks={timeblocks}
181
+ showBestTimes={showBestTimes}
182
+ />
183
+ </section>
184
+ <aside className="rounded-2xl border bg-background/80 p-2 xl:sticky xl:top-4">
185
+ <SidebarDisplay plan={plan} polls={polls} users={users} />
186
+ </aside>
173
187
  </div>
174
- )}
175
- </div>
176
- <div
177
- className={cn(
178
- 'mt-8 grid w-full grid-cols-1 items-start justify-between gap-4 md:grid-cols-3'
179
- )}
180
- >
181
- <div className={cn('md:col-span-2')}>
182
- <UnifiedAvailability
188
+ </TabsContent>
189
+
190
+ <TabsContent value="suggestions" className="mt-0">
191
+ <MeetSuggestionsPanel
183
192
  plan={plan}
193
+ users={users}
184
194
  timeblocks={timeblocks}
185
- showBestTimes={showBestTimes}
186
- onBestTimesStatusByDateAction={setBestTimesStatusByDate}
195
+ finalizedTimeframes={finalizedTimeframes}
196
+ isCreator={isCreator}
187
197
  />
188
- </div>
189
- <SidebarDisplay plan={plan} polls={polls} users={users} />
190
- </div>
198
+ </TabsContent>
191
199
 
192
- <Separator className="my-8" />
200
+ <TabsContent value="insights" className="mt-0">
201
+ <MeetInsightsPanel
202
+ plan={plan}
203
+ users={users}
204
+ timeblocks={timeblocks}
205
+ />
206
+ </TabsContent>
207
+ </Tabs>
208
+ </div>
193
209
 
210
+ <section className="rounded-2xl border bg-background/80 p-4 sm:p-5">
194
211
  <AgendaDetails plan={plan} />
195
- </div>
212
+ </section>
196
213
  </div>
197
- </div>
198
-
199
- {/* Discord-style sticky bottom indicator for unsaved changes */}
214
+ </main>
200
215
  {isDirty && <StickyBottomIndicator />}
201
-
202
216
  <PlanLogin plan={plan} baseUrl={baseUrl} />
203
217
  </>
204
218
  );
@@ -2,6 +2,7 @@ import { useTimeBlocking } from '@tuturuuu/ui/hooks/time-blocking-provider';
2
2
  import { timetzToTime } from '@tuturuuu/utils/date-helper';
3
3
  import { cn } from '@tuturuuu/utils/format';
4
4
  import dayjs from 'dayjs';
5
+ import type { PointerEvent as ReactPointerEvent } from 'react';
5
6
 
6
7
  export default function SelectableDayTime({
7
8
  date,
@@ -19,6 +20,36 @@ export default function SelectableDayTime({
19
20
  const { editing, selectedTimeBlocks, edit, setPreviewDate, endEditing } =
20
21
  useTimeBlocking();
21
22
 
23
+ const editCellAtPointer = (event: ReactPointerEvent<HTMLElement>) => {
24
+ const edge = 72;
25
+ const scrollStep = 18;
26
+ if (event.clientY < edge) window.scrollBy({ top: -scrollStep });
27
+ else if (event.clientY > window.innerHeight - edge)
28
+ window.scrollBy({ top: scrollStep });
29
+
30
+ const horizontalScroller = event.currentTarget.closest<HTMLElement>(
31
+ '[data-meet-grid-scroller]'
32
+ );
33
+ if (horizontalScroller) {
34
+ const bounds = horizontalScroller.getBoundingClientRect();
35
+ if (event.clientX < bounds.left + edge)
36
+ horizontalScroller.scrollBy({ left: -scrollStep });
37
+ else if (event.clientX > bounds.right - edge)
38
+ horizontalScroller.scrollBy({ left: scrollStep });
39
+ }
40
+
41
+ const target = document
42
+ .elementFromPoint(event.clientX, event.clientY)
43
+ ?.closest<HTMLElement>('[data-meet-slot]');
44
+ const isoDate = target?.dataset.meetSlot;
45
+ if (!isoDate) return;
46
+ edit({
47
+ mode: (target.dataset.meetMode as 'add' | 'remove') || 'add',
48
+ date: new Date(isoDate),
49
+ tentativeMode: target.dataset.meetTentative === 'true',
50
+ });
51
+ };
52
+
22
53
  const hourBlocks = Array.from(Array(Math.floor(end + 1 - start)).keys());
23
54
  const hourSplits = 4;
24
55
 
@@ -153,67 +184,68 @@ export default function SelectableDayTime({
153
184
  } as const;
154
185
 
155
186
  return (
156
- // biome-ignore lint/a11y/useKeyWithMouseEvents: mouse drag selection is intentional UX for time blocking
157
- <div
187
+ <button
188
+ type="button"
158
189
  key={`${date}-${i}`}
159
- onMouseDown={
160
- disabled
161
- ? undefined
162
- : isSelectable
163
- ? (e) => {
164
- e.preventDefault();
165
- edit(editData);
166
- }
167
- : undefined
190
+ data-meet-slot={currentDate.toISOString()}
191
+ data-meet-mode={editData.mode}
192
+ data-meet-tentative={String(editData.tentativeMode ?? false)}
193
+ disabled={!isSelectable}
194
+ aria-label={
195
+ isSelectable
196
+ ? `${date} ${dayjs(currentDate).format('HH:mm')}`
197
+ : undefined
168
198
  }
169
- onMouseOver={
170
- disabled
171
- ? undefined
172
- : isSelectable
173
- ? (e) => {
174
- e.preventDefault();
175
- if (!editing.enabled) return;
176
- edit(editData);
177
- }
178
- : (e) => {
179
- e.preventDefault();
180
- setPreviewDate(editData.date);
181
- }
199
+ aria-pressed={isSelectable ? isSelected : undefined}
200
+ onPointerDown={
201
+ isSelectable
202
+ ? (event) => {
203
+ event.preventDefault();
204
+ event.currentTarget.setPointerCapture(event.pointerId);
205
+ edit(editData);
206
+ }
207
+ : undefined
182
208
  }
183
- onTouchStart={
184
- disabled
185
- ? undefined
186
- : isSelectable
187
- ? (e) => {
188
- if (editing.enabled) return;
189
- e.preventDefault();
190
- edit(editData, e.nativeEvent);
191
- }
192
- : (e) => {
193
- e.preventDefault();
194
- setPreviewDate(editData.date);
195
- }
209
+ onPointerMove={
210
+ isSelectable
211
+ ? (event) => {
212
+ if (!editing.enabled) return;
213
+ event.preventDefault();
214
+ editCellAtPointer(event);
215
+ }
216
+ : undefined
196
217
  }
197
- onTouchMove={
198
- disabled
199
- ? undefined
200
- : isSelectable
201
- ? (e) => {
202
- if (!editing.enabled) return;
203
- e.preventDefault();
204
- edit(editData, e.nativeEvent);
205
- }
206
- : undefined
218
+ onPointerUp={
219
+ isSelectable
220
+ ? (event) => {
221
+ event.preventDefault();
222
+ if (
223
+ event.currentTarget.hasPointerCapture(event.pointerId)
224
+ )
225
+ event.currentTarget.releasePointerCapture(
226
+ event.pointerId
227
+ );
228
+ endEditing();
229
+ }
230
+ : undefined
231
+ }
232
+ onPointerCancel={isSelectable ? endEditing : undefined}
233
+ onPointerEnter={
234
+ isSelectable
235
+ ? () => {
236
+ if (editing.enabled) edit(editData);
237
+ }
238
+ : () => setPreviewDate(editData.date)
207
239
  }
208
- onTouchEnd={
209
- disabled
210
- ? undefined
211
- : (e) => {
212
- e.preventDefault();
213
- if (editing.enabled) {
214
- endEditing();
215
- }
240
+ onKeyDown={
241
+ isSelectable
242
+ ? (event) => {
243
+ if (event.key !== ' ' && event.key !== 'Enter') return;
244
+ event.preventDefault();
245
+ edit(editData);
246
+ queueMicrotask(endEditing);
216
247
  }
248
+ : undefined
217
249
  }
218
250
  className={`${
219
251
  i + hourSplits < array.length
@@ -229,7 +261,7 @@ export default function SelectableDayTime({
229
261
  ? 'bg-dynamic-red/50'
230
262
  : 'bg-dynamic-red/20'
231
263
  : ''
232
- } relative h-3 w-full ${cn(
264
+ } relative h-3 w-full touch-none focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-primary ${cn(
233
265
  hideBorder
234
266
  ? ''
235
267
  : (i + 1) % hourSplits === 0
@@ -13,12 +13,10 @@ import {
13
13
  } from '@tuturuuu/ui/dialog';
14
14
  import { useTranslations } from 'next-intl';
15
15
  import { QRCodeCanvas } from 'qrcode.react';
16
- import CopyLinkButton, { generateTumeetMeUrl } from './copy-link-button';
16
+ import CopyLinkButton from './copy-link-button';
17
17
 
18
18
  export default function ShowQRButton({ url }: { url: string }) {
19
19
  const t = useTranslations();
20
- const tumeetMeUrl = generateTumeetMeUrl(url);
21
-
22
20
  return (
23
21
  <Dialog>
24
22
  <DialogTrigger asChild>
@@ -37,7 +35,7 @@ export default function ShowQRButton({ url }: { url: string }) {
37
35
 
38
36
  <div className="flex items-center justify-center">
39
37
  <QRCodeCanvas
40
- value={tumeetMeUrl}
38
+ value={url}
41
39
  size={256}
42
40
  marginSize={2}
43
41
  className="rounded-lg"
@@ -45,7 +43,7 @@ export default function ShowQRButton({ url }: { url: string }) {
45
43
  </div>
46
44
 
47
45
  <DialogFooter>
48
- <CopyLinkButton url={tumeetMeUrl} className="md:w-full" />
46
+ <CopyLinkButton url={url} className="md:w-full" />
49
47
  </DialogFooter>
50
48
  </DialogContent>
51
49
  </Dialog>
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { Calendar, Save, Users } from '@tuturuuu/icons';
3
+ import { Calendar, Redo2, Save, Undo2, Users } from '@tuturuuu/icons';
4
4
  import type { MeetTogetherPlan } from '@tuturuuu/types/primitives/MeetTogetherPlan';
5
5
  import type { Timeblock } from '@tuturuuu/types/primitives/Timeblock';
6
6
  import { Button } from '@tuturuuu/ui/button';
7
7
  import { useTimeBlocking } from '@tuturuuu/ui/hooks/time-blocking-provider';
8
+ import { useTranslations } from 'next-intl';
8
9
  import { useEffect, useState } from 'react';
9
10
  import AllAvailabilities from './all-availabilities';
10
11
  import AvailabilityPlanner from './availability-planner';
@@ -22,7 +23,9 @@ export default function UnifiedAvailability({
22
23
  showBestTimes = false,
23
24
  onBestTimesStatusByDateAction,
24
25
  }: UnifiedAvailabilityProps) {
25
- const { isDirty, handleSave, isSaving } = useTimeBlocking();
26
+ const t = useTranslations('meet-together-plan-details');
27
+ const { isDirty, handleSave, isSaving, canUndo, canRedo, undo, redo } =
28
+ useTimeBlocking();
26
29
  const [isEditing, setIsEditing] = useState(false);
27
30
  const { user, setDisplayMode } = useTimeBlocking();
28
31
 
@@ -56,25 +59,45 @@ export default function UnifiedAvailability({
56
59
  {isEditing ? (
57
60
  <>
58
61
  <Users size={16} />
59
- View Everyone
62
+ {t('view_everyone')}
60
63
  </>
61
64
  ) : (
62
65
  <>
63
66
  <Calendar size={16} />
64
- Add Availability
67
+ {t('add_availability')}
65
68
  </>
66
69
  )}
67
70
  </Button>
68
71
  {isEditing && (
69
- <Button
70
- variant="default"
71
- size="lg"
72
- onClick={handleSave}
73
- disabled={!isDirty || isSaving}
74
- >
75
- <Save size={16} />
76
- {isSaving ? 'Saving...' : 'Save Changes'}
77
- </Button>
72
+ <div className="flex items-center gap-1">
73
+ <Button
74
+ variant="outline"
75
+ size="icon"
76
+ onClick={undo}
77
+ disabled={!canUndo}
78
+ aria-label={t('undo')}
79
+ >
80
+ <Undo2 size={16} />
81
+ </Button>
82
+ <Button
83
+ variant="outline"
84
+ size="icon"
85
+ onClick={redo}
86
+ disabled={!canRedo}
87
+ aria-label={t('redo')}
88
+ >
89
+ <Redo2 size={16} />
90
+ </Button>
91
+ <Button
92
+ variant="default"
93
+ size="lg"
94
+ onClick={handleSave}
95
+ disabled={!isDirty || isSaving}
96
+ >
97
+ <Save size={16} />
98
+ {isSaving ? t('saving') : t('save_changes')}
99
+ </Button>
100
+ </div>
78
101
  )}
79
102
  </div>
80
103