@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 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.11.1",
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.13.0",
91
+ "@tuturuuu/internal-api": "0.14.0",
92
92
  "@tuturuuu/supabase": "0.4.0",
93
- "@tuturuuu/utils": "0.12.0",
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.13.1",
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
- getStorefrontLinePrice,
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 unitPrice = getStorefrontLinePrice(listing, variant);
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 lineTotal = formatStorefrontPrice(
238
- unitPrice * line.quantity,
239
- currency
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(line.listingId, line.variantId)}
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} × {formatStorefrontPrice(unitPrice, currency)}
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
- getStorefrontLinePrice,
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
- { line, listing, variant: resolveVariant(listing, line.variantId) },
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 + getStorefrontLinePrice(entry.listing, entry.variant) * quantity
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 line = cartLines.find(
341
- (item) => item.listingId === listing.id
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={line?.quantity ?? 0}
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. */