@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.
Files changed (108) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/biome.json +1 -1
  3. package/package.json +11 -11
  4. package/src/components/ui/calendar-app/components/calendar-connections.tsx +17 -13
  5. package/src/components/ui/calendar-app/components/connected-accounts-dialog.tsx +2 -5
  6. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +2 -5
  7. package/src/components/ui/calendar.test.tsx +24 -0
  8. package/src/components/ui/calendar.tsx +1 -0
  9. package/src/components/ui/currency-input.test.tsx +43 -0
  10. package/src/components/ui/currency-input.tsx +1 -1
  11. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  12. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  13. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  14. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  15. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  16. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  17. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  18. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  19. package/src/components/ui/date-time-picker.tsx +352 -234
  20. package/src/components/ui/finance/categories-tags-tabs.tsx +23 -1
  21. package/src/components/ui/finance/command/finance-command-actions.test.tsx +48 -0
  22. package/src/components/ui/finance/command/finance-command-actions.tsx +200 -0
  23. package/src/components/ui/finance/command/finance-command-provider.test.tsx +151 -0
  24. package/src/components/ui/finance/command/finance-command-provider.tsx +250 -0
  25. package/src/components/ui/finance/command/finance-command-results.tsx +262 -0
  26. package/src/components/ui/finance/invoices/pending-invoices-table.tsx +22 -9
  27. package/src/components/ui/finance/shared/quick-actions.tsx +39 -90
  28. package/src/components/ui/finance/tags/tag-manager.tsx +24 -5
  29. package/src/components/ui/finance/transactions/form-basic-tab.tsx +33 -49
  30. package/src/components/ui/finance/transactions/form-types.ts +5 -0
  31. package/src/components/ui/finance/transactions/form.test.tsx +105 -22
  32. package/src/components/ui/finance/transactions/form.tsx +116 -20
  33. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +13 -6
  34. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  35. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +25 -1
  36. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +16 -3
  37. package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +3 -0
  38. package/src/components/ui/finance/transactions/transactionId/transaction-details-page.tsx +3 -0
  39. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +6 -0
  40. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +20 -2
  41. package/src/components/ui/finance/transactions/transactions-page.tsx +4 -0
  42. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +7 -2
  43. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +7 -2
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +38 -1
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +5 -0
  46. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +18 -2
  47. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +3 -0
  48. package/src/components/ui/finance/wallets/wallets-page.tsx +3 -0
  49. package/src/components/ui/legacy/calendar/settings/google-calendar-settings.tsx +2 -9
  50. package/src/components/ui/money-input.test.tsx +64 -0
  51. package/src/components/ui/money-input.tsx +63 -0
  52. package/src/components/ui/optional-time-picker.tsx +95 -0
  53. package/src/components/ui/quick-command-center.test.tsx +90 -0
  54. package/src/components/ui/quick-command-center.tsx +190 -0
  55. package/src/components/ui/storefront/cart-summary.tsx +126 -50
  56. package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
  57. package/src/components/ui/storefront/hero-panel.tsx +23 -20
  58. package/src/components/ui/storefront/image-panel.tsx +6 -0
  59. package/src/components/ui/storefront/index.ts +11 -0
  60. package/src/components/ui/storefront/listing-card.tsx +84 -22
  61. package/src/components/ui/storefront/product-detail.tsx +289 -0
  62. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  63. package/src/components/ui/storefront/storefront-surface.test.tsx +132 -5
  64. package/src/components/ui/storefront/storefront-surface.tsx +371 -128
  65. package/src/components/ui/storefront/types.ts +25 -1
  66. package/src/components/ui/storefront/utils.ts +118 -13
  67. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  68. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  69. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  70. package/src/components/ui/text-editor/content-migration.ts +41 -18
  71. package/src/components/ui/text-editor/extensions.ts +1 -1
  72. package/src/components/ui/text-editor/image-extension.ts +40 -18
  73. package/src/components/ui/text-editor/video-extension.ts +11 -2
  74. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  75. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  76. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
  77. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
  78. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  79. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
  80. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
  81. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
  82. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
  83. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
  84. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  85. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  86. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  87. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  88. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  89. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  90. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
  91. package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
  92. package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
  93. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  94. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  95. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.test.ts +171 -0
  100. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.ts +200 -36
  101. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +21 -2
  102. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
  103. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  104. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  105. package/src/hooks/useBoardRealtime.ts +6 -3
  106. package/src/hooks/useBoardRealtime.types.ts +11 -0
  107. package/src/hooks/useCursorTracking.ts +91 -27
  108. 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 type { StorefrontCartEntry, StorefrontSurfaceLabels } from './types';
11
- import { formatStorefrontPrice, storefrontSurfaceClasses } from './utils';
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 = !hasCart || isSubmitting || isCheckoutDisabled;
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
- <div
62
- className="flex items-center justify-between gap-3 text-sm"
63
- key={line.listingId}
64
- >
65
- <span className="min-w-0 truncate">
66
- {line.quantity} x {listing.title}
67
- </span>
68
- <span className="font-medium">
69
- {formatStorefrontPrice(listing.price * line.quantity, currency)}
70
- </span>
71
- </div>
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-4 grid gap-2"
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
- <input
95
- className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
96
- name="name"
97
- placeholder={labels.form.name}
98
- required
99
- />
100
- <input
101
- className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
102
- name="email"
103
- placeholder={labels.form.email}
104
- required
105
- type="email"
106
- />
107
- <input
108
- className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
109
- name="phone"
110
- placeholder={labels.form.phone}
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-20 rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
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="h-4 w-4" />
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
- asChild
129
- className={cn('mt-4 w-full', radius)}
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
- 'grid min-h-44 overflow-hidden',
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
- <div className="flex min-w-0 flex-col justify-between gap-6 p-5">
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';