@tuturuuu/ui 0.19.0 → 0.20.1

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 (55) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/biome.json +1 -1
  3. package/package.json +62 -50
  4. package/src/components/ui/badge.tsx +2 -0
  5. package/src/components/ui/button.tsx +2 -0
  6. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +48 -4
  7. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +22 -0
  8. package/src/components/ui/custom/animated-slot-text.tsx +20 -0
  9. package/src/components/ui/custom/common-footer.tsx +262 -237
  10. package/src/components/ui/custom/language-dropdown-item.tsx +3 -10
  11. package/src/components/ui/custom/language-toggle.test.tsx +30 -0
  12. package/src/components/ui/custom/language-toggle.tsx +11 -10
  13. package/src/components/ui/custom/locale-preference.ts +32 -0
  14. package/src/components/ui/custom/settings/appearance-settings.tsx +5 -13
  15. package/src/components/ui/custom/settings-dialog-shell.tsx +40 -9
  16. package/src/components/ui/custom/structure.tsx +12 -0
  17. package/src/components/ui/custom/system-language-dropdown-item.tsx +3 -6
  18. package/src/components/ui/custom/workspace-access/adapters.test.ts +10 -1
  19. package/src/components/ui/custom/workspace-access/adapters.ts +36 -4
  20. package/src/components/ui/custom/workspace-access/member-filter-utils.test.ts +14 -0
  21. package/src/components/ui/custom/workspace-access/member-filter-utils.ts +8 -0
  22. package/src/components/ui/custom/workspace-access/types.ts +20 -0
  23. package/src/components/ui/custom/workspace-access/workspace-access-context.test.tsx +111 -0
  24. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +15 -5
  25. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +155 -48
  26. package/src/components/ui/custom/workspace-access/workspace-access-member-profile-dialog.tsx +92 -0
  27. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +171 -76
  28. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +11 -2
  29. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +11 -11
  30. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +180 -22
  31. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +29 -15
  32. package/src/components/ui/custom/workspace-access/workspace-access-permission-checklist.tsx +56 -10
  33. package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.test.ts +33 -0
  34. package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.tsx +24 -1
  35. package/src/components/ui/custom/workspace-access/workspace-access-responsive.test.ts +64 -0
  36. package/src/components/ui/custom/workspace-access/workspace-access-role-editor-dialog.tsx +28 -16
  37. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +35 -11
  38. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +23 -11
  39. package/src/components/ui/custom/workspace-select-helpers.ts +5 -3
  40. package/src/components/ui/custom/workspace-select.tsx +8 -2
  41. package/src/components/ui/finance/shared/charts/monthly-total-chart-client.tsx +1 -1
  42. package/src/components/ui/finance/shared/charts/monthly-total-chart.tsx +1 -1
  43. package/src/components/ui/storefront/cart-summary.tsx +12 -2
  44. package/src/components/ui/storefront/storefront-surface.test.tsx +21 -0
  45. package/src/components/ui/storefront/storefront-surface.tsx +6 -0
  46. package/src/components/ui/text-editor/__tests__/collaboration-binding.test.tsx +161 -0
  47. package/src/components/ui/text-editor/editor.tsx +267 -235
  48. package/src/globals.css +166 -0
  49. package/src/hooks/__tests__/use-workspace-identity-mutation.test.tsx +181 -0
  50. package/src/hooks/use-workspace-identity-mutation.ts +136 -0
  51. package/vendor/xlsx/LICENSE +201 -0
  52. package/vendor/xlsx/types/index.d.ts +1065 -0
  53. package/vendor/xlsx/xlsx.js +28103 -0
  54. package/vendor/xlsx/xlsx.mjs +28228 -0
  55. package/vendor/xlsx-0.20.3.tgz +0 -0
@@ -0,0 +1,64 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ function readWorkspaceAccessSource(fileName: string) {
6
+ const sourcePath = [
7
+ join(
8
+ process.cwd(),
9
+ 'packages/ui/src/components/ui/custom/workspace-access',
10
+ fileName
11
+ ),
12
+ join(process.cwd(), 'src/components/ui/custom/workspace-access', fileName),
13
+ ].find((candidate) => existsSync(candidate));
14
+
15
+ if (!sourcePath) {
16
+ throw new Error(`Unable to locate ${fileName}`);
17
+ }
18
+
19
+ return readFileSync(sourcePath, 'utf8');
20
+ }
21
+
22
+ describe('workspace access responsive layout', () => {
23
+ it.each([
24
+ 'workspace-access-invite-dialog.tsx',
25
+ 'workspace-access-role-editor-dialog.tsx',
26
+ ])('keeps %s within the mobile viewport with a scrollable body', (file) => {
27
+ const source = readWorkspaceAccessSource(file);
28
+
29
+ expect(source).toContain('max-h-[calc(100dvh-1rem)]');
30
+ expect(source).toContain('max-sm:bottom-0');
31
+ expect(source).toContain('overflow-y-auto overscroll-contain');
32
+ expect(source).toContain('<DialogFooter');
33
+ });
34
+
35
+ it('uses compact navigation and controls on small screens', () => {
36
+ const toolbar = readWorkspaceAccessSource(
37
+ 'workspace-access-tabs-toolbar.tsx'
38
+ );
39
+ const memberRow = readWorkspaceAccessSource(
40
+ 'workspace-access-member-row.tsx'
41
+ );
42
+ const peopleFilters = readWorkspaceAccessSource(
43
+ 'workspace-access-people-filters.tsx'
44
+ );
45
+
46
+ expect(toolbar).toContain('grid-cols-4');
47
+ expect(toolbar).toContain('shrink-0 grid-cols-4');
48
+ expect(toolbar).not.toContain('2xl:flex-row');
49
+ expect(toolbar).toContain('sr-only sm:hidden');
50
+ expect(memberRow).toContain('size-8 shrink-0');
51
+ expect(memberRow).toContain('<DropdownMenuContent');
52
+ expect(memberRow).not.toContain('sm:grid-cols-[1fr_auto]');
53
+ expect(peopleFilters).toContain('className="h-full min-h-0"');
54
+ });
55
+
56
+ it('creates the recommended administrator role with only the admin grant', () => {
57
+ const page = readWorkspaceAccessSource('workspace-access-page.tsx');
58
+
59
+ expect(page).toContain("permissions: [{ enabled: true, id: 'admin' }]");
60
+ expect(page).not.toContain(
61
+ 'permissions: permissionDefinitions.map((permission)'
62
+ );
63
+ });
64
+ });
@@ -1,12 +1,14 @@
1
1
  'use client';
2
2
 
3
3
  import { useMutation, useQueryClient } from '@tanstack/react-query';
4
+ import { ShieldCheck } from '@tuturuuu/icons';
4
5
  import type { SupabaseUser } from '@tuturuuu/supabase/next/user';
5
6
  import { Button } from '@tuturuuu/ui/button';
6
7
  import {
7
8
  Dialog,
8
9
  DialogContent,
9
10
  DialogDescription,
11
+ DialogFooter,
10
12
  DialogHeader,
11
13
  DialogTitle,
12
14
  } from '@tuturuuu/ui/dialog';
@@ -131,13 +133,22 @@ export function WorkspaceAccessRoleEditorDialog({
131
133
 
132
134
  return (
133
135
  <Dialog open={open} onOpenChange={onOpenChange}>
134
- <DialogContent className="max-w-3xl">
135
- <DialogHeader>
136
- <DialogTitle>{labels.title}</DialogTitle>
137
- <DialogDescription>{labels.description}</DialogDescription>
136
+ <DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-none flex-col gap-0 overflow-hidden rounded-b-none p-0 max-sm:top-auto max-sm:bottom-0 max-sm:left-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-h-[min(90dvh,56rem)] sm:max-w-3xl sm:rounded-lg">
137
+ <DialogHeader className="shrink-0 gap-0 border-b p-4 pr-12 text-left sm:p-6 sm:pr-12">
138
+ <div className="flex items-start gap-3">
139
+ <div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-dynamic-purple/25 bg-dynamic-purple/10 text-dynamic-purple">
140
+ <ShieldCheck className="size-4" />
141
+ </div>
142
+ <div className="min-w-0 space-y-1">
143
+ <DialogTitle>{labels.title}</DialogTitle>
144
+ <DialogDescription className="leading-5">
145
+ {labels.description}
146
+ </DialogDescription>
147
+ </div>
148
+ </div>
138
149
  </DialogHeader>
139
150
 
140
- <div className="space-y-4">
151
+ <div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain p-4 sm:p-6">
141
152
  {state.mode !== 'default' ? (
142
153
  <div className="grid gap-2">
143
154
  <Label htmlFor="workspace-access-role-name">
@@ -152,9 +163,9 @@ export function WorkspaceAccessRoleEditorDialog({
152
163
  </div>
153
164
  ) : null}
154
165
 
155
- <div className="flex items-center justify-between rounded-lg border bg-muted/35 px-4 py-3 text-sm">
166
+ <div className="flex items-center justify-between rounded-lg border bg-muted/35 px-3 py-2.5 text-sm sm:px-4 sm:py-3">
156
167
  <span className="font-medium">{t('ws-roles.permissions')}</span>
157
- <span className="tabular-nums">
168
+ <span className="rounded-full bg-background px-2 py-0.5 font-medium tabular-nums">
158
169
  {selectedCount}/{totalCount}
159
170
  </span>
160
171
  </div>
@@ -164,16 +175,17 @@ export function WorkspaceAccessRoleEditorDialog({
164
175
  onSelectedPermissionsChange={setSelectedPermissions}
165
176
  selectedPermissions={selectedPermissions}
166
177
  />
167
-
168
- <div className="flex justify-end">
169
- <Button
170
- disabled={disabled || saveMutation.isPending}
171
- onClick={() => saveMutation.mutate()}
172
- >
173
- {saveMutation.isPending ? t('common.processing') : labels.save}
174
- </Button>
175
- </div>
176
178
  </div>
179
+
180
+ <DialogFooter className="shrink-0 border-t bg-muted/20 p-3 sm:p-4">
181
+ <Button
182
+ className="w-full sm:w-auto"
183
+ disabled={disabled || saveMutation.isPending}
184
+ onClick={() => saveMutation.mutate()}
185
+ >
186
+ {saveMutation.isPending ? t('common.processing') : labels.save}
187
+ </Button>
188
+ </DialogFooter>
177
189
  </DialogContent>
178
190
  </Dialog>
179
191
  );
@@ -49,7 +49,7 @@ export function WorkspaceAccessRoles({
49
49
 
50
50
  return (
51
51
  <div className="space-y-4">
52
- <div className="flex flex-wrap items-start justify-between gap-4">
52
+ <div className="flex items-start justify-between gap-3">
53
53
  <div>
54
54
  <h2 className="font-semibold text-lg">{labels.accessLevelsLabel}</h2>
55
55
  <p className="text-muted-foreground text-sm">
@@ -57,9 +57,13 @@ export function WorkspaceAccessRoles({
57
57
  </p>
58
58
  </div>
59
59
  {canManageRoles ? (
60
- <Button onClick={onCreateRole}>
61
- <Plus className="mr-2 h-4 w-4" />
62
- {t('ws-roles.create')}
60
+ <Button
61
+ onClick={onCreateRole}
62
+ className="size-9 shrink-0 px-0 sm:w-auto sm:px-4"
63
+ >
64
+ <Plus className="size-4 sm:mr-2" />
65
+ <span className="hidden sm:inline">{t('ws-roles.create')}</span>
66
+ <span className="sr-only sm:hidden">{t('ws-roles.create')}</span>
63
67
  </Button>
64
68
  ) : null}
65
69
  </div>
@@ -86,7 +90,10 @@ export function WorkspaceAccessRoles({
86
90
  role.members && role.members.length > 0
87
91
  ? role.members
88
92
  : assignedMembersForRole(role.id, members);
89
- const enabled = enabledPermissionCount(role);
93
+ const enabled = enabledPermissionCount(role, permissionCount);
94
+ const isAdministrator = role.permissions.some(
95
+ (permission) => permission.id === 'admin' && permission.enabled
96
+ );
90
97
  const pct =
91
98
  permissionCount > 0
92
99
  ? Math.round((enabled / permissionCount) * 100)
@@ -95,7 +102,7 @@ export function WorkspaceAccessRoles({
95
102
  return (
96
103
  <div
97
104
  key={role.id}
98
- className="rounded-xl border border-border bg-background p-5 transition-colors hover:border-foreground/20"
105
+ className="rounded-xl border border-border bg-background p-4 transition-colors hover:border-foreground/20 sm:p-5"
99
106
  >
100
107
  <div className="flex flex-wrap items-start justify-between gap-4">
101
108
  <div className="flex min-w-0 gap-3">
@@ -133,22 +140,34 @@ export function WorkspaceAccessRoles({
133
140
  </div>
134
141
 
135
142
  {canManageRoles ? (
136
- <div className="flex flex-wrap gap-2">
143
+ <div className="flex shrink-0 gap-1.5 sm:gap-2">
137
144
  <Button
138
145
  variant="outline"
139
146
  size="sm"
147
+ className="size-8 px-0 sm:w-auto sm:px-3"
140
148
  onClick={() => onEditRole(role)}
141
149
  >
142
- <Pencil className="mr-2 h-4 w-4" />
143
- {t('common.edit')}
150
+ <Pencil className="size-3.5 sm:mr-2" />
151
+ <span className="hidden sm:inline">
152
+ {t('common.edit')}
153
+ </span>
154
+ <span className="sr-only sm:hidden">
155
+ {t('common.edit')}
156
+ </span>
144
157
  </Button>
145
158
  <Button
146
159
  variant="outline"
147
160
  size="sm"
161
+ className="size-8 px-0 sm:w-auto sm:px-3"
148
162
  onClick={() => onDeleteRole(role)}
149
163
  >
150
- <Trash2 className="mr-2 h-4 w-4" />
151
- {t('common.delete')}
164
+ <Trash2 className="size-3.5 sm:mr-2" />
165
+ <span className="hidden sm:inline">
166
+ {t('common.delete')}
167
+ </span>
168
+ <span className="sr-only sm:hidden">
169
+ {t('common.delete')}
170
+ </span>
152
171
  </Button>
153
172
  </div>
154
173
  ) : null}
@@ -161,6 +180,11 @@ export function WorkspaceAccessRoles({
161
180
  role={role}
162
181
  />
163
182
  </div>
183
+ {isAdministrator ? (
184
+ <p className="mt-2 text-dynamic-green text-sm">
185
+ {t('ws-members.admin_has_all_permissions')}
186
+ </p>
187
+ ) : null}
164
188
 
165
189
  {assignedMembers.length > 0 ? (
166
190
  <div className="mt-3 flex flex-wrap gap-1.5 border-border border-t pt-3">
@@ -27,7 +27,7 @@ type Props = {
27
27
  };
28
28
 
29
29
  const TAB_TRIGGER_CLASS =
30
- 'rounded-none border-transparent border-b-2 bg-transparent px-1 pt-1 pb-3 text-muted-foreground shadow-none transition-colors hover:text-foreground data-[state=active]:border-dynamic-blue data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none';
30
+ 'h-full min-w-0 w-full gap-1.5 overflow-hidden rounded-lg px-2 text-muted-foreground shadow-none transition-colors hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm sm:px-3';
31
31
 
32
32
  export function WorkspaceAccessTabsToolbar({
33
33
  activeTab,
@@ -73,8 +73,8 @@ export function WorkspaceAccessTabsToolbar({
73
73
  ];
74
74
 
75
75
  return (
76
- <div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
77
- <TabsList className="h-auto w-full justify-start gap-5 overflow-x-auto rounded-none border-border border-b bg-transparent p-0 lg:w-auto">
76
+ <div className="flex flex-col gap-3">
77
+ <TabsList className="grid h-11 w-full shrink-0 grid-cols-4 gap-1 overflow-hidden rounded-xl border bg-muted/30 p-1">
78
78
  {tabs.map((tab) => (
79
79
  <TabsTrigger
80
80
  key={tab.value}
@@ -83,13 +83,16 @@ export function WorkspaceAccessTabsToolbar({
83
83
  className={TAB_TRIGGER_CLASS}
84
84
  >
85
85
  {tab.icon}
86
- {tab.label}
86
+ <span className="hidden min-w-0 truncate sm:inline">
87
+ {tab.label}
88
+ </span>
89
+ <span className="sr-only sm:hidden">{tab.label}</span>
87
90
  </TabsTrigger>
88
91
  ))}
89
92
  </TabsList>
90
93
 
91
- <div className="flex w-full shrink-0 flex-col gap-2 sm:flex-row lg:w-auto">
92
- <div className="relative min-w-0 sm:min-w-[280px]">
94
+ <div className="flex w-full min-w-0 gap-2">
95
+ <div className="relative min-w-0 flex-1">
93
96
  <Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
94
97
  <Input
95
98
  value={search}
@@ -99,11 +102,20 @@ export function WorkspaceAccessTabsToolbar({
99
102
  />
100
103
  </div>
101
104
  {activeTab === 'people' ? (
102
- <Button disabled={!canInvite} onClick={onInviteClick}>
103
- <UserPlus className="mr-2 h-4 w-4" />
104
- {disableInvite
105
- ? t('ws-members.invite_member_disabled')
106
- : t('ws-members.invite_member')}
105
+ <Button
106
+ disabled={!canInvite}
107
+ onClick={onInviteClick}
108
+ className="shrink-0 px-3 sm:px-4"
109
+ >
110
+ <UserPlus className="size-4 sm:mr-2" />
111
+ <span className="hidden sm:inline">
112
+ {disableInvite
113
+ ? t('ws-members.invite_member_disabled')
114
+ : t('ws-members.invite_member')}
115
+ </span>
116
+ <span className="sr-only sm:hidden">
117
+ {t('ws-members.invite_member')}
118
+ </span>
107
119
  </Button>
108
120
  ) : null}
109
121
  </div>
@@ -23,11 +23,13 @@ export function normalizeWorkspaceSwitchPath(
23
23
  pathname: string,
24
24
  nextSlug: string
25
25
  ) {
26
- const taskBoardsPath = `/${nextSlug}/tasks/boards`;
26
+ const taskBoardPaths = [`/${nextSlug}/boards`, `/${nextSlug}/tasks/boards`];
27
27
 
28
28
  if (
29
- pathname === taskBoardsPath ||
30
- pathname.startsWith(`${taskBoardsPath}/`)
29
+ taskBoardPaths.some(
30
+ (taskBoardsPath) =>
31
+ pathname === taskBoardsPath || pathname.startsWith(`${taskBoardsPath}/`)
32
+ )
31
33
  ) {
32
34
  return `/${nextSlug}/tasks`;
33
35
  }
@@ -156,6 +156,8 @@ export function WorkspaceSelect({
156
156
  createWorkspaceDescription,
157
157
  fallbackLogoUrl = TUTURUUU_LOGO_URL,
158
158
  resolveNextPathname,
159
+ triggerClassName,
160
+ popoverModal = false,
159
161
  }: {
160
162
  wsId: string;
161
163
  hideLeading?: boolean;
@@ -171,6 +173,9 @@ export function WorkspaceSelect({
171
173
  currentPathname: string;
172
174
  nextSlug: string;
173
175
  }) => string;
176
+ triggerClassName?: string;
177
+ /** Keep the picker interactive and scrollable when rendered inside a modal. */
178
+ popoverModal?: boolean;
174
179
  }) {
175
180
  const t = useTranslations();
176
181
  const router = useRouter();
@@ -554,7 +559,7 @@ export function WorkspaceSelect({
554
559
  setShowNewWorkspaceDialog(open);
555
560
  }}
556
561
  >
557
- <Popover open={open} onOpenChange={setOpen}>
562
+ <Popover modal={popoverModal} open={open} onOpenChange={setOpen}>
558
563
  <PopoverTrigger asChild disabled={!hasSelectableWorkspaces}>
559
564
  <Button
560
565
  size="xs"
@@ -563,7 +568,8 @@ export function WorkspaceSelect({
563
568
  aria-label="Select a workspace"
564
569
  className={cn(
565
570
  hideLeading ? 'justify-center p-0' : 'justify-start',
566
- 'w-full whitespace-normal text-start'
571
+ 'w-full whitespace-normal text-start',
572
+ triggerClassName
567
573
  )}
568
574
  disabled={!hasSelectableWorkspaces}
569
575
  >
@@ -310,7 +310,7 @@ export function MonthlyTotalChartClient({
310
310
  return Intl.DateTimeFormat(locale, {
311
311
  month: 'long',
312
312
  year: 'numeric',
313
- }).format(new Date(value));
313
+ }).format(new Date(String(value ?? '')));
314
314
  } catch {
315
315
  return value;
316
316
  }
@@ -215,7 +215,7 @@ export function MonthlyTotalChart({
215
215
  return Intl.DateTimeFormat(locale, {
216
216
  month: 'long',
217
217
  year: 'numeric',
218
- }).format(new Date(value));
218
+ }).format(new Date(String(value ?? '')));
219
219
  } catch {
220
220
  return value;
221
221
  }
@@ -3,7 +3,7 @@
3
3
  import { ArrowRight, ShoppingCart } from '@tuturuuu/icons';
4
4
  import type { InventoryStorefront } from '@tuturuuu/internal-api/inventory';
5
5
  import { cn } from '@tuturuuu/utils/format';
6
- import type { FormEvent } from 'react';
6
+ import type { FormEvent, ReactNode } from 'react';
7
7
  import { Badge } from '../badge';
8
8
  import { AccentButton } from './accent-button';
9
9
  import {
@@ -25,6 +25,8 @@ type StorefrontCartSummaryVariant = 'checkout' | 'panel' | 'popover';
25
25
  export function StorefrontCartSummary({
26
26
  buyerDefaults,
27
27
  cartEntries,
28
+ checkoutBlocked = false,
29
+ checkoutFields,
28
30
  checkoutHref,
29
31
  className,
30
32
  currency,
@@ -43,6 +45,8 @@ export function StorefrontCartSummary({
43
45
  }: {
44
46
  buyerDefaults?: StorefrontBuyerDefaults;
45
47
  cartEntries: StorefrontCartEntry[];
48
+ checkoutBlocked?: boolean;
49
+ checkoutFields?: ReactNode;
46
50
  checkoutHref?: string;
47
51
  className?: string;
48
52
  currency: string;
@@ -63,7 +67,11 @@ export function StorefrontCartSummary({
63
67
  const hasCart = cartEntries.length > 0;
64
68
  const isCheckoutDisabled = storefront.checkoutMode === 'disabled';
65
69
  const submitDisabled =
66
- !hasCart || isSubmitting || isCheckoutDisabled || !onCheckoutSubmit;
70
+ !hasCart ||
71
+ isSubmitting ||
72
+ isCheckoutDisabled ||
73
+ checkoutBlocked ||
74
+ !onCheckoutSubmit;
67
75
  const canOpenCheckout = hasCart && Boolean(checkoutHref || onCheckoutOpen);
68
76
 
69
77
  if (presentation === 'checkout') {
@@ -101,6 +109,8 @@ export function StorefrontCartSummary({
101
109
  />
102
110
  </CheckoutSection>
103
111
 
112
+ {checkoutFields}
113
+
104
114
  <AccentButton disabled={submitDisabled} radius={radius}>
105
115
  {isSubmitting ? labels.reserving : labels.reserve}
106
116
  <ArrowRight className="size-4 shrink-0" />
@@ -485,6 +485,27 @@ describe('StorefrontSurface', () => {
485
485
  expect(screen.getByLabelText('Email')).toBeEnabled();
486
486
  });
487
487
 
488
+ it('shows checkout routing guidance and blocks dispatch until it is ready', () => {
489
+ render(
490
+ <StorefrontSurface
491
+ cartLines={[{ listingId: listing.id, quantity: 1 }]}
492
+ checkoutBlocked
493
+ checkoutFields={<div>Choose an approved payment station</div>}
494
+ listings={[listing]}
495
+ mode="checkout"
496
+ onCheckoutSubmit={() => undefined}
497
+ storefront={{ ...storefront, checkoutMode: 'square_terminal' }}
498
+ />
499
+ );
500
+
501
+ expect(
502
+ screen.getByText('Choose an approved payment station')
503
+ ).toBeInTheDocument();
504
+ expect(
505
+ screen.getByRole('button', { name: 'Reserve with Polar' })
506
+ ).toBeDisabled();
507
+ });
508
+
488
509
  it('keeps simulated storefront chrome customer-facing', () => {
489
510
  render(
490
511
  <StorefrontSurface
@@ -50,6 +50,8 @@ export function StorefrontSurface({
50
50
  buyerDefaults,
51
51
  cartLines = [],
52
52
  cartHref,
53
+ checkoutBlocked = false,
54
+ checkoutFields,
53
55
  checkoutOpen,
54
56
  checkoutHref,
55
57
  className,
@@ -83,6 +85,8 @@ export function StorefrontSurface({
83
85
  buyerDefaults?: StorefrontBuyerDefaults;
84
86
  cartLines?: StorefrontCartLine[];
85
87
  cartHref?: string;
88
+ checkoutBlocked?: boolean;
89
+ checkoutFields?: ReactNode;
86
90
  checkoutOpen?: boolean;
87
91
  checkoutHref?: string;
88
92
  className?: string;
@@ -269,6 +273,8 @@ export function StorefrontSurface({
269
273
  <StorefrontCartSummary
270
274
  buyerDefaults={buyerDefaults}
271
275
  cartEntries={checkoutEntries}
276
+ checkoutBlocked={checkoutBlocked}
277
+ checkoutFields={checkoutFields}
272
278
  checkoutHref={checkoutHref}
273
279
  currency={currency}
274
280
  isCheckout
@@ -0,0 +1,161 @@
1
+ import { render } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import * as Y from 'yjs';
4
+ import { RichTextEditor } from '../editor';
5
+
6
+ const useEditorMock = vi.hoisted(() => vi.fn((..._args: unknown[]) => null));
7
+
8
+ vi.mock('@tiptap/react', async (importOriginal) => ({
9
+ ...(await importOriginal<typeof import('@tiptap/react')>()),
10
+ EditorContent: () => null,
11
+ useEditor: (...args: unknown[]) => useEditorMock(...args),
12
+ }));
13
+
14
+ vi.mock('../tool-bar', () => ({
15
+ FixedToolbar: ({ className }: { className?: string }) => (
16
+ <div className={className} data-testid="fixed-toolbar" />
17
+ ),
18
+ ToolBar: () => null,
19
+ }));
20
+
21
+ type UseEditorCall = [
22
+ { extensions: Array<{ name: string }> },
23
+ unknown[] | undefined,
24
+ ];
25
+
26
+ function lastCall(): UseEditorCall {
27
+ const calls = useEditorMock.mock.calls as unknown as UseEditorCall[];
28
+ const call = calls.at(-1);
29
+ if (!call) throw new Error('useEditor was never called');
30
+ return call;
31
+ }
32
+
33
+ function extensionNames() {
34
+ return lastCall()[0].extensions.map((extension) => extension.name);
35
+ }
36
+
37
+ describe('RichTextEditor collaboration binding', () => {
38
+ beforeEach(() => {
39
+ useEditorMock.mockClear();
40
+ });
41
+
42
+ it('omits the collaboration extension while collaboration is off', () => {
43
+ render(
44
+ <RichTextEditor
45
+ allowCollaboration={false}
46
+ content={null}
47
+ yjsDoc={undefined}
48
+ />
49
+ );
50
+
51
+ expect(extensionNames()).not.toContain('collaboration');
52
+ });
53
+
54
+ // Regression: `Editor.setOptions({ extensions })` does not rebuild the
55
+ // extension manager, so the only way to attach the Collaboration extension
56
+ // after mount is to recreate the editor. A task dialog opened from a deep link
57
+ // mounts before the task hydrates — i.e. with collaboration off — and used to
58
+ // stay non-collaborative forever, leaving the description permanently empty.
59
+ it('rebuilds the editor when collaboration turns on after mount', () => {
60
+ const doc = new Y.Doc();
61
+ const { rerender } = render(
62
+ <RichTextEditor allowCollaboration={false} content={null} yjsDoc={doc} />
63
+ );
64
+
65
+ const initialDeps = lastCall()[1];
66
+ expect(extensionNames()).not.toContain('collaboration');
67
+
68
+ rerender(<RichTextEditor allowCollaboration content={null} yjsDoc={doc} />);
69
+
70
+ const collaborativeCall = lastCall();
71
+ expect(collaborativeCall[0].extensions.map((e) => e.name)).toContain(
72
+ 'collaboration'
73
+ );
74
+ // A changed dependency array is what makes `useEditor` throw away the
75
+ // non-collaborative instance and build a new one.
76
+ expect(collaborativeCall[1]).not.toEqual(initialDeps);
77
+ expect(collaborativeCall[1]).toContain(doc);
78
+ });
79
+
80
+ it('keeps a stable dependency array across unrelated re-renders', () => {
81
+ const doc = new Y.Doc();
82
+ const { rerender } = render(
83
+ <RichTextEditor allowCollaboration content={null} yjsDoc={doc} />
84
+ );
85
+
86
+ const initialDeps = lastCall()[1];
87
+
88
+ // `collaborationUser` is a fresh object on every render in the task dialog;
89
+ // it must not be part of the dependency array or the editor would be
90
+ // rebuilt (and the caret reset) on every render.
91
+ rerender(
92
+ <RichTextEditor
93
+ allowCollaboration
94
+ collaborationUser={{ color: '#fff', name: 'Ada' }}
95
+ content={null}
96
+ yjsDoc={doc}
97
+ />
98
+ );
99
+
100
+ expect(lastCall()[1]).toEqual(initialDeps);
101
+ });
102
+
103
+ // The provider only drives CollaborationCaret (cosmetic remote cursors).
104
+ // Rebuilding a live editor for it would tear down the ProseMirror view and
105
+ // re-run the Yjs binding over the whole document mid-session — expensive and
106
+ // user-visible on a large document, for no content benefit.
107
+ it('does not rebuild the editor when only the provider arrives', () => {
108
+ const doc = new Y.Doc();
109
+ const provider = { awareness: { setLocalStateField: () => {} } };
110
+ const { rerender } = render(
111
+ <RichTextEditor allowCollaboration content={null} yjsDoc={doc} />
112
+ );
113
+
114
+ const initialDeps = lastCall()[1];
115
+
116
+ rerender(
117
+ <RichTextEditor
118
+ allowCollaboration
119
+ collaborationUser={{ color: '#fff', name: 'Ada' }}
120
+ content={null}
121
+ yjsDoc={doc}
122
+ yjsProvider={provider as never}
123
+ />
124
+ );
125
+
126
+ expect(lastCall()[1]).toEqual(initialDeps);
127
+ });
128
+ });
129
+
130
+ describe('RichTextEditor toolbar reveal', () => {
131
+ beforeEach(() => {
132
+ useEditorMock.mockClear();
133
+ });
134
+
135
+ // The toolbar must reveal on focus *within* the editor wrapper, not on editor
136
+ // focus alone: clicking a toolbar button moves focus out of the text, so a
137
+ // stricter rule would hide the toolbar on mousedown and swallow the click.
138
+ it('hides the toolbar until focus lands inside the editor', () => {
139
+ const { container } = render(
140
+ <RichTextEditor content={null} revealToolbarOnFocus />
141
+ );
142
+
143
+ const toolbar = container.querySelector('[data-testid="fixed-toolbar"]');
144
+ expect(toolbar?.className).toContain('opacity-0');
145
+ expect(toolbar?.className).toContain('group-focus-within:opacity-100');
146
+ // Inert while hidden, so it cannot show hover states or tooltips; the
147
+ // wrapper turns a press in that strip into editor focus instead.
148
+ expect(toolbar?.className).toContain('pointer-events-none');
149
+ expect(toolbar?.className).toContain(
150
+ 'group-focus-within:pointer-events-auto'
151
+ );
152
+ expect(container.firstElementChild?.className).toContain('group');
153
+ });
154
+
155
+ it('leaves the toolbar visible by default', () => {
156
+ const { container } = render(<RichTextEditor content={null} />);
157
+
158
+ const toolbar = container.querySelector('[data-testid="fixed-toolbar"]');
159
+ expect(toolbar?.className ?? '').not.toContain('opacity-0');
160
+ });
161
+ });