@tuturuuu/ui 0.6.2 → 0.8.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 +66 -0
- package/biome.json +1 -1
- package/package.json +11 -11
- package/src/components/ui/calendar-app/components/calendar-connections.tsx +17 -13
- package/src/components/ui/calendar-app/components/connected-accounts-dialog.tsx +2 -5
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +2 -5
- package/src/components/ui/calendar.test.tsx +24 -0
- package/src/components/ui/calendar.tsx +1 -0
- package/src/components/ui/currency-input.test.tsx +43 -0
- package/src/components/ui/currency-input.tsx +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
- package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
- package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
- package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
- package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
- package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
- package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
- package/src/components/ui/date-time-picker.tsx +352 -234
- package/src/components/ui/finance/categories-tags-tabs.tsx +23 -1
- package/src/components/ui/finance/command/finance-command-actions.test.tsx +48 -0
- package/src/components/ui/finance/command/finance-command-actions.tsx +200 -0
- package/src/components/ui/finance/command/finance-command-provider.test.tsx +151 -0
- package/src/components/ui/finance/command/finance-command-provider.tsx +250 -0
- package/src/components/ui/finance/command/finance-command-results.tsx +262 -0
- package/src/components/ui/finance/invoices/pending-invoices-table.tsx +22 -9
- package/src/components/ui/finance/shared/quick-actions.tsx +39 -90
- package/src/components/ui/finance/tags/tag-manager.tsx +24 -5
- package/src/components/ui/finance/transactions/form-basic-tab.tsx +33 -49
- package/src/components/ui/finance/transactions/form-types.ts +5 -0
- package/src/components/ui/finance/transactions/form.test.tsx +105 -22
- package/src/components/ui/finance/transactions/form.tsx +116 -20
- package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +13 -6
- package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
- package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +25 -1
- package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +16 -3
- package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +3 -0
- package/src/components/ui/finance/transactions/transactionId/transaction-details-page.tsx +3 -0
- package/src/components/ui/finance/transactions/transactions-create-summary.tsx +6 -0
- package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +20 -2
- package/src/components/ui/finance/transactions/transactions-page.tsx +4 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +7 -2
- package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +7 -2
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +38 -1
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +5 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +18 -2
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +3 -0
- package/src/components/ui/finance/wallets/wallets-page.tsx +3 -0
- package/src/components/ui/legacy/calendar/settings/google-calendar-settings.tsx +2 -9
- package/src/components/ui/money-input.test.tsx +64 -0
- package/src/components/ui/money-input.tsx +63 -0
- package/src/components/ui/optional-time-picker.tsx +95 -0
- package/src/components/ui/quick-command-center.test.tsx +90 -0
- package/src/components/ui/quick-command-center.tsx +190 -0
- package/src/components/ui/storefront/cart-summary.tsx +126 -50
- package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
- package/src/components/ui/storefront/hero-panel.tsx +23 -20
- package/src/components/ui/storefront/image-panel.tsx +6 -0
- package/src/components/ui/storefront/index.ts +11 -0
- package/src/components/ui/storefront/listing-card.tsx +84 -22
- package/src/components/ui/storefront/product-detail.tsx +289 -0
- package/src/components/ui/storefront/product-dialog.tsx +72 -0
- package/src/components/ui/storefront/storefront-surface.test.tsx +132 -5
- package/src/components/ui/storefront/storefront-surface.tsx +371 -128
- package/src/components/ui/storefront/types.ts +25 -1
- package/src/components/ui/storefront/utils.ts +118 -13
- package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
- package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
- package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
- package/src/components/ui/text-editor/content-migration.ts +41 -18
- package/src/components/ui/text-editor/extensions.ts +1 -1
- package/src/components/ui/text-editor/image-extension.ts +40 -18
- package/src/components/ui/text-editor/video-extension.ts +11 -2
- package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
- package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
- package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
- package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
- package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
- package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
- package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
- package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.test.ts +171 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.ts +200 -36
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +21 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
- package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
- package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
- package/src/hooks/useBoardRealtime.ts +6 -3
- package/src/hooks/useBoardRealtime.types.ts +11 -0
- package/src/hooks/useCursorTracking.ts +91 -27
- package/src/hooks/useTaskUserRealtime.ts +5 -3
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { QuickCommandCenter } from './quick-command-center';
|
|
4
|
+
|
|
5
|
+
describe('QuickCommandCenter', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
globalThis.ResizeObserver = class ResizeObserver {
|
|
8
|
+
disconnect() {}
|
|
9
|
+
observe() {}
|
|
10
|
+
unobserve() {}
|
|
11
|
+
};
|
|
12
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('renders grouped commands and activates digit shortcuts', () => {
|
|
16
|
+
const first = vi.fn();
|
|
17
|
+
const second = vi.fn();
|
|
18
|
+
|
|
19
|
+
render(
|
|
20
|
+
<QuickCommandCenter
|
|
21
|
+
digitShortcuts
|
|
22
|
+
emptyLabel="No commands"
|
|
23
|
+
groups={[
|
|
24
|
+
{
|
|
25
|
+
heading: 'Create',
|
|
26
|
+
id: 'create',
|
|
27
|
+
items: [
|
|
28
|
+
{
|
|
29
|
+
id: 'transaction',
|
|
30
|
+
onSelect: first,
|
|
31
|
+
title: 'New transaction',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'wallet',
|
|
35
|
+
onSelect: second,
|
|
36
|
+
title: 'New wallet',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
]}
|
|
41
|
+
onOpenChange={() => undefined}
|
|
42
|
+
open
|
|
43
|
+
placeholder="Search commands"
|
|
44
|
+
title="Quick command center"
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(screen.getByText('New transaction')).toBeVisible();
|
|
49
|
+
expect(screen.getByText('New wallet')).toBeVisible();
|
|
50
|
+
|
|
51
|
+
fireEvent.keyDown(window, { key: '2' });
|
|
52
|
+
|
|
53
|
+
expect(first).not.toHaveBeenCalled();
|
|
54
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('filters commands by search text', () => {
|
|
58
|
+
render(
|
|
59
|
+
<QuickCommandCenter
|
|
60
|
+
emptyLabel="No commands"
|
|
61
|
+
groups={[
|
|
62
|
+
{
|
|
63
|
+
heading: 'Create',
|
|
64
|
+
id: 'create',
|
|
65
|
+
items: [
|
|
66
|
+
{
|
|
67
|
+
id: 'transaction',
|
|
68
|
+
onSelect: vi.fn(),
|
|
69
|
+
title: 'New transaction',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'wallet',
|
|
73
|
+
onSelect: vi.fn(),
|
|
74
|
+
title: 'New wallet',
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
]}
|
|
79
|
+
onOpenChange={() => undefined}
|
|
80
|
+
open
|
|
81
|
+
placeholder="Search commands"
|
|
82
|
+
searchValue="wallet"
|
|
83
|
+
title="Quick command center"
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(screen.getByText('New wallet')).toBeVisible();
|
|
88
|
+
expect(screen.queryByText('New transaction')).not.toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
5
|
+
import {
|
|
6
|
+
CommandDialog,
|
|
7
|
+
CommandEmpty,
|
|
8
|
+
CommandGroup,
|
|
9
|
+
CommandInput,
|
|
10
|
+
CommandItem,
|
|
11
|
+
CommandList,
|
|
12
|
+
CommandShortcut,
|
|
13
|
+
} from './command';
|
|
14
|
+
|
|
15
|
+
export interface QuickCommandCenterItem {
|
|
16
|
+
id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
icon?: ReactNode;
|
|
21
|
+
keywords?: string[];
|
|
22
|
+
onSelect: () => void;
|
|
23
|
+
shortcut?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface QuickCommandCenterGroup {
|
|
27
|
+
heading: string;
|
|
28
|
+
id: string;
|
|
29
|
+
items: QuickCommandCenterItem[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface QuickCommandCenterProps {
|
|
33
|
+
digitShortcuts?: boolean;
|
|
34
|
+
emptyLabel: string;
|
|
35
|
+
groups: QuickCommandCenterGroup[];
|
|
36
|
+
onOpenChange: (open: boolean) => void;
|
|
37
|
+
onSearchValueChange?: (value: string) => void;
|
|
38
|
+
open: boolean;
|
|
39
|
+
placeholder: string;
|
|
40
|
+
searchValue?: string;
|
|
41
|
+
title: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function matchesSearch(item: QuickCommandCenterItem, query: string) {
|
|
46
|
+
if (!query) return true;
|
|
47
|
+
|
|
48
|
+
const haystack = [
|
|
49
|
+
item.title,
|
|
50
|
+
item.description,
|
|
51
|
+
item.shortcut,
|
|
52
|
+
...(item.keywords ?? []),
|
|
53
|
+
]
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.join(' ')
|
|
56
|
+
.toLowerCase();
|
|
57
|
+
|
|
58
|
+
return haystack.includes(query.toLowerCase());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function QuickCommandCenter({
|
|
62
|
+
digitShortcuts = false,
|
|
63
|
+
emptyLabel,
|
|
64
|
+
groups,
|
|
65
|
+
onOpenChange,
|
|
66
|
+
onSearchValueChange,
|
|
67
|
+
open,
|
|
68
|
+
placeholder,
|
|
69
|
+
searchValue,
|
|
70
|
+
title,
|
|
71
|
+
description,
|
|
72
|
+
}: QuickCommandCenterProps) {
|
|
73
|
+
const [internalSearch, setInternalSearch] = useState('');
|
|
74
|
+
const query = searchValue ?? internalSearch;
|
|
75
|
+
const setQuery = onSearchValueChange ?? setInternalSearch;
|
|
76
|
+
|
|
77
|
+
const visibleGroups = useMemo(
|
|
78
|
+
() =>
|
|
79
|
+
groups
|
|
80
|
+
.map((group) => ({
|
|
81
|
+
...group,
|
|
82
|
+
items: group.items.filter((item) => matchesSearch(item, query)),
|
|
83
|
+
}))
|
|
84
|
+
.filter((group) => group.items.length > 0),
|
|
85
|
+
[groups, query]
|
|
86
|
+
);
|
|
87
|
+
const visibleItems = visibleGroups.flatMap((group) => group.items);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!(open && digitShortcuts)) return;
|
|
91
|
+
|
|
92
|
+
const handleDigitShortcut = (event: KeyboardEvent) => {
|
|
93
|
+
if (
|
|
94
|
+
event.metaKey ||
|
|
95
|
+
event.ctrlKey ||
|
|
96
|
+
event.altKey ||
|
|
97
|
+
event.isComposing ||
|
|
98
|
+
!/^[0-9]$/u.test(event.key)
|
|
99
|
+
) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const index = event.key === '0' ? 9 : Number(event.key) - 1;
|
|
104
|
+
const item = visibleItems[index];
|
|
105
|
+
|
|
106
|
+
if (!item || item.disabled) return;
|
|
107
|
+
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
event.stopPropagation();
|
|
110
|
+
event.stopImmediatePropagation();
|
|
111
|
+
item.onSelect();
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
window.addEventListener('keydown', handleDigitShortcut, {
|
|
115
|
+
capture: true,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return () =>
|
|
119
|
+
window.removeEventListener('keydown', handleDigitShortcut, {
|
|
120
|
+
capture: true,
|
|
121
|
+
});
|
|
122
|
+
}, [digitShortcuts, open, visibleItems]);
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<CommandDialog
|
|
126
|
+
open={open}
|
|
127
|
+
onOpenChange={onOpenChange}
|
|
128
|
+
title={title}
|
|
129
|
+
description={description ?? title}
|
|
130
|
+
contentClassName="max-w-xl"
|
|
131
|
+
>
|
|
132
|
+
<CommandInput
|
|
133
|
+
placeholder={placeholder}
|
|
134
|
+
value={query}
|
|
135
|
+
onValueChange={setQuery}
|
|
136
|
+
/>
|
|
137
|
+
<CommandList className="max-h-[min(70vh,28rem)]">
|
|
138
|
+
<CommandEmpty>{emptyLabel}</CommandEmpty>
|
|
139
|
+
{visibleGroups.map((group) => (
|
|
140
|
+
<CommandGroup heading={group.heading} key={group.id}>
|
|
141
|
+
{group.items.map((item) => {
|
|
142
|
+
const itemIndex = visibleItems.findIndex(
|
|
143
|
+
(visibleItem) => visibleItem.id === item.id
|
|
144
|
+
);
|
|
145
|
+
const digitShortcut =
|
|
146
|
+
digitShortcuts && itemIndex >= 0 && itemIndex < 10
|
|
147
|
+
? itemIndex === 9
|
|
148
|
+
? '0'
|
|
149
|
+
: String(itemIndex + 1)
|
|
150
|
+
: item.shortcut;
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<CommandItem
|
|
154
|
+
disabled={item.disabled}
|
|
155
|
+
key={item.id}
|
|
156
|
+
onSelect={() => {
|
|
157
|
+
if (!item.disabled) item.onSelect();
|
|
158
|
+
}}
|
|
159
|
+
value={[
|
|
160
|
+
item.title,
|
|
161
|
+
item.description,
|
|
162
|
+
item.shortcut,
|
|
163
|
+
...(item.keywords ?? []),
|
|
164
|
+
]
|
|
165
|
+
.filter(Boolean)
|
|
166
|
+
.join(' ')}
|
|
167
|
+
>
|
|
168
|
+
{item.icon}
|
|
169
|
+
<span className="min-w-0 flex-1">
|
|
170
|
+
<span className="block truncate font-medium">
|
|
171
|
+
{item.title}
|
|
172
|
+
</span>
|
|
173
|
+
{item.description && (
|
|
174
|
+
<span className="block truncate text-muted-foreground text-xs">
|
|
175
|
+
{item.description}
|
|
176
|
+
</span>
|
|
177
|
+
)}
|
|
178
|
+
</span>
|
|
179
|
+
{digitShortcut && (
|
|
180
|
+
<CommandShortcut>{digitShortcut}</CommandShortcut>
|
|
181
|
+
)}
|
|
182
|
+
</CommandItem>
|
|
183
|
+
);
|
|
184
|
+
})}
|
|
185
|
+
</CommandGroup>
|
|
186
|
+
))}
|
|
187
|
+
</CommandList>
|
|
188
|
+
</CommandDialog>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { ArrowRight, TriangleAlert } from '@tuturuuu/icons';
|
|
3
|
+
import { ArrowRight, Tag, TriangleAlert, Zap } from '@tuturuuu/icons';
|
|
4
4
|
import type { InventoryStorefront } from '@tuturuuu/internal-api/inventory';
|
|
5
5
|
import { cn } from '@tuturuuu/utils/format';
|
|
6
6
|
import type { FormEvent } from 'react';
|
|
7
7
|
import { Badge } from '../badge';
|
|
8
8
|
import { Button } from '../button';
|
|
9
9
|
import { AccentButton } from './accent-button';
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
10
|
+
import { StorefrontImagePanel } from './image-panel';
|
|
11
|
+
import type {
|
|
12
|
+
StorefrontBuyerDefaults,
|
|
13
|
+
StorefrontCartEntry,
|
|
14
|
+
StorefrontSurfaceLabels,
|
|
15
|
+
} from './types';
|
|
16
|
+
import {
|
|
17
|
+
formatStorefrontPrice,
|
|
18
|
+
getStorefrontLinePrice,
|
|
19
|
+
getStorefrontVariantLabel,
|
|
20
|
+
storefrontCartLineKey,
|
|
21
|
+
storefrontSurfaceClasses,
|
|
22
|
+
} from './utils';
|
|
12
23
|
|
|
13
24
|
export function StorefrontCartSummary({
|
|
25
|
+
buyerDefaults,
|
|
14
26
|
cartEntries,
|
|
15
27
|
checkoutHref,
|
|
16
28
|
currency,
|
|
@@ -19,10 +31,12 @@ export function StorefrontCartSummary({
|
|
|
19
31
|
isSubmitting,
|
|
20
32
|
labels,
|
|
21
33
|
onCheckoutSubmit,
|
|
34
|
+
onInstantCheckout,
|
|
22
35
|
radius,
|
|
23
36
|
storefront,
|
|
24
37
|
total,
|
|
25
38
|
}: {
|
|
39
|
+
buyerDefaults?: StorefrontBuyerDefaults;
|
|
26
40
|
cartEntries: StorefrontCartEntry[];
|
|
27
41
|
checkoutHref?: string;
|
|
28
42
|
currency: string;
|
|
@@ -31,18 +45,27 @@ export function StorefrontCartSummary({
|
|
|
31
45
|
isSubmitting: boolean;
|
|
32
46
|
labels: StorefrontSurfaceLabels;
|
|
33
47
|
onCheckoutSubmit?: (formData: FormData) => void;
|
|
48
|
+
onInstantCheckout?: () => void;
|
|
34
49
|
radius: string;
|
|
35
50
|
storefront: InventoryStorefront;
|
|
36
51
|
total: number;
|
|
37
52
|
}) {
|
|
38
53
|
const hasCart = cartEntries.length > 0;
|
|
39
54
|
const isCheckoutDisabled = storefront.checkoutMode === 'disabled';
|
|
40
|
-
const submitDisabled =
|
|
55
|
+
const submitDisabled =
|
|
56
|
+
!hasCart || isSubmitting || isCheckoutDisabled || !onCheckoutSubmit;
|
|
57
|
+
const canOpenCheckout = hasCart && Boolean(checkoutHref);
|
|
58
|
+
const buyerEmail = buyerDefaults?.email?.trim() || undefined;
|
|
59
|
+
const buyerName = buyerDefaults?.name?.trim() || undefined;
|
|
60
|
+
const inputClassName =
|
|
61
|
+
'h-11 rounded-md border border-input bg-background px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring/40';
|
|
62
|
+
const labelClassName = 'grid gap-1.5 text-sm';
|
|
41
63
|
|
|
42
64
|
return (
|
|
43
65
|
<aside
|
|
44
66
|
className={cn(
|
|
45
67
|
'h-fit p-4 lg:sticky lg:top-4',
|
|
68
|
+
isCheckout ? 'p-5 sm:p-6' : null,
|
|
46
69
|
storefrontSurfaceClasses[storefront.surfaceStyle],
|
|
47
70
|
radius
|
|
48
71
|
)}
|
|
@@ -56,27 +79,52 @@ export function StorefrontCartSummary({
|
|
|
56
79
|
<p className="mt-2 text-muted-foreground text-sm leading-6">
|
|
57
80
|
{labels.reservedCopy}
|
|
58
81
|
</p>
|
|
59
|
-
<div className="mt-4 grid gap-2">
|
|
60
|
-
{cartEntries.map(({ line, listing }) =>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
82
|
+
<div className="mt-4 -mr-1 grid max-h-72 gap-2.5 overflow-y-auto pr-1">
|
|
83
|
+
{cartEntries.map(({ line, listing, variant }) => {
|
|
84
|
+
const unitPrice = getStorefrontLinePrice(listing, variant);
|
|
85
|
+
const variantLabel = variant
|
|
86
|
+
? getStorefrontVariantLabel(variant)
|
|
87
|
+
: null;
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
className="flex items-center gap-3 text-sm"
|
|
91
|
+
key={storefrontCartLineKey(line.listingId, line.variantId)}
|
|
92
|
+
>
|
|
93
|
+
<StorefrontImagePanel
|
|
94
|
+
className={cn('size-10 shrink-0 rounded-md', radius)}
|
|
95
|
+
imageUrl={variant?.imageUrl ?? listing.imageUrl}
|
|
96
|
+
label={listing.title}
|
|
97
|
+
/>
|
|
98
|
+
<div className="min-w-0 flex-1">
|
|
99
|
+
<p className="truncate font-medium">{listing.title}</p>
|
|
100
|
+
{variantLabel ? (
|
|
101
|
+
<p className="truncate text-muted-foreground text-xs">
|
|
102
|
+
{variantLabel}
|
|
103
|
+
</p>
|
|
104
|
+
) : null}
|
|
105
|
+
<p className="truncate text-muted-foreground text-xs tabular-nums">
|
|
106
|
+
{line.quantity} × {formatStorefrontPrice(unitPrice, currency)}
|
|
107
|
+
</p>
|
|
108
|
+
</div>
|
|
109
|
+
<span className="shrink-0 whitespace-nowrap font-medium tabular-nums">
|
|
110
|
+
{formatStorefrontPrice(unitPrice * line.quantity, currency)}
|
|
111
|
+
</span>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
73
115
|
</div>
|
|
74
|
-
<div className="mt-4 flex items-center justify-between border-border border-t pt-4">
|
|
116
|
+
<div className="mt-4 flex items-center justify-between gap-2 border-border border-t pt-4">
|
|
75
117
|
<span className="text-muted-foreground text-sm">{labels.total}</span>
|
|
76
|
-
<span className="font-semibold">
|
|
118
|
+
<span className="shrink-0 whitespace-nowrap font-semibold tabular-nums">
|
|
77
119
|
{formatStorefrontPrice(total, currency)}
|
|
78
120
|
</span>
|
|
79
121
|
</div>
|
|
122
|
+
{hasCart && !isCheckoutDisabled ? (
|
|
123
|
+
<p className="mt-3 flex items-center gap-2 rounded-md border border-border border-dashed bg-muted/30 px-3 py-2 text-muted-foreground text-xs leading-5">
|
|
124
|
+
<Tag className="h-3.5 w-3.5 shrink-0" />
|
|
125
|
+
{labels.couponNote}
|
|
126
|
+
</p>
|
|
127
|
+
) : null}
|
|
80
128
|
{!hasCart ? (
|
|
81
129
|
<p className="mt-4 flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-muted-foreground text-sm">
|
|
82
130
|
<TriangleAlert className="h-4 w-4" />
|
|
@@ -85,54 +133,82 @@ export function StorefrontCartSummary({
|
|
|
85
133
|
) : null}
|
|
86
134
|
{isCheckout ? (
|
|
87
135
|
<form
|
|
88
|
-
className="mt-
|
|
136
|
+
className="mt-5 grid gap-3"
|
|
89
137
|
onSubmit={(event: FormEvent<HTMLFormElement>) => {
|
|
90
138
|
event.preventDefault();
|
|
91
139
|
onCheckoutSubmit?.(new FormData(event.currentTarget));
|
|
92
140
|
}}
|
|
93
141
|
>
|
|
94
|
-
<
|
|
95
|
-
className="
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
142
|
+
<label className={labelClassName}>
|
|
143
|
+
<span className="font-medium text-xs">{labels.form.name}</span>
|
|
144
|
+
<input
|
|
145
|
+
autoComplete="name"
|
|
146
|
+
className={inputClassName}
|
|
147
|
+
defaultValue={buyerName}
|
|
148
|
+
name="customerName"
|
|
149
|
+
placeholder={labels.form.name}
|
|
150
|
+
required
|
|
151
|
+
/>
|
|
152
|
+
</label>
|
|
153
|
+
<label className={labelClassName}>
|
|
154
|
+
<span className="font-medium text-xs">{labels.form.email}</span>
|
|
155
|
+
<input
|
|
156
|
+
autoComplete="email"
|
|
157
|
+
className={inputClassName}
|
|
158
|
+
defaultValue={buyerEmail}
|
|
159
|
+
name="customerEmail"
|
|
160
|
+
placeholder={labels.form.email}
|
|
161
|
+
required
|
|
162
|
+
type="email"
|
|
163
|
+
/>
|
|
164
|
+
</label>
|
|
165
|
+
<label className={labelClassName}>
|
|
166
|
+
<span className="font-medium text-xs">{labels.form.phone}</span>
|
|
167
|
+
<input
|
|
168
|
+
autoComplete="tel"
|
|
169
|
+
className={inputClassName}
|
|
170
|
+
name="customerPhone"
|
|
171
|
+
placeholder={labels.form.phone}
|
|
172
|
+
type="tel"
|
|
173
|
+
/>
|
|
174
|
+
</label>
|
|
112
175
|
<textarea
|
|
113
|
-
className="min-h-
|
|
176
|
+
className="min-h-24 rounded-md border border-input bg-background px-3 py-2.5 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
114
177
|
name="note"
|
|
115
178
|
placeholder={labels.form.note}
|
|
116
179
|
/>
|
|
117
180
|
<AccentButton disabled={submitDisabled} radius={radius}>
|
|
118
181
|
{isSubmitting ? labels.reserving : labels.reserve}
|
|
119
|
-
<ArrowRight className="
|
|
182
|
+
<ArrowRight className="size-4 shrink-0" />
|
|
120
183
|
</AccentButton>
|
|
121
184
|
</form>
|
|
122
185
|
) : isPreview || isCheckoutDisabled ? (
|
|
123
186
|
<Button className={cn('mt-4 w-full', radius)} disabled type="button">
|
|
124
187
|
{labels.checkoutDisabled}
|
|
125
188
|
</Button>
|
|
189
|
+
) : canOpenCheckout ? (
|
|
190
|
+
<div className="mt-4 grid gap-2">
|
|
191
|
+
{onInstantCheckout ? (
|
|
192
|
+
<AccentButton
|
|
193
|
+
disabled={isSubmitting}
|
|
194
|
+
onClick={onInstantCheckout}
|
|
195
|
+
radius={radius}
|
|
196
|
+
>
|
|
197
|
+
<Zap className="size-4 shrink-0" />
|
|
198
|
+
{isSubmitting ? labels.reserving : labels.instantCheckout}
|
|
199
|
+
</AccentButton>
|
|
200
|
+
) : null}
|
|
201
|
+
<Button asChild className={cn('w-full', radius)} variant="outline">
|
|
202
|
+
<a href={checkoutHref}>
|
|
203
|
+
{labels.checkout}
|
|
204
|
+
<ArrowRight className="size-4 shrink-0" />
|
|
205
|
+
</a>
|
|
206
|
+
</Button>
|
|
207
|
+
</div>
|
|
126
208
|
) : (
|
|
127
|
-
<Button
|
|
128
|
-
|
|
129
|
-
className=
|
|
130
|
-
disabled={!hasCart}
|
|
131
|
-
>
|
|
132
|
-
<a aria-disabled={!hasCart} href={hasCart ? checkoutHref : undefined}>
|
|
133
|
-
{labels.checkout}
|
|
134
|
-
<ArrowRight className="h-4 w-4" />
|
|
135
|
-
</a>
|
|
209
|
+
<Button className={cn('mt-4 w-full', radius)} disabled type="button">
|
|
210
|
+
{labels.checkout}
|
|
211
|
+
<ArrowRight className="size-4 shrink-0" />
|
|
136
212
|
</Button>
|
|
137
213
|
)}
|
|
138
214
|
</aside>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Loader2 } from '@tuturuuu/icons';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Full-screen blocking overlay shown while a checkout session is being created
|
|
7
|
+
* and while the browser is redirecting to the Polar-hosted checkout. Kept
|
|
8
|
+
* visible across the redirect (the page is navigating away) so the buyer always
|
|
9
|
+
* sees progress instead of a frozen button.
|
|
10
|
+
*/
|
|
11
|
+
export function StorefrontCheckoutOverlay({ label }: { label: string }) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
aria-busy="true"
|
|
15
|
+
aria-live="assertive"
|
|
16
|
+
className="fixed inset-0 z-[100] grid place-items-center bg-background/80 backdrop-blur-sm"
|
|
17
|
+
role="status"
|
|
18
|
+
>
|
|
19
|
+
<div className="flex flex-col items-center gap-4 px-6 text-center">
|
|
20
|
+
<span className="grid size-14 place-items-center rounded-full bg-[var(--storefront-accent,var(--primary))]/10 text-[var(--storefront-accent,var(--primary))]">
|
|
21
|
+
<Loader2 className="size-7 animate-spin" />
|
|
22
|
+
</span>
|
|
23
|
+
<p className="font-medium text-sm">{label}</p>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -19,31 +19,40 @@ export function StorefrontHeroPanel({
|
|
|
19
19
|
radius: string;
|
|
20
20
|
storefront: InventoryStorefront;
|
|
21
21
|
}) {
|
|
22
|
+
const heroImage = storefront.coverImageUrl ?? storefront.heroImageUrl;
|
|
23
|
+
|
|
22
24
|
return (
|
|
23
25
|
<section
|
|
24
26
|
className={cn(
|
|
25
|
-
'
|
|
27
|
+
'relative isolate overflow-hidden',
|
|
26
28
|
storefrontSurfaceClasses[storefront.surfaceStyle],
|
|
27
|
-
radius
|
|
28
|
-
storefront.themePreset === 'editorial'
|
|
29
|
-
? 'md:grid-cols-[minmax(0,1.15fr)_360px]'
|
|
30
|
-
: 'md:grid-cols-[minmax(0,1fr)_280px]'
|
|
29
|
+
radius
|
|
31
30
|
)}
|
|
32
31
|
>
|
|
33
|
-
|
|
32
|
+
{/* Full-width featured banner backdrop. */}
|
|
33
|
+
<div className="relative h-40 w-full sm:h-52 md:h-60">
|
|
34
|
+
{heroImage ? (
|
|
35
|
+
<StorefrontImagePanel
|
|
36
|
+
className="absolute inset-0 h-full w-full"
|
|
37
|
+
imageUrl={heroImage}
|
|
38
|
+
label={storefront.name}
|
|
39
|
+
priority
|
|
40
|
+
/>
|
|
41
|
+
) : (
|
|
42
|
+
<div className="absolute inset-0 bg-gradient-to-br from-muted via-muted/60 to-background" />
|
|
43
|
+
)}
|
|
44
|
+
{/* Scrim that fades the banner into the page so overlaid text stays legible. */}
|
|
45
|
+
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/55 to-transparent" />
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{/* Overlaid title block — pulled up onto the lower, faded part of the banner. */}
|
|
49
|
+
<div className="relative -mt-20 flex flex-col gap-4 p-5">
|
|
34
50
|
<div>
|
|
35
51
|
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
|
36
52
|
<Store className="h-4 w-4" />
|
|
37
53
|
<span>{labels.browse}</span>
|
|
38
54
|
</div>
|
|
39
|
-
<h2
|
|
40
|
-
className={cn(
|
|
41
|
-
'mt-3 text-balance font-semibold tracking-normal',
|
|
42
|
-
storefront.themePreset === 'editorial'
|
|
43
|
-
? 'text-3xl md:text-4xl'
|
|
44
|
-
: 'text-2xl'
|
|
45
|
-
)}
|
|
46
|
-
>
|
|
55
|
+
<h2 className="mt-2 text-balance font-semibold text-3xl tracking-tight md:text-4xl">
|
|
47
56
|
{storefront.name}
|
|
48
57
|
</h2>
|
|
49
58
|
<p className="mt-2 max-w-2xl text-muted-foreground text-sm leading-6">
|
|
@@ -59,12 +68,6 @@ export function StorefrontHeroPanel({
|
|
|
59
68
|
</Badge>
|
|
60
69
|
</div>
|
|
61
70
|
</div>
|
|
62
|
-
|
|
63
|
-
<StorefrontImagePanel
|
|
64
|
-
className="min-h-44 md:min-h-full"
|
|
65
|
-
imageUrl={storefront.heroImageUrl}
|
|
66
|
-
label={storefront.name}
|
|
67
|
-
/>
|
|
68
71
|
</section>
|
|
69
72
|
);
|
|
70
73
|
}
|
|
@@ -6,10 +6,13 @@ export function StorefrontImagePanel({
|
|
|
6
6
|
className,
|
|
7
7
|
imageUrl,
|
|
8
8
|
label,
|
|
9
|
+
priority = false,
|
|
9
10
|
}: {
|
|
10
11
|
className?: string;
|
|
11
12
|
imageUrl: string | null;
|
|
12
13
|
label: string;
|
|
14
|
+
/** Eager-load above-the-fold images (e.g. the hero) to protect LCP. */
|
|
15
|
+
priority?: boolean;
|
|
13
16
|
}) {
|
|
14
17
|
if (imageUrl) {
|
|
15
18
|
return (
|
|
@@ -17,6 +20,9 @@ export function StorefrontImagePanel({
|
|
|
17
20
|
<img
|
|
18
21
|
alt=""
|
|
19
22
|
className={cn('w-full object-cover', className)}
|
|
23
|
+
decoding="async"
|
|
24
|
+
fetchPriority={priority ? 'high' : 'auto'}
|
|
25
|
+
loading={priority ? 'eager' : 'lazy'}
|
|
20
26
|
src={imageUrl}
|
|
21
27
|
/>
|
|
22
28
|
);
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
export { StorefrontCheckoutOverlay } from './checkout-overlay';
|
|
2
|
+
export { StorefrontProductDetail } from './product-detail';
|
|
3
|
+
export { StorefrontProductDialog } from './product-dialog';
|
|
1
4
|
export { StorefrontSurface } from './storefront-surface';
|
|
2
5
|
export type {
|
|
6
|
+
StorefrontBuyerDefaults,
|
|
3
7
|
StorefrontCartEntry,
|
|
4
8
|
StorefrontCartLine,
|
|
5
9
|
StorefrontSurfaceLabels,
|
|
@@ -7,6 +11,13 @@ export type {
|
|
|
7
11
|
} from './types';
|
|
8
12
|
export {
|
|
9
13
|
formatStorefrontPrice,
|
|
14
|
+
getStorefrontLinePrice,
|
|
15
|
+
getStorefrontListingFromPrice,
|
|
10
16
|
getStorefrontListingLimit,
|
|
17
|
+
getStorefrontListingVariants,
|
|
18
|
+
getStorefrontVariantLabel,
|
|
19
|
+
getStorefrontVariantLimit,
|
|
20
|
+
listingHasVariants,
|
|
11
21
|
sanitizeStorefrontAccentColor,
|
|
22
|
+
storefrontCartLineKey,
|
|
12
23
|
} from './utils';
|