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.
- package/dist/index.js +152 -9
- package/messages/en.json +8 -1
- package/messages/he.json +8 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.eslintrc.json +2 -0
- package/templates/nextjs/base/AI-GUIDE.md +32 -2
- package/templates/nextjs/base/package.json.ejs +53 -52
- package/templates/nextjs/base/scripts/connect.mjs +149 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +18 -0
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +22 -0
- package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +49 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +18 -0
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +49 -0
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +96 -2
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +17 -0
- package/templates/nextjs/base/src/ui/product/product-card.tsx +17 -0
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +13 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +18 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +49 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +101 -5
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +17 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +17 -0
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +13 -0
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +16 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +45 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +95 -2
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +15 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +15 -0
- 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
|
-
|
|
75
|
-
|
|
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,
|