create-brainerce-store 1.79.0 → 1.81.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 (34) hide show
  1. package/dist/index.js +168 -10
  2. package/messages/en.json +13 -4
  3. package/messages/he.json +13 -4
  4. package/package.json +10 -1
  5. package/templates/nextjs/base/.eslintrc.json +2 -0
  6. package/templates/nextjs/base/AI-GUIDE.md +32 -2
  7. package/templates/nextjs/base/package.json.ejs +53 -52
  8. package/templates/nextjs/base/scripts/connect.mjs +149 -0
  9. package/templates/nextjs/base/src/app/checkout/page.tsx +18 -0
  10. package/templates/nextjs/base/src/components/account/profile-section.tsx +7 -1
  11. package/templates/nextjs/base/src/components/auth/register-form.tsx +18 -1
  12. package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +1 -1
  13. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +22 -0
  14. package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +49 -0
  15. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +18 -0
  16. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +49 -0
  17. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +96 -2
  18. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +59 -12
  19. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +17 -0
  20. package/templates/nextjs/base/src/ui/product/product-card.tsx +17 -0
  21. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +13 -0
  22. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +18 -0
  23. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +49 -0
  24. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +101 -5
  25. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +17 -0
  26. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +17 -0
  27. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +13 -0
  28. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +16 -0
  29. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +45 -0
  30. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +95 -2
  31. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +50 -8
  32. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +15 -0
  33. package/templates/nextjs/ui-canvas/product/product-card.tsx +15 -0
  34. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +13 -0
@@ -8,6 +8,7 @@ import { formatPrice } from 'brainerce';
8
8
  import { getClient } from '@/core/lib/brainerce';
9
9
  import { useCurrency } from '@/core/lib/use-currency';
10
10
  import { useTranslations } from '@/core/lib/translations';
11
+ import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
11
12
  import { Button } from '@/components/ui/button';
12
13
  import { cn } from '@/core/lib/utils';
13
14
 
@@ -25,9 +26,20 @@ export function CartUpgradeBanner({
25
26
  className,
26
27
  }: CartUpgradeBannerProps) {
27
28
  const t = useTranslations('cart');
29
+ const tp = useTranslations('productDetail');
28
30
  const currency = useCurrency();
29
31
  const [upgrading, setUpgrading] = useState(false);
30
32
  const [dismissed, setDismissed] = useState(false);
33
+ // True once the upgraded product is in the cart. A retry after a failed
34
+ // removal must retry the REMOVAL only, or a second press buys two of them.
35
+ const [upgradeAdded, setUpgradeAdded] = useState(false);
36
+ // `'ORIGINAL_NOT_REMOVED'` is kept local instead of being added to the shared
37
+ // `AddToCartError`: every other consumer of that type branches on
38
+ // `=== 'CART_FULL'` and would silently render "we could not add this" for a
39
+ // case where the add actually succeeded.
40
+ const [upgradeError, setUpgradeError] = useState<AddToCartError | 'ORIGINAL_NOT_REMOVED' | null>(
41
+ null
42
+ );
31
43
 
32
44
  const storageKey = `dismissed_upgrade_${suggestion.sourceProductId}`;
33
45
 
@@ -44,6 +56,16 @@ export function CartUpgradeBanner({
44
56
  if (dismissed) return null;
45
57
 
46
58
  const target = suggestion.targetProduct;
59
+
60
+ // A one-click upgrade cannot choose a variation for the shopper. When the
61
+ // target is VARIABLE with no pinned variant the backend says so
62
+ // (`requiresVariantSelection`), and adding it without a `variantId` is
63
+ // rejected outright, so offering the button here would only ever produce an
64
+ // error. Say nothing instead of promising something that cannot work; the
65
+ // shopper can still reach the product from the catalogue and pick a variation
66
+ // there. If you want to support this properly, add a variation picker to the
67
+ // banner and pass the chosen `variantId` to `smartAddToCart` below.
68
+ if (target.requiresVariantSelection && !target.pinnedVariant?.id) return null;
47
69
  const firstImage = target.images?.[0];
48
70
  const imageUrl = firstImage
49
71
  ? typeof firstImage === 'string'
@@ -61,15 +83,72 @@ export function CartUpgradeBanner({
61
83
  setDismissed(true);
62
84
  }
63
85
 
86
+ /**
87
+ * Swap the cart line for the upgraded product: ADD FIRST, THEN REMOVE.
88
+ *
89
+ * The order is load-bearing, not incidental. This used to remove and then
90
+ * add, so any failure of the add (a network blip, the target selling out, or
91
+ * the 50-line cart cap answering 400) left the shopper with NEITHER product
92
+ * and nothing on screen: the remove had committed, the add had not, and the
93
+ * whole thing went to the console. Adding first means a failed add leaves the
94
+ * cart exactly as it was.
95
+ *
96
+ * The other candidate, remove-then-add with a compensating re-add, was
97
+ * rejected because the compensation is not a restore. A cart line carries
98
+ * `modifiers`, `customizations`, `notes` and `parentCartItemId`, and
99
+ * `smartAddToCart` accepts none of those back in that shape (it takes
100
+ * `selections`), so re-adding would silently strip the shopper's choices even
101
+ * when it SUCCEEDS, and orphan a nested-combo child line. The SDK has no swap
102
+ * primitive to sidestep the choice with (`smartRemoveFromCart` is only
103
+ * `smartUpdateCartItem(productId, 0, variantId)`), so these two orderings are
104
+ * the whole option space.
105
+ *
106
+ * The accepted cost: the cart is briefly one line longer, so a cart sitting
107
+ * at the 50-line cap refuses a swap that is net-zero. The shopper is told the
108
+ * cart is full and the cart is untouched, which is a far better outcome than
109
+ * losing the line. Do NOT add a "fall back to remove-then-add when the cart is
110
+ * full" path; that hands the loss window back for a rare edge.
111
+ */
64
112
  async function handleUpgrade() {
65
113
  if (upgrading) return;
114
+ setUpgradeError(null);
66
115
  try {
67
116
  setUpgrading(true);
68
117
  const client = getClient();
69
- await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
70
- await client.smartAddToCart({ productId: target.id, quantity: cartItem.quantity });
118
+ if (!upgradeAdded) {
119
+ // A VARIABLE target needs a variant. The backend already tells us which
120
+ // case we are in (`pinnedVariant` when the slot pins one,
121
+ // `requiresVariantSelection` when the shopper must choose), but this
122
+ // banner used to send neither -- and `CartService.addItem` rejects a
123
+ // VARIABLE product with no `variantId`, so an upgrade to a variable
124
+ // product failed EVERY time, not occasionally. Under the old
125
+ // remove-then-add ordering that silently destroyed the original line.
126
+ // The unpinned case is filtered out before render, so by here either a
127
+ // pin exists or the target is SIMPLE.
128
+ await client.smartAddToCart({
129
+ productId: target.id,
130
+ quantity: cartItem.quantity,
131
+ ...(target.pinnedVariant?.id ? { variantId: target.pinnedVariant.id } : {}),
132
+ });
133
+ setUpgradeAdded(true);
134
+ }
135
+ try {
136
+ await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
137
+ } catch (err) {
138
+ // The upgrade IS in the cart and only the old line survived, so nothing
139
+ // was lost and this is not a failed upgrade. Deliberately does NOT call
140
+ // onUpgrade(): the backend drops an upgrade suggestion once its target
141
+ // is in the cart, so refreshing here unmounts this banner and takes the
142
+ // message with it. Pressing Upgrade again retries the removal alone.
143
+ setUpgradeError('ORIGINAL_NOT_REMOVED');
144
+ console.error('Upgraded, but could not remove the original cart item:', err);
145
+ return;
146
+ }
71
147
  onUpgrade();
72
148
  } catch (err) {
149
+ // The add failed, so the cart is untouched. Say so: logging alone left the
150
+ // button spinning back to idle with nothing changed and no explanation.
151
+ setUpgradeError(toAddToCartError(err));
73
152
  console.error('Failed to upgrade cart item:', err);
74
153
  } finally {
75
154
  setUpgrading(false);
@@ -109,6 +188,21 @@ export function CartUpgradeBanner({
109
188
  <p className="text-foreground text-sm font-medium">
110
189
  {t('upgradeFor', { name: target.name, amount: formattedDelta })}
111
190
  </p>
191
+
192
+ {/*
193
+ Why the swap did not complete. Without this the button resets, the
194
+ cart looks unchanged, and the shopper has no idea whether anything
195
+ happened.
196
+ */}
197
+ {upgradeError && (
198
+ <p role="alert" className="text-destructive mt-1 text-xs">
199
+ {upgradeError === 'ORIGINAL_NOT_REMOVED'
200
+ ? t('upgradeOriginalNotRemoved')
201
+ : upgradeError === 'CART_FULL'
202
+ ? tp('cartFull')
203
+ : tp('addToCartFailed')}
204
+ </p>
205
+ )}
112
206
  </div>
113
207
 
114
208
  {/* Upgrade button */}
@@ -21,17 +21,26 @@
21
21
  * commonest reason a signup never becomes a subscriber, and the resend cooldown
22
22
  * means no second copy arrives for 24 hours.
23
23
  *
24
- * **Discount codes.** Subscribing mints nothing. For a "10% off your first
25
- * order" offer, the merchant creates a coupon in the dashboard with the
26
- * `customer_first_order` condition and you render that fixed code in the
27
- * success state — see the commented line below. Show it immediately: it must
28
- * not wait for the confirmation click, or the shopper loses the reason they
29
- * filled the form in.
24
+ * **The welcome offer.** `marketing.getBenefit()` returns what the merchant is
25
+ * offering people who confirm, or `null` when they offer nothing. Show its
26
+ * terms beside the field so the shopper knows what they are signing up for.
27
+ *
28
+ * DO NOT RENDER A COUPON CODE HERE, now or after the success state. No coupon
29
+ * exists at either moment. It is minted when the recipient clicks the link in
30
+ * their own mailbox and is emailed to them there — which is exactly what stops a
31
+ * forwarded link handing the discount to someone who never asked. The
32
+ * confirmation page is served by the API and already shows the code, its expiry
33
+ * and its terms, so there is nothing here to poll for either.
34
+ *
35
+ * ⛔ There is no eligibility check to call, deliberately: a per-address answer
36
+ * would let anyone test who already subscribed. The offer is one per address per
37
+ * store, forever. Say so in the terms rather than trying to detect it.
30
38
  *
31
39
  * Rate limited to 3 requests / 60s per IP.
32
40
  */
33
41
 
34
- import { useMemo, useState } from 'react';
42
+ import { useEffect, useMemo, useState } from 'react';
43
+ import type { PublicNewsletterBenefitOffer } from 'brainerce';
35
44
  import { getClient } from '@/core/lib/brainerce';
36
45
  import { useTranslations } from '@/core/lib/translations';
37
46
 
@@ -42,6 +51,10 @@ export function NewsletterSignup() {
42
51
  const [loading, setLoading] = useState(false);
43
52
  const [done, setDone] = useState(false);
44
53
  const [error, setError] = useState<string | null>(null);
54
+ // null covers both "no offer configured" and "the read failed". Neither is
55
+ // worth an error state on a footer signup: the form still works, it just
56
+ // promises nothing, and promising nothing is always safe.
57
+ const [offer, setOffer] = useState<PublicNewsletterBenefitOffer | null>(null);
45
58
 
46
59
  // Same source the contact form uses — <html lang> is set from the active
47
60
  // locale, and passing it is what decides the language of the confirmation
@@ -51,6 +64,31 @@ export function NewsletterSignup() {
51
64
  return undefined;
52
65
  }, []);
53
66
 
67
+ // Read once per mount. The offer belongs to the store, not to the visitor,
68
+ // so re-reading it per keystroke or per submit is wasted work.
69
+ useEffect(() => {
70
+ let cancelled = false;
71
+ getClient()
72
+ .marketing.getBenefit(locale)
73
+ .then((result) => {
74
+ if (!cancelled) setOffer(result);
75
+ })
76
+ .catch(() => {
77
+ // Silent on purpose. A failed offer read must not stop someone
78
+ // subscribing, and there is nothing the shopper could do about it.
79
+ });
80
+ return () => {
81
+ cancelled = true;
82
+ };
83
+ }, [locale]);
84
+
85
+ const offerHeadline = offer
86
+ ? offer.headline?.trim() ||
87
+ (offer.discountType === 'PERCENTAGE'
88
+ ? t('offerPercent', { value: String(offer.discountValue) })
89
+ : t('offerAmount', { value: String(offer.discountValue) }))
90
+ : null;
91
+
54
92
  async function handleSubmit(e: React.FormEvent) {
55
93
  e.preventDefault();
56
94
  if (loading || !email.trim()) return;
@@ -80,11 +118,10 @@ export function NewsletterSignup() {
80
118
  <div className="space-y-1">
81
119
  <p className="text-sm font-medium">{t('checkEmailTitle')}</p>
82
120
  <p className="text-sm opacity-70">{t('checkEmailBody')}</p>
83
- {/*
84
- Merchant offering a signup discount? Render the coupon code here, now
85
- not after the confirmation click, which may never come:
86
- <p className="text-sm font-medium">{t('discountCode', { code: 'WELCOME10' })}</p>
87
- */}
121
+ {/* Says where the code comes from, without ever showing one. The coupon
122
+ is created by the confirmation click and arrives in the same inbox,
123
+ so "check your email" is the whole instruction. */}
124
+ {offer ? <p className="text-sm opacity-70">{t('offerByEmail')}</p> : null}
88
125
  </div>
89
126
  );
90
127
  }
@@ -96,6 +133,16 @@ export function NewsletterSignup() {
96
133
  </label>
97
134
  <p className="text-sm opacity-70">{t('subtitle')}</p>
98
135
 
136
+ {/* The merchant's own words when they wrote any, a plain statement of the
137
+ discount when they did not. Both are the promise the shopper is
138
+ agreeing to, so they belong above the input, not under the button. */}
139
+ {offerHeadline ? (
140
+ <div className="space-y-0.5">
141
+ <p className="text-sm font-medium">{offerHeadline}</p>
142
+ {offer?.terms ? <p className="text-xs opacity-70">{offer.terms}</p> : null}
143
+ </div>
144
+ ) : null}
145
+
99
146
  <div className="flex flex-wrap gap-2">
100
147
  <input
101
148
  id="newsletter-email"
@@ -8,6 +8,7 @@ import { formatPrice } from 'brainerce';
8
8
  import { useCart, useStoreInfo } from '@/core/providers/store-provider';
9
9
  import { useCurrency } from '@/core/lib/use-currency';
10
10
  import { useTranslations } from '@/core/lib/translations';
11
+ import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
11
12
  import { Button } from '@/components/ui/button';
12
13
  import { Card } from '@/components/ui/card';
13
14
  import { Checkbox } from '@/components/ui/checkbox';
@@ -109,6 +110,7 @@ export function FrequentlyBoughtTogether({
109
110
 
110
111
  const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
111
112
  const [adding, setAdding] = useState(false);
113
+ const [addError, setAddError] = useState<AddToCartError | null>(null);
112
114
 
113
115
  if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
114
116
  if (crossSells.length === 0) return null;
@@ -139,6 +141,7 @@ export function FrequentlyBoughtTogether({
139
141
 
140
142
  async function handleAddAll() {
141
143
  if (adding || selected.size === 0) return;
144
+ setAddError(null);
142
145
  try {
143
146
  setAdding(true);
144
147
  const { getClient } = await import('@/core/lib/brainerce');
@@ -149,6 +152,10 @@ export function FrequentlyBoughtTogether({
149
152
  }
150
153
  await refreshCart();
151
154
  } catch (err) {
155
+ // The loop is not transactional: if the cart hits its 50-line cap
156
+ // part-way through, the earlier items ARE in the cart and the rest are
157
+ // not. Saying so beats a silent stop the shopper reads as a dead button.
158
+ setAddError(toAddToCartError(err));
152
159
  console.error('Failed to add items to cart:', err);
153
160
  } finally {
154
161
  setAdding(false);
@@ -200,6 +207,16 @@ export function FrequentlyBoughtTogether({
200
207
  {adding ? t('addingAll') : t('addSelectedToCart')}
201
208
  </Button>
202
209
  </div>
210
+
211
+ {/*
212
+ Why the add was refused. Some items may already be in the cart:
213
+ the loop above adds one at a time and stops at the first refusal.
214
+ */}
215
+ {addError && (
216
+ <p role="alert" className="text-destructive mt-3 text-sm">
217
+ {addError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
218
+ </p>
219
+ )}
203
220
  </Card>
204
221
  );
205
222
  }
@@ -18,6 +18,7 @@ import { Card } from '@/components/ui/card';
18
18
  import { useCart } from '@/core/providers/store-provider';
19
19
  import { useCurrency } from '@/core/lib/use-currency';
20
20
  import { trackAddToCart } from '@/core/lib/tracking';
21
+ import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
21
22
  import { cn } from '@/core/lib/utils';
22
23
 
23
24
  interface ProductCardProps {
@@ -63,6 +64,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
63
64
 
64
65
  const [adding, setAdding] = useState(false);
65
66
  const [added, setAdded] = useState(false);
67
+ const [addError, setAddError] = useState<AddToCartError | null>(null);
66
68
 
67
69
  // ⛔ A KIT carries NO `inventory` object — its stock is on `kitAvailable`
68
70
  // (`null` = unlimited, `0` = not sellable). Reading `inventory` here left a
@@ -84,6 +86,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
84
86
  }
85
87
 
86
88
  if (adding || !canPurchase) return;
89
+ setAddError(null);
87
90
 
88
91
  try {
89
92
  setAdding(true);
@@ -99,6 +102,10 @@ export function ProductCard({ product, className }: ProductCardProps) {
99
102
  setAdded(true);
100
103
  setTimeout(() => setAdded(false), 2000);
101
104
  } catch (err) {
105
+ // Quick-add used to fail in silence: the button spun, reset, and the
106
+ // item never appeared. A cart at the 50-line cap is the commonest
107
+ // cause and the shopper can fix it, so say so on the card.
108
+ setAddError(toAddToCartError(err));
102
109
  console.error('Failed to add to cart:', err);
103
110
  } finally {
104
111
  setAdding(false);
@@ -224,6 +231,16 @@ export function ProductCard({ product, className }: ProductCardProps) {
224
231
  // `resolveStockInfo()` using ONE definition of 'this is a kit'.
225
232
  kitAvailable={product.type === 'KIT' ? product.kitAvailable : undefined}
226
233
  />
234
+
235
+ {/*
236
+ Quick-add refusal. The card has no other error surface, and without
237
+ this the shopper taps, the button resets, and nothing appears.
238
+ */}
239
+ {addError && (
240
+ <p className="text-destructive text-xs" role="alert">
241
+ {addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
242
+ </p>
243
+ )}
227
244
  </div>
228
245
  </Card>
229
246
  );
@@ -141,6 +141,7 @@ export function ProductClientSection({
141
141
  setQuantity,
142
142
  addingToCart,
143
143
  addedMessage,
144
+ addToCartError,
144
145
  handleAddToCart,
145
146
  customizationFields,
146
147
  customizationValues,
@@ -437,6 +438,18 @@ export function ProductClientSection({
437
438
  </Button>
438
439
  </div>
439
440
 
441
+ {/*
442
+ Why the add was refused. Deliberately OUTSIDE the modifier block
443
+ above: that block only renders when the product HAS modifier
444
+ groups, so a refusal shown there would be invisible on almost
445
+ every product and the shopper would just watch the button reset.
446
+ */}
447
+ {addToCartError && (
448
+ <p className="text-destructive text-sm" role="alert">
449
+ {addToCartError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
450
+ </p>
451
+ )}
452
+
440
453
  {/*
441
454
  Sold out is not the end of the page. `canOfferStockAlert` is the whole
442
455
  gate — it also covers the merchant's switch and backorderable items,
@@ -6,6 +6,7 @@ import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
6
6
  import { formatPrice } from 'brainerce';
7
7
  import { useCurrency } from '@/core/lib/use-currency';
8
8
  import { useTranslations } from '@/core/lib/translations';
9
+ import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
9
10
  import { cn } from '@/core/lib/utils';
10
11
  import { IconBag } from '@/ui/shared/icons';
11
12
 
@@ -23,8 +24,10 @@ interface CartBundleOfferCardProps {
23
24
  */
24
25
  export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBundleOfferCardProps) {
25
26
  const t = useTranslations('cart');
27
+ const tp = useTranslations('productDetail');
26
28
  const currency = useCurrency();
27
29
  const [adding, setAdding] = useState(false);
30
+ const [addError, setAddError] = useState<AddToCartError | null>(null);
28
31
 
29
32
  const offered = offer.offeredProducts;
30
33
 
@@ -37,6 +40,7 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
37
40
 
38
41
  async function handleAdd() {
39
42
  if (adding) return;
43
+ setAddError(null);
40
44
  try {
41
45
  setAdding(true);
42
46
  const { getClient } = await import('@/core/lib/brainerce');
@@ -46,6 +50,10 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
46
50
  await client.addBundleToCart(cartId, offer.id);
47
51
  onAdd();
48
52
  } catch (err) {
53
+ // The cart page is where a shopper is most likely to be AT the 50-line
54
+ // cap, and a bundle adds one line per offered product. Logging alone
55
+ // left the button spinning back to idle with nothing added.
56
+ setAddError(toAddToCartError(err));
49
57
  console.error('Failed to add bundle:', err);
50
58
  } finally {
51
59
  setAdding(false);
@@ -105,6 +113,16 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
105
113
  {adding ? t('addingBundle') : t('addBundleItem')}
106
114
  </button>
107
115
  </div>
116
+
117
+ {/*
118
+ Why the bundle was refused. Without this the button resets and
119
+ nothing is added, which reads as a broken button.
120
+ */}
121
+ {addError && (
122
+ <p role="alert" className="text-destructive mt-2 text-xs">
123
+ {addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
124
+ </p>
125
+ )}
108
126
  </article>
109
127
  );
110
128
  }
@@ -9,6 +9,7 @@ import { useTranslations } from '@/core/lib/translations';
9
9
  import { useCurrency } from '@/core/lib/use-currency';
10
10
  import { LoadingSpinner } from '@/ui/shared/loading-spinner';
11
11
  import { cn } from '@/core/lib/utils';
12
+ import { getErrorCode } from '@/core/lib/add-to-cart-error';
12
13
  import { OrderCustomizations } from '@/components/account/order-customizations';
13
14
  import { IconMinus, IconPlus, IconTrash } from '@/ui/shared/icons';
14
15
 
@@ -18,6 +19,17 @@ interface CartItemProps {
18
19
  className?: string;
19
20
  }
20
21
 
22
+ /**
23
+ * Which `cart.*` string each refusal shows. A map rather than a ternary chain:
24
+ * four outcomes nest badly, and this keeps the copy next to the reason.
25
+ */
26
+ const LINE_ERROR_KEYS = {
27
+ NOT_ENOUGH_STOCK: 'notEnoughStock',
28
+ ITEM_UNAVAILABLE: 'itemUnavailable',
29
+ UPDATE_FAILED: 'quantityUpdateFailed',
30
+ REMOVE_FAILED: 'removeFailed',
31
+ } as const;
32
+
21
33
  /**
22
34
  * Single cart line: image, name, variant, unit price, quantity controls,
23
35
  * remove, line total; the `item` prop comes from useCartPage().cart.
@@ -25,9 +37,14 @@ interface CartItemProps {
25
37
  export function CartItem({ item, onUpdate, className }: CartItemProps) {
26
38
  const t = useTranslations('common');
27
39
  const td = useTranslations('productDetail');
40
+ const tc = useTranslations('cart');
28
41
  const currency = useCurrency();
29
42
  const [updating, setUpdating] = useState(false);
30
43
  const [removing, setRemoving] = useState(false);
44
+ // Why the last quantity change or removal did not take. Both calls used to
45
+ // fail into console.error alone: the row snapped back to the quantity it
46
+ // already had, or stayed put after Remove, and nothing on screen said why.
47
+ const [lineError, setLineError] = useState<keyof typeof LINE_ERROR_KEYS | null>(null);
31
48
 
32
49
  const productName = item.product.name;
33
50
  const imageUrl = getCartItemImage(item);
@@ -49,12 +66,28 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
49
66
  async function handleQuantityChange(newQuantity: number) {
50
67
  if (newQuantity < 1 || updating) return;
51
68
 
69
+ setLineError(null);
52
70
  try {
53
71
  setUpdating(true);
54
72
  const client = getClient();
55
73
  await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
56
74
  onUpdate();
57
75
  } catch (err) {
76
+ // Two of these refusals are PERMANENT, and "please try again" would send
77
+ // the shopper round a loop that cannot succeed: INSUFFICIENT_STOCK (more
78
+ // than the inventory left) and PRODUCT_UNAVAILABLE (the line cannot be
79
+ // bought at all any more). Both get their own sentence; everything else
80
+ // is a blip worth one more tap. The 50-line cart cap is NOT a cause here
81
+ // at all, it only ever refuses a brand new line. Keep the log: the
82
+ // shopper gets the sentence, the developer still needs the detail.
83
+ const code = getErrorCode(err);
84
+ setLineError(
85
+ code === 'INSUFFICIENT_STOCK'
86
+ ? 'NOT_ENOUGH_STOCK'
87
+ : code === 'PRODUCT_UNAVAILABLE'
88
+ ? 'ITEM_UNAVAILABLE'
89
+ : 'UPDATE_FAILED'
90
+ );
58
91
  console.error('Failed to update quantity:', err);
59
92
  } finally {
60
93
  setUpdating(false);
@@ -64,12 +97,17 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
64
97
  async function handleRemove() {
65
98
  if (removing) return;
66
99
 
100
+ setLineError(null);
67
101
  try {
68
102
  setRemoving(true);
69
103
  const client = getClient();
70
104
  await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
71
105
  onUpdate();
72
106
  } catch (err) {
107
+ // Nothing is lost when a remove fails, so the line is still there and
108
+ // still correct. Say so anyway: a Remove that visibly does nothing reads
109
+ // as a broken button.
110
+ setLineError('REMOVE_FAILED');
73
111
  console.error('Failed to remove item:', err);
74
112
  } finally {
75
113
  setRemoving(false);
@@ -157,6 +195,17 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
157
195
  {removing ? t('removing') : t('remove')}
158
196
  </button>
159
197
  </div>
198
+
199
+ {/*
200
+ Why the quantity change or the removal did not take. Keep it inside
201
+ this line's own column: it describes THIS row, and a shopper with ten
202
+ rows on screen has to be able to tell which one refused.
203
+ */}
204
+ {lineError && (
205
+ <p role="alert" className="text-destructive mt-2 text-xs font-medium">
206
+ {tc(LINE_ERROR_KEYS[lineError])}
207
+ </p>
208
+ )}
160
209
  </div>
161
210
 
162
211
  {/* Line total */}