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.
- package/dist/index.js +375 -9
- package/messages/en.json +13 -6
- package/messages/he.json +13 -6
- 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 +452 -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 +50 -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/messages-patch/en.json +5 -5
- package/templates/nextjs/designs/atelier/messages-patch/he.json +5 -5
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +18 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +50 -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 +46 -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
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
} from 'brainerce';
|
|
13
13
|
import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
|
|
14
14
|
import { resolveDisplayPrice, type DisplayPrice } from '@/core/lib/display-price';
|
|
15
|
+
import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
|
|
15
16
|
import { resolveStockInfo } from '@/core/lib/kit';
|
|
16
17
|
import { useCart, useStoreInfo } from '@/core/providers/store-provider';
|
|
17
18
|
import { trackAddToCart, trackProductView } from '@/core/lib/tracking';
|
|
@@ -74,6 +75,16 @@ export interface UseProductPageResult {
|
|
|
74
75
|
/** Add-to-cart state machine. */
|
|
75
76
|
addingToCart: boolean;
|
|
76
77
|
addedMessage: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Set when the server refused the add, cleared on the next attempt.
|
|
80
|
+
*
|
|
81
|
+
* ⛔ Render this OUTSIDE any `modifierGroups.length > 0` block. `modifierError`
|
|
82
|
+
* below lives inside one in every design, which is correct for a modifier
|
|
83
|
+
* message and useless here: most products carry no modifier groups, so a
|
|
84
|
+
* refusal shown there would render nothing at all and the shopper would tap
|
|
85
|
+
* "Add to cart" and watch it silently reset.
|
|
86
|
+
*/
|
|
87
|
+
addToCartError: AddToCartError | null;
|
|
77
88
|
handleAddToCart: () => Promise<void>;
|
|
78
89
|
/** Customization (buyer input) sub-contract. */
|
|
79
90
|
customizationFields: ProductCustomizationField[];
|
|
@@ -115,6 +126,7 @@ export function useProductPage(initialProduct: Product): UseProductPageResult {
|
|
|
115
126
|
const [quantity, setQuantity] = useState(1);
|
|
116
127
|
const [addingToCart, setAddingToCart] = useState(false);
|
|
117
128
|
const [addedMessage, setAddedMessage] = useState(false);
|
|
129
|
+
const [addToCartError, setAddToCartError] = useState<AddToCartError | null>(null);
|
|
118
130
|
const customizationFields = product.customizationFields ?? [];
|
|
119
131
|
const [customizationValues, setCustomizationValues] = useState<CustomizationValues>(() => {
|
|
120
132
|
const initial: CustomizationValues = {};
|
|
@@ -250,6 +262,10 @@ export function useProductPage(initialProduct: Product): UseProductPageResult {
|
|
|
250
262
|
async function handleAddToCart() {
|
|
251
263
|
if (!product || addingToCart) return;
|
|
252
264
|
|
|
265
|
+
// Clear the previous refusal before re-trying, so a stale "cart is full"
|
|
266
|
+
// does not sit under a button that has since succeeded.
|
|
267
|
+
setAddToCartError(null);
|
|
268
|
+
|
|
253
269
|
if (customizationFields.length > 0) {
|
|
254
270
|
const errs = validateCustomization(customizationFields, customizationValues);
|
|
255
271
|
if (Object.keys(errs).length > 0) {
|
|
@@ -305,6 +321,11 @@ export function useProductPage(initialProduct: Product): UseProductPageResult {
|
|
|
305
321
|
if (e?.details?.code === 'MODIFIER_VALIDATION_FAILED' && validationErrors?.length) {
|
|
306
322
|
setModifierError(validationErrors.map((v) => v.message).join('; '));
|
|
307
323
|
} else {
|
|
324
|
+
// Everything else used to stop at `console.error`, which the shopper
|
|
325
|
+
// cannot read: the button spun, reset, and the item never appeared.
|
|
326
|
+
// The commonest cause is now the 50-line cart cap, which is entirely
|
|
327
|
+
// actionable, so give the UI a code to render and keep the log for us.
|
|
328
|
+
setAddToCartError(toAddToCartError(err));
|
|
308
329
|
console.error('Failed to add to cart:', err);
|
|
309
330
|
}
|
|
310
331
|
} finally {
|
|
@@ -330,6 +351,7 @@ export function useProductPage(initialProduct: Product): UseProductPageResult {
|
|
|
330
351
|
setQuantity,
|
|
331
352
|
addingToCart,
|
|
332
353
|
addedMessage,
|
|
354
|
+
addToCartError,
|
|
333
355
|
handleAddToCart,
|
|
334
356
|
customizationFields,
|
|
335
357
|
customizationValues,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Add-to-cart refusals the shopper has to be told about.
|
|
3
|
+
*
|
|
4
|
+
* Lives in `core/` and returns a CODE, never a message: `ui/` owns the copy, so
|
|
5
|
+
* each design maps this to its own `productDetail.*` string rather than showing
|
|
6
|
+
* the raw API sentence. Shared by the product page hook and the grid quick-add.
|
|
7
|
+
*/
|
|
8
|
+
export type AddToCartError = 'CART_FULL' | 'FAILED';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The API's stable, machine-readable error code for a failed call, or
|
|
12
|
+
* `undefined` when the failure carried none (a network drop, a timeout, an
|
|
13
|
+
* older backend).
|
|
14
|
+
*
|
|
15
|
+
* Where it comes from: the API answers an error as
|
|
16
|
+
* `{ statusCode, code, message, details?, timestamp, path }`, and the SDK hands
|
|
17
|
+
* that whole body back as `BrainerceError.details`. So the code lives one level
|
|
18
|
+
* in, at `err.details.code` — not on the error itself. Read it rather than the
|
|
19
|
+
* message: the message is human-readable prose that changes freely (and is
|
|
20
|
+
* English regardless of the shopper's language), the code does not.
|
|
21
|
+
*/
|
|
22
|
+
export function getErrorCode(err: unknown): string | undefined {
|
|
23
|
+
const body = (err as { details?: { code?: unknown } } | null)?.details;
|
|
24
|
+
return typeof body?.code === 'string' ? body.code : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A storefront cart holds at most 50 DISTINCT product lines. The 51st add is
|
|
29
|
+
* refused with `400 CART_LINE_LIMIT_REACHED`.
|
|
30
|
+
*
|
|
31
|
+
* The cap applies to storefront traffic only (`storeId` and `vc_*` modes). It
|
|
32
|
+
* does NOT block quantity changes or removals, so "remove something first" is
|
|
33
|
+
* advice the shopper can actually act on.
|
|
34
|
+
*
|
|
35
|
+
* The message match is a FALLBACK, kept only for the window where a storefront
|
|
36
|
+
* built from this template talks to a backend older than the code. Once the
|
|
37
|
+
* backend carrying `CART_LINE_LIMIT_REACHED` is live everywhere, the regex can
|
|
38
|
+
* go: every storefront talks to the current API, so the code is always there.
|
|
39
|
+
* It cannot produce a false `CART_FULL` in the meantime, which is why it costs
|
|
40
|
+
* nothing to keep until then.
|
|
41
|
+
*
|
|
42
|
+
* Anything unrecognised falls through to `FAILED`, which is still a visible
|
|
43
|
+
* message rather than the silence this replaced.
|
|
44
|
+
*/
|
|
45
|
+
export function toAddToCartError(err: unknown): AddToCartError {
|
|
46
|
+
if (getErrorCode(err) === 'CART_LINE_LIMIT_REACHED') return 'CART_FULL';
|
|
47
|
+
const message = (err as { message?: string } | null)?.message ?? '';
|
|
48
|
+
return /cart can hold at most/i.test(message) ? 'CART_FULL' : 'FAILED';
|
|
49
|
+
}
|
|
@@ -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 { Badge } from '@/components/ui/badge';
|
|
10
11
|
import { Button } from '@/components/ui/button';
|
|
11
12
|
import { Card } from '@/components/ui/card';
|
|
@@ -21,8 +22,10 @@ interface CartBundleOfferCardProps {
|
|
|
21
22
|
|
|
22
23
|
export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBundleOfferCardProps) {
|
|
23
24
|
const t = useTranslations('cart');
|
|
25
|
+
const tp = useTranslations('productDetail');
|
|
24
26
|
const currency = useCurrency();
|
|
25
27
|
const [adding, setAdding] = useState(false);
|
|
28
|
+
const [addError, setAddError] = useState<AddToCartError | null>(null);
|
|
26
29
|
|
|
27
30
|
const offered = offer.offeredProducts;
|
|
28
31
|
|
|
@@ -35,6 +38,7 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
|
|
|
35
38
|
|
|
36
39
|
async function handleAdd() {
|
|
37
40
|
if (adding) return;
|
|
41
|
+
setAddError(null);
|
|
38
42
|
try {
|
|
39
43
|
setAdding(true);
|
|
40
44
|
const { getClient } = await import('@/core/lib/brainerce');
|
|
@@ -44,6 +48,10 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
|
|
|
44
48
|
await client.addBundleToCart(cartId, offer.id);
|
|
45
49
|
onAdd();
|
|
46
50
|
} catch (err) {
|
|
51
|
+
// The cart page is where a shopper is most likely to be AT the 50-line
|
|
52
|
+
// cap, and a bundle adds one line per offered product. Logging alone
|
|
53
|
+
// left the button spinning back to idle with nothing added.
|
|
54
|
+
setAddError(toAddToCartError(err));
|
|
47
55
|
console.error('Failed to add bundle:', err);
|
|
48
56
|
} finally {
|
|
49
57
|
setAdding(false);
|
|
@@ -112,6 +120,16 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
|
|
|
112
120
|
{adding ? t('addingBundle') : t('addBundleItem')}
|
|
113
121
|
</Button>
|
|
114
122
|
</div>
|
|
123
|
+
|
|
124
|
+
{/*
|
|
125
|
+
Why the bundle was refused. Without this the button resets and
|
|
126
|
+
nothing is added, which reads as a broken button.
|
|
127
|
+
*/}
|
|
128
|
+
{addError && (
|
|
129
|
+
<p role="alert" className="text-destructive mt-2 text-xs">
|
|
130
|
+
{addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
|
|
131
|
+
</p>
|
|
132
|
+
)}
|
|
115
133
|
</Card>
|
|
116
134
|
);
|
|
117
135
|
}
|
|
@@ -10,6 +10,7 @@ import { useTranslations } from '@/core/lib/translations';
|
|
|
10
10
|
import { useCurrency } from '@/core/lib/use-currency';
|
|
11
11
|
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
12
12
|
import { cn } from '@/core/lib/utils';
|
|
13
|
+
import { getErrorCode } from '@/core/lib/add-to-cart-error';
|
|
13
14
|
import { OrderCustomizations } from '@/components/account/order-customizations';
|
|
14
15
|
|
|
15
16
|
interface CartItemProps {
|
|
@@ -18,12 +19,28 @@ 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
|
export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
22
34
|
const t = useTranslations('common');
|
|
23
35
|
const td = useTranslations('productDetail');
|
|
36
|
+
const tc = useTranslations('cart');
|
|
24
37
|
const currency = useCurrency();
|
|
25
38
|
const [updating, setUpdating] = useState(false);
|
|
26
39
|
const [removing, setRemoving] = useState(false);
|
|
40
|
+
// Why the last quantity change or removal did not take. Both calls used to
|
|
41
|
+
// fail into console.error alone: the row snapped back to the quantity it
|
|
42
|
+
// already had, or stayed put after Remove, and nothing on screen said why.
|
|
43
|
+
const [lineError, setLineError] = useState<keyof typeof LINE_ERROR_KEYS | null>(null);
|
|
27
44
|
|
|
28
45
|
const productName = item.product.name;
|
|
29
46
|
const imageUrl = getCartItemImage(item);
|
|
@@ -45,12 +62,29 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
45
62
|
async function handleQuantityChange(newQuantity: number) {
|
|
46
63
|
if (newQuantity < 1 || updating) return;
|
|
47
64
|
|
|
65
|
+
setLineError(null);
|
|
48
66
|
try {
|
|
49
67
|
setUpdating(true);
|
|
50
68
|
const client = getClient();
|
|
51
69
|
await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
|
|
52
70
|
onUpdate();
|
|
53
71
|
} catch (err) {
|
|
72
|
+
// Two of these refusals are PERMANENT, and "please try again" would send
|
|
73
|
+
// the shopper round a loop that cannot succeed: INSUFFICIENT_STOCK (more
|
|
74
|
+
// than the inventory left) and PRODUCT_UNAVAILABLE (the line cannot be
|
|
75
|
+
// bought at all any more). Both get their own sentence; everything else
|
|
76
|
+
// is a blip worth one more tap. The 50-line cart cap is NOT a cause here
|
|
77
|
+
// at all, it only ever refuses a brand new line. Both codes are part of
|
|
78
|
+
// the documented API error catalogue, not guesses. Keep the log: the
|
|
79
|
+
// shopper gets the sentence, the developer still needs the detail.
|
|
80
|
+
const code = getErrorCode(err);
|
|
81
|
+
setLineError(
|
|
82
|
+
code === 'INSUFFICIENT_STOCK'
|
|
83
|
+
? 'NOT_ENOUGH_STOCK'
|
|
84
|
+
: code === 'PRODUCT_UNAVAILABLE'
|
|
85
|
+
? 'ITEM_UNAVAILABLE'
|
|
86
|
+
: 'UPDATE_FAILED'
|
|
87
|
+
);
|
|
54
88
|
console.error('Failed to update quantity:', err);
|
|
55
89
|
} finally {
|
|
56
90
|
setUpdating(false);
|
|
@@ -60,12 +94,17 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
60
94
|
async function handleRemove() {
|
|
61
95
|
if (removing) return;
|
|
62
96
|
|
|
97
|
+
setLineError(null);
|
|
63
98
|
try {
|
|
64
99
|
setRemoving(true);
|
|
65
100
|
const client = getClient();
|
|
66
101
|
await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
|
|
67
102
|
onUpdate();
|
|
68
103
|
} catch (err) {
|
|
104
|
+
// Nothing is lost when a remove fails, so the line is still there and
|
|
105
|
+
// still correct. Say so anyway: a Remove that visibly does nothing reads
|
|
106
|
+
// as a broken button.
|
|
107
|
+
setLineError('REMOVE_FAILED');
|
|
69
108
|
console.error('Failed to remove item:', err);
|
|
70
109
|
} finally {
|
|
71
110
|
setRemoving(false);
|
|
@@ -161,6 +200,17 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
161
200
|
{removing ? t('removing') : t('remove')}
|
|
162
201
|
</button>
|
|
163
202
|
</div>
|
|
203
|
+
|
|
204
|
+
{/*
|
|
205
|
+
Why the quantity change or the removal did not take. Keep it inside
|
|
206
|
+
this line's own column: it describes THIS row, and a shopper with ten
|
|
207
|
+
rows on screen has to be able to tell which one refused.
|
|
208
|
+
*/}
|
|
209
|
+
{lineError && (
|
|
210
|
+
<p role="alert" className="text-destructive mt-2 text-xs">
|
|
211
|
+
{tc(LINE_ERROR_KEYS[lineError])}
|
|
212
|
+
</p>
|
|
213
|
+
)}
|
|
164
214
|
</div>
|
|
165
215
|
|
|
166
216
|
{/* Line total */}
|
|
@@ -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
|
-
|
|
70
|
-
|
|
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 */}
|
|
@@ -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,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"home": {
|
|
10
10
|
"heroEyebrow": "New collection · Handcrafted",
|
|
11
11
|
"heroTitle": "Pieces that tell your story",
|
|
12
|
-
"heroLead": "Thoughtfully designed
|
|
12
|
+
"heroLead": "Thoughtfully designed and handcrafted with love, made to be with you through the small moments and the big ones.",
|
|
13
13
|
"heroCtaSecondary": "Browse categories",
|
|
14
14
|
"heroTrust": "Secure checkout on every order",
|
|
15
15
|
"benefit1Title": "Delivery",
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"benefit4Title": "Returns",
|
|
22
22
|
"benefit4Desc": "See our returns policy for details",
|
|
23
23
|
"featuredEyebrow": "Our picks",
|
|
24
|
-
"featuredSubtitle": "The pieces our customers love most
|
|
24
|
+
"featuredSubtitle": "The pieces our customers love most, hand-picked from the new collection.",
|
|
25
25
|
"categoriesEyebrow": "Categories",
|
|
26
26
|
"categoriesTitle": "Find the perfect piece",
|
|
27
|
-
"categoriesSubtitle": "From everyday favorites to statement pieces
|
|
27
|
+
"categoriesSubtitle": "From everyday favorites to statement pieces, there's something here for everyone.",
|
|
28
28
|
"catRings": "Rings",
|
|
29
29
|
"catNecklaces": "Necklaces",
|
|
30
30
|
"catEarrings": "Earrings",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"heroEmptyHint": "Your first published products will appear here"
|
|
53
53
|
},
|
|
54
54
|
"products": {
|
|
55
|
-
"listingSubtitle": "Our full collection
|
|
55
|
+
"listingSubtitle": "Our full collection, handmade from genuine materials with love for detail.",
|
|
56
56
|
"filtersLabel": "Filter",
|
|
57
57
|
"emptyTitle": "No results found",
|
|
58
58
|
"emptyBody": "Try clearing the filters or searching for something else.",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"shippingAtCheckout": "Shipping calculated at checkout"
|
|
85
85
|
},
|
|
86
86
|
"footer": {
|
|
87
|
-
"tagline": "{{storeName}}
|
|
87
|
+
"tagline": "{{storeName}}, handcrafted with love in Israel.",
|
|
88
88
|
"followUs": "Follow us",
|
|
89
89
|
"quickLinksTitle": "Quick links",
|
|
90
90
|
"linkProducts": "All products",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"home": {
|
|
10
10
|
"heroEyebrow": "קולקציה חדשה · עבודת יד",
|
|
11
11
|
"heroTitle": "פריטים שמספרים את הסיפור שלך",
|
|
12
|
-
"heroLead": "עיצובים עדינים, בעבודת יד
|
|
12
|
+
"heroLead": "עיצובים עדינים, בעבודת יד ובאהבה, שנוצרו ללוות אתכן ברגעים הקטנים והגדולים.",
|
|
13
13
|
"heroCtaSecondary": "לצפייה בקטגוריות",
|
|
14
14
|
"heroTrust": "קופה מאובטחת בכל הזמנה",
|
|
15
15
|
"benefit1Title": "משלוח",
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"benefit4Title": "החזרות",
|
|
22
22
|
"benefit4Desc": "הפרטים במדיניות ההחזרות שלנו",
|
|
23
23
|
"featuredEyebrow": "הנבחרים שלנו",
|
|
24
|
-
"featuredSubtitle": "הפריטים שהלקוחות שלנו הכי
|
|
24
|
+
"featuredSubtitle": "הפריטים שהלקוחות שלנו הכי אוהבות, שנבחרו בקפידה מהקולקציה החדשה.",
|
|
25
25
|
"categoriesEyebrow": "קטגוריות",
|
|
26
26
|
"categoriesTitle": "מצאו את הפריט המושלם",
|
|
27
|
-
"categoriesSubtitle": "מהפריטים העדינים ועד
|
|
27
|
+
"categoriesSubtitle": "מהפריטים העדינים ועד הבולטים, לכל אחת יש את השפה שלה.",
|
|
28
28
|
"catRings": "טבעות",
|
|
29
29
|
"catNecklaces": "שרשראות",
|
|
30
30
|
"catEarrings": "עגילים",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"heroEmptyHint": "המוצרים הראשונים שתפרסמו לחנות יופיעו כאן"
|
|
53
53
|
},
|
|
54
54
|
"products": {
|
|
55
|
-
"listingSubtitle": "הקולקציה המלאה
|
|
55
|
+
"listingSubtitle": "הקולקציה המלאה שלנו, בעבודת יד, מחומרים אמיתיים ומתוך אהבה לפרטים.",
|
|
56
56
|
"filtersLabel": "סינון",
|
|
57
57
|
"emptyTitle": "לא מצאנו תוצאות",
|
|
58
58
|
"emptyBody": "נסו לנקות את הסינון או לחפש משהו אחר.",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"shippingAtCheckout": "משלוח יחושב בקופה"
|
|
85
85
|
},
|
|
86
86
|
"footer": {
|
|
87
|
-
"tagline": "{{storeName}}
|
|
87
|
+
"tagline": "{{storeName}}, עבודת יד ועיצוב באהבה בישראל.",
|
|
88
88
|
"followUs": "עקבו אחרינו",
|
|
89
89
|
"quickLinksTitle": "ניווט מהיר",
|
|
90
90
|
"linkProducts": "כל המוצרים",
|