create-brainerce-store 1.80.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 (29) hide show
  1. package/dist/index.js +152 -9
  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/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/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 +49 -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/ui/cart/cart-bundle-offer.tsx +18 -0
  19. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +49 -0
  20. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +101 -5
  21. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +17 -0
  22. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +17 -0
  23. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +13 -0
  24. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +16 -0
  25. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +45 -0
  26. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +95 -2
  27. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +15 -0
  28. package/templates/nextjs/ui-canvas/product/product-card.tsx +15 -0
  29. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +13 -0
@@ -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
 
12
13
  interface CartUpgradeBannerProps {
@@ -30,9 +31,20 @@ export function CartUpgradeBanner({
30
31
  className,
31
32
  }: CartUpgradeBannerProps) {
32
33
  const t = useTranslations('cart');
34
+ const tp = useTranslations('productDetail');
33
35
  const currency = useCurrency();
34
36
  const [upgrading, setUpgrading] = useState(false);
35
37
  const [dismissed, setDismissed] = useState(false);
38
+ // True once the upgraded product is in the cart. A retry after a failed
39
+ // removal must retry the REMOVAL only, or a second press buys two of them.
40
+ const [upgradeAdded, setUpgradeAdded] = useState(false);
41
+ // `'ORIGINAL_NOT_REMOVED'` is kept local instead of being added to the shared
42
+ // `AddToCartError`: every other consumer of that type branches on
43
+ // `=== 'CART_FULL'` and would silently render "we could not add this" for a
44
+ // case where the add actually succeeded.
45
+ const [upgradeError, setUpgradeError] = useState<AddToCartError | 'ORIGINAL_NOT_REMOVED' | null>(
46
+ null
47
+ );
36
48
 
37
49
  const storageKey = `dismissed_upgrade_${suggestion.sourceProductId}`;
38
50
 
@@ -49,6 +61,16 @@ export function CartUpgradeBanner({
49
61
  if (dismissed) return null;
50
62
 
51
63
  const target = suggestion.targetProduct;
64
+
65
+ // A one-click upgrade cannot choose a variation for the shopper. When the
66
+ // target is VARIABLE with no pinned variant the backend says so
67
+ // (`requiresVariantSelection`), and adding it without a `variantId` is
68
+ // rejected outright, so offering the button here would only ever produce an
69
+ // error. Say nothing instead of promising something that cannot work; the
70
+ // shopper can still reach the product from the catalogue and pick a variation
71
+ // there. If you want to support this properly, add a variation picker to the
72
+ // banner and pass the chosen `variantId` to `smartAddToCart` below.
73
+ if (target.requiresVariantSelection && !target.pinnedVariant?.id) return null;
52
74
  const firstImage = target.images?.[0];
53
75
  const imageUrl = firstImage
54
76
  ? typeof firstImage === 'string'
@@ -66,15 +88,72 @@ export function CartUpgradeBanner({
66
88
  setDismissed(true);
67
89
  }
68
90
 
91
+ /**
92
+ * Swap the cart line for the upgraded product: ADD FIRST, THEN REMOVE.
93
+ *
94
+ * The order is load-bearing, not incidental. This used to remove and then
95
+ * add, so any failure of the add (a network blip, the target selling out, or
96
+ * the 50-line cart cap answering 400) left the shopper with NEITHER product
97
+ * and nothing on screen: the remove had committed, the add had not, and the
98
+ * whole thing went to the console. Adding first means a failed add leaves the
99
+ * cart exactly as it was.
100
+ *
101
+ * The other candidate, remove-then-add with a compensating re-add, was
102
+ * rejected because the compensation is not a restore. A cart line carries
103
+ * `modifiers`, `customizations`, `notes` and `parentCartItemId`, and
104
+ * `smartAddToCart` accepts none of those back in that shape (it takes
105
+ * `selections`), so re-adding would silently strip the shopper's choices even
106
+ * when it SUCCEEDS, and orphan a nested-combo child line. The SDK has no swap
107
+ * primitive to sidestep the choice with (`smartRemoveFromCart` is only
108
+ * `smartUpdateCartItem(productId, 0, variantId)`), so these two orderings are
109
+ * the whole option space.
110
+ *
111
+ * The accepted cost: the cart is briefly one line longer, so a cart sitting
112
+ * at the 50-line cap refuses a swap that is net-zero. The shopper is told the
113
+ * cart is full and the cart is untouched, which is a far better outcome than
114
+ * losing the line. Do NOT add a "fall back to remove-then-add when the cart is
115
+ * full" path; that hands the loss window back for a rare edge.
116
+ */
69
117
  async function handleUpgrade() {
70
118
  if (upgrading) return;
119
+ setUpgradeError(null);
71
120
  try {
72
121
  setUpgrading(true);
73
122
  const client = getClient();
74
- await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
75
- await client.smartAddToCart({ productId: target.id, quantity: cartItem.quantity });
123
+ if (!upgradeAdded) {
124
+ // A VARIABLE target needs a variant. The backend already tells us which
125
+ // case we are in (`pinnedVariant` when the slot pins one,
126
+ // `requiresVariantSelection` when the shopper must choose), but this
127
+ // banner used to send neither -- and `CartService.addItem` rejects a
128
+ // VARIABLE product with no `variantId`, so an upgrade to a variable
129
+ // product failed EVERY time, not occasionally. Under the old
130
+ // remove-then-add ordering that silently destroyed the original line.
131
+ // The unpinned case is filtered out before render, so by here either a
132
+ // pin exists or the target is SIMPLE.
133
+ await client.smartAddToCart({
134
+ productId: target.id,
135
+ quantity: cartItem.quantity,
136
+ ...(target.pinnedVariant?.id ? { variantId: target.pinnedVariant.id } : {}),
137
+ });
138
+ setUpgradeAdded(true);
139
+ }
140
+ try {
141
+ await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
142
+ } catch (err) {
143
+ // The upgrade IS in the cart and only the old line survived, so nothing
144
+ // was lost and this is not a failed upgrade. Deliberately does NOT call
145
+ // onUpgrade(): the backend drops an upgrade suggestion once its target
146
+ // is in the cart, so refreshing here unmounts this banner and takes the
147
+ // message with it. Pressing Upgrade again retries the removal alone.
148
+ setUpgradeError('ORIGINAL_NOT_REMOVED');
149
+ console.error('Upgraded, but could not remove the original cart item:', err);
150
+ return;
151
+ }
76
152
  onUpgrade();
77
153
  } catch (err) {
154
+ // The add failed, so the cart is untouched. Say so: logging alone left the
155
+ // button spinning back to idle with nothing changed and no explanation.
156
+ setUpgradeError(toAddToCartError(err));
78
157
  console.error('Failed to upgrade cart item:', err);
79
158
  } finally {
80
159
  setUpgrading(false);
@@ -100,6 +179,20 @@ export function CartUpgradeBanner({
100
179
  <button type="button" onClick={handleDismiss} aria-label={t('dismissUpgrade')}>
101
180
  ×
102
181
  </button>
182
+
183
+ {/*
184
+ Why the swap did not complete. Without this the button resets, the cart
185
+ looks unchanged, and the shopper has no idea whether anything happened.
186
+ */}
187
+ {upgradeError && (
188
+ <p role="alert">
189
+ {upgradeError === 'ORIGINAL_NOT_REMOVED'
190
+ ? t('upgradeOriginalNotRemoved')
191
+ : upgradeError === 'CART_FULL'
192
+ ? tp('cartFull')
193
+ : tp('addToCartFailed')}
194
+ </p>
195
+ )}
103
196
  </div>
104
197
  );
105
198
  }
@@ -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
 
12
13
  interface FrequentlyBoughtTogetherProps {
@@ -90,6 +91,7 @@ export function FrequentlyBoughtTogether({
90
91
 
91
92
  const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
92
93
  const [adding, setAdding] = useState(false);
94
+ const [addError, setAddError] = useState<AddToCartError | null>(null);
93
95
 
94
96
  if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
95
97
  if (crossSells.length === 0) return null;
@@ -120,6 +122,7 @@ export function FrequentlyBoughtTogether({
120
122
 
121
123
  async function handleAddAll() {
122
124
  if (adding || selected.size === 0) return;
125
+ setAddError(null);
123
126
  try {
124
127
  setAdding(true);
125
128
  const { getClient } = await import('@/core/lib/brainerce');
@@ -130,6 +133,10 @@ export function FrequentlyBoughtTogether({
130
133
  }
131
134
  await refreshCart();
132
135
  } catch (err) {
136
+ // The loop is not transactional: if the cart hits its 50-line cap
137
+ // part-way through, the earlier items ARE in the cart and the rest are
138
+ // not. Saying so beats a silent stop the shopper reads as a dead button.
139
+ setAddError(toAddToCartError(err));
133
140
  console.error('Failed to add items to cart:', err);
134
141
  } finally {
135
142
  setAdding(false);
@@ -177,6 +184,14 @@ export function FrequentlyBoughtTogether({
177
184
  {adding ? t('addingAll') : t('addSelectedToCart')}
178
185
  </button>
179
186
  </div>
187
+
188
+ {/*
189
+ Why the add was refused. Some items may already be in the cart:
190
+ the loop above adds one at a time and stops at the first refusal.
191
+ */}
192
+ {addError && (
193
+ <p role="alert">{addError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}</p>
194
+ )}
180
195
  </section>
181
196
  );
182
197
  }
@@ -14,6 +14,7 @@ import { DiscountBadge } from '@/ui/product/discount-badge';
14
14
  import { useCart } from '@/core/providers/store-provider';
15
15
  import { useCurrency } from '@/core/lib/use-currency';
16
16
  import { trackAddToCart } from '@/core/lib/tracking';
17
+ import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
17
18
  import { cn } from '@/core/lib/utils';
18
19
 
19
20
  interface ProductCardProps {
@@ -58,6 +59,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
58
59
 
59
60
  const [adding, setAdding] = useState(false);
60
61
  const [added, setAdded] = useState(false);
62
+ const [addError, setAddError] = useState<AddToCartError | null>(null);
61
63
 
62
64
  // ⛔ A KIT carries NO `inventory` object — its stock is on `kitAvailable`
63
65
  // (`null` = unlimited, `0` = not sellable). Reading `inventory` here left a
@@ -79,6 +81,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
79
81
  }
80
82
 
81
83
  if (adding || !canPurchase) return;
84
+ setAddError(null);
82
85
 
83
86
  try {
84
87
  setAdding(true);
@@ -94,6 +97,10 @@ export function ProductCard({ product, className }: ProductCardProps) {
94
97
  setAdded(true);
95
98
  setTimeout(() => setAdded(false), 2000);
96
99
  } catch (err) {
100
+ // Quick-add used to fail in silence: the button spun, reset, and the
101
+ // item never appeared. A cart at the 50-line cap is the commonest
102
+ // cause and the shopper can fix it, so say so on the card.
103
+ setAddError(toAddToCartError(err));
97
104
  console.error('Failed to add to cart:', err);
98
105
  } finally {
99
106
  setAdding(false);
@@ -169,6 +176,14 @@ export function ProductCard({ product, className }: ProductCardProps) {
169
176
  {added ? tp('addedToCart') : isVariable ? tProd('selectOptions') : tp('addToCart')}
170
177
  </button>
171
178
  )}
179
+
180
+ {/*
181
+ Quick-add refusal. The card has no other error surface, and without
182
+ this the shopper taps, the button resets, and nothing appears.
183
+ */}
184
+ {addError && (
185
+ <p role="alert">{addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}</p>
186
+ )}
172
187
  </article>
173
188
  );
174
189
  }
@@ -107,6 +107,7 @@ export function ProductClientSection({
107
107
  setQuantity,
108
108
  addingToCart,
109
109
  addedMessage,
110
+ addToCartError,
110
111
  handleAddToCart,
111
112
  customizationFields,
112
113
  customizationValues,
@@ -347,6 +348,18 @@ export function ProductClientSection({
347
348
  </button>
348
349
  </div>
349
350
 
351
+ {/*
352
+ Why the add was refused. Deliberately OUTSIDE the modifier block
353
+ above: that block only renders when the product HAS modifier
354
+ groups, so a refusal shown there would be invisible on almost
355
+ every product and the shopper would just watch the button reset.
356
+ */}
357
+ {addToCartError && (
358
+ <p role="alert">
359
+ {addToCartError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
360
+ </p>
361
+ )}
362
+
350
363
  {/*
351
364
  Sold out is not the end of the page. `canOfferStockAlert` is the whole
352
365
  gate — it also covers the merchant's switch and backorderable items,