@tuturuuu/ui 0.4.1 → 0.6.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 (107) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +41 -34
  3. package/src/components/ui/currency-input.tsx +65 -23
  4. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
  5. package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
  6. package/src/components/ui/custom/combobox.test.tsx +141 -0
  7. package/src/components/ui/custom/combobox.tsx +105 -36
  8. package/src/components/ui/custom/settings/task-settings.tsx +126 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +146 -0
  10. package/src/components/ui/custom/sidebar-context.tsx +68 -6
  11. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
  12. package/src/components/ui/finance/finance-layout.tsx +2 -4
  13. package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
  14. package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
  15. package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
  16. package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
  17. package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
  18. package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
  19. package/src/components/ui/finance/transactions/form-types.ts +23 -0
  20. package/src/components/ui/finance/transactions/form.tsx +81 -22
  21. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +29 -18
  22. package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
  23. package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
  24. package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
  25. package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +219 -0
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
  28. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
  29. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
  30. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  31. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +197 -0
  32. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
  33. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +541 -0
  34. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +362 -0
  35. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  36. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  37. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  38. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  39. package/src/components/ui/finance/wallets/form.tsx +41 -197
  40. package/src/components/ui/finance/wallets/query-invalidation.ts +3 -0
  41. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  42. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  43. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  46. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  47. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  48. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +71 -5
  49. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +52 -35
  50. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  51. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  52. package/src/components/ui/finance/wallets/wallets-page.test.tsx +117 -36
  53. package/src/components/ui/finance/wallets/wallets-page.tsx +40 -64
  54. package/src/components/ui/storefront/accent-button.tsx +33 -0
  55. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  56. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  57. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  58. package/src/components/ui/storefront/image-panel.tsx +40 -0
  59. package/src/components/ui/storefront/index.ts +12 -0
  60. package/src/components/ui/storefront/listing-card.tsx +129 -0
  61. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  62. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  63. package/src/components/ui/storefront/types.ts +99 -0
  64. package/src/components/ui/storefront/utils.ts +90 -0
  65. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
  66. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
  67. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  68. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  69. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  70. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  71. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  72. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  73. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
  74. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  75. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +124 -7
  76. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  77. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  78. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  79. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  80. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  81. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  82. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  83. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +268 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +243 -0
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  88. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  89. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  90. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  91. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
  92. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +41 -1
  93. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +157 -102
  94. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  95. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
  100. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +959 -340
  101. package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
  102. package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
  103. package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
  104. package/src/hooks/use-task-actions.ts +45 -0
  105. package/src/hooks/useBoardRealtime.ts +54 -1
  106. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  107. package/src/hooks/useTaskUserRealtime.ts +338 -0
@@ -0,0 +1,85 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import type { InventoryStorefront } from '@tuturuuu/internal-api/inventory';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { StorefrontSurface } from './storefront-surface';
5
+ import { sanitizeStorefrontAccentColor } from './utils';
6
+
7
+ const storefront: InventoryStorefront = {
8
+ accentColor: '#abc',
9
+ cornerStyle: 'rounded',
10
+ createdAt: '2026-06-12T00:00:00.000Z',
11
+ currency: 'USD',
12
+ checkoutMode: 'polar',
13
+ description: 'Buyer-facing copy',
14
+ heroImageUrl: null,
15
+ id: 'storefront-1',
16
+ layoutStyle: 'grid',
17
+ listingsCount: 0,
18
+ name: 'Preview Store',
19
+ showInventoryBadges: true,
20
+ slug: 'preview-store',
21
+ status: 'published',
22
+ surfaceStyle: 'soft',
23
+ themePreset: 'catalog',
24
+ updatedAt: '2026-06-12T00:00:00.000Z',
25
+ visibility: 'public',
26
+ wsId: 'ws-1',
27
+ };
28
+
29
+ describe('StorefrontSurface', () => {
30
+ it('sanitizes hex accent colors only', () => {
31
+ expect(sanitizeStorefrontAccentColor('#abc')).toBe('#aabbcc');
32
+ expect(sanitizeStorefrontAccentColor('#123abc')).toBe('#123abc');
33
+ expect(sanitizeStorefrontAccentColor('red')).toBeNull();
34
+ });
35
+
36
+ it('renders a preview empty state without enabling checkout', () => {
37
+ render(
38
+ <StorefrontSurface
39
+ labels={{
40
+ checkoutDisabled: 'Preview checkout disabled',
41
+ emptyListingsDescription: 'Create a listing next.',
42
+ emptyListingsTitle: 'No buyer listings',
43
+ }}
44
+ listings={[]}
45
+ mode="preview"
46
+ storefront={storefront}
47
+ />
48
+ );
49
+
50
+ expect(screen.getAllByText('Preview Store')).toHaveLength(2);
51
+ expect(screen.getByText('No buyer listings')).toBeInTheDocument();
52
+ expect(screen.getByText('Create a listing next.')).toBeInTheDocument();
53
+ expect(screen.getByText('Preview checkout disabled')).toBeDisabled();
54
+ });
55
+
56
+ it('shows simulated checkout mode badges', () => {
57
+ render(
58
+ <StorefrontSurface
59
+ labels={{ simulatedBadge: 'Simulated checkout' }}
60
+ listings={[]}
61
+ mode="store"
62
+ storefront={{ ...storefront, checkoutMode: 'simulated' }}
63
+ />
64
+ );
65
+
66
+ expect(screen.getByText('Simulated checkout')).toBeInTheDocument();
67
+ });
68
+
69
+ it('shows disabled checkout mode badges and blocks checkout', () => {
70
+ render(
71
+ <StorefrontSurface
72
+ labels={{
73
+ checkoutDisabled: 'Checkout unavailable',
74
+ checkoutDisabledBadge: 'Checkout disabled',
75
+ }}
76
+ listings={[]}
77
+ mode="store"
78
+ storefront={{ ...storefront, checkoutMode: 'disabled' }}
79
+ />
80
+ );
81
+
82
+ expect(screen.getByText('Checkout disabled')).toBeInTheDocument();
83
+ expect(screen.getByText('Checkout unavailable')).toBeDisabled();
84
+ });
85
+ });
@@ -0,0 +1,235 @@
1
+ 'use client';
2
+
3
+ import { ShoppingCart } from '@tuturuuu/icons';
4
+ import type {
5
+ InventoryStorefront,
6
+ InventoryStorefrontListing,
7
+ } from '@tuturuuu/internal-api/inventory';
8
+ import { cn } from '@tuturuuu/utils/format';
9
+ import type { ReactNode } from 'react';
10
+ import { Badge } from '../badge';
11
+ import { StorefrontCartSummary } from './cart-summary';
12
+ import { StorefrontEmptyListings } from './empty-listings';
13
+ import { StorefrontHeroPanel } from './hero-panel';
14
+ import { StorefrontListingCard } from './listing-card';
15
+ import type {
16
+ StorefrontCartLine,
17
+ StorefrontSurfaceLabels,
18
+ StorefrontSurfaceMode,
19
+ } from './types';
20
+ import { mergeStorefrontSurfaceLabels } from './types';
21
+ import {
22
+ getAccentStyle,
23
+ getStorefrontListingLimit,
24
+ sanitizeStorefrontAccentColor,
25
+ storefrontRadiusClasses,
26
+ storefrontSurfaceClasses,
27
+ } from './utils';
28
+
29
+ export function StorefrontSurface({
30
+ cartLines = [],
31
+ checkoutHref,
32
+ className,
33
+ emptyAction,
34
+ headerActions,
35
+ isDemo = false,
36
+ isSubmitting = false,
37
+ labels: labelOverrides,
38
+ listings,
39
+ mode,
40
+ notice,
41
+ onCheckoutSubmit,
42
+ onDecrement,
43
+ onIncrement,
44
+ selectedListingId,
45
+ storefront,
46
+ }: {
47
+ cartLines?: StorefrontCartLine[];
48
+ checkoutHref?: string;
49
+ className?: string;
50
+ emptyAction?: ReactNode;
51
+ headerActions?: ReactNode;
52
+ isDemo?: boolean;
53
+ isSubmitting?: boolean;
54
+ labels?: Partial<StorefrontSurfaceLabels>;
55
+ listings: InventoryStorefrontListing[];
56
+ mode: StorefrontSurfaceMode;
57
+ notice?: ReactNode;
58
+ onCheckoutSubmit?: (formData: FormData) => void;
59
+ onDecrement?: (listingId: string) => void;
60
+ onIncrement?: (listingId: string, maxQuantity: number) => void;
61
+ selectedListingId?: string;
62
+ storefront: InventoryStorefront;
63
+ }) {
64
+ const labels = mergeStorefrontSurfaceLabels(labelOverrides);
65
+ const accentColor = sanitizeStorefrontAccentColor(storefront.accentColor);
66
+ const radius = storefrontRadiusClasses[storefront.cornerStyle];
67
+ const cartEntries = cartLines.flatMap((line) => {
68
+ const listing = listings.find((item) => item.id === line.listingId);
69
+ return listing ? [{ line, listing }] : [];
70
+ });
71
+ const checkoutEntries = cartEntries.filter(({ line, listing }) => {
72
+ const quantity = Math.min(
73
+ line.quantity,
74
+ getStorefrontListingLimit(listing)
75
+ );
76
+ return quantity > 0;
77
+ });
78
+ const total = checkoutEntries.reduce((sum, { line, listing }) => {
79
+ const quantity = Math.min(
80
+ line.quantity,
81
+ getStorefrontListingLimit(listing)
82
+ );
83
+ return sum + listing.price * quantity;
84
+ }, 0);
85
+ const cartQuantity = cartLines.reduce((sum, line) => sum + line.quantity, 0);
86
+ const visibleListings =
87
+ mode === 'product' && selectedListingId
88
+ ? listings.filter((listing) => listing.id === selectedListingId)
89
+ : listings;
90
+ const isCheckout = mode === 'checkout';
91
+ const isPreview = mode === 'preview';
92
+ const showCartListings = mode === 'cart' || isCheckout;
93
+ const listingRows = showCartListings
94
+ ? cartEntries.map(({ listing }) => listing)
95
+ : visibleListings;
96
+ const currency = storefront.currency ?? 'USD';
97
+
98
+ return (
99
+ <main
100
+ className={cn('min-h-dvh bg-background text-foreground', className)}
101
+ style={getAccentStyle(accentColor)}
102
+ >
103
+ {notice ? (
104
+ <div className="border-border border-b bg-muted/35 px-4 py-2 text-center text-muted-foreground text-sm">
105
+ {notice}
106
+ </div>
107
+ ) : null}
108
+
109
+ <header className="border-border border-b bg-background/90 backdrop-blur">
110
+ <div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3 px-4 py-3">
111
+ <div className="min-w-0">
112
+ <div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
113
+ <span>
114
+ {storefront.visibility === 'private'
115
+ ? labels.privateStore
116
+ : labels.publicStore}
117
+ </span>
118
+ {isDemo ? (
119
+ <Badge variant="secondary">{labels.demoBadge}</Badge>
120
+ ) : null}
121
+ {isPreview ? (
122
+ <Badge
123
+ className="border-border bg-background"
124
+ variant="outline"
125
+ >
126
+ {labels.previewBadge}
127
+ </Badge>
128
+ ) : null}
129
+ {storefront.checkoutMode === 'simulated' ? (
130
+ <Badge
131
+ className="border-border bg-background"
132
+ variant="outline"
133
+ >
134
+ {labels.simulatedBadge}
135
+ </Badge>
136
+ ) : null}
137
+ {storefront.checkoutMode === 'disabled' ? (
138
+ <Badge
139
+ className="border-border bg-background"
140
+ variant="outline"
141
+ >
142
+ {labels.checkoutDisabledBadge}
143
+ </Badge>
144
+ ) : null}
145
+ </div>
146
+ <h1 className="mt-0.5 truncate font-semibold text-xl">
147
+ {storefront.name}
148
+ </h1>
149
+ </div>
150
+ <div className="flex items-center gap-2">
151
+ {headerActions}
152
+ <span
153
+ className={cn(
154
+ 'inline-flex h-9 min-w-12 items-center justify-center gap-2 border bg-card px-3 font-medium text-sm',
155
+ radius
156
+ )}
157
+ >
158
+ <ShoppingCart className="h-4 w-4" />
159
+ {cartQuantity}
160
+ </span>
161
+ </div>
162
+ </div>
163
+ </header>
164
+
165
+ <section className="mx-auto grid max-w-7xl gap-4 px-4 py-5 lg:grid-cols-[minmax(0,1fr)_340px]">
166
+ <div className="min-w-0">
167
+ <StorefrontHeroPanel
168
+ currency={currency}
169
+ labels={labels}
170
+ listingsCount={listings.length}
171
+ radius={radius}
172
+ storefront={storefront}
173
+ />
174
+
175
+ <div
176
+ className={cn(
177
+ 'mt-4',
178
+ storefront.layoutStyle === 'list'
179
+ ? 'grid gap-3'
180
+ : 'grid gap-3 sm:grid-cols-2 xl:grid-cols-3',
181
+ storefront.layoutStyle === 'feature' &&
182
+ '[&>article:first-child]:sm:col-span-2'
183
+ )}
184
+ >
185
+ {listingRows.length === 0 ? (
186
+ <StorefrontEmptyListings
187
+ action={emptyAction}
188
+ labels={labels}
189
+ radius={radius}
190
+ />
191
+ ) : (
192
+ listingRows.map((listing) => {
193
+ const line = cartLines.find(
194
+ (item) => item.listingId === listing.id
195
+ );
196
+
197
+ return (
198
+ <StorefrontListingCard
199
+ currency={currency}
200
+ isList={storefront.layoutStyle === 'list'}
201
+ key={listing.id}
202
+ labels={labels}
203
+ listing={listing}
204
+ onDecrement={onDecrement}
205
+ onIncrement={onIncrement}
206
+ quantity={line?.quantity ?? 0}
207
+ radius={radius}
208
+ showInventoryBadges={storefront.showInventoryBadges}
209
+ surfaceClassName={
210
+ storefrontSurfaceClasses[storefront.surfaceStyle]
211
+ }
212
+ />
213
+ );
214
+ })
215
+ )}
216
+ </div>
217
+ </div>
218
+
219
+ <StorefrontCartSummary
220
+ cartEntries={checkoutEntries}
221
+ checkoutHref={checkoutHref}
222
+ currency={currency}
223
+ isCheckout={isCheckout}
224
+ isPreview={isPreview}
225
+ isSubmitting={isSubmitting}
226
+ labels={labels}
227
+ onCheckoutSubmit={onCheckoutSubmit}
228
+ radius={radius}
229
+ storefront={storefront}
230
+ total={total}
231
+ />
232
+ </section>
233
+ </main>
234
+ );
235
+ }
@@ -0,0 +1,99 @@
1
+ import type { InventoryStorefrontListing } from '@tuturuuu/internal-api/inventory';
2
+
3
+ export type StorefrontCartLine = {
4
+ listingId: string;
5
+ quantity: number;
6
+ };
7
+
8
+ export type StorefrontCartEntry = {
9
+ line: StorefrontCartLine;
10
+ listing: InventoryStorefrontListing;
11
+ };
12
+
13
+ export type StorefrontSurfaceMode =
14
+ | 'cart'
15
+ | 'checkout'
16
+ | 'preview'
17
+ | 'product'
18
+ | 'store';
19
+
20
+ export type StorefrontSurfaceLabels = {
21
+ add: string;
22
+ available: string;
23
+ browse: string;
24
+ bundle: string;
25
+ cart: string;
26
+ checkout: string;
27
+ checkoutDisabled: string;
28
+ checkoutDisabledBadge: string;
29
+ demoBadge: string;
30
+ emptyCart: string;
31
+ emptyListingsDescription: string;
32
+ emptyListingsTitle: string;
33
+ fallbackDescription: string;
34
+ form: {
35
+ email: string;
36
+ name: string;
37
+ note: string;
38
+ phone: string;
39
+ };
40
+ privateStore: string;
41
+ previewBadge: string;
42
+ product: string;
43
+ publicStore: string;
44
+ quantity: string;
45
+ reserve: string;
46
+ reserving: string;
47
+ reservedCopy: string;
48
+ simulatedBadge: string;
49
+ soldOut: string;
50
+ total: string;
51
+ };
52
+
53
+ export const defaultStorefrontSurfaceLabels: StorefrontSurfaceLabels = {
54
+ add: 'Add',
55
+ available: 'available',
56
+ browse: 'Browse',
57
+ bundle: 'Bundle',
58
+ cart: 'Cart',
59
+ checkout: 'Checkout',
60
+ checkoutDisabled: 'Checkout is disabled in preview',
61
+ checkoutDisabledBadge: 'Checkout disabled',
62
+ demoBadge: 'Demo',
63
+ emptyCart: 'Add a listing to start checkout.',
64
+ emptyListingsDescription:
65
+ 'Publish a listing to make this storefront ready for buyers.',
66
+ emptyListingsTitle: 'No listings yet',
67
+ fallbackDescription: 'This listing is available for checkout.',
68
+ form: {
69
+ email: 'Email',
70
+ name: 'Name',
71
+ note: 'Order note',
72
+ phone: 'Phone',
73
+ },
74
+ privateStore: 'Private',
75
+ previewBadge: 'Preview',
76
+ product: 'Product',
77
+ publicStore: 'Public',
78
+ quantity: 'Qty',
79
+ reserve: 'Reserve with Polar',
80
+ reserving: 'Reserving...',
81
+ reservedCopy:
82
+ 'Review your cart, then continue with the available checkout mode.',
83
+ simulatedBadge: 'Simulated checkout',
84
+ soldOut: 'Sold out',
85
+ total: 'Total',
86
+ };
87
+
88
+ export function mergeStorefrontSurfaceLabels(
89
+ labels?: Partial<StorefrontSurfaceLabels>
90
+ ) {
91
+ return {
92
+ ...defaultStorefrontSurfaceLabels,
93
+ ...labels,
94
+ form: {
95
+ ...defaultStorefrontSurfaceLabels.form,
96
+ ...labels?.form,
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,90 @@
1
+ import type {
2
+ InventoryStorefront,
3
+ InventoryStorefrontListing,
4
+ } from '@tuturuuu/internal-api/inventory';
5
+ import type { CSSProperties } from 'react';
6
+
7
+ export const storefrontRadiusClasses: Record<
8
+ InventoryStorefront['cornerStyle'],
9
+ string
10
+ > = {
11
+ compact: 'rounded-md',
12
+ rounded: 'rounded-lg',
13
+ soft: 'rounded-2xl',
14
+ };
15
+
16
+ export const storefrontSurfaceClasses: Record<
17
+ InventoryStorefront['surfaceStyle'],
18
+ string
19
+ > = {
20
+ glass:
21
+ 'border border-border/70 bg-card/75 shadow-sm shadow-foreground/5 backdrop-blur',
22
+ soft: 'border border-border/70 bg-muted/35 shadow-sm shadow-foreground/5',
23
+ solid: 'border border-border bg-card shadow-sm shadow-foreground/5',
24
+ };
25
+
26
+ export type StorefrontAccentStyle = CSSProperties & {
27
+ '--storefront-accent'?: string;
28
+ '--storefront-accent-foreground'?: string;
29
+ };
30
+
31
+ export function sanitizeStorefrontAccentColor(value?: string | null) {
32
+ const normalized = value?.trim();
33
+ if (!normalized) return null;
34
+
35
+ if (/^#[0-9a-f]{3}$/i.test(normalized)) {
36
+ const [, r, g, b] = normalized;
37
+ return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
38
+ }
39
+
40
+ if (/^#[0-9a-f]{6}$/i.test(normalized)) {
41
+ return normalized.toLowerCase();
42
+ }
43
+
44
+ return null;
45
+ }
46
+
47
+ export function getStorefrontListingLimit(listing: InventoryStorefrontListing) {
48
+ const available =
49
+ typeof listing.availableQuantity === 'number'
50
+ ? listing.availableQuantity
51
+ : Number.POSITIVE_INFINITY;
52
+
53
+ return Math.max(0, Math.min(listing.maxPerOrder, available));
54
+ }
55
+
56
+ export function formatStorefrontPrice(value: number, currency: string) {
57
+ return new Intl.NumberFormat(undefined, {
58
+ currency,
59
+ maximumFractionDigits: 0,
60
+ style: 'currency',
61
+ }).format(value);
62
+ }
63
+
64
+ export function getListingInitials(title: string) {
65
+ return title
66
+ .split(/\s+/)
67
+ .filter(Boolean)
68
+ .slice(0, 2)
69
+ .map((part) => part[0]?.toUpperCase())
70
+ .join('');
71
+ }
72
+
73
+ export function getAccentStyle(
74
+ accentColor: string | null
75
+ ): StorefrontAccentStyle {
76
+ if (!accentColor) return {};
77
+
78
+ return {
79
+ '--storefront-accent': accentColor,
80
+ '--storefront-accent-foreground': getAccentForeground(accentColor),
81
+ };
82
+ }
83
+
84
+ function getAccentForeground(hexColor: string) {
85
+ const red = Number.parseInt(hexColor.slice(1, 3), 16);
86
+ const green = Number.parseInt(hexColor.slice(3, 5), 16);
87
+ const blue = Number.parseInt(hexColor.slice(5, 7), 16);
88
+ const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
89
+ return luminance > 0.64 ? '#111111' : '#ffffff';
90
+ }
@@ -10,6 +10,10 @@ import type { ReactElement, ReactNode } from 'react';
10
10
  import { beforeEach, describe, expect, it, vi } from 'vitest';
11
11
  import { useBulkOperations } from '../bulk-operations';
12
12
 
13
+ const { mockDispatchTaskSoundCue } = vi.hoisted(() => ({
14
+ mockDispatchTaskSoundCue: vi.fn(),
15
+ }));
16
+
13
17
  vi.mock('next-intl', () => ({
14
18
  useTranslations: () => {
15
19
  const translate = (key: string, values?: Record<string, unknown>) =>
@@ -49,6 +53,10 @@ vi.mock('@tuturuuu/ui/sonner', () => ({
49
53
  },
50
54
  }));
51
55
 
56
+ vi.mock('../../../../../shared/task-sound-effects', () => ({
57
+ dispatchTaskSoundCue: mockDispatchTaskSoundCue,
58
+ }));
59
+
52
60
  describe('bulk move mutations', () => {
53
61
  let queryClient: QueryClient;
54
62
  let wrapper: ({ children }: { children: ReactNode }) => ReactElement;
@@ -172,5 +180,11 @@ describe('bulk move mutations', () => {
172
180
  personal_list_id: 'list-2',
173
181
  })
174
182
  );
183
+ expect(mockDispatchTaskSoundCue).toHaveBeenCalledTimes(1);
184
+ expect(mockDispatchTaskSoundCue).toHaveBeenCalledWith({
185
+ count: 2,
186
+ cue: 'move',
187
+ intensity: 1.2,
188
+ });
175
189
  });
176
190
  });
@@ -2,6 +2,10 @@
2
2
 
3
3
  import type { Task } from '@tuturuuu/types/primitives/Task';
4
4
  import { useEffect } from 'react';
5
+ import {
6
+ dispatchTaskSoundCue,
7
+ type TaskSoundCue,
8
+ } from '../../../../shared/task-sound-effects';
5
9
  import {
6
10
  useBulkClearAssignees,
7
11
  useBulkClearLabels,
@@ -31,6 +35,14 @@ import { useBulkOperationI18n } from './bulk-operation-i18n';
31
35
  import type { BulkOperationsConfig } from './bulk-operation-types';
32
36
  import { getBulkTaskWorkspaceGroups } from './bulk-operation-utils';
33
37
 
38
+ function dispatchBulkTaskSoundCue(cue: TaskSoundCue, count: number) {
39
+ dispatchTaskSoundCue({
40
+ cue,
41
+ count,
42
+ intensity: count > 1 ? 1.2 : 1,
43
+ });
44
+ }
45
+
34
46
  export function useBulkOperations(config: BulkOperationsConfig) {
35
47
  const i18n = useBulkOperationI18n();
36
48
 
@@ -213,11 +225,13 @@ export function useBulkOperations(config: BulkOperationsConfig) {
213
225
  const taskIds = Array.from(selectedTasks);
214
226
  if (!taskIds.length) return;
215
227
  await priorityMutation.mutateAsync({ priority, taskIds });
228
+ dispatchBulkTaskSoundCue('update', taskIds.length);
216
229
  },
217
230
  bulkUpdateEstimation: async (points: number | null) => {
218
231
  const taskIds = Array.from(selectedTasks);
219
232
  if (!taskIds.length) return;
220
233
  await estimationMutation.mutateAsync({ points, taskIds });
234
+ dispatchBulkTaskSoundCue('update', taskIds.length);
221
235
  },
222
236
  bulkUpdateDueDate: async (
223
237
  preset: 'today' | 'tomorrow' | 'this_week' | 'next_week' | 'clear'
@@ -225,16 +239,19 @@ export function useBulkOperations(config: BulkOperationsConfig) {
225
239
  const taskIds = Array.from(selectedTasks);
226
240
  if (!taskIds.length) return;
227
241
  await dueDateMutation.mutateAsync({ preset, taskIds });
242
+ dispatchBulkTaskSoundCue('update', taskIds.length);
228
243
  },
229
244
  bulkUpdateCustomDueDate: async (date: Date | null) => {
230
245
  const taskIds = Array.from(selectedTasks);
231
246
  if (!taskIds.length) return;
232
247
  await customDueDateMutation.mutateAsync({ date, taskIds });
248
+ dispatchBulkTaskSoundCue('update', taskIds.length);
233
249
  },
234
250
  bulkMoveToList: async (listId: string, listName: string) => {
235
251
  const taskIds = Array.from(selectedTasks);
236
252
  if (!taskIds.length) return;
237
253
  await moveToListMutation.mutateAsync({ listId, listName, taskIds });
254
+ dispatchBulkTaskSoundCue('move', taskIds.length);
238
255
  },
239
256
  bulkMoveToStatus: async (status: 'done' | 'closed') => {
240
257
  const taskIds = Array.from(selectedTasks);
@@ -242,51 +259,61 @@ export function useBulkOperations(config: BulkOperationsConfig) {
242
259
  const listId = getListIdByStatus(status);
243
260
  if (!listId) return;
244
261
  await statusMutation.mutateAsync({ status, taskIds });
262
+ dispatchBulkTaskSoundCue('complete', taskIds.length);
245
263
  },
246
264
  bulkAddLabel: async (labelId: string) => {
247
265
  const taskIds = Array.from(selectedTasks);
248
266
  if (!taskIds.length) return;
249
267
  await addLabelMutation.mutateAsync({ labelId, taskIds });
268
+ dispatchBulkTaskSoundCue('update', taskIds.length);
250
269
  },
251
270
  bulkRemoveLabel: async (labelId: string) => {
252
271
  const taskIds = Array.from(selectedTasks);
253
272
  if (!taskIds.length) return;
254
273
  await removeLabelMutation.mutateAsync({ labelId, taskIds });
274
+ dispatchBulkTaskSoundCue('update', taskIds.length);
255
275
  },
256
276
  bulkAddProject: async (projectId: string) => {
257
277
  const taskIds = Array.from(selectedTasks);
258
278
  if (!taskIds.length) return;
259
279
  await addProjectMutation.mutateAsync({ projectId, taskIds });
280
+ dispatchBulkTaskSoundCue('update', taskIds.length);
260
281
  },
261
282
  bulkRemoveProject: async (projectId: string) => {
262
283
  const taskIds = Array.from(selectedTasks);
263
284
  if (!taskIds.length) return;
264
285
  await removeProjectMutation.mutateAsync({ projectId, taskIds });
286
+ dispatchBulkTaskSoundCue('update', taskIds.length);
265
287
  },
266
288
  bulkAddAssignee: async (assigneeId: string) => {
267
289
  const taskIds = Array.from(selectedTasks);
268
290
  if (!taskIds.length) return;
269
291
  await addAssigneeMutation.mutateAsync({ assigneeId, taskIds });
292
+ dispatchBulkTaskSoundCue('update', taskIds.length);
270
293
  },
271
294
  bulkRemoveAssignee: async (assigneeId: string) => {
272
295
  const taskIds = Array.from(selectedTasks);
273
296
  if (!taskIds.length) return;
274
297
  await removeAssigneeMutation.mutateAsync({ assigneeId, taskIds });
298
+ dispatchBulkTaskSoundCue('update', taskIds.length);
275
299
  },
276
300
  bulkClearLabels: async () => {
277
301
  const taskIds = Array.from(selectedTasks);
278
302
  if (!taskIds.length) return;
279
303
  await clearLabelsMutation.mutateAsync({ taskIds });
304
+ dispatchBulkTaskSoundCue('update', taskIds.length);
280
305
  },
281
306
  bulkClearProjects: async () => {
282
307
  const taskIds = Array.from(selectedTasks);
283
308
  if (!taskIds.length) return;
284
309
  await clearProjectsMutation.mutateAsync({ taskIds });
310
+ dispatchBulkTaskSoundCue('update', taskIds.length);
285
311
  },
286
312
  bulkClearAssignees: async () => {
287
313
  const taskIds = Array.from(selectedTasks);
288
314
  if (!taskIds.length) return;
289
315
  await clearAssigneesMutation.mutateAsync({ taskIds });
316
+ dispatchBulkTaskSoundCue('update', taskIds.length);
290
317
  },
291
318
  bulkDeleteTasks: async () => {
292
319
  const taskIds = Array.from(selectedTasks);
@@ -298,6 +325,7 @@ export function useBulkOperations(config: BulkOperationsConfig) {
298
325
  taskIds,
299
326
  });
300
327
  await deleteMutation.mutateAsync({ taskIds, workspaceGroups });
328
+ dispatchBulkTaskSoundCue('delete', taskIds.length);
301
329
  },
302
330
  bulkMoveToBoard: async (targetBoardId: string, targetListId: string) => {
303
331
  const taskIds = Array.from(selectedTasks);
@@ -307,6 +335,7 @@ export function useBulkOperations(config: BulkOperationsConfig) {
307
335
  targetListId,
308
336
  taskIds,
309
337
  });
338
+ dispatchBulkTaskSoundCue('move', taskIds.length);
310
339
  },
311
340
  getListIdByStatus,
312
341
  };