@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.
- package/CHANGELOG.md +113 -0
- package/biome.json +1 -1
- package/package.json +60 -48
- package/src/components/ui/badge.tsx +2 -0
- package/src/components/ui/button.tsx +2 -0
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +48 -4
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +33 -0
- package/src/components/ui/custom/__tests__/workspace-select-reveal.test.tsx +88 -0
- package/src/components/ui/custom/animated-slot-text.tsx +20 -0
- package/src/components/ui/custom/common-footer.tsx +262 -237
- package/src/components/ui/custom/language-dropdown-item.tsx +3 -10
- package/src/components/ui/custom/language-toggle.test.tsx +30 -0
- package/src/components/ui/custom/language-toggle.tsx +11 -10
- package/src/components/ui/custom/locale-preference.ts +32 -0
- package/src/components/ui/custom/settings/appearance-settings.tsx +5 -13
- package/src/components/ui/custom/settings-dialog-shell.tsx +40 -9
- package/src/components/ui/custom/structure.tsx +12 -0
- package/src/components/ui/custom/system-language-dropdown-item.tsx +3 -6
- package/src/components/ui/custom/workspace-access/adapters.test.ts +10 -1
- package/src/components/ui/custom/workspace-access/adapters.ts +36 -4
- package/src/components/ui/custom/workspace-access/member-filter-utils.test.ts +14 -0
- package/src/components/ui/custom/workspace-access/member-filter-utils.ts +8 -0
- package/src/components/ui/custom/workspace-access/types.ts +20 -0
- package/src/components/ui/custom/workspace-access/workspace-access-context.test.tsx +111 -0
- package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +15 -5
- package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +155 -48
- package/src/components/ui/custom/workspace-access/workspace-access-member-profile-dialog.tsx +92 -0
- package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +171 -76
- package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +11 -2
- package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +11 -11
- package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +180 -22
- package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +29 -15
- package/src/components/ui/custom/workspace-access/workspace-access-permission-checklist.tsx +56 -10
- package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.test.ts +33 -0
- package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.tsx +24 -1
- package/src/components/ui/custom/workspace-access/workspace-access-responsive.test.ts +64 -0
- package/src/components/ui/custom/workspace-access/workspace-access-role-editor-dialog.tsx +28 -16
- package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +35 -11
- package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +23 -11
- package/src/components/ui/custom/workspace-select-helpers.ts +5 -3
- package/src/components/ui/custom/workspace-select-reveal.tsx +46 -0
- package/src/components/ui/custom/workspace-select.tsx +13 -5
- package/src/components/ui/finance/shared/charts/monthly-total-chart-client.tsx +1 -1
- package/src/components/ui/finance/shared/charts/monthly-total-chart.tsx +1 -1
- package/src/components/ui/storefront/cart-summary.tsx +12 -2
- package/src/components/ui/storefront/storefront-surface.test.tsx +21 -0
- package/src/components/ui/storefront/storefront-surface.tsx +6 -0
- package/src/components/ui/text-editor/__tests__/collaboration-binding.test.tsx +161 -0
- package/src/components/ui/text-editor/editor.tsx +267 -235
- package/src/globals.css +166 -0
- package/src/hooks/__tests__/use-workspace-identity-mutation.test.tsx +181 -0
- 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
|
-
<
|
|
137
|
-
|
|
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-
|
|
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
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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-
|
|
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
|
|
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
|
|
143
|
-
|
|
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
|
|
151
|
-
|
|
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
|
-
'
|
|
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
|
|
77
|
-
<TabsList className="h-
|
|
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
|
-
|
|
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
|
|
92
|
-
<div className="relative min-w-0
|
|
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
|
|
103
|
-
|
|
104
|
-
{
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
26
|
+
const taskBoardPaths = [`/${nextSlug}/boards`, `/${nextSlug}/tasks/boards`];
|
|
27
27
|
|
|
28
28
|
if (
|
|
29
|
-
|
|
30
|
-
|
|
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) => (
|
|
@@ -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 ||
|
|
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
|