create-brainerce-store 1.78.0 → 1.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/index.js +12 -2
  2. package/messages/en.json +8 -1
  3. package/messages/he.json +8 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/.eslintrc.json +2 -0
  6. package/templates/nextjs/base/AGENTS.md.ejs +23 -5
  7. package/templates/nextjs/base/CLAUDE.md.ejs +23 -5
  8. package/templates/nextjs/base/src/app/checkout/page.tsx +19 -2
  9. package/templates/nextjs/base/src/app/order-status/page.tsx +18 -3
  10. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +220 -214
  11. package/templates/nextjs/base/src/components/account/order-history.tsx +11 -4
  12. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -2
  13. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +343 -328
  14. package/templates/nextjs/base/src/core/lib/kit.ts +88 -0
  15. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +87 -12
  16. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +205 -197
  17. package/templates/nextjs/base/src/ui/product/product-card.tsx +230 -221
  18. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +524 -493
  19. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +117 -108
  20. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +23 -3
  21. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +210 -202
  22. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +251 -242
  23. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +540 -509
  24. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +110 -101
  25. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +22 -2
  26. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +41 -11
  27. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +182 -174
  28. package/templates/nextjs/ui-canvas/product/product-card.tsx +174 -165
  29. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +31 -0
  30. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +114 -105
  31. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +22 -2
@@ -0,0 +1,88 @@
1
+ import type { InventoryInfo, Product } from 'brainerce';
2
+
3
+ /**
4
+ * KIT stock, for every surface that shows availability.
5
+ *
6
+ * A `KIT` is one purchasable product assembled from other catalog products, and
7
+ * it carries **no `inventory` row of its own** — the component that runs out
8
+ * first decides how many kits can be sold, and the API reports that as
9
+ * `product.kitAvailable`:
10
+ *
11
+ * - `null` → unlimited (every component is untracked)
12
+ * - `0` → not sellable (a component is out, or the kit has none)
13
+ * - a number → that many kits
14
+ * - `undefined` → not a kit; the field is absent on SIMPLE / VARIABLE
15
+ *
16
+ * ⛔ `null` and `undefined` mean OPPOSITE things here. Treating `null` as "no
17
+ * stock" renders an unlimited kit as sold out, which is the same bug as the one
18
+ * this file exists to fix, only inverted.
19
+ *
20
+ * Why a helper rather than `product.inventory` at each call site: reading
21
+ * `inventory` alone gave every kit two contradictory answers on one page — a red
22
+ * "Out of stock" badge (because `!inventory`) beside an ENABLED add-to-cart
23
+ * button (because `inventory?.canPurchase !== false` is `true` when inventory is
24
+ * null). Both now read the same resolved value.
25
+ */
26
+
27
+ /** The subset of a product this module needs. Keeps it usable with list rows. */
28
+ type StockSource = Pick<Product, 'type'> & {
29
+ inventory?: InventoryInfo | null;
30
+ kitAvailable?: number | null;
31
+ };
32
+
33
+ /**
34
+ * Build the `InventoryInfo` a kit would have if it had one.
35
+ *
36
+ * Mirrors the shape the backend synthesizes for the sales-channel read, so a
37
+ * storefront cannot disagree with the API about the same kit.
38
+ */
39
+ export function kitInventory(kitAvailable: number | null): InventoryInfo {
40
+ const unlimited = kitAvailable === null;
41
+ const available = kitAvailable ?? 0;
42
+ return {
43
+ total: available,
44
+ reserved: 0,
45
+ available,
46
+ trackingMode: unlimited ? 'UNLIMITED' : 'TRACKED',
47
+ inStock: unlimited || available > 0,
48
+ canPurchase: unlimited || available > 0,
49
+ // A kit is never backorderable: its components decide, not the kit.
50
+ backorderMode: 'NONE',
51
+ };
52
+ }
53
+
54
+ /**
55
+ * The stock signal to render for a product — kit or not.
56
+ *
57
+ * Pass the selected variant's inventory as the second argument on a product
58
+ * page; it wins for VARIABLE products and is irrelevant for a kit, which takes
59
+ * no variant at all.
60
+ */
61
+ export function resolveStockInfo(
62
+ product: StockSource | null | undefined,
63
+ variantInventory?: InventoryInfo | null
64
+ ): InventoryInfo | null {
65
+ if (variantInventory) return variantInventory;
66
+ if (!product) return null;
67
+ if (product.type === 'KIT') {
68
+ // `kitAvailable` first. Some reads also synthesize an `inventory` block for
69
+ // kits, so fall back to it before giving up — and only treat the kit as
70
+ // unresolved (`null`) when neither field arrived.
71
+ if (product.kitAvailable !== undefined) return kitInventory(product.kitAvailable);
72
+ return product.inventory ?? null;
73
+ }
74
+ return product.inventory ?? null;
75
+ }
76
+
77
+ /**
78
+ * Can this product go in the cart right now?
79
+ *
80
+ * `!== false` rather than `=== true` on purpose: a missing stock signal must not
81
+ * disable the button on a store that does not track inventory at all.
82
+ */
83
+ export function canPurchaseProduct(
84
+ product: StockSource | null | undefined,
85
+ variantInventory?: InventoryInfo | null
86
+ ): boolean {
87
+ return resolveStockInfo(product, variantInventory)?.canPurchase !== false;
88
+ }
@@ -3,6 +3,7 @@
3
3
  import { useState } from 'react';
4
4
  import { Gift } from 'lucide-react';
5
5
  import type { Checkout } from 'brainerce';
6
+ import { BrainerceError } from 'brainerce';
6
7
  import { getClient } from '@/core/lib/brainerce';
7
8
  import { useTranslations } from '@/core/lib/translations';
8
9
  import { LoadingSpinner } from '@/ui/shared/loading-spinner';
@@ -24,6 +25,17 @@ interface GiftCardInputProps {
24
25
  * from being confused again.
25
26
  */
26
27
  tenders: NonNullable<Checkout['tenders']>;
28
+ /**
29
+ * Whether the checkout can still take a card.
30
+ *
31
+ * False from the moment a payment intent exists: the server refuses every
32
+ * tender change on a `PAYMENT_PENDING` / `PAYMENT_PROCESSING` checkout,
33
+ * because changing what a card pays underneath a created intent would leave
34
+ * the provider charging the wrong amount. That refusal is correct. What was
35
+ * wrong was leaving the field enabled in front of it, so a shopper typed a
36
+ * good code into a box that could never accept one.
37
+ */
38
+ locked: boolean;
27
39
  /** Formats an amount in the checkout's currency. */
28
40
  formatAmount: (value: string) => string;
29
41
  onUpdate: () => void;
@@ -60,12 +72,20 @@ interface GiftCardInputProps {
60
72
  *
61
73
  * The server answers identically for "no such code", "expired", "already spent"
62
74
  * and "wrong currency". That is deliberate: any response that distinguishes them
63
- * is an oracle someone walks the code space against. Show what the server said;
64
- * never guess a more specific reason.
75
+ * is an oracle someone walks the code space against. So this shows ONE message
76
+ * of its own for all of them, rather than forwarding the server's text -- which
77
+ * would put the API's English on a translated storefront and make the guarantee
78
+ * depend on the server never adding a more helpful sentence.
79
+ *
80
+ * Two refusals are NOT about the card and must not read as if they were: a 5xx
81
+ * or a dropped connection, and a checkout already locked for payment. Telling
82
+ * someone their card is bad in either case sends them to support over a card
83
+ * that works.
65
84
  */
66
85
  export function GiftCardInput({
67
86
  checkoutId,
68
87
  tenders,
88
+ locked,
69
89
  formatAmount,
70
90
  onUpdate,
71
91
  className,
@@ -77,6 +97,17 @@ export function GiftCardInput({
77
97
  const [removingId, setRemovingId] = useState<string | null>(null);
78
98
  const [error, setError] = useState<string | null>(null);
79
99
 
100
+ /**
101
+ * `CHECKOUT_LOCKED` is the one 4xx that is not a statement about the card.
102
+ * The code travels in the response body, which the SDK hands over whole as
103
+ * `details`; read it defensively, since an older backend may not send one.
104
+ */
105
+ function isCheckoutLocked(err: unknown): boolean {
106
+ if (!(err instanceof BrainerceError)) return false;
107
+ const details = err.details as { code?: unknown } | null | undefined;
108
+ return details?.code === 'CHECKOUT_LOCKED';
109
+ }
110
+
80
111
  async function handleApply() {
81
112
  const trimmed = code.trim();
82
113
  if (!trimmed || applying) return;
@@ -90,7 +121,25 @@ export function GiftCardInput({
90
121
  setCode('');
91
122
  onUpdate();
92
123
  } catch (err) {
93
- setError(err instanceof Error ? err.message : t('invalidCode'));
124
+ // ONE message for every rejected code, written by this store.
125
+ //
126
+ // Echoing the server's text put raw English on a Hebrew storefront — the
127
+ // class-validator string for a too-short code came through verbatim. The
128
+ // deeper reason is that a refusal must not vary: the backend answers
129
+ // "expired", "revoked", "wrong currency" and "no such code" with a single
130
+ // sentence on purpose, because a response that tells them apart is an
131
+ // oracle for walking the code space. Forwarding whatever arrives makes
132
+ // that guarantee depend on the server never adding a more helpful
133
+ // message, which is not a guarantee at all.
134
+ //
135
+ // A 5xx or a dropped connection is NOT a refusal and must not read as
136
+ // one — telling someone their card is bad when the network failed sends
137
+ // them to support over a working card. Neither is a locked checkout,
138
+ // which is a 400 and says nothing whatever about the code typed in.
139
+ const refused = err instanceof BrainerceError && err.statusCode < 500;
140
+ setError(
141
+ isCheckoutLocked(err) ? t('locked') : refused ? t('invalidCode') : t('applyFailed')
142
+ );
94
143
  } finally {
95
144
  setApplying(false);
96
145
  }
@@ -104,7 +153,9 @@ export function GiftCardInput({
104
153
  await getClient().removeGiftCard(checkoutId, tenderId);
105
154
  onUpdate();
106
155
  } catch (err) {
107
- setError(err instanceof Error ? err.message : t('removeFailed'));
156
+ // Same three cases as apply, and for the same reason: the server's own
157
+ // text is English, and a locked checkout is not a failed removal.
158
+ setError(isCheckoutLocked(err) ? t('locked') : t('removeFailed'));
108
159
  } finally {
109
160
  setRemovingId(null);
110
161
  }
@@ -128,19 +179,42 @@ export function GiftCardInput({
128
179
  {t('applied', { amount: formatAmount(tender.amountApplied) })}
129
180
  </span>
130
181
  </div>
131
- <button
132
- type="button"
133
- onClick={() => handleRemove(tender.tenderId)}
134
- disabled={removingId === tender.tenderId}
135
- className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
136
- >
137
- {removingId === tender.tenderId ? tc('removing') : tc('remove')}
138
- </button>
182
+ {/* Gone rather than disabled once the checkout is locked. The
183
+ server refuses a removal at that point too, and a dimmed
184
+ button still reads as something to press. */}
185
+ {!locked && (
186
+ <button
187
+ type="button"
188
+ onClick={() => handleRemove(tender.tenderId)}
189
+ disabled={removingId === tender.tenderId}
190
+ className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
191
+ >
192
+ {removingId === tender.tenderId ? tc('removing') : tc('remove')}
193
+ </button>
194
+ )}
139
195
  </li>
140
196
  ))}
141
197
  </ul>
142
198
  )}
143
199
 
200
+ {/* The field is GONE once the checkout is locked, not disabled and not
201
+ silently broken.
202
+
203
+ Payment starts the moment the shopper picks a shipping rate, and from
204
+ then on the server refuses every tender change — correctly, since the
205
+ provider is already holding an amount. The box stayed on screen and
206
+ accepted typing anyway, so a shopper at the payment step could enter a
207
+ perfectly good code and be told the card was no good. A card is added
208
+ before payment or not at all, and saying so is kinder than a field
209
+ that cannot work. */}
210
+ {locked ? (
211
+ // Only to someone who has a card on this order. With nothing applied
212
+ // there is nothing to explain, and unprompted gift-card copy at the
213
+ // payment step is just noise.
214
+ tenders.length > 0 ? (
215
+ <p className="text-muted-foreground text-xs">{t('lockedHint')}</p>
216
+ ) : null
217
+ ) : (
144
218
  <div className="flex gap-2">
145
219
  <Input
146
220
  type="text"
@@ -180,6 +254,7 @@ export function GiftCardInput({
180
254
  )}
181
255
  </Button>
182
256
  </div>
257
+ )}
183
258
 
184
259
  {error && (
185
260
  <p role="alert" className="text-destructive text-xs">
@@ -1,197 +1,205 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import { Image as ImageIcon } from 'lucide-react';
5
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
- import type { Product, ProductRecommendation } from 'brainerce';
7
- import { formatPrice } from 'brainerce';
8
- import { useCart, useStoreInfo } from '@/core/providers/store-provider';
9
- import { useCurrency } from '@/core/lib/use-currency';
10
- import { useTranslations } from '@/core/lib/translations';
11
- import { Button } from '@/components/ui/button';
12
- import { Card } from '@/components/ui/card';
13
- import { Checkbox } from '@/components/ui/checkbox';
14
- import { cn } from '@/core/lib/utils';
15
-
16
- interface FrequentlyBoughtTogetherProps {
17
- items: ProductRecommendation[];
18
- currentProduct: Product;
19
- className?: string;
20
- }
21
-
22
- // ⛔ STORE CURRENCY THROUGHOUT, deliberately. The cross-sells are
23
- // `ProductRecommendation`, a trimmed shape with no displayPrice/displayCurrency,
24
- // so there is nothing to convert them to. Converting only `currentProduct`
25
- // (which is a full Product and does carry FX fields) would add a euro amount to
26
- // two dollar amounts and print the result as one total. One currency wins, and
27
- // it has to be the one the cart actually charges.
28
- function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
29
- const sale = item.salePrice ? parseFloat(item.salePrice) : null;
30
- const base = parseFloat(item.basePrice);
31
- return sale != null && sale < base ? sale : base;
32
- }
33
-
34
- function ProductThumb({
35
- name,
36
- imageUrl,
37
- price,
38
- currency,
39
- checked,
40
- onToggle,
41
- disabled,
42
- }: {
43
- name: string;
44
- imageUrl: string | null;
45
- price: number;
46
- currency: string;
47
- checked: boolean;
48
- onToggle?: () => void;
49
- disabled?: boolean;
50
- }) {
51
- return (
52
- <label
53
- className={cn(
54
- 'border-border bg-background relative flex cursor-pointer flex-col items-center rounded-lg border p-3 transition-all',
55
- checked ? 'ring-primary ring-2' : 'opacity-60',
56
- disabled && 'pointer-events-none'
57
- )}
58
- >
59
- {onToggle && (
60
- <Checkbox
61
- checked={checked}
62
- onCheckedChange={onToggle}
63
- className="absolute start-2 top-2"
64
- />
65
- )}
66
- <div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
67
- {imageUrl ? (
68
- <Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
69
- ) : (
70
- <div className="flex h-full w-full items-center justify-center">
71
- <ImageIcon
72
- className="text-muted-foreground h-8 w-8"
73
- strokeWidth={1.5}
74
- aria-hidden="true"
75
- />
76
- </div>
77
- )}
78
- </div>
79
- <span className="text-foreground line-clamp-2 text-center text-xs font-medium">{name}</span>
80
- <span className="text-muted-foreground mt-1 text-xs">
81
- {formatPrice(price, { currency }) as string}
82
- </span>
83
- </label>
84
- );
85
- }
86
-
87
- export function FrequentlyBoughtTogether({
88
- items,
89
- currentProduct,
90
- className,
91
- }: FrequentlyBoughtTogetherProps) {
92
- // Hooks must be called unconditionally and in the same order on every
93
- // render — keep all of them above any early `return null` branch.
94
- const { storeInfo } = useStoreInfo();
95
- const { refreshCart } = useCart();
96
- const t = useTranslations('productDetail');
97
- const currency = useCurrency();
98
-
99
- // Only show up to 3 cross-sells
100
- const crossSells = items.slice(0, 3);
101
-
102
- const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
103
- const [adding, setAdding] = useState(false);
104
-
105
- if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
106
- if (crossSells.length === 0) return null;
107
-
108
- const currentPrice = getEffectivePrice(currentProduct);
109
- const currentImage = currentProduct.images?.[0];
110
- const currentImageUrl = currentImage
111
- ? typeof currentImage === 'string'
112
- ? currentImage
113
- : currentImage.url
114
- : null;
115
-
116
- const totalPrice = crossSells
117
- .filter((item) => selected.has(item.id))
118
- .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
119
-
120
- const toggleItem = (id: string) => {
121
- setSelected((prev) => {
122
- const next = new Set(prev);
123
- if (next.has(id)) {
124
- next.delete(id);
125
- } else {
126
- next.add(id);
127
- }
128
- return next;
129
- });
130
- };
131
-
132
- async function handleAddAll() {
133
- if (adding || selected.size === 0) return;
134
- try {
135
- setAdding(true);
136
- const { getClient } = await import('@/core/lib/brainerce');
137
- const client = getClient();
138
- const selectedItems = crossSells.filter((item) => selected.has(item.id));
139
- for (const item of selectedItems) {
140
- await client.smartAddToCart({ productId: item.id, quantity: 1 });
141
- }
142
- await refreshCart();
143
- } catch (err) {
144
- console.error('Failed to add items to cart:', err);
145
- } finally {
146
- setAdding(false);
147
- }
148
- }
149
-
150
- return (
151
- <Card className={cn('shadow-none p-6', className)}>
152
- <h2 className="text-foreground mb-4 text-xl font-semibold">
153
- {t('frequentlyBoughtTogether')}
154
- </h2>
155
-
156
- <div className="flex flex-wrap items-center gap-3">
157
- {/* Current product (always included, no checkbox) */}
158
- <ProductThumb
159
- name={currentProduct.name}
160
- imageUrl={currentImageUrl}
161
- price={currentPrice}
162
- currency={currency}
163
- checked={true}
164
- disabled
165
- />
166
-
167
- {crossSells.map((item) => {
168
- const img = item.images?.[0];
169
- const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
170
- return (
171
- <div key={item.id} className="flex items-center gap-3">
172
- <span className="text-muted-foreground text-lg font-light">+</span>
173
- <ProductThumb
174
- name={item.name}
175
- imageUrl={imgUrl}
176
- price={getEffectivePrice(item)}
177
- currency={currency}
178
- checked={selected.has(item.id)}
179
- onToggle={() => toggleItem(item.id)}
180
- />
181
- </div>
182
- );
183
- })}
184
- </div>
185
-
186
- {/* Total + Add button */}
187
- <div className="mt-4 flex flex-wrap items-center gap-4">
188
- <span className="text-foreground text-lg font-semibold">
189
- {t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
190
- </span>
191
- <Button onClick={handleAddAll} disabled={adding || selected.size === 0} className="rounded px-5">
192
- {adding ? t('addingAll') : t('addSelectedToCart')}
193
- </Button>
194
- </div>
195
- </Card>
196
- );
197
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Image as ImageIcon } from 'lucide-react';
5
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
+ import type { Product, ProductRecommendation } from 'brainerce';
7
+ import { formatPrice } from 'brainerce';
8
+ import { useCart, useStoreInfo } from '@/core/providers/store-provider';
9
+ import { useCurrency } from '@/core/lib/use-currency';
10
+ import { useTranslations } from '@/core/lib/translations';
11
+ import { Button } from '@/components/ui/button';
12
+ import { Card } from '@/components/ui/card';
13
+ import { Checkbox } from '@/components/ui/checkbox';
14
+ import { cn } from '@/core/lib/utils';
15
+
16
+ interface FrequentlyBoughtTogetherProps {
17
+ items: ProductRecommendation[];
18
+ currentProduct: Product;
19
+ className?: string;
20
+ }
21
+
22
+ // ⛔ STORE CURRENCY THROUGHOUT, deliberately. The cross-sells are
23
+ // `ProductRecommendation`, a trimmed shape with no displayPrice/displayCurrency,
24
+ // so there is nothing to convert them to. Converting only `currentProduct`
25
+ // (which is a full Product and does carry FX fields) would add a euro amount to
26
+ // two dollar amounts and print the result as one total. One currency wins, and
27
+ // it has to be the one the cart actually charges.
28
+ function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
29
+ const sale = item.salePrice ? parseFloat(item.salePrice) : null;
30
+ const base = parseFloat(item.basePrice);
31
+ return sale != null && sale < base ? sale : base;
32
+ }
33
+
34
+ function ProductThumb({
35
+ name,
36
+ imageUrl,
37
+ price,
38
+ currency,
39
+ checked,
40
+ onToggle,
41
+ disabled,
42
+ }: {
43
+ name: string;
44
+ imageUrl: string | null;
45
+ price: number;
46
+ currency: string;
47
+ checked: boolean;
48
+ onToggle?: () => void;
49
+ disabled?: boolean;
50
+ }) {
51
+ return (
52
+ <label
53
+ className={cn(
54
+ 'border-border bg-background relative flex cursor-pointer flex-col items-center rounded-lg border p-3 transition-all',
55
+ checked ? 'ring-primary ring-2' : 'opacity-60',
56
+ disabled && 'pointer-events-none'
57
+ )}
58
+ >
59
+ {onToggle && (
60
+ <Checkbox
61
+ checked={checked}
62
+ onCheckedChange={onToggle}
63
+ className="absolute start-2 top-2"
64
+ />
65
+ )}
66
+ <div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
67
+ {imageUrl ? (
68
+ <Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
69
+ ) : (
70
+ <div className="flex h-full w-full items-center justify-center">
71
+ <ImageIcon
72
+ className="text-muted-foreground h-8 w-8"
73
+ strokeWidth={1.5}
74
+ aria-hidden="true"
75
+ />
76
+ </div>
77
+ )}
78
+ </div>
79
+ <span className="text-foreground line-clamp-2 text-center text-xs font-medium">{name}</span>
80
+ <span className="text-muted-foreground mt-1 text-xs">
81
+ {formatPrice(price, { currency }) as string}
82
+ </span>
83
+ </label>
84
+ );
85
+ }
86
+
87
+ export function FrequentlyBoughtTogether({
88
+ items,
89
+ currentProduct,
90
+ className,
91
+ }: FrequentlyBoughtTogetherProps) {
92
+ // Hooks must be called unconditionally and in the same order on every
93
+ // render — keep all of them above any early `return null` branch.
94
+ const { storeInfo } = useStoreInfo();
95
+ const { refreshCart } = useCart();
96
+ const t = useTranslations('productDetail');
97
+ const currency = useCurrency();
98
+
99
+ // KITs are dropped, not priced. A recommendation is a `ProductRecommendation`
100
+ // an UNRESOLVED list shape, and the recommendations endpoint does not resolve
101
+ // kits, so `item.basePrice` on a kit is the stored PLACEHOLDER, wrong for every
102
+ // kit priced SUM / SUM_MINUS_PERCENT. This block sums those numbers into one
103
+ // "Total" beside a button that adds them all, so a wrong figure here is a
104
+ // number the shopper commits to. Adding a kit by its own `productId` would in
105
+ // fact be correct — it is the displayed price that cannot be trusted — so a
106
+ // kit still sells fine from its own product page, where the API resolves it.
107
+ // Only show up to 3 cross-sells.
108
+ const crossSells = items.filter((item) => item.type !== 'KIT').slice(0, 3);
109
+
110
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
111
+ const [adding, setAdding] = useState(false);
112
+
113
+ if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
114
+ if (crossSells.length === 0) return null;
115
+
116
+ const currentPrice = getEffectivePrice(currentProduct);
117
+ const currentImage = currentProduct.images?.[0];
118
+ const currentImageUrl = currentImage
119
+ ? typeof currentImage === 'string'
120
+ ? currentImage
121
+ : currentImage.url
122
+ : null;
123
+
124
+ const totalPrice = crossSells
125
+ .filter((item) => selected.has(item.id))
126
+ .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
127
+
128
+ const toggleItem = (id: string) => {
129
+ setSelected((prev) => {
130
+ const next = new Set(prev);
131
+ if (next.has(id)) {
132
+ next.delete(id);
133
+ } else {
134
+ next.add(id);
135
+ }
136
+ return next;
137
+ });
138
+ };
139
+
140
+ async function handleAddAll() {
141
+ if (adding || selected.size === 0) return;
142
+ try {
143
+ setAdding(true);
144
+ const { getClient } = await import('@/core/lib/brainerce');
145
+ const client = getClient();
146
+ const selectedItems = crossSells.filter((item) => selected.has(item.id));
147
+ for (const item of selectedItems) {
148
+ await client.smartAddToCart({ productId: item.id, quantity: 1 });
149
+ }
150
+ await refreshCart();
151
+ } catch (err) {
152
+ console.error('Failed to add items to cart:', err);
153
+ } finally {
154
+ setAdding(false);
155
+ }
156
+ }
157
+
158
+ return (
159
+ <Card className={cn('shadow-none p-6', className)}>
160
+ <h2 className="text-foreground mb-4 text-xl font-semibold">
161
+ {t('frequentlyBoughtTogether')}
162
+ </h2>
163
+
164
+ <div className="flex flex-wrap items-center gap-3">
165
+ {/* Current product (always included, no checkbox) */}
166
+ <ProductThumb
167
+ name={currentProduct.name}
168
+ imageUrl={currentImageUrl}
169
+ price={currentPrice}
170
+ currency={currency}
171
+ checked={true}
172
+ disabled
173
+ />
174
+
175
+ {crossSells.map((item) => {
176
+ const img = item.images?.[0];
177
+ const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
178
+ return (
179
+ <div key={item.id} className="flex items-center gap-3">
180
+ <span className="text-muted-foreground text-lg font-light">+</span>
181
+ <ProductThumb
182
+ name={item.name}
183
+ imageUrl={imgUrl}
184
+ price={getEffectivePrice(item)}
185
+ currency={currency}
186
+ checked={selected.has(item.id)}
187
+ onToggle={() => toggleItem(item.id)}
188
+ />
189
+ </div>
190
+ );
191
+ })}
192
+ </div>
193
+
194
+ {/* Total + Add button */}
195
+ <div className="mt-4 flex flex-wrap items-center gap-4">
196
+ <span className="text-foreground text-lg font-semibold">
197
+ {t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
198
+ </span>
199
+ <Button onClick={handleAddAll} disabled={adding || selected.size === 0} className="rounded px-5">
200
+ {adding ? t('addingAll') : t('addSelectedToCart')}
201
+ </Button>
202
+ </div>
203
+ </Card>
204
+ );
205
+ }