@tuturuuu/ui 0.11.1 → 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 +7 -0
- package/package.json +4 -4
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.12.0](https://github.com/tutur3u/platform/compare/ui-v0.11.1...ui-v0.12.0) (2026-07-03)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **inventory:** add revenue share and category bundles ([20b2e1e](https://github.com/tutur3u/platform/commit/20b2e1e5302d1db275766b7b4b92d9bdf69de04a))
|
|
9
|
+
|
|
3
10
|
## [0.11.1](https://github.com/tutur3u/platform/compare/ui-v0.11.0...ui-v0.11.1) (2026-07-02)
|
|
4
11
|
|
|
5
12
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -88,9 +88,9 @@
|
|
|
88
88
|
"@tuturuuu/apis": "0.7.0",
|
|
89
89
|
"@tuturuuu/hooks": "0.0.2",
|
|
90
90
|
"@tuturuuu/icons": "0.0.6",
|
|
91
|
-
"@tuturuuu/internal-api": "0.
|
|
91
|
+
"@tuturuuu/internal-api": "0.14.0",
|
|
92
92
|
"@tuturuuu/supabase": "0.4.0",
|
|
93
|
-
"@tuturuuu/utils": "0.12.
|
|
93
|
+
"@tuturuuu/utils": "0.12.1",
|
|
94
94
|
"@types/debug": "^4.1.13",
|
|
95
95
|
"browser-image-compression": "^2.0.2",
|
|
96
96
|
"class-variance-authority": "^0.7.1",
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
"@tanstack/react-table": "^8.21.3",
|
|
150
150
|
"@testing-library/jest-dom": "^6.9.1",
|
|
151
151
|
"@testing-library/react": "^16.3.2",
|
|
152
|
-
"@tuturuuu/types": "0.
|
|
152
|
+
"@tuturuuu/types": "0.14.0",
|
|
153
153
|
"@tuturuuu/typescript-config": "0.1.1",
|
|
154
154
|
"@types/html2canvas": "^1.0.0",
|
|
155
155
|
"@types/lodash": "^4.17.24",
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Check, PackagePlus, Search } from '@tuturuuu/icons';
|
|
4
|
+
import type {
|
|
5
|
+
InventoryBundle,
|
|
6
|
+
InventoryBundleCategoryCandidate,
|
|
7
|
+
InventoryStorefrontListing,
|
|
8
|
+
} from '@tuturuuu/internal-api/inventory';
|
|
9
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
10
|
+
import { useMemo, useState } from 'react';
|
|
11
|
+
import { Badge } from '../badge';
|
|
12
|
+
import { Button } from '../button';
|
|
13
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../dialog';
|
|
14
|
+
import { Input } from '../input';
|
|
15
|
+
import type { StorefrontCartLine, StorefrontSurfaceLabels } from './types';
|
|
16
|
+
import {
|
|
17
|
+
createStorefrontBundleSelectionKey,
|
|
18
|
+
formatStorefrontPrice,
|
|
19
|
+
getStorefrontBundleSelectionSubtotal,
|
|
20
|
+
} from './utils';
|
|
21
|
+
|
|
22
|
+
type BundleSelectionDialogProps = {
|
|
23
|
+
bundle: InventoryBundle | null;
|
|
24
|
+
currency: string;
|
|
25
|
+
labels: StorefrontSurfaceLabels;
|
|
26
|
+
listing: InventoryStorefrontListing | null;
|
|
27
|
+
onAdd?: (line: StorefrontCartLine) => void;
|
|
28
|
+
onOpenChange: (open: boolean) => void;
|
|
29
|
+
open: boolean;
|
|
30
|
+
radius: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function StorefrontBundleSelectionDialog({
|
|
34
|
+
bundle,
|
|
35
|
+
currency,
|
|
36
|
+
labels,
|
|
37
|
+
listing,
|
|
38
|
+
onAdd,
|
|
39
|
+
onOpenChange,
|
|
40
|
+
open,
|
|
41
|
+
radius,
|
|
42
|
+
}: BundleSelectionDialogProps) {
|
|
43
|
+
const [query, setQuery] = useState('');
|
|
44
|
+
const [selected, setSelected] = useState<Record<string, string[]>>({});
|
|
45
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
46
|
+
const selections = useMemo(() => {
|
|
47
|
+
if (!bundle) return {};
|
|
48
|
+
|
|
49
|
+
return Object.fromEntries(
|
|
50
|
+
bundle.categoryComponents.map((component) => {
|
|
51
|
+
const candidates = component.candidates ?? [];
|
|
52
|
+
const items = (selected[component.id] ?? []).flatMap((key) => {
|
|
53
|
+
const candidate = candidates.find(
|
|
54
|
+
(item) => getCandidateKey(item) === key
|
|
55
|
+
);
|
|
56
|
+
return candidate ? [candidateToSelection(candidate)] : [];
|
|
57
|
+
});
|
|
58
|
+
return [component.id, items];
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
}, [bundle, selected]);
|
|
62
|
+
const selectionLine: StorefrontCartLine | null =
|
|
63
|
+
listing && bundle
|
|
64
|
+
? {
|
|
65
|
+
bundleSelections: selections,
|
|
66
|
+
listingId: listing.id,
|
|
67
|
+
quantity: 1,
|
|
68
|
+
selectionKey: createStorefrontBundleSelectionKey(bundle, selections),
|
|
69
|
+
}
|
|
70
|
+
: null;
|
|
71
|
+
const subtotal =
|
|
72
|
+
bundle && selectionLine
|
|
73
|
+
? getStorefrontBundleSelectionSubtotal(bundle, selectionLine)
|
|
74
|
+
: null;
|
|
75
|
+
const isComplete =
|
|
76
|
+
Boolean(bundle?.categoryComponents.length) &&
|
|
77
|
+
bundle?.categoryComponents.every(
|
|
78
|
+
(component) =>
|
|
79
|
+
(selected[component.id]?.length ?? 0) === component.quantityRequired
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (!bundle || !listing) return null;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Dialog
|
|
86
|
+
onOpenChange={(nextOpen) => {
|
|
87
|
+
if (!nextOpen) {
|
|
88
|
+
setQuery('');
|
|
89
|
+
setSelected({});
|
|
90
|
+
}
|
|
91
|
+
onOpenChange(nextOpen);
|
|
92
|
+
}}
|
|
93
|
+
open={open}
|
|
94
|
+
>
|
|
95
|
+
<DialogContent className="grid max-h-[90dvh] max-w-[min(42rem,calc(100vw-1rem))] grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-4 overflow-hidden border-border/60 p-5 sm:rounded-2xl">
|
|
96
|
+
<DialogHeader className="text-left">
|
|
97
|
+
<DialogTitle>{labels.bundleSelectionTitle}</DialogTitle>
|
|
98
|
+
<p className="text-muted-foreground text-sm">{listing.title}</p>
|
|
99
|
+
</DialogHeader>
|
|
100
|
+
<div className="grid gap-2">
|
|
101
|
+
<label className="relative block">
|
|
102
|
+
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
103
|
+
<Input
|
|
104
|
+
className="h-10 pl-9"
|
|
105
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
106
|
+
placeholder={labels.searchBundleItems}
|
|
107
|
+
value={query}
|
|
108
|
+
/>
|
|
109
|
+
</label>
|
|
110
|
+
<p className="rounded-md border border-border bg-muted/25 px-3 py-2 text-muted-foreground text-xs">
|
|
111
|
+
{labels.cheapestFreePreview}
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
<div className="-mr-1 grid gap-4 overflow-y-auto pr-1">
|
|
115
|
+
{bundle.categoryComponents.map((component) => {
|
|
116
|
+
const selectedKeys = selected[component.id] ?? [];
|
|
117
|
+
const filteredCandidates = (component.candidates ?? []).filter(
|
|
118
|
+
(candidate) =>
|
|
119
|
+
!normalizedQuery ||
|
|
120
|
+
candidate.title.toLowerCase().includes(normalizedQuery)
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<section className="grid gap-2" key={component.id}>
|
|
125
|
+
<div className="flex items-center justify-between gap-3">
|
|
126
|
+
<div className="min-w-0">
|
|
127
|
+
<p className="truncate font-medium text-sm">
|
|
128
|
+
{component.categoryName}
|
|
129
|
+
</p>
|
|
130
|
+
<p className="text-muted-foreground text-xs">
|
|
131
|
+
{labels.selectedItems
|
|
132
|
+
.replace('{selected}', String(selectedKeys.length))
|
|
133
|
+
.replace(
|
|
134
|
+
'{required}',
|
|
135
|
+
String(component.quantityRequired)
|
|
136
|
+
)}
|
|
137
|
+
</p>
|
|
138
|
+
</div>
|
|
139
|
+
<Badge
|
|
140
|
+
className="border-border bg-background"
|
|
141
|
+
variant="outline"
|
|
142
|
+
>
|
|
143
|
+
{labels.requiredItems.replace(
|
|
144
|
+
'{count}',
|
|
145
|
+
String(component.quantityRequired)
|
|
146
|
+
)}
|
|
147
|
+
</Badge>
|
|
148
|
+
</div>
|
|
149
|
+
<div className="grid gap-2 sm:grid-cols-2">
|
|
150
|
+
{filteredCandidates.map((candidate) => {
|
|
151
|
+
const key = getCandidateKey(candidate);
|
|
152
|
+
const active = selectedKeys.includes(key);
|
|
153
|
+
const full =
|
|
154
|
+
!active &&
|
|
155
|
+
selectedKeys.length >= component.quantityRequired;
|
|
156
|
+
const disabled =
|
|
157
|
+
full || candidate.availableQuantity === 0 || !onAdd;
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<button
|
|
161
|
+
className={cn(
|
|
162
|
+
'grid min-h-20 gap-1 rounded-lg border bg-background p-3 text-left text-sm transition',
|
|
163
|
+
active
|
|
164
|
+
? 'border-[var(--storefront-accent,var(--primary))] bg-[var(--storefront-accent-soft,var(--muted))]'
|
|
165
|
+
: 'border-border hover:border-foreground/35',
|
|
166
|
+
disabled ? 'cursor-not-allowed opacity-60' : null
|
|
167
|
+
)}
|
|
168
|
+
disabled={disabled}
|
|
169
|
+
key={key}
|
|
170
|
+
onClick={() =>
|
|
171
|
+
setSelected((current) => ({
|
|
172
|
+
...current,
|
|
173
|
+
[component.id]: active
|
|
174
|
+
? selectedKeys.filter((item) => item !== key)
|
|
175
|
+
: [...selectedKeys, key],
|
|
176
|
+
}))
|
|
177
|
+
}
|
|
178
|
+
type="button"
|
|
179
|
+
>
|
|
180
|
+
<span className="flex min-w-0 items-center justify-between gap-2">
|
|
181
|
+
<span className="line-clamp-2 font-medium">
|
|
182
|
+
{candidate.title}
|
|
183
|
+
</span>
|
|
184
|
+
{active ? <Check className="h-4 w-4" /> : null}
|
|
185
|
+
</span>
|
|
186
|
+
<span className="text-muted-foreground text-xs">
|
|
187
|
+
{candidate.unitName || candidate.unitId} /{' '}
|
|
188
|
+
{candidate.warehouseName || candidate.warehouseId}
|
|
189
|
+
</span>
|
|
190
|
+
<span className="font-semibold tabular-nums">
|
|
191
|
+
{formatStorefrontPrice(candidate.price, currency)}
|
|
192
|
+
</span>
|
|
193
|
+
</button>
|
|
194
|
+
);
|
|
195
|
+
})}
|
|
196
|
+
</div>
|
|
197
|
+
</section>
|
|
198
|
+
);
|
|
199
|
+
})}
|
|
200
|
+
</div>
|
|
201
|
+
<div className="flex flex-wrap items-center justify-between gap-3 border-border border-t pt-4">
|
|
202
|
+
<div className="min-w-0">
|
|
203
|
+
<p className="text-muted-foreground text-xs">{labels.total}</p>
|
|
204
|
+
<p className="font-semibold tabular-nums">
|
|
205
|
+
{formatStorefrontPrice(subtotal ?? 0, currency)}
|
|
206
|
+
</p>
|
|
207
|
+
</div>
|
|
208
|
+
<Button
|
|
209
|
+
className={cn('min-w-36', radius)}
|
|
210
|
+
disabled={!isComplete || !selectionLine || !onAdd}
|
|
211
|
+
onClick={() => {
|
|
212
|
+
if (!selectionLine || !onAdd) return;
|
|
213
|
+
onAdd(selectionLine);
|
|
214
|
+
onOpenChange(false);
|
|
215
|
+
setQuery('');
|
|
216
|
+
setSelected({});
|
|
217
|
+
}}
|
|
218
|
+
type="button"
|
|
219
|
+
>
|
|
220
|
+
<PackagePlus className="h-4 w-4" />
|
|
221
|
+
{labels.add}
|
|
222
|
+
</Button>
|
|
223
|
+
</div>
|
|
224
|
+
</DialogContent>
|
|
225
|
+
</Dialog>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function candidateToSelection(candidate: InventoryBundleCategoryCandidate) {
|
|
230
|
+
return {
|
|
231
|
+
listingId: candidate.listingId,
|
|
232
|
+
productId: candidate.productId,
|
|
233
|
+
quantity: 1,
|
|
234
|
+
unitId: candidate.unitId,
|
|
235
|
+
variantId: candidate.variantId,
|
|
236
|
+
warehouseId: candidate.warehouseId,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function getCandidateKey(candidate: InventoryBundleCategoryCandidate) {
|
|
241
|
+
return [
|
|
242
|
+
candidate.selectionKind,
|
|
243
|
+
candidate.listingId ?? '',
|
|
244
|
+
candidate.variantId ?? '',
|
|
245
|
+
candidate.productId,
|
|
246
|
+
candidate.unitId,
|
|
247
|
+
candidate.warehouseId,
|
|
248
|
+
].join(':');
|
|
249
|
+
}
|
|
@@ -18,7 +18,8 @@ import type {
|
|
|
18
18
|
} from './types';
|
|
19
19
|
import {
|
|
20
20
|
formatStorefrontPrice,
|
|
21
|
-
|
|
21
|
+
getStorefrontBundleSelectionLabels,
|
|
22
|
+
getStorefrontCartLineSubtotal,
|
|
22
23
|
getStorefrontVariantLabel,
|
|
23
24
|
storefrontCartLineKey,
|
|
24
25
|
} from './utils';
|
|
@@ -229,20 +230,30 @@ function CartLines({
|
|
|
229
230
|
}) {
|
|
230
231
|
return (
|
|
231
232
|
<div className="-mr-1 grid max-h-72 gap-3 overflow-y-auto pr-1">
|
|
232
|
-
{cartEntries.map(({ line, listing, variant }) => {
|
|
233
|
-
const
|
|
233
|
+
{cartEntries.map(({ bundle, line, listing, variant }) => {
|
|
234
|
+
const selectedSubtotal = getStorefrontCartLineSubtotal({
|
|
235
|
+
bundle,
|
|
236
|
+
line,
|
|
237
|
+
listing,
|
|
238
|
+
variant,
|
|
239
|
+
});
|
|
234
240
|
const variantLabel = variant
|
|
235
241
|
? getStorefrontVariantLabel(variant)
|
|
236
242
|
: null;
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
243
|
+
const selectionLabels = getStorefrontBundleSelectionLabels(
|
|
244
|
+
bundle,
|
|
245
|
+
line
|
|
240
246
|
);
|
|
247
|
+
const lineTotal = formatStorefrontPrice(selectedSubtotal, currency);
|
|
241
248
|
|
|
242
249
|
return (
|
|
243
250
|
<div
|
|
244
251
|
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-1 text-sm"
|
|
245
|
-
key={storefrontCartLineKey(
|
|
252
|
+
key={storefrontCartLineKey(
|
|
253
|
+
line.listingId,
|
|
254
|
+
line.variantId,
|
|
255
|
+
line.selectionKey
|
|
256
|
+
)}
|
|
246
257
|
>
|
|
247
258
|
<div className="min-w-0">
|
|
248
259
|
<p className="line-clamp-2 break-words font-medium leading-5">
|
|
@@ -253,8 +264,17 @@ function CartLines({
|
|
|
253
264
|
{variantLabel}
|
|
254
265
|
</p>
|
|
255
266
|
) : null}
|
|
267
|
+
{selectionLabels.length ? (
|
|
268
|
+
<p className="line-clamp-2 break-words text-muted-foreground text-xs">
|
|
269
|
+
{selectionLabels.join(', ')}
|
|
270
|
+
</p>
|
|
271
|
+
) : null}
|
|
256
272
|
<p className="text-muted-foreground text-xs tabular-nums">
|
|
257
|
-
{line.quantity} ×
|
|
273
|
+
{line.quantity} ×{' '}
|
|
274
|
+
{formatStorefrontPrice(
|
|
275
|
+
Math.round(selectedSubtotal / Math.max(1, line.quantity)),
|
|
276
|
+
currency
|
|
277
|
+
)}
|
|
258
278
|
</p>
|
|
259
279
|
</div>
|
|
260
280
|
<span
|
|
@@ -21,6 +21,7 @@ export function StorefrontListingCard({
|
|
|
21
21
|
labels,
|
|
22
22
|
listing,
|
|
23
23
|
onDecrement,
|
|
24
|
+
onConfigureBundle,
|
|
24
25
|
onIncrement,
|
|
25
26
|
onOpenDetail,
|
|
26
27
|
quantity,
|
|
@@ -33,6 +34,7 @@ export function StorefrontListingCard({
|
|
|
33
34
|
labels: StorefrontSurfaceLabels;
|
|
34
35
|
listing: InventoryStorefrontListing;
|
|
35
36
|
onDecrement?: (listingId: string, variantId?: string | null) => void;
|
|
37
|
+
onConfigureBundle?: () => void;
|
|
36
38
|
onIncrement?: (
|
|
37
39
|
listingId: string,
|
|
38
40
|
maxQuantity: number,
|
|
@@ -45,6 +47,7 @@ export function StorefrontListingCard({
|
|
|
45
47
|
surfaceClassName: string;
|
|
46
48
|
}) {
|
|
47
49
|
const hasVariants = listingHasVariants(listing);
|
|
50
|
+
const needsBundleConfiguration = Boolean(onConfigureBundle);
|
|
48
51
|
const limit = getStorefrontListingLimit(listing);
|
|
49
52
|
const disabled = limit === 0 || quantity >= limit;
|
|
50
53
|
const canChange = Boolean(onIncrement || onDecrement);
|
|
@@ -139,10 +142,10 @@ export function StorefrontListingCard({
|
|
|
139
142
|
</p>
|
|
140
143
|
) : null}
|
|
141
144
|
</div>
|
|
142
|
-
{hasVariants ? (
|
|
145
|
+
{hasVariants || needsBundleConfiguration ? (
|
|
143
146
|
<AccentButton
|
|
144
|
-
disabled={limit === 0 || !openDetail}
|
|
145
|
-
onClick={openDetail}
|
|
147
|
+
disabled={limit === 0 || (!openDetail && !onConfigureBundle)}
|
|
148
|
+
onClick={needsBundleConfiguration ? onConfigureBundle : openDetail}
|
|
146
149
|
radius={radius}
|
|
147
150
|
>
|
|
148
151
|
<SlidersHorizontal className="h-4 w-4" />
|
|
@@ -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. */
|