create-brainerce-store 1.80.0 → 1.82.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 +375 -9
  2. package/messages/en.json +13 -6
  3. package/messages/he.json +13 -6
  4. package/package.json +1 -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 +452 -0
  9. package/templates/nextjs/base/src/app/checkout/page.tsx +18 -0
  10. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +22 -0
  11. package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +49 -0
  12. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +18 -0
  13. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +50 -0
  14. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +96 -2
  15. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +17 -0
  16. package/templates/nextjs/base/src/ui/product/product-card.tsx +17 -0
  17. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +13 -0
  18. package/templates/nextjs/designs/atelier/messages-patch/en.json +5 -5
  19. package/templates/nextjs/designs/atelier/messages-patch/he.json +5 -5
  20. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +18 -0
  21. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +50 -0
  22. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +101 -5
  23. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +17 -0
  24. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +17 -0
  25. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +13 -0
  26. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +16 -0
  27. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +46 -0
  28. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +95 -2
  29. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +15 -0
  30. package/templates/nextjs/ui-canvas/product/product-card.tsx +15 -0
  31. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +13 -0
@@ -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,29 @@ 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. Both codes are part of
82
+ // the documented API error catalogue, not guesses. Keep the log: the
83
+ // shopper gets the sentence, the developer still needs the detail.
84
+ const code = getErrorCode(err);
85
+ setLineError(
86
+ code === 'INSUFFICIENT_STOCK'
87
+ ? 'NOT_ENOUGH_STOCK'
88
+ : code === 'PRODUCT_UNAVAILABLE'
89
+ ? 'ITEM_UNAVAILABLE'
90
+ : 'UPDATE_FAILED'
91
+ );
58
92
  console.error('Failed to update quantity:', err);
59
93
  } finally {
60
94
  setUpdating(false);
@@ -64,12 +98,17 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
64
98
  async function handleRemove() {
65
99
  if (removing) return;
66
100
 
101
+ setLineError(null);
67
102
  try {
68
103
  setRemoving(true);
69
104
  const client = getClient();
70
105
  await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
71
106
  onUpdate();
72
107
  } catch (err) {
108
+ // Nothing is lost when a remove fails, so the line is still there and
109
+ // still correct. Say so anyway: a Remove that visibly does nothing reads
110
+ // as a broken button.
111
+ setLineError('REMOVE_FAILED');
73
112
  console.error('Failed to remove item:', err);
74
113
  } finally {
75
114
  setRemoving(false);
@@ -157,6 +196,17 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
157
196
  {removing ? t('removing') : t('remove')}
158
197
  </button>
159
198
  </div>
199
+
200
+ {/*
201
+ Why the quantity change or the removal did not take. Keep it inside
202
+ this line's own column: it describes THIS row, and a shopper with ten
203
+ rows on screen has to be able to tell which one refused.
204
+ */}
205
+ {lineError && (
206
+ <p role="alert" className="text-destructive mt-2 text-xs font-medium">
207
+ {tc(LINE_ERROR_KEYS[lineError])}
208
+ </p>
209
+ )}
160
210
  </div>
161
211
 
162
212
  {/* Line total */}
@@ -7,6 +7,7 @@ import { formatPrice } from 'brainerce';
7
7
  import { getClient } from '@/core/lib/brainerce';
8
8
  import { useCurrency } from '@/core/lib/use-currency';
9
9
  import { useTranslations } from '@/core/lib/translations';
10
+ import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
10
11
  import { cn } from '@/core/lib/utils';
11
12
  import { IconX } from '@/ui/shared/icons';
12
13
 
@@ -29,9 +30,20 @@ export function CartUpgradeBanner({
29
30
  className,
30
31
  }: CartUpgradeBannerProps) {
31
32
  const t = useTranslations('cart');
33
+ const tp = useTranslations('productDetail');
32
34
  const currency = useCurrency();
33
35
  const [upgrading, setUpgrading] = useState(false);
34
36
  const [dismissed, setDismissed] = useState(false);
37
+ // True once the upgraded product is in the cart. A retry after a failed
38
+ // removal must retry the REMOVAL only, or a second press buys two of them.
39
+ const [upgradeAdded, setUpgradeAdded] = useState(false);
40
+ // `'ORIGINAL_NOT_REMOVED'` is kept local instead of being added to the shared
41
+ // `AddToCartError`: every other consumer of that type branches on
42
+ // `=== 'CART_FULL'` and would silently render "we could not add this" for a
43
+ // case where the add actually succeeded.
44
+ const [upgradeError, setUpgradeError] = useState<AddToCartError | 'ORIGINAL_NOT_REMOVED' | null>(
45
+ null
46
+ );
35
47
 
36
48
  const storageKey = `dismissed_upgrade_${suggestion.sourceProductId}`;
37
49
 
@@ -48,6 +60,16 @@ export function CartUpgradeBanner({
48
60
  if (dismissed) return null;
49
61
 
50
62
  const target = suggestion.targetProduct;
63
+
64
+ // A one-click upgrade cannot choose a variation for the shopper. When the
65
+ // target is VARIABLE with no pinned variant the backend says so
66
+ // (`requiresVariantSelection`), and adding it without a `variantId` is
67
+ // rejected outright, so offering the button here would only ever produce an
68
+ // error. Say nothing instead of promising something that cannot work; the
69
+ // shopper can still reach the product from the catalogue and pick a variation
70
+ // there. If you want to support this properly, add a variation picker to the
71
+ // banner and pass the chosen `variantId` to `smartAddToCart` below.
72
+ if (target.requiresVariantSelection && !target.pinnedVariant?.id) return null;
51
73
  const firstImage = target.images?.[0];
52
74
  const imageUrl = firstImage
53
75
  ? typeof firstImage === 'string'
@@ -65,15 +87,72 @@ export function CartUpgradeBanner({
65
87
  setDismissed(true);
66
88
  }
67
89
 
90
+ /**
91
+ * Swap the cart line for the upgraded product: ADD FIRST, THEN REMOVE.
92
+ *
93
+ * The order is load-bearing, not incidental. This used to remove and then
94
+ * add, so any failure of the add (a network blip, the target selling out, or
95
+ * the 50-line cart cap answering 400) left the shopper with NEITHER product
96
+ * and nothing on screen: the remove had committed, the add had not, and the
97
+ * whole thing went to the console. Adding first means a failed add leaves the
98
+ * cart exactly as it was.
99
+ *
100
+ * The other candidate, remove-then-add with a compensating re-add, was
101
+ * rejected because the compensation is not a restore. A cart line carries
102
+ * `modifiers`, `customizations`, `notes` and `parentCartItemId`, and
103
+ * `smartAddToCart` accepts none of those back in that shape (it takes
104
+ * `selections`), so re-adding would silently strip the shopper's choices even
105
+ * when it SUCCEEDS, and orphan a nested-combo child line. The SDK has no swap
106
+ * primitive to sidestep the choice with (`smartRemoveFromCart` is only
107
+ * `smartUpdateCartItem(productId, 0, variantId)`), so these two orderings are
108
+ * the whole option space.
109
+ *
110
+ * The accepted cost: the cart is briefly one line longer, so a cart sitting
111
+ * at the 50-line cap refuses a swap that is net-zero. The shopper is told the
112
+ * cart is full and the cart is untouched, which is a far better outcome than
113
+ * losing the line. Do NOT add a "fall back to remove-then-add when the cart is
114
+ * full" path; that hands the loss window back for a rare edge.
115
+ */
68
116
  async function handleUpgrade() {
69
117
  if (upgrading) return;
118
+ setUpgradeError(null);
70
119
  try {
71
120
  setUpgrading(true);
72
121
  const client = getClient();
73
- await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
74
- await client.smartAddToCart({ productId: target.id, quantity: cartItem.quantity });
122
+ if (!upgradeAdded) {
123
+ // A VARIABLE target needs a variant. The backend already tells us which
124
+ // case we are in (`pinnedVariant` when the slot pins one,
125
+ // `requiresVariantSelection` when the shopper must choose), but this
126
+ // banner used to send neither -- and `CartService.addItem` rejects a
127
+ // VARIABLE product with no `variantId`, so an upgrade to a variable
128
+ // product failed EVERY time, not occasionally. Under the old
129
+ // remove-then-add ordering that silently destroyed the original line.
130
+ // The unpinned case is filtered out before render, so by here either a
131
+ // pin exists or the target is SIMPLE.
132
+ await client.smartAddToCart({
133
+ productId: target.id,
134
+ quantity: cartItem.quantity,
135
+ ...(target.pinnedVariant?.id ? { variantId: target.pinnedVariant.id } : {}),
136
+ });
137
+ setUpgradeAdded(true);
138
+ }
139
+ try {
140
+ await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
141
+ } catch (err) {
142
+ // The upgrade IS in the cart and only the old line survived, so nothing
143
+ // was lost and this is not a failed upgrade. Deliberately does NOT call
144
+ // onUpgrade(): the backend drops an upgrade suggestion once its target
145
+ // is in the cart, so refreshing here unmounts this banner and takes the
146
+ // message with it. Pressing Upgrade again retries the removal alone.
147
+ setUpgradeError('ORIGINAL_NOT_REMOVED');
148
+ console.error('Upgraded, but could not remove the original cart item:', err);
149
+ return;
150
+ }
75
151
  onUpgrade();
76
152
  } catch (err) {
153
+ // The add failed, so the cart is untouched. Say so: logging alone left the
154
+ // button spinning back to idle with nothing changed and no explanation.
155
+ setUpgradeError(toAddToCartError(err));
77
156
  console.error('Failed to upgrade cart item:', err);
78
157
  } finally {
79
158
  setUpgrading(false);
@@ -95,9 +174,26 @@ export function CartUpgradeBanner({
95
174
  </span>
96
175
 
97
176
  {/* Text */}
98
- <p className="min-w-0 flex-1 text-sm text-foreground">
99
- {t('upgradeFor', { name: target.name, amount: formattedDelta })}
100
- </p>
177
+ <div className="min-w-0 flex-1">
178
+ <p className="text-sm text-foreground">
179
+ {t('upgradeFor', { name: target.name, amount: formattedDelta })}
180
+ </p>
181
+
182
+ {/*
183
+ Why the swap did not complete. Without this the button resets, the
184
+ cart looks unchanged, and the shopper has no idea whether anything
185
+ happened.
186
+ */}
187
+ {upgradeError && (
188
+ <p role="alert" className="text-destructive mt-1 text-xs font-medium">
189
+ {upgradeError === 'ORIGINAL_NOT_REMOVED'
190
+ ? t('upgradeOriginalNotRemoved')
191
+ : upgradeError === 'CART_FULL'
192
+ ? tp('cartFull')
193
+ : tp('addToCartFailed')}
194
+ </p>
195
+ )}
196
+ </div>
101
197
 
102
198
  {/* Upgrade button */}
103
199
  <button
@@ -7,6 +7,7 @@ import { formatPrice } from 'brainerce';
7
7
  import { useCart, useStoreInfo } from '@/core/providers/store-provider';
8
8
  import { useCurrency } from '@/core/lib/use-currency';
9
9
  import { useTranslations } from '@/core/lib/translations';
10
+ import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
10
11
  import { cn } from '@/core/lib/utils';
11
12
  import { IconPlus, IconBag } from '@/ui/shared/icons';
12
13
 
@@ -108,6 +109,7 @@ export function FrequentlyBoughtTogether({
108
109
 
109
110
  const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
110
111
  const [adding, setAdding] = useState(false);
112
+ const [addError, setAddError] = useState<AddToCartError | null>(null);
111
113
 
112
114
  if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
113
115
  if (crossSells.length === 0) return null;
@@ -138,6 +140,7 @@ export function FrequentlyBoughtTogether({
138
140
 
139
141
  async function handleAddAll() {
140
142
  if (adding || selected.size === 0) return;
143
+ setAddError(null);
141
144
  try {
142
145
  setAdding(true);
143
146
  const { getClient } = await import('@/core/lib/brainerce');
@@ -148,6 +151,10 @@ export function FrequentlyBoughtTogether({
148
151
  }
149
152
  await refreshCart();
150
153
  } catch (err) {
154
+ // The loop is not transactional: if the cart hits its 50-line cap
155
+ // part-way through, the earlier items ARE in the cart and the rest are
156
+ // not. Saying so beats a silent stop the shopper reads as a dead button.
157
+ setAddError(toAddToCartError(err));
151
158
  console.error('Failed to add items to cart:', err);
152
159
  } finally {
153
160
  setAdding(false);
@@ -205,6 +212,16 @@ export function FrequentlyBoughtTogether({
205
212
  {adding ? t('addingAll') : t('addSelectedToCart')}
206
213
  </button>
207
214
  </div>
215
+
216
+ {/*
217
+ Why the add was refused. Some items may already be in the cart:
218
+ the loop above adds one at a time and stops at the first refusal.
219
+ */}
220
+ {addError && (
221
+ <p role="alert" className="text-destructive mt-3 text-sm font-medium">
222
+ {addError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
223
+ </p>
224
+ )}
208
225
  </section>
209
226
  );
210
227
  }
@@ -16,6 +16,7 @@ import { flyToCart } from '@/ui/shared/fly-to-cart';
16
16
  import { openCartDrawer } from '@/ui/cart/cart-drawer';
17
17
  import { useCurrency } from '@/core/lib/use-currency';
18
18
  import { trackAddToCart } from '@/core/lib/tracking';
19
+ import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
19
20
  import { cn } from '@/core/lib/utils';
20
21
  import { IconBag, IconCheck, IconDownload, IconStar } from '@/ui/shared/icons';
21
22
 
@@ -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
@@ -86,6 +88,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
86
88
  }
87
89
 
88
90
  if (adding || !canPurchase) return;
91
+ setAddError(null);
89
92
 
90
93
  try {
91
94
  flyToCart(imageWellRef.current);
@@ -103,6 +106,10 @@ export function ProductCard({ product, className }: ProductCardProps) {
103
106
  setAdded(true);
104
107
  setTimeout(() => setAdded(false), 2000);
105
108
  } catch (err) {
109
+ // Quick-add used to fail in silence: the button spun, reset, and the
110
+ // item never appeared. A cart at the 50-line cap is the commonest
111
+ // cause and the shopper can fix it, so say so on the card.
112
+ setAddError(toAddToCartError(err));
106
113
  console.error('Failed to add to cart:', err);
107
114
  } finally {
108
115
  setAdding(false);
@@ -245,6 +252,16 @@ export function ProductCard({ product, className }: ProductCardProps) {
245
252
  )}
246
253
  </button>
247
254
  )}
255
+
256
+ {/*
257
+ Quick-add refusal. The card has no other error surface, and without
258
+ this the shopper taps, the button resets, and nothing appears.
259
+ */}
260
+ {addError && (
261
+ <p role="alert" className="text-destructive mt-2 text-xs">
262
+ {addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
263
+ </p>
264
+ )}
248
265
  </div>
249
266
  </article>
250
267
  );
@@ -147,6 +147,7 @@ export function ProductClientSection({
147
147
  setQuantity,
148
148
  addingToCart,
149
149
  addedMessage,
150
+ addToCartError,
150
151
  handleAddToCart,
151
152
  customizationFields,
152
153
  customizationValues,
@@ -434,6 +435,18 @@ export function ProductClientSection({
434
435
  </button>
435
436
  </div>
436
437
 
438
+ {/*
439
+ Why the add was refused. Deliberately OUTSIDE the modifier block
440
+ above: that block only renders when the product HAS modifier
441
+ groups, so a refusal shown there would be invisible on almost
442
+ every product and the shopper would just watch the button reset.
443
+ */}
444
+ {addToCartError && (
445
+ <p role="alert" className="text-destructive mt-3 text-sm font-medium">
446
+ {addToCartError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
447
+ </p>
448
+ )}
449
+
437
450
  {/*
438
451
  Sold out is not the end of the page. `canOfferStockAlert` is the whole
439
452
  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
 
11
12
  interface CartBundleOfferCardProps {
@@ -24,8 +25,10 @@ interface CartBundleOfferCardProps {
24
25
  */
25
26
  export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBundleOfferCardProps) {
26
27
  const t = useTranslations('cart');
28
+ const tp = useTranslations('productDetail');
27
29
  const currency = useCurrency();
28
30
  const [adding, setAdding] = useState(false);
31
+ const [addError, setAddError] = useState<AddToCartError | null>(null);
29
32
 
30
33
  const offered = offer.offeredProducts;
31
34
 
@@ -38,6 +41,7 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
38
41
 
39
42
  async function handleAdd() {
40
43
  if (adding) return;
44
+ setAddError(null);
41
45
  try {
42
46
  setAdding(true);
43
47
  const { getClient } = await import('@/core/lib/brainerce');
@@ -47,6 +51,10 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
47
51
  await client.addBundleToCart(cartId, offer.id);
48
52
  onAdd();
49
53
  } catch (err) {
54
+ // The cart page is where a shopper is most likely to be AT the 50-line
55
+ // cap, and a bundle adds one line per offered product. Logging alone
56
+ // left the button spinning back to idle with nothing added.
57
+ setAddError(toAddToCartError(err));
50
58
  console.error('Failed to add bundle:', err);
51
59
  } finally {
52
60
  setAdding(false);
@@ -84,6 +92,14 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
84
92
  {adding ? t('addingBundle') : t('addBundleItem')}
85
93
  </button>
86
94
  </div>
95
+
96
+ {/*
97
+ Why the bundle was refused. Without this the button resets and
98
+ nothing is added, which reads as a broken button.
99
+ */}
100
+ {addError && (
101
+ <p role="alert">{addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}</p>
102
+ )}
87
103
  </article>
88
104
  );
89
105
  }
@@ -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
 
13
14
  interface CartItemProps {
14
15
  item: CartItemType;
@@ -16,6 +17,17 @@ interface CartItemProps {
16
17
  className?: string;
17
18
  }
18
19
 
20
+ /**
21
+ * Which `cart.*` string each refusal shows. A map rather than a ternary chain:
22
+ * four outcomes nest badly, and this keeps the copy next to the reason.
23
+ */
24
+ const LINE_ERROR_KEYS = {
25
+ NOT_ENOUGH_STOCK: 'notEnoughStock',
26
+ ITEM_UNAVAILABLE: 'itemUnavailable',
27
+ UPDATE_FAILED: 'quantityUpdateFailed',
28
+ REMOVE_FAILED: 'removeFailed',
29
+ } as const;
30
+
19
31
  /**
20
32
  * DESIGN ME — single cart line: image, name, variant, unit price, quantity
21
33
  * controls, 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,29 @@ 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. Both codes are part of
82
+ // the documented API error catalogue, not guesses. Keep the log: the
83
+ // shopper gets the sentence, the developer still needs the detail.
84
+ const code = getErrorCode(err);
85
+ setLineError(
86
+ code === 'INSUFFICIENT_STOCK'
87
+ ? 'NOT_ENOUGH_STOCK'
88
+ : code === 'PRODUCT_UNAVAILABLE'
89
+ ? 'ITEM_UNAVAILABLE'
90
+ : 'UPDATE_FAILED'
91
+ );
58
92
  console.error('Failed to update quantity:', err);
59
93
  } finally {
60
94
  setUpdating(false);
@@ -64,12 +98,17 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
64
98
  async function handleRemove() {
65
99
  if (removing) return;
66
100
 
101
+ setLineError(null);
67
102
  try {
68
103
  setRemoving(true);
69
104
  const client = getClient();
70
105
  await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
71
106
  onUpdate();
72
107
  } catch (err) {
108
+ // Nothing is lost when a remove fails, so the line is still there and
109
+ // still correct. Say so anyway: a Remove that visibly does nothing reads
110
+ // as a broken button.
111
+ setLineError('REMOVE_FAILED');
73
112
  console.error('Failed to remove item:', err);
74
113
  } finally {
75
114
  setRemoving(false);
@@ -128,6 +167,13 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
128
167
  {removing ? t('removing') : t('remove')}
129
168
  </button>
130
169
  </div>
170
+
171
+ {/*
172
+ Why the quantity change or the removal did not take. Keep it inside
173
+ this line's own column: it describes THIS row, and a shopper with ten
174
+ rows on screen has to be able to tell which one refused.
175
+ */}
176
+ {lineError && <p role="alert">{tc(LINE_ERROR_KEYS[lineError])}</p>}
131
177
  </div>
132
178
 
133
179
  {/* Line total */}