@tuturuuu/ui 0.11.0 → 0.12.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 +16 -0
- package/package.json +47 -47
- package/src/components/ui/storefront/bundle-selection-dialog.tsx +249 -0
- package/src/components/ui/storefront/cart-summary-parts.tsx +28 -8
- package/src/components/ui/storefront/listing-card.tsx +6 -3
- package/src/components/ui/storefront/storefront-surface.test.tsx +134 -1
- package/src/components/ui/storefront/storefront-surface.tsx +66 -7
- package/src/components/ui/storefront/types.ts +15 -0
- package/src/components/ui/storefront/utils.ts +164 -2
- package/src/components/ui/text-editor/__tests__/extensions.test.ts +22 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +18 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +157 -4
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +178 -14
- package/src/components/ui/tu-do/templates/task-template-client.test.tsx +52 -0
- package/src/components/ui/tu-do/templates/task-template-client.tsx +1 -1
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
2
|
import type {
|
|
3
|
+
InventoryBundle,
|
|
3
4
|
InventoryStorefront,
|
|
4
5
|
InventoryStorefrontListing,
|
|
5
6
|
} from '@tuturuuu/internal-api/inventory';
|
|
6
|
-
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
8
|
import { StorefrontSurface } from './storefront-surface';
|
|
8
9
|
import { formatStorefrontPrice, sanitizeStorefrontAccentColor } from './utils';
|
|
9
10
|
|
|
@@ -65,6 +66,95 @@ const secondListing: InventoryStorefrontListing = {
|
|
|
65
66
|
unitId: 'unit-2',
|
|
66
67
|
};
|
|
67
68
|
|
|
69
|
+
const categoryBundle: InventoryBundle = {
|
|
70
|
+
availableQuantity: 9,
|
|
71
|
+
categoryCandidateScope: 'all_stock',
|
|
72
|
+
categoryComponents: [
|
|
73
|
+
{
|
|
74
|
+
bundleId: 'bundle-keychains',
|
|
75
|
+
candidates: [
|
|
76
|
+
{
|
|
77
|
+
availableQuantity: 5,
|
|
78
|
+
componentId: 'component-keychains',
|
|
79
|
+
listingId: 'listing-keychain-a',
|
|
80
|
+
price: 1200,
|
|
81
|
+
productId: 'product-keychain-a',
|
|
82
|
+
selectionKind: 'listing',
|
|
83
|
+
title: 'Acrylic Keychain',
|
|
84
|
+
unitId: 'unit-1',
|
|
85
|
+
unitName: 'Each',
|
|
86
|
+
variantId: null,
|
|
87
|
+
warehouseId: 'warehouse-1',
|
|
88
|
+
warehouseName: 'Main',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
availableQuantity: 5,
|
|
92
|
+
componentId: 'component-keychains',
|
|
93
|
+
listingId: 'listing-keychain-b',
|
|
94
|
+
price: 900,
|
|
95
|
+
productId: 'product-keychain-b',
|
|
96
|
+
selectionKind: 'listing',
|
|
97
|
+
title: 'Metal Keychain',
|
|
98
|
+
unitId: 'unit-1',
|
|
99
|
+
unitName: 'Each',
|
|
100
|
+
variantId: null,
|
|
101
|
+
warehouseId: 'warehouse-1',
|
|
102
|
+
warehouseName: 'Main',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
availableQuantity: 5,
|
|
106
|
+
componentId: 'component-keychains',
|
|
107
|
+
listingId: 'listing-keychain-c',
|
|
108
|
+
price: 1500,
|
|
109
|
+
productId: 'product-keychain-c',
|
|
110
|
+
selectionKind: 'listing',
|
|
111
|
+
title: 'Charm Keychain',
|
|
112
|
+
unitId: 'unit-1',
|
|
113
|
+
unitName: 'Each',
|
|
114
|
+
variantId: null,
|
|
115
|
+
warehouseId: 'warehouse-1',
|
|
116
|
+
warehouseName: 'Main',
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
categoryId: 'category-keychains',
|
|
120
|
+
categoryName: 'Keychains',
|
|
121
|
+
discountStrategy: 'cheapest_free',
|
|
122
|
+
freeQuantity: 1,
|
|
123
|
+
id: 'component-keychains',
|
|
124
|
+
quantityRequired: 3,
|
|
125
|
+
sortOrder: 0,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
components: [],
|
|
129
|
+
createdAt: '2026-07-03T00:00:00.000Z',
|
|
130
|
+
description: 'Choose any three keychains.',
|
|
131
|
+
id: 'bundle-keychains',
|
|
132
|
+
imageUrl: null,
|
|
133
|
+
maxPerOrder: 4,
|
|
134
|
+
name: 'Buy 2 Get 1 Keychains',
|
|
135
|
+
price: 0,
|
|
136
|
+
pricingMode: 'selected_items',
|
|
137
|
+
slug: 'buy-2-get-1-keychains',
|
|
138
|
+
status: 'active',
|
|
139
|
+
storefrontId: storefront.id,
|
|
140
|
+
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
141
|
+
wsId: storefront.wsId,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const bundleListing: InventoryStorefrontListing = {
|
|
145
|
+
...listing,
|
|
146
|
+
bundleId: categoryBundle.id,
|
|
147
|
+
id: 'listing-keychain-bundle',
|
|
148
|
+
listingType: 'bundle',
|
|
149
|
+
price: 0,
|
|
150
|
+
productId: null,
|
|
151
|
+
title: 'Buy 2 Get 1 Keychains',
|
|
152
|
+
unitId: null,
|
|
153
|
+
unitName: null,
|
|
154
|
+
warehouseId: null,
|
|
155
|
+
warehouseName: null,
|
|
156
|
+
};
|
|
157
|
+
|
|
68
158
|
describe('StorefrontSurface', () => {
|
|
69
159
|
it('sanitizes hex accent colors only', () => {
|
|
70
160
|
expect(sanitizeStorefrontAccentColor('#abc')).toBe('#aabbcc');
|
|
@@ -143,6 +233,49 @@ describe('StorefrontSurface', () => {
|
|
|
143
233
|
expect(screen.getByRole('button', { name: /Checkout/ })).toBeEnabled();
|
|
144
234
|
});
|
|
145
235
|
|
|
236
|
+
it('opens category bundle selection and adds the configured selection to cart', () => {
|
|
237
|
+
const onAddCartLine = vi.fn();
|
|
238
|
+
|
|
239
|
+
render(
|
|
240
|
+
<StorefrontSurface
|
|
241
|
+
bundles={[categoryBundle]}
|
|
242
|
+
listings={[bundleListing]}
|
|
243
|
+
mode="store"
|
|
244
|
+
onAddCartLine={onAddCartLine}
|
|
245
|
+
storefront={storefront}
|
|
246
|
+
/>
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
fireEvent.click(screen.getByRole('button', { name: /Select options/ }));
|
|
250
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
251
|
+
expect(screen.getByText('0 of 3 selected')).toBeInTheDocument();
|
|
252
|
+
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
|
|
253
|
+
|
|
254
|
+
fireEvent.click(screen.getByRole('button', { name: /Acrylic Keychain/ }));
|
|
255
|
+
fireEvent.click(screen.getByRole('button', { name: /Metal Keychain/ }));
|
|
256
|
+
fireEvent.click(screen.getByRole('button', { name: /Charm Keychain/ }));
|
|
257
|
+
|
|
258
|
+
expect(screen.getByText('3 of 3 selected')).toBeInTheDocument();
|
|
259
|
+
expect(screen.getByText('$27.00')).toBeInTheDocument();
|
|
260
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
|
261
|
+
|
|
262
|
+
expect(onAddCartLine).toHaveBeenCalledWith(
|
|
263
|
+
expect.objectContaining({
|
|
264
|
+
bundleSelections: {
|
|
265
|
+
'component-keychains': [
|
|
266
|
+
expect.objectContaining({ listingId: 'listing-keychain-a' }),
|
|
267
|
+
expect.objectContaining({ listingId: 'listing-keychain-b' }),
|
|
268
|
+
expect.objectContaining({ listingId: 'listing-keychain-c' }),
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
listingId: bundleListing.id,
|
|
272
|
+
quantity: 1,
|
|
273
|
+
selectionKey: expect.stringContaining('component-keychains='),
|
|
274
|
+
}),
|
|
275
|
+
5
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
146
279
|
it('keeps long item names and large totals inside the cart row bounds', () => {
|
|
147
280
|
const largeListing: InventoryStorefrontListing = {
|
|
148
281
|
...listing,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { ArrowLeft } from '@tuturuuu/icons';
|
|
4
4
|
import type {
|
|
5
|
+
InventoryBundle,
|
|
5
6
|
InventoryStorefront,
|
|
6
7
|
InventoryStorefrontListing,
|
|
7
8
|
} from '@tuturuuu/internal-api/inventory';
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
DialogHeader,
|
|
15
16
|
DialogTitle,
|
|
16
17
|
} from '../dialog';
|
|
18
|
+
import { StorefrontBundleSelectionDialog } from './bundle-selection-dialog';
|
|
17
19
|
import { StorefrontCartPopover } from './cart-popover';
|
|
18
20
|
import { StorefrontCartSummary } from './cart-summary';
|
|
19
21
|
import { StorefrontCheckoutOverlay } from './checkout-overlay';
|
|
@@ -32,7 +34,7 @@ import type {
|
|
|
32
34
|
import { mergeStorefrontSurfaceLabels } from './types';
|
|
33
35
|
import {
|
|
34
36
|
getAccentStyle,
|
|
35
|
-
|
|
37
|
+
getStorefrontCartLineSubtotal,
|
|
36
38
|
getStorefrontListingLimit,
|
|
37
39
|
getStorefrontVariantLimit,
|
|
38
40
|
sanitizeStorefrontAccentColor,
|
|
@@ -42,6 +44,7 @@ import {
|
|
|
42
44
|
} from './utils';
|
|
43
45
|
|
|
44
46
|
export function StorefrontSurface({
|
|
47
|
+
bundles = [],
|
|
45
48
|
buyerDefaults,
|
|
46
49
|
cartLines = [],
|
|
47
50
|
cartHref,
|
|
@@ -65,12 +68,14 @@ export function StorefrontSurface({
|
|
|
65
68
|
onCheckoutSubmit,
|
|
66
69
|
onDecrement,
|
|
67
70
|
onDetailListingChange,
|
|
71
|
+
onAddCartLine,
|
|
68
72
|
onIncrement,
|
|
69
73
|
onInstantCheckout,
|
|
70
74
|
selectedListingId,
|
|
71
75
|
storefront,
|
|
72
76
|
storefrontHref,
|
|
73
77
|
}: {
|
|
78
|
+
bundles?: InventoryBundle[];
|
|
74
79
|
buyerDefaults?: StorefrontBuyerDefaults;
|
|
75
80
|
cartLines?: StorefrontCartLine[];
|
|
76
81
|
cartHref?: string;
|
|
@@ -93,6 +98,7 @@ export function StorefrontSurface({
|
|
|
93
98
|
onCheckoutOpenChange?: (open: boolean) => void;
|
|
94
99
|
onCheckoutSubmit?: (formData: FormData) => void;
|
|
95
100
|
onDecrement?: (listingId: string, variantId?: string | null) => void;
|
|
101
|
+
onAddCartLine?: (line: StorefrontCartLine, maxQuantity?: number) => void;
|
|
96
102
|
onDetailListingChange?: (listingId: string | null) => void;
|
|
97
103
|
onIncrement?: (
|
|
98
104
|
listingId: string,
|
|
@@ -106,6 +112,9 @@ export function StorefrontSurface({
|
|
|
106
112
|
}) {
|
|
107
113
|
const labels = mergeStorefrontSurfaceLabels(labelOverrides);
|
|
108
114
|
const [isCartPopoverOpen, setIsCartPopoverOpen] = useState(false);
|
|
115
|
+
const [bundleSelectionListingId, setBundleSelectionListingId] = useState<
|
|
116
|
+
string | null
|
|
117
|
+
>(null);
|
|
109
118
|
const accentColor = sanitizeStorefrontAccentColor(storefront.accentColor);
|
|
110
119
|
const radius = storefrontRadiusClasses[storefront.cornerStyle];
|
|
111
120
|
const resolveVariant = (
|
|
@@ -119,7 +128,14 @@ export function StorefrontSurface({
|
|
|
119
128
|
const listing = listings.find((item) => item.id === line.listingId);
|
|
120
129
|
if (!listing) return [];
|
|
121
130
|
return [
|
|
122
|
-
{
|
|
131
|
+
{
|
|
132
|
+
bundle: listing.bundleId
|
|
133
|
+
? bundles.find((bundle) => bundle.id === listing.bundleId)
|
|
134
|
+
: undefined,
|
|
135
|
+
line,
|
|
136
|
+
listing,
|
|
137
|
+
variant: resolveVariant(listing, line.variantId),
|
|
138
|
+
},
|
|
123
139
|
];
|
|
124
140
|
});
|
|
125
141
|
const lineLimit = (entry: (typeof cartEntries)[number]) =>
|
|
@@ -132,7 +148,13 @@ export function StorefrontSurface({
|
|
|
132
148
|
const total = checkoutEntries.reduce((sum, entry) => {
|
|
133
149
|
const quantity = Math.min(entry.line.quantity, lineLimit(entry));
|
|
134
150
|
return (
|
|
135
|
-
sum +
|
|
151
|
+
sum +
|
|
152
|
+
getStorefrontCartLineSubtotal({
|
|
153
|
+
bundle: entry.bundle,
|
|
154
|
+
line: { ...entry.line, quantity },
|
|
155
|
+
listing: entry.listing,
|
|
156
|
+
variant: entry.variant,
|
|
157
|
+
})
|
|
136
158
|
);
|
|
137
159
|
}, 0);
|
|
138
160
|
const cartQuantity = cartLines.reduce((sum, line) => sum + line.quantity, 0);
|
|
@@ -152,6 +174,14 @@ export function StorefrontSurface({
|
|
|
152
174
|
const isPreview = mode === 'preview';
|
|
153
175
|
const isCartPage = mode === 'cart';
|
|
154
176
|
const listingRows = visibleListings;
|
|
177
|
+
const bundleSelectionListing = bundleSelectionListingId
|
|
178
|
+
? listings.find((listing) => listing.id === bundleSelectionListingId)
|
|
179
|
+
: null;
|
|
180
|
+
const bundleSelectionBundle = bundleSelectionListing?.bundleId
|
|
181
|
+
? (bundles.find(
|
|
182
|
+
(bundle) => bundle.id === bundleSelectionListing.bundleId
|
|
183
|
+
) ?? null)
|
|
184
|
+
: null;
|
|
155
185
|
const currency = storefront.currency ?? 'USD';
|
|
156
186
|
const handleCheckoutOpen = () => {
|
|
157
187
|
setIsCartPopoverOpen(false);
|
|
@@ -337,9 +367,14 @@ export function StorefrontSurface({
|
|
|
337
367
|
/>
|
|
338
368
|
) : (
|
|
339
369
|
listingRows.map((listing) => {
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
370
|
+
const listingBundle = listing.bundleId
|
|
371
|
+
? bundles.find(
|
|
372
|
+
(bundle) => bundle.id === listing.bundleId
|
|
373
|
+
)
|
|
374
|
+
: undefined;
|
|
375
|
+
const quantity = cartLines
|
|
376
|
+
.filter((item) => item.listingId === listing.id)
|
|
377
|
+
.reduce((sum, item) => sum + item.quantity, 0);
|
|
343
378
|
|
|
344
379
|
return (
|
|
345
380
|
<StorefrontListingCard
|
|
@@ -349,13 +384,18 @@ export function StorefrontSurface({
|
|
|
349
384
|
labels={labels}
|
|
350
385
|
listing={listing}
|
|
351
386
|
onDecrement={onDecrement}
|
|
387
|
+
onConfigureBundle={
|
|
388
|
+
listingBundle?.categoryComponents.length
|
|
389
|
+
? () => setBundleSelectionListingId(listing.id)
|
|
390
|
+
: undefined
|
|
391
|
+
}
|
|
352
392
|
onIncrement={onIncrement}
|
|
353
393
|
onOpenDetail={
|
|
354
394
|
onDetailListingChange
|
|
355
395
|
? (id) => onDetailListingChange(id)
|
|
356
396
|
: undefined
|
|
357
397
|
}
|
|
358
|
-
quantity={
|
|
398
|
+
quantity={quantity}
|
|
359
399
|
radius={radius}
|
|
360
400
|
showInventoryBadges={storefront.showInventoryBadges}
|
|
361
401
|
surfaceClassName={
|
|
@@ -390,6 +430,25 @@ export function StorefrontSurface({
|
|
|
390
430
|
surfaceClassName={storefrontSurfaceClasses[storefront.surfaceStyle]}
|
|
391
431
|
/>
|
|
392
432
|
|
|
433
|
+
<StorefrontBundleSelectionDialog
|
|
434
|
+
bundle={bundleSelectionBundle}
|
|
435
|
+
currency={currency}
|
|
436
|
+
labels={labels}
|
|
437
|
+
listing={bundleSelectionListing ?? null}
|
|
438
|
+
onAdd={(line) => {
|
|
439
|
+
if (!bundleSelectionListing) return;
|
|
440
|
+
onAddCartLine?.(
|
|
441
|
+
line,
|
|
442
|
+
getStorefrontListingLimit(bundleSelectionListing)
|
|
443
|
+
);
|
|
444
|
+
}}
|
|
445
|
+
onOpenChange={(open) => {
|
|
446
|
+
if (!open) setBundleSelectionListingId(null);
|
|
447
|
+
}}
|
|
448
|
+
open={Boolean(bundleSelectionListing && bundleSelectionBundle)}
|
|
449
|
+
radius={radius}
|
|
450
|
+
/>
|
|
451
|
+
|
|
393
452
|
<Dialog
|
|
394
453
|
onOpenChange={(open) => onCheckoutOpenChange?.(open)}
|
|
395
454
|
open={isCheckoutDialogOpen}
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
InventoryBundle,
|
|
3
|
+
InventoryCheckoutBundleSelections,
|
|
2
4
|
InventoryListingVariant,
|
|
3
5
|
InventoryStorefrontListing,
|
|
4
6
|
} from '@tuturuuu/internal-api/inventory';
|
|
5
7
|
|
|
6
8
|
export type StorefrontCartLine = {
|
|
7
9
|
listingId: string;
|
|
10
|
+
bundleSelections?: InventoryCheckoutBundleSelections;
|
|
11
|
+
selectionKey?: string | null;
|
|
8
12
|
variantId?: string | null;
|
|
9
13
|
quantity: number;
|
|
10
14
|
};
|
|
11
15
|
|
|
12
16
|
export type StorefrontCartEntry = {
|
|
17
|
+
bundle?: InventoryBundle;
|
|
13
18
|
line: StorefrontCartLine;
|
|
14
19
|
listing: InventoryStorefrontListing;
|
|
15
20
|
variant?: InventoryListingVariant;
|
|
@@ -32,8 +37,10 @@ export type StorefrontSurfaceLabels = {
|
|
|
32
37
|
available: string;
|
|
33
38
|
browse: string;
|
|
34
39
|
bundle: string;
|
|
40
|
+
bundleSelectionTitle: string;
|
|
35
41
|
buyNow: string;
|
|
36
42
|
cart: string;
|
|
43
|
+
cheapestFreePreview: string;
|
|
37
44
|
checkout: string;
|
|
38
45
|
checkoutDisabled: string;
|
|
39
46
|
checkoutDisabledBadge: string;
|
|
@@ -45,7 +52,10 @@ export type StorefrontSurfaceLabels = {
|
|
|
45
52
|
instantCheckout: string;
|
|
46
53
|
orderSummary: string;
|
|
47
54
|
redirectingToCheckout: string;
|
|
55
|
+
requiredItems: string;
|
|
48
56
|
selectOptions: string;
|
|
57
|
+
searchBundleItems: string;
|
|
58
|
+
selectedItems: string;
|
|
49
59
|
viewDetails: string;
|
|
50
60
|
emptyListingsDescription: string;
|
|
51
61
|
emptyListingsTitle: string;
|
|
@@ -74,8 +84,10 @@ export const defaultStorefrontSurfaceLabels: StorefrontSurfaceLabels = {
|
|
|
74
84
|
available: 'available',
|
|
75
85
|
browse: 'Browse',
|
|
76
86
|
bundle: 'Bundle',
|
|
87
|
+
bundleSelectionTitle: 'Build bundle',
|
|
77
88
|
buyNow: 'Buy now',
|
|
78
89
|
cart: 'Cart',
|
|
90
|
+
cheapestFreePreview: 'Cheapest eligible item is free.',
|
|
79
91
|
checkout: 'Checkout',
|
|
80
92
|
checkoutDisabled: 'Checkout is disabled in preview',
|
|
81
93
|
checkoutDisabledBadge: 'Checkout disabled',
|
|
@@ -87,7 +99,10 @@ export const defaultStorefrontSurfaceLabels: StorefrontSurfaceLabels = {
|
|
|
87
99
|
instantCheckout: 'Instant checkout',
|
|
88
100
|
orderSummary: 'Order summary',
|
|
89
101
|
redirectingToCheckout: 'Taking you to secure checkout…',
|
|
102
|
+
requiredItems: 'Select {count} items',
|
|
90
103
|
selectOptions: 'Select options',
|
|
104
|
+
searchBundleItems: 'Search items',
|
|
105
|
+
selectedItems: '{selected} of {required} selected',
|
|
91
106
|
viewDetails: 'View details',
|
|
92
107
|
emptyListingsDescription:
|
|
93
108
|
'Publish a listing to make this storefront ready for buyers.',
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
InventoryBundle,
|
|
3
|
+
InventoryBundleCategoryCandidate,
|
|
4
|
+
InventoryBundleCategoryComponent,
|
|
2
5
|
InventoryListingVariant,
|
|
3
6
|
InventoryStorefront,
|
|
4
7
|
InventoryStorefrontListing,
|
|
5
8
|
} from '@tuturuuu/internal-api/inventory';
|
|
6
9
|
import { formatMoneyFromMinor } from '@tuturuuu/utils/money';
|
|
7
10
|
import type { CSSProperties } from 'react';
|
|
11
|
+
import type { StorefrontCartLine } from './types';
|
|
8
12
|
|
|
9
13
|
// The storefront now ships a single, unified design language. The merchant
|
|
10
14
|
// preset fields (cornerStyle/surfaceStyle/themePreset/layoutStyle) are retained
|
|
@@ -130,6 +134,163 @@ export function getStorefrontLinePrice(
|
|
|
130
134
|
return variant ? variant.price : listing.price;
|
|
131
135
|
}
|
|
132
136
|
|
|
137
|
+
function getCategorySelectionItems(
|
|
138
|
+
line: StorefrontCartLine,
|
|
139
|
+
component: InventoryBundleCategoryComponent
|
|
140
|
+
) {
|
|
141
|
+
const selections = line.bundleSelections;
|
|
142
|
+
if (!selections) return [];
|
|
143
|
+
|
|
144
|
+
if (Array.isArray(selections)) {
|
|
145
|
+
return (
|
|
146
|
+
selections.find((selection) => selection.componentId === component.id)
|
|
147
|
+
?.items ?? []
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return selections[component.id] ?? [];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getCandidateKey(candidate: InventoryBundleCategoryCandidate) {
|
|
155
|
+
return [
|
|
156
|
+
candidate.selectionKind,
|
|
157
|
+
candidate.listingId ?? '',
|
|
158
|
+
candidate.variantId ?? '',
|
|
159
|
+
candidate.productId,
|
|
160
|
+
candidate.unitId,
|
|
161
|
+
candidate.warehouseId,
|
|
162
|
+
].join(':');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function resolveBundleCandidate(
|
|
166
|
+
component: InventoryBundleCategoryComponent,
|
|
167
|
+
item: ReturnType<typeof getCategorySelectionItems>[number]
|
|
168
|
+
) {
|
|
169
|
+
return component.candidates?.find((candidate) => {
|
|
170
|
+
if (item.variantId) {
|
|
171
|
+
return (
|
|
172
|
+
candidate.variantId === item.variantId &&
|
|
173
|
+
candidate.listingId === item.listingId
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (item.listingId) {
|
|
177
|
+
return candidate.listingId === item.listingId && !candidate.variantId;
|
|
178
|
+
}
|
|
179
|
+
return (
|
|
180
|
+
candidate.productId === item.productId &&
|
|
181
|
+
candidate.unitId === item.unitId &&
|
|
182
|
+
candidate.warehouseId === item.warehouseId
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function getStorefrontBundleSelectionSubtotal(
|
|
188
|
+
bundle: InventoryBundle | undefined,
|
|
189
|
+
line: StorefrontCartLine
|
|
190
|
+
) {
|
|
191
|
+
if (!bundle?.categoryComponents?.length || !line.bundleSelections) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let subtotal = 0;
|
|
196
|
+
for (const component of bundle.categoryComponents) {
|
|
197
|
+
const pricedItems = getCategorySelectionItems(line, component).map(
|
|
198
|
+
(item) => {
|
|
199
|
+
const candidate = resolveBundleCandidate(component, item);
|
|
200
|
+
return candidate
|
|
201
|
+
? {
|
|
202
|
+
candidate,
|
|
203
|
+
quantity: item.quantity ?? 1,
|
|
204
|
+
}
|
|
205
|
+
: null;
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
if (pricedItems.some((item) => !item)) return null;
|
|
210
|
+
|
|
211
|
+
const validPricedItems = pricedItems.filter(
|
|
212
|
+
(
|
|
213
|
+
item
|
|
214
|
+
): item is {
|
|
215
|
+
candidate: InventoryBundleCategoryCandidate;
|
|
216
|
+
quantity: number;
|
|
217
|
+
} => Boolean(item)
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
let freeRemaining =
|
|
221
|
+
component.discountStrategy === 'cheapest_free'
|
|
222
|
+
? component.freeQuantity * line.quantity
|
|
223
|
+
: 0;
|
|
224
|
+
|
|
225
|
+
for (const pricedItem of validPricedItems.sort(
|
|
226
|
+
(a, b) => a.candidate.price - b.candidate.price
|
|
227
|
+
)) {
|
|
228
|
+
const units = pricedItem.quantity * line.quantity;
|
|
229
|
+
const freeUnits = Math.min(units, freeRemaining);
|
|
230
|
+
freeRemaining -= freeUnits;
|
|
231
|
+
subtotal += (units - freeUnits) * pricedItem.candidate.price;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return subtotal;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function getStorefrontBundleSelectionLabels(
|
|
239
|
+
bundle: InventoryBundle | undefined,
|
|
240
|
+
line: StorefrontCartLine
|
|
241
|
+
) {
|
|
242
|
+
if (!bundle?.categoryComponents?.length || !line.bundleSelections) return [];
|
|
243
|
+
|
|
244
|
+
return bundle.categoryComponents.flatMap((component) =>
|
|
245
|
+
getCategorySelectionItems(line, component).flatMap((item) => {
|
|
246
|
+
const candidate = resolveBundleCandidate(component, item);
|
|
247
|
+
if (!candidate) return [];
|
|
248
|
+
const quantity = item.quantity ?? 1;
|
|
249
|
+
return quantity > 1 ? `${quantity}x ${candidate.title}` : candidate.title;
|
|
250
|
+
})
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function getStorefrontCartLineSubtotal({
|
|
255
|
+
bundle,
|
|
256
|
+
line,
|
|
257
|
+
listing,
|
|
258
|
+
variant,
|
|
259
|
+
}: {
|
|
260
|
+
bundle?: InventoryBundle;
|
|
261
|
+
line: StorefrontCartLine;
|
|
262
|
+
listing: InventoryStorefrontListing;
|
|
263
|
+
variant?: InventoryListingVariant | null;
|
|
264
|
+
}) {
|
|
265
|
+
const selectedSubtotal = getStorefrontBundleSelectionSubtotal(bundle, line);
|
|
266
|
+
if (selectedSubtotal != null) return selectedSubtotal;
|
|
267
|
+
|
|
268
|
+
return getStorefrontLinePrice(listing, variant) * line.quantity;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function createStorefrontBundleSelectionKey(
|
|
272
|
+
bundle: InventoryBundle,
|
|
273
|
+
selections: NonNullable<StorefrontCartLine['bundleSelections']>
|
|
274
|
+
) {
|
|
275
|
+
const chunks = bundle.categoryComponents.map((component) => {
|
|
276
|
+
const items = Array.isArray(selections)
|
|
277
|
+
? (selections.find((selection) => selection.componentId === component.id)
|
|
278
|
+
?.items ?? [])
|
|
279
|
+
: (selections[component.id] ?? []);
|
|
280
|
+
const keys = items
|
|
281
|
+
.map((item) => {
|
|
282
|
+
const candidate = resolveBundleCandidate(component, item);
|
|
283
|
+
return candidate
|
|
284
|
+
? `${getCandidateKey(candidate)}@${item.quantity ?? 1}`
|
|
285
|
+
: JSON.stringify(item);
|
|
286
|
+
})
|
|
287
|
+
.sort();
|
|
288
|
+
return `${component.id}=${keys.join(',')}`;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return chunks.join('|');
|
|
292
|
+
}
|
|
293
|
+
|
|
133
294
|
/** Lowest active-variant price, for a "from {price}" label on variant listings. */
|
|
134
295
|
export function getStorefrontListingFromPrice(
|
|
135
296
|
listing: InventoryStorefrontListing
|
|
@@ -145,9 +306,10 @@ export function getStorefrontListingFromPrice(
|
|
|
145
306
|
/** Stable identity for a cart line so listing+variant combos stay distinct. */
|
|
146
307
|
export function storefrontCartLineKey(
|
|
147
308
|
listingId: string,
|
|
148
|
-
variantId?: string | null
|
|
309
|
+
variantId?: string | null,
|
|
310
|
+
selectionKey?: string | null
|
|
149
311
|
) {
|
|
150
|
-
return `${listingId}::${variantId ?? ''}`;
|
|
312
|
+
return `${listingId}::${variantId ?? ''}::${selectionKey ?? ''}`;
|
|
151
313
|
}
|
|
152
314
|
|
|
153
315
|
/** Composes a human label for the selected variant from its option values. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { generateHTML, generateJSON } from '@tiptap/core';
|
|
2
2
|
import type SupabaseProvider from '@tuturuuu/ui/hooks/supabase-provider';
|
|
3
|
+
import { parseTaskDescriptionInput } from '@tuturuuu/utils/task-description-codec';
|
|
3
4
|
import { describe, expect, it } from 'vitest';
|
|
4
5
|
import * as Y from 'yjs';
|
|
5
6
|
import { getEditorExtensions } from '../extensions';
|
|
@@ -157,6 +158,27 @@ describe('text editor extensions', () => {
|
|
|
157
158
|
expect(html).toContain('#FFF59D');
|
|
158
159
|
});
|
|
159
160
|
|
|
161
|
+
it('renders shared markdown-codec table JSON with editor table extensions', () => {
|
|
162
|
+
const extensions = getEditorExtensions({ readOnly: true });
|
|
163
|
+
const content = parseTaskDescriptionInput(
|
|
164
|
+
['| Field | Value |', '| --- | --- |', '| Owner | Platform |'].join('\n'),
|
|
165
|
+
'markdown'
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const html = generateHTML(content, extensions);
|
|
169
|
+
|
|
170
|
+
expect(html).toContain('<table');
|
|
171
|
+
expect(html).toContain('<th');
|
|
172
|
+
expect(html).toContain('<td');
|
|
173
|
+
expect(html).toContain('Platform');
|
|
174
|
+
|
|
175
|
+
const parsed = generateJSON(html, extensions);
|
|
176
|
+
expect(parsed.content?.[0]?.type).toBe('table');
|
|
177
|
+
expect(parsed.content?.[0]?.content?.[0]?.content?.[0]?.type).toBe(
|
|
178
|
+
'tableHeader'
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
160
182
|
it('round-trips task mention workspace metadata through HTML attrs', () => {
|
|
161
183
|
const renderOutput = (Mention.config as any).renderHTML({
|
|
162
184
|
HTMLAttributes: {
|
|
@@ -5,7 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
|
|
|
5
5
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
|
|
6
6
|
import { cn } from '@tuturuuu/utils/format';
|
|
7
7
|
import { useTranslations } from 'next-intl';
|
|
8
|
-
import { useMemo, useState } from 'react';
|
|
8
|
+
import { type ComponentProps, useMemo, useState } from 'react';
|
|
9
9
|
import { CreateListDialog } from '../../create-list-dialog';
|
|
10
10
|
import { translateTaskListNameForDisplay } from '../../utils/translate-task-list-display-name';
|
|
11
11
|
import { TaskListPickerPanel } from './task-list-picker-panel';
|
|
@@ -23,7 +23,14 @@ interface TaskListSelectorProps {
|
|
|
23
23
|
compact?: boolean;
|
|
24
24
|
open?: boolean;
|
|
25
25
|
onOpenChange?: (open: boolean) => void;
|
|
26
|
+
onPopoverCloseAutoFocus?: ComponentProps<
|
|
27
|
+
typeof PopoverContent
|
|
28
|
+
>['onCloseAutoFocus'];
|
|
29
|
+
onPopoverInteractOutside?: ComponentProps<
|
|
30
|
+
typeof PopoverContent
|
|
31
|
+
>['onInteractOutside'];
|
|
26
32
|
onListChange: (listId: string) => void;
|
|
33
|
+
propertyPopoverId?: string;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
export function TaskListSelector({
|
|
@@ -35,7 +42,10 @@ export function TaskListSelector({
|
|
|
35
42
|
compact = false,
|
|
36
43
|
open,
|
|
37
44
|
onOpenChange,
|
|
45
|
+
onPopoverCloseAutoFocus,
|
|
46
|
+
onPopoverInteractOutside,
|
|
38
47
|
onListChange,
|
|
48
|
+
propertyPopoverId,
|
|
39
49
|
}: TaskListSelectorProps) {
|
|
40
50
|
const t = useTranslations();
|
|
41
51
|
const [uncontrolledPopoverOpen, setUncontrolledPopoverOpen] = useState(false);
|
|
@@ -80,6 +90,7 @@ export function TaskListSelector({
|
|
|
80
90
|
const triggerButton = (
|
|
81
91
|
<button
|
|
82
92
|
type="button"
|
|
93
|
+
data-task-property-popover-trigger={propertyPopoverId}
|
|
83
94
|
disabled={disabled}
|
|
84
95
|
aria-label={compact ? triggerLabel : undefined}
|
|
85
96
|
className={cn(
|
|
@@ -111,7 +122,12 @@ export function TaskListSelector({
|
|
|
111
122
|
) : (
|
|
112
123
|
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
|
|
113
124
|
)}
|
|
114
|
-
<PopoverContent
|
|
125
|
+
<PopoverContent
|
|
126
|
+
align="start"
|
|
127
|
+
className="w-80 p-0"
|
|
128
|
+
onCloseAutoFocus={onPopoverCloseAutoFocus}
|
|
129
|
+
onInteractOutside={onPopoverInteractOutside}
|
|
130
|
+
>
|
|
115
131
|
<TaskListPickerPanel
|
|
116
132
|
selectedListId={selectedListId}
|
|
117
133
|
availableLists={availableLists}
|