create-brainerce-store 1.78.0 → 1.80.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 +27 -2
- package/messages/en.json +12 -3
- package/messages/he.json +12 -3
- package/package.json +10 -1
- package/templates/nextjs/base/.eslintrc.json +2 -0
- package/templates/nextjs/base/AGENTS.md.ejs +23 -5
- package/templates/nextjs/base/CLAUDE.md.ejs +23 -5
- package/templates/nextjs/base/src/app/checkout/page.tsx +19 -2
- package/templates/nextjs/base/src/app/order-status/page.tsx +18 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +220 -214
- package/templates/nextjs/base/src/components/account/order-history.tsx +11 -4
- package/templates/nextjs/base/src/components/account/profile-section.tsx +7 -1
- package/templates/nextjs/base/src/components/auth/register-form.tsx +18 -1
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -2
- package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +1 -1
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +343 -328
- package/templates/nextjs/base/src/core/lib/kit.ts +88 -0
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +87 -12
- package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +59 -12
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +205 -197
- package/templates/nextjs/base/src/ui/product/product-card.tsx +230 -221
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +524 -493
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +117 -108
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +23 -3
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +210 -202
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +251 -242
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +540 -509
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +110 -101
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +22 -2
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +41 -11
- package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +50 -8
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +182 -174
- package/templates/nextjs/ui-canvas/product/product-card.tsx +174 -165
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +31 -0
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +114 -105
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +22 -2
|
@@ -1,328 +1,343 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useEffect, useRef, useState, useMemo } from 'react';
|
|
4
|
-
import type {
|
|
5
|
-
Product,
|
|
6
|
-
ProductCustomizationField,
|
|
7
|
-
ProductImage,
|
|
8
|
-
ProductRecommendationsResponse,
|
|
9
|
-
ProductVariant,
|
|
10
|
-
InventoryInfo,
|
|
11
|
-
ModifierGroup,
|
|
12
|
-
} from 'brainerce';
|
|
13
|
-
import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
|
|
14
|
-
import { resolveDisplayPrice, type DisplayPrice } from '@/core/lib/display-price';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
inventory
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
description
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const [
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, useMemo } from 'react';
|
|
4
|
+
import type {
|
|
5
|
+
Product,
|
|
6
|
+
ProductCustomizationField,
|
|
7
|
+
ProductImage,
|
|
8
|
+
ProductRecommendationsResponse,
|
|
9
|
+
ProductVariant,
|
|
10
|
+
InventoryInfo,
|
|
11
|
+
ModifierGroup,
|
|
12
|
+
} from 'brainerce';
|
|
13
|
+
import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
|
|
14
|
+
import { resolveDisplayPrice, type DisplayPrice } from '@/core/lib/display-price';
|
|
15
|
+
import { resolveStockInfo } from '@/core/lib/kit';
|
|
16
|
+
import { useCart, useStoreInfo } from '@/core/providers/store-provider';
|
|
17
|
+
import { trackAddToCart, trackProductView } from '@/core/lib/tracking';
|
|
18
|
+
import {
|
|
19
|
+
buildInitialSelections,
|
|
20
|
+
toModifierSelections,
|
|
21
|
+
validateCustomization,
|
|
22
|
+
validateSelections,
|
|
23
|
+
type CustomizationValues,
|
|
24
|
+
} from '@/core/lib/product-options';
|
|
25
|
+
|
|
26
|
+
export interface ProductPriceInfo {
|
|
27
|
+
price: number;
|
|
28
|
+
originalPrice: number;
|
|
29
|
+
isOnSale: boolean;
|
|
30
|
+
discountAmount?: number;
|
|
31
|
+
discountPercent: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UseProductPageResult {
|
|
35
|
+
product: Product;
|
|
36
|
+
recommendations: ProductRecommendationsResponse | null;
|
|
37
|
+
/** Product image list. */
|
|
38
|
+
images: ProductImage[];
|
|
39
|
+
/** Index into `images`; -1 means "variant image not in the list". */
|
|
40
|
+
selectedImageIndex: number;
|
|
41
|
+
setSelectedImageIndex: (index: number) => void;
|
|
42
|
+
/** URL of the image to show as the main image (variant-aware). */
|
|
43
|
+
mainImageUrl: string | null;
|
|
44
|
+
/** Variant selection. */
|
|
45
|
+
selectedVariant: ProductVariant | null;
|
|
46
|
+
setSelectedVariant: (variant: ProductVariant | null) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Effective price info in the STORE currency (variant price + discount-rule
|
|
49
|
+
* overlay).
|
|
50
|
+
*
|
|
51
|
+
* ⛔ This is the CHARGED amount, and it is what feeds `view_item` /
|
|
52
|
+
* `add_to_cart`. Analytics revenue has to be one currency across the store,
|
|
53
|
+
* so do NOT swap in a converted figure here. Render `displayPrice` instead.
|
|
54
|
+
*/
|
|
55
|
+
priceInfo: ProductPriceInfo;
|
|
56
|
+
/**
|
|
57
|
+
* The price to RENDER, region-aware: the FX-converted amounts when the
|
|
58
|
+
* product was read with a `regionId` whose currency differs from the store's,
|
|
59
|
+
* and `priceInfo` in the store currency otherwise.
|
|
60
|
+
*
|
|
61
|
+
* ⛔ Pass `displayPrice.currency` to `<PriceDisplay currency>` alongside the
|
|
62
|
+
* numbers. It defaults to the store currency, so a converted amount rendered
|
|
63
|
+
* without it is a euro figure wearing a dollar sign.
|
|
64
|
+
*/
|
|
65
|
+
displayPrice: DisplayPrice;
|
|
66
|
+
/** Variant inventory when a variant is selected, else product inventory. */
|
|
67
|
+
inventory: InventoryInfo | null;
|
|
68
|
+
canPurchase: boolean;
|
|
69
|
+
/** Resolved description content ({ html } or { text }). */
|
|
70
|
+
description: ReturnType<typeof getDescriptionContent> | null;
|
|
71
|
+
/** Quantity picker. */
|
|
72
|
+
quantity: number;
|
|
73
|
+
setQuantity: (update: number | ((q: number) => number)) => void;
|
|
74
|
+
/** Add-to-cart state machine. */
|
|
75
|
+
addingToCart: boolean;
|
|
76
|
+
addedMessage: boolean;
|
|
77
|
+
handleAddToCart: () => Promise<void>;
|
|
78
|
+
/** Customization (buyer input) sub-contract. */
|
|
79
|
+
customizationFields: ProductCustomizationField[];
|
|
80
|
+
customizationValues: CustomizationValues;
|
|
81
|
+
setCustomizationValues: (values: CustomizationValues) => void;
|
|
82
|
+
customizationErrors: Record<string, string>;
|
|
83
|
+
/** Modifier groups (toppings, sauce, …) sub-contract. */
|
|
84
|
+
modifierGroups: ModifierGroup[];
|
|
85
|
+
modifierSelections: Record<string, string[]>;
|
|
86
|
+
setModifierSelection: (groupId: string, next: string[]) => void;
|
|
87
|
+
modifierError: string | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Product-page behavior: variant selection, variant-aware image + price +
|
|
92
|
+
* inventory resolution, quantity, customization + modifier state and
|
|
93
|
+
* validation, and the add-to-cart state machine. Pure data/behavior —
|
|
94
|
+
* rendering lives in `ui/product/product-client-section.tsx`.
|
|
95
|
+
*/
|
|
96
|
+
export function useProductPage(initialProduct: Product): UseProductPageResult {
|
|
97
|
+
const { refreshCart } = useCart();
|
|
98
|
+
const { storeInfo } = useStoreInfo();
|
|
99
|
+
const currency = storeInfo?.currency;
|
|
100
|
+
|
|
101
|
+
const product = initialProduct;
|
|
102
|
+
const recommendations = product?.recommendations ?? null;
|
|
103
|
+
|
|
104
|
+
// ⛔ Never pre-select a variant on a KIT. A kit is added to the cart as ONE
|
|
105
|
+
// line using its own `productId`, and add-to-cart rejects a kit carrying a
|
|
106
|
+
// `variantId` with a 400 — so an auto-selected variant would make the buy
|
|
107
|
+
// button fail. Kits carry no variants today; this keeps that from becoming a
|
|
108
|
+
// silent dependency.
|
|
109
|
+
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
|
|
110
|
+
product.type !== 'KIT' && product.variants && product.variants.length > 0
|
|
111
|
+
? product.variants[0]
|
|
112
|
+
: null
|
|
113
|
+
);
|
|
114
|
+
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
|
115
|
+
const [quantity, setQuantity] = useState(1);
|
|
116
|
+
const [addingToCart, setAddingToCart] = useState(false);
|
|
117
|
+
const [addedMessage, setAddedMessage] = useState(false);
|
|
118
|
+
const customizationFields = product.customizationFields ?? [];
|
|
119
|
+
const [customizationValues, setCustomizationValues] = useState<CustomizationValues>(() => {
|
|
120
|
+
const initial: CustomizationValues = {};
|
|
121
|
+
for (const field of customizationFields) {
|
|
122
|
+
if (field.defaultValue != null) initial[field.key] = field.defaultValue;
|
|
123
|
+
}
|
|
124
|
+
return initial;
|
|
125
|
+
});
|
|
126
|
+
const [customizationErrors, setCustomizationErrors] = useState<Record<string, string>>({});
|
|
127
|
+
|
|
128
|
+
// Modifier groups (PRD §8.4) — only present on restaurant / build-your-own products.
|
|
129
|
+
const modifierGroups: ModifierGroup[] = useMemo(
|
|
130
|
+
() => (product as Product & { modifierGroups?: ModifierGroup[] }).modifierGroups ?? [],
|
|
131
|
+
[product]
|
|
132
|
+
);
|
|
133
|
+
const [modifierSelections, setModifierSelections] = useState<Record<string, string[]>>(() =>
|
|
134
|
+
buildInitialSelections(modifierGroups)
|
|
135
|
+
);
|
|
136
|
+
const [modifierError, setModifierError] = useState<string | null>(null);
|
|
137
|
+
|
|
138
|
+
function setModifierSelection(groupId: string, next: string[]) {
|
|
139
|
+
setModifierSelections((prev) => ({ ...prev, [groupId]: next }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Images list - switch main image when variant changes
|
|
143
|
+
const images: ProductImage[] = useMemo(() => {
|
|
144
|
+
return product?.images || [];
|
|
145
|
+
}, [product]);
|
|
146
|
+
|
|
147
|
+
// When variant changes, update selected image to variant image if available
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!selectedVariant?.image || !product) return;
|
|
150
|
+
|
|
151
|
+
const variantImgUrl =
|
|
152
|
+
typeof selectedVariant.image === 'string' ? selectedVariant.image : selectedVariant.image.url;
|
|
153
|
+
|
|
154
|
+
// Find if variant image exists in product images
|
|
155
|
+
const idx = images.findIndex((img) => img.url === variantImgUrl);
|
|
156
|
+
if (idx >= 0) {
|
|
157
|
+
setSelectedImageIndex(idx);
|
|
158
|
+
} else {
|
|
159
|
+
// Variant image not in product images - select index 0 as fallback
|
|
160
|
+
setSelectedImageIndex(-1);
|
|
161
|
+
}
|
|
162
|
+
}, [selectedVariant, images, product]);
|
|
163
|
+
|
|
164
|
+
// Determine which image to show
|
|
165
|
+
const mainImageUrl = useMemo(() => {
|
|
166
|
+
if (selectedImageIndex === -1 && selectedVariant?.image) {
|
|
167
|
+
const img = selectedVariant.image;
|
|
168
|
+
return typeof img === 'string' ? img : img.url;
|
|
169
|
+
}
|
|
170
|
+
return images[selectedImageIndex]?.url || null;
|
|
171
|
+
}, [selectedImageIndex, selectedVariant, images]);
|
|
172
|
+
|
|
173
|
+
// Price info - use variant price if selected, else product price
|
|
174
|
+
const priceInfo = useMemo(() => {
|
|
175
|
+
if (selectedVariant?.price) {
|
|
176
|
+
const variantBase = parseFloat(selectedVariant.price);
|
|
177
|
+
const variantSale = selectedVariant.salePrice ? parseFloat(selectedVariant.salePrice) : null;
|
|
178
|
+
const variantEffective =
|
|
179
|
+
variantSale != null && variantSale < variantBase ? variantSale : variantBase;
|
|
180
|
+
|
|
181
|
+
// Overlay any product-level discount rule onto the variant price using the rule's ratio
|
|
182
|
+
if (product.discount) {
|
|
183
|
+
const ruleOriginal = parseFloat(product.discount.originalPrice) || 0;
|
|
184
|
+
const ruleDiscounted = parseFloat(product.discount.discountedPrice) || 0;
|
|
185
|
+
const ratio = ruleOriginal > 0 ? ruleDiscounted / ruleOriginal : 1;
|
|
186
|
+
const discounted = variantEffective * ratio;
|
|
187
|
+
const amount = Math.max(0, variantEffective - discounted);
|
|
188
|
+
return {
|
|
189
|
+
price: discounted,
|
|
190
|
+
originalPrice: variantEffective,
|
|
191
|
+
isOnSale: discounted < variantEffective,
|
|
192
|
+
discountAmount: amount,
|
|
193
|
+
discountPercent: variantEffective > 0 ? Math.round((amount / variantEffective) * 100) : 0,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
price: variantEffective,
|
|
199
|
+
originalPrice: variantBase,
|
|
200
|
+
isOnSale: variantEffective < variantBase,
|
|
201
|
+
discountPercent:
|
|
202
|
+
variantEffective < variantBase && variantBase > 0
|
|
203
|
+
? Math.round(((variantBase - variantEffective) / variantBase) * 100)
|
|
204
|
+
: 0,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return getProductPriceInfo(product);
|
|
208
|
+
}, [product, selectedVariant]);
|
|
209
|
+
|
|
210
|
+
// Region display pricing, variant-aware. The selected variant carries its own
|
|
211
|
+
// FX fields; fall back to the product's, then to the store-currency figures
|
|
212
|
+
// above. Same helper the product card uses, so a card and the page it opens
|
|
213
|
+
// can never quote different currencies for the same item.
|
|
214
|
+
const displayPrice = useMemo<DisplayPrice>(() => {
|
|
215
|
+
const fallback = {
|
|
216
|
+
price: priceInfo.originalPrice,
|
|
217
|
+
salePrice: priceInfo.isOnSale ? priceInfo.price : null,
|
|
218
|
+
};
|
|
219
|
+
const source = selectedVariant?.displayPrice != null ? selectedVariant : product;
|
|
220
|
+
return resolveDisplayPrice(source, fallback, currency);
|
|
221
|
+
}, [product, selectedVariant, priceInfo, currency]);
|
|
222
|
+
|
|
223
|
+
// Inventory: the selected variant's when there is one, else the product's.
|
|
224
|
+
//
|
|
225
|
+
// ⛔ Goes through `resolveStockInfo` because a KIT has NO `inventory` block —
|
|
226
|
+
// its stock is `product.kitAvailable`. Reading `inventory` directly gave one
|
|
227
|
+
// kit two contradictory answers on the same page: `<StockBadge>` printed a red
|
|
228
|
+
// "Out of stock" (nullish inventory) while the add-to-cart button stayed
|
|
229
|
+
// ENABLED (`undefined?.canPurchase !== false` is `true`). Both now read the
|
|
230
|
+
// same resolved value, so the badge and the button can never disagree.
|
|
231
|
+
const inventory = resolveStockInfo(product, selectedVariant?.inventory);
|
|
232
|
+
const canPurchase = inventory?.canPurchase !== false;
|
|
233
|
+
|
|
234
|
+
// Description
|
|
235
|
+
const description = useMemo(() => {
|
|
236
|
+
return product ? getDescriptionContent(product) : null;
|
|
237
|
+
}, [product]);
|
|
238
|
+
|
|
239
|
+
// `view_item` — the event Meta and Google build remarketing audiences from.
|
|
240
|
+
// The ref keeps it to once per product rather than once per render: the
|
|
241
|
+
// shopper is looking at one product, and re-firing on every swatch click or
|
|
242
|
+
// quantity change would inflate the audience with phantom views.
|
|
243
|
+
const viewedProductIdRef = useRef<string | null>(null);
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (!product || viewedProductIdRef.current === product.id) return;
|
|
246
|
+
viewedProductIdRef.current = product.id;
|
|
247
|
+
trackProductView(product, selectedVariant, priceInfo.price, currency);
|
|
248
|
+
}, [product, selectedVariant, priceInfo.price, currency]);
|
|
249
|
+
|
|
250
|
+
async function handleAddToCart() {
|
|
251
|
+
if (!product || addingToCart) return;
|
|
252
|
+
|
|
253
|
+
if (customizationFields.length > 0) {
|
|
254
|
+
const errs = validateCustomization(customizationFields, customizationValues);
|
|
255
|
+
if (Object.keys(errs).length > 0) {
|
|
256
|
+
setCustomizationErrors(errs);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
setCustomizationErrors({});
|
|
261
|
+
|
|
262
|
+
// Client-side modifier validation mirrors the server's checks. The server
|
|
263
|
+
// is authoritative — `MODIFIER_VALIDATION_FAILED` envelope on add-to-cart
|
|
264
|
+
// failure is the source of truth — but pre-flighting catches the obvious
|
|
265
|
+
// issues without a round-trip.
|
|
266
|
+
if (modifierGroups.length > 0) {
|
|
267
|
+
const error = validateSelections(modifierGroups, modifierSelections);
|
|
268
|
+
if (error) {
|
|
269
|
+
setModifierError(error);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
setModifierError(null);
|
|
274
|
+
|
|
275
|
+
const selections =
|
|
276
|
+
modifierGroups.length > 0
|
|
277
|
+
? toModifierSelections(modifierGroups, modifierSelections)
|
|
278
|
+
: undefined;
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
setAddingToCart(true);
|
|
282
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
283
|
+
const client = getClient();
|
|
284
|
+
await client.smartAddToCart({
|
|
285
|
+
productId: product.id,
|
|
286
|
+
variantId: selectedVariant?.id,
|
|
287
|
+
quantity,
|
|
288
|
+
metadata:
|
|
289
|
+
customizationFields.length > 0 && Object.keys(customizationValues).length > 0
|
|
290
|
+
? customizationValues
|
|
291
|
+
: undefined,
|
|
292
|
+
...(selections && selections.length > 0 ? { selections } : {}),
|
|
293
|
+
});
|
|
294
|
+
await refreshCart();
|
|
295
|
+
// Report only after the server accepted the line — an add that failed
|
|
296
|
+
// validation is not an add-to-cart, and feeding the ad platforms events
|
|
297
|
+
// that never became cart lines poisons their conversion modelling.
|
|
298
|
+
trackAddToCart(product, selectedVariant, quantity, priceInfo.price, currency);
|
|
299
|
+
setAddedMessage(true);
|
|
300
|
+
setTimeout(() => setAddedMessage(false), 2000);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
// Surface the structured `MODIFIER_VALIDATION_FAILED` envelope when present.
|
|
303
|
+
const e = err as { details?: { code?: string; errors?: Array<{ message: string }> } };
|
|
304
|
+
const validationErrors = e?.details?.errors;
|
|
305
|
+
if (e?.details?.code === 'MODIFIER_VALIDATION_FAILED' && validationErrors?.length) {
|
|
306
|
+
setModifierError(validationErrors.map((v) => v.message).join('; '));
|
|
307
|
+
} else {
|
|
308
|
+
console.error('Failed to add to cart:', err);
|
|
309
|
+
}
|
|
310
|
+
} finally {
|
|
311
|
+
setAddingToCart(false);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
product,
|
|
317
|
+
recommendations,
|
|
318
|
+
images,
|
|
319
|
+
selectedImageIndex,
|
|
320
|
+
setSelectedImageIndex,
|
|
321
|
+
mainImageUrl,
|
|
322
|
+
selectedVariant,
|
|
323
|
+
setSelectedVariant,
|
|
324
|
+
priceInfo,
|
|
325
|
+
displayPrice,
|
|
326
|
+
inventory,
|
|
327
|
+
canPurchase,
|
|
328
|
+
description,
|
|
329
|
+
quantity,
|
|
330
|
+
setQuantity,
|
|
331
|
+
addingToCart,
|
|
332
|
+
addedMessage,
|
|
333
|
+
handleAddToCart,
|
|
334
|
+
customizationFields,
|
|
335
|
+
customizationValues,
|
|
336
|
+
setCustomizationValues,
|
|
337
|
+
customizationErrors,
|
|
338
|
+
modifierGroups,
|
|
339
|
+
modifierSelections,
|
|
340
|
+
setModifierSelection,
|
|
341
|
+
modifierError,
|
|
342
|
+
};
|
|
343
|
+
}
|