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