@tuturuuu/ui 0.19.1 → 0.21.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 (52) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/biome.json +1 -1
  3. package/package.json +60 -48
  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 +33 -0
  8. package/src/components/ui/custom/__tests__/workspace-select-reveal.test.tsx +88 -0
  9. package/src/components/ui/custom/animated-slot-text.tsx +20 -0
  10. package/src/components/ui/custom/common-footer.tsx +262 -237
  11. package/src/components/ui/custom/language-dropdown-item.tsx +3 -10
  12. package/src/components/ui/custom/language-toggle.test.tsx +30 -0
  13. package/src/components/ui/custom/language-toggle.tsx +11 -10
  14. package/src/components/ui/custom/locale-preference.ts +32 -0
  15. package/src/components/ui/custom/settings/appearance-settings.tsx +5 -13
  16. package/src/components/ui/custom/settings-dialog-shell.tsx +40 -9
  17. package/src/components/ui/custom/structure.tsx +12 -0
  18. package/src/components/ui/custom/system-language-dropdown-item.tsx +3 -6
  19. package/src/components/ui/custom/workspace-access/adapters.test.ts +10 -1
  20. package/src/components/ui/custom/workspace-access/adapters.ts +36 -4
  21. package/src/components/ui/custom/workspace-access/member-filter-utils.test.ts +14 -0
  22. package/src/components/ui/custom/workspace-access/member-filter-utils.ts +8 -0
  23. package/src/components/ui/custom/workspace-access/types.ts +20 -0
  24. package/src/components/ui/custom/workspace-access/workspace-access-context.test.tsx +111 -0
  25. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +15 -5
  26. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +155 -48
  27. package/src/components/ui/custom/workspace-access/workspace-access-member-profile-dialog.tsx +92 -0
  28. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +171 -76
  29. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +11 -2
  30. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +11 -11
  31. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +180 -22
  32. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +29 -15
  33. package/src/components/ui/custom/workspace-access/workspace-access-permission-checklist.tsx +56 -10
  34. package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.test.ts +33 -0
  35. package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.tsx +24 -1
  36. package/src/components/ui/custom/workspace-access/workspace-access-responsive.test.ts +64 -0
  37. package/src/components/ui/custom/workspace-access/workspace-access-role-editor-dialog.tsx +28 -16
  38. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +35 -11
  39. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +23 -11
  40. package/src/components/ui/custom/workspace-select-helpers.ts +5 -3
  41. package/src/components/ui/custom/workspace-select-reveal.tsx +46 -0
  42. package/src/components/ui/custom/workspace-select.tsx +13 -5
  43. package/src/components/ui/finance/shared/charts/monthly-total-chart-client.tsx +1 -1
  44. package/src/components/ui/finance/shared/charts/monthly-total-chart.tsx +1 -1
  45. package/src/components/ui/storefront/cart-summary.tsx +12 -2
  46. package/src/components/ui/storefront/storefront-surface.test.tsx +21 -0
  47. package/src/components/ui/storefront/storefront-surface.tsx +6 -0
  48. package/src/components/ui/text-editor/__tests__/collaboration-binding.test.tsx +161 -0
  49. package/src/components/ui/text-editor/editor.tsx +267 -235
  50. package/src/globals.css +166 -0
  51. package/src/hooks/__tests__/use-workspace-identity-mutation.test.tsx +181 -0
  52. package/src/hooks/use-workspace-identity-mutation.ts +136 -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
  }
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import {
4
+ createContext,
5
+ type Dispatch,
6
+ type ReactNode,
7
+ type SetStateAction,
8
+ useContext,
9
+ useEffect,
10
+ } from 'react';
11
+
12
+ const WorkspaceSelectRevealContext = createContext<boolean | null>(null);
13
+
14
+ export function WorkspaceSelectRevealProvider({
15
+ children,
16
+ revealed,
17
+ }: {
18
+ children: ReactNode;
19
+ revealed: boolean;
20
+ }) {
21
+ return (
22
+ <WorkspaceSelectRevealContext.Provider value={revealed}>
23
+ {children}
24
+ </WorkspaceSelectRevealContext.Provider>
25
+ );
26
+ }
27
+
28
+ export function useOpenWorkspaceSelectWhenRevealed(
29
+ hasSelectableWorkspaces: boolean,
30
+ setOpen: Dispatch<SetStateAction<boolean>>
31
+ ) {
32
+ const revealed = useContext(WorkspaceSelectRevealContext);
33
+
34
+ useEffect(() => {
35
+ if (revealed === null) return;
36
+
37
+ if (!revealed) {
38
+ setOpen(false);
39
+ return;
40
+ }
41
+
42
+ if (revealed && hasSelectableWorkspaces) {
43
+ setOpen(true);
44
+ }
45
+ }, [hasSelectableWorkspaces, revealed, setOpen]);
46
+ }
@@ -72,6 +72,7 @@ import {
72
72
  mergeWorkspaceSelectWorkspaces,
73
73
  normalizeWorkspaceSwitchPath,
74
74
  } from './workspace-select-helpers';
75
+ import { useOpenWorkspaceSelectWhenRevealed } from './workspace-select-reveal';
75
76
 
76
77
  const FormSchema = z.object({
77
78
  name: z.string().min(1).max(100),
@@ -156,6 +157,8 @@ export function WorkspaceSelect({
156
157
  createWorkspaceDescription,
157
158
  fallbackLogoUrl = TUTURUUU_LOGO_URL,
158
159
  resolveNextPathname,
160
+ triggerClassName,
161
+ popoverModal = false,
159
162
  }: {
160
163
  wsId: string;
161
164
  hideLeading?: boolean;
@@ -171,6 +174,9 @@ export function WorkspaceSelect({
171
174
  currentPathname: string;
172
175
  nextSlug: string;
173
176
  }) => string;
177
+ triggerClassName?: string;
178
+ /** Keep the picker interactive and scrollable when rendered inside a modal. */
179
+ popoverModal?: boolean;
174
180
  }) {
175
181
  const t = useTranslations();
176
182
  const router = useRouter();
@@ -444,6 +450,9 @@ export function WorkspaceSelect({
444
450
  }
445
451
  };
446
452
 
453
+ const hasSelectableWorkspaces = workspaces.length > 0;
454
+ useOpenWorkspaceSelectWhenRevealed(hasSelectableWorkspaces, setOpen);
455
+
447
456
  const workspace =
448
457
  wsId === PERSONAL_WORKSPACE_SLUG
449
458
  ? personalWorkspace
@@ -451,8 +460,6 @@ export function WorkspaceSelect({
451
460
  guestWorkspaces.find((ws) => ws.id === resolvedWorkspaceId));
452
461
  if (!wsId) return <div />;
453
462
 
454
- const hasSelectableWorkspaces = workspaces.length > 0;
455
-
456
463
  async function onJoinByHandleSubmit(
457
464
  formData: z.infer<typeof JoinWorkspaceByHandleFormSchema>
458
465
  ) {
@@ -554,7 +561,7 @@ export function WorkspaceSelect({
554
561
  setShowNewWorkspaceDialog(open);
555
562
  }}
556
563
  >
557
- <Popover open={open} onOpenChange={setOpen}>
564
+ <Popover modal={popoverModal} open={open} onOpenChange={setOpen}>
558
565
  <PopoverTrigger asChild disabled={!hasSelectableWorkspaces}>
559
566
  <Button
560
567
  size="xs"
@@ -563,7 +570,8 @@ export function WorkspaceSelect({
563
570
  aria-label="Select a workspace"
564
571
  className={cn(
565
572
  hideLeading ? 'justify-center p-0' : 'justify-start',
566
- 'w-full whitespace-normal text-start'
573
+ 'w-full whitespace-normal text-start',
574
+ triggerClassName
567
575
  )}
568
576
  disabled={!hasSelectableWorkspaces}
569
577
  >
@@ -616,7 +624,7 @@ export function WorkspaceSelect({
616
624
  </PopoverTrigger>
617
625
  <PopoverContent className="w-full max-w-[16rem] p-0">
618
626
  <Command>
619
- <CommandInput placeholder="Search workspace..." />
627
+ <CommandInput autoFocus placeholder="Search workspace..." />
620
628
  <CommandEmpty>No workspace found.</CommandEmpty>
621
629
  <CommandList className="max-h-64">
622
630
  {groups.map((group) => (
@@ -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