create-brainerce-store 1.15.1 → 1.16.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 CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "create-brainerce-store",
34
- version: "1.15.0",
34
+ version: "1.15.2",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
36
  bin: {
37
37
  "create-brainerce-store": "dist/index.js"
package/messages/en.json CHANGED
@@ -76,6 +76,10 @@
76
76
  "addingToCart": "Adding...",
77
77
  "addedToCart": "Added to Cart!",
78
78
  "outOfStock": "Out of Stock",
79
+ "inStock": "In Stock",
80
+ "unavailable": "Unavailable",
81
+ "onlyLeft": "Only {available} left",
82
+ "availableInStock": "{available} in stock",
79
83
  "decreaseQuantity": "Decrease quantity",
80
84
  "increaseQuantity": "Increase quantity",
81
85
  "youMayAlsoLike": "You May Also Like",
@@ -84,7 +88,11 @@
84
88
  "digitalProduct": "Digital Product",
85
89
  "instantDownload": "Instant Download",
86
90
  "downloadAfterPurchase": "Available for download after purchase",
87
- "filesIncluded": "{count} files included"
91
+ "filesIncluded": "{count} files included",
92
+ "frequentlyBoughtTogether": "Frequently Bought Together",
93
+ "addSelectedToCart": "Add Selected to Cart",
94
+ "totalPrice": "Total: {price}",
95
+ "addingAll": "Adding..."
88
96
  },
89
97
  "cart": {
90
98
  "pageTitle": "Shopping Cart",
@@ -94,7 +102,16 @@
94
102
  "proceedToCheckout": "Proceed to Checkout",
95
103
  "orderSummary": "Order Summary",
96
104
  "taxAtCheckout": "Calculated at checkout",
97
- "youMightAlsoNeed": "You Might Also Need"
105
+ "youMightAlsoNeed": "You Might Also Need",
106
+ "freeShippingQualified": "You've got free shipping!",
107
+ "freeShippingRemaining": "You're {amount} away from free shipping!",
108
+ "upgradeFor": "Upgrade to {name} for only +{amount}",
109
+ "upgrade": "Upgrade",
110
+ "upgrading": "Upgrading...",
111
+ "dismissUpgrade": "Dismiss",
112
+ "bundleOffers": "Bundle & Save",
113
+ "addBundleItem": "Add & Save",
114
+ "addingBundle": "Adding..."
98
115
  },
99
116
  "checkout": {
100
117
  "pageTitle": "Checkout",
@@ -151,7 +168,8 @@
151
168
  "failedToSetDeliveryMethod": "Failed to set delivery method",
152
169
  "failedToSelectPickup": "Failed to select pickup location",
153
170
  "failedToLoadPaymentSdk": "Failed to load payment SDK",
154
- "paymentTimedOut": "Payment session timed out. Please try again."
171
+ "paymentTimedOut": "Payment session timed out. Please try again.",
172
+ "addToYourOrder": "Add to your order"
155
173
  },
156
174
  "checkoutForm": {
157
175
  "email": "Email",
package/messages/he.json CHANGED
@@ -76,6 +76,10 @@
76
76
  "addingToCart": "...מוסיף",
77
77
  "addedToCart": "נוסף לעגלה!",
78
78
  "outOfStock": "אזל מהמלאי",
79
+ "inStock": "במלאי",
80
+ "unavailable": "לא זמין",
81
+ "onlyLeft": "נותרו {available} בלבד",
82
+ "availableInStock": "{available} במלאי",
79
83
  "decreaseQuantity": "הפחת כמות",
80
84
  "increaseQuantity": "הגדל כמות",
81
85
  "youMayAlsoLike": "אולי גם תאהבו",
@@ -84,7 +88,11 @@
84
88
  "digitalProduct": "מוצר דיגיטלי",
85
89
  "instantDownload": "הורדה מיידית",
86
90
  "downloadAfterPurchase": "זמין להורדה לאחר רכישה",
87
- "filesIncluded": "{count} קבצים כלולים"
91
+ "filesIncluded": "{count} קבצים כלולים",
92
+ "frequentlyBoughtTogether": "לקוחות קנו גם",
93
+ "addSelectedToCart": "הוסף הנבחרים לעגלה",
94
+ "totalPrice": "סה\"כ: {price}",
95
+ "addingAll": "...מוסיף"
88
96
  },
89
97
  "cart": {
90
98
  "pageTitle": "עגלת קניות",
@@ -94,7 +102,16 @@
94
102
  "proceedToCheckout": "המשך לתשלום",
95
103
  "orderSummary": "סיכום הזמנה",
96
104
  "taxAtCheckout": "יחושב בקופה",
97
- "youMightAlsoNeed": "אולי גם תצטרכו"
105
+ "youMightAlsoNeed": "אולי גם תצטרכו",
106
+ "freeShippingQualified": "!יש לך משלוח חינם",
107
+ "freeShippingRemaining": "חסרים לך {amount} למשלוח חינם!",
108
+ "upgradeFor": "שדרגו ל-{name} בתוספת של {amount} בלבד",
109
+ "upgrade": "שדרג",
110
+ "upgrading": "...משדרג",
111
+ "dismissUpgrade": "סגור",
112
+ "bundleOffers": "חבילה וחיסכון",
113
+ "addBundleItem": "הוסף וחסוך",
114
+ "addingBundle": "...מוסיף"
98
115
  },
99
116
  "checkout": {
100
117
  "pageTitle": "תשלום",
@@ -151,7 +168,8 @@
151
168
  "failedToSetDeliveryMethod": "שגיאה בהגדרת שיטת המשלוח",
152
169
  "failedToSelectPickup": "שגיאה בבחירת נקודת האיסוף",
153
170
  "failedToLoadPaymentSdk": "שגיאה בטעינת מערכת התשלום",
154
- "paymentTimedOut": "פג תוקף הפעלת התשלום. אנא נסו שנית."
171
+ "paymentTimedOut": "פג תוקף הפעלת התשלום. אנא נסו שנית.",
172
+ "addToYourOrder": "הוסיפו להזמנה"
155
173
  },
156
174
  "checkoutForm": {
157
175
  "email": "אימייל",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.15.1",
3
+ "version": "1.16.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -2,13 +2,17 @@
2
2
 
3
3
  import { useEffect, useState } from 'react';
4
4
  import Link from 'next/link';
5
- import type { CartRecommendationsResponse } from 'brainerce';
5
+ import type { CartRecommendationsResponse, CartUpgradesResponse, CartBundlesResponse } from 'brainerce';
6
6
  import { getClient } from '@/lib/brainerce';
7
7
  import { useCart } from '@/providers/store-provider';
8
+ import { useStoreInfo } from '@/providers/store-provider';
8
9
  import { CartItem } from '@/components/cart/cart-item';
10
+ import { CartUpgradeBanner } from '@/components/cart/cart-upgrade-banner';
11
+ import { CartBundleOfferCard } from '@/components/cart/cart-bundle-offer';
9
12
  import { CartSummary } from '@/components/cart/cart-summary';
10
13
  import { CouponInput } from '@/components/cart/coupon-input';
11
14
  import { CartNudges } from '@/components/cart/cart-nudges';
15
+ import { FreeShippingBar } from '@/components/cart/free-shipping-bar';
12
16
  import { ReservationCountdown } from '@/components/cart/reservation-countdown';
13
17
  import { CartRecommendationSection } from '@/components/products/recommendation-section';
14
18
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
@@ -16,9 +20,12 @@ import { useTranslations } from '@/lib/translations';
16
20
 
17
21
  export default function CartPage() {
18
22
  const { cart, cartLoading, refreshCart, itemCount } = useCart();
23
+ const { storeInfo } = useStoreInfo();
19
24
  const t = useTranslations('cart');
20
25
  const tc = useTranslations('common');
21
26
  const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
27
+ const [upgrades, setUpgrades] = useState<CartUpgradesResponse | null>(null);
28
+ const [bundles, setBundles] = useState<CartBundlesResponse | null>(null);
22
29
 
23
30
  // Load cross-sell recommendations when cart changes
24
31
  useEffect(() => {
@@ -33,6 +40,32 @@ export default function CartPage() {
33
40
  .catch(() => {});
34
41
  }, [cart?.id, cart?.items.length]);
35
42
 
43
+ // Load upgrade suggestions when cart changes
44
+ useEffect(() => {
45
+ if (!cart?.id || cart.items.length === 0 || storeInfo?.upsell?.cartUpgradeBannerEnabled === false) {
46
+ setUpgrades(null);
47
+ return;
48
+ }
49
+ const client = getClient();
50
+ client
51
+ .getCartUpgrades(cart.id)
52
+ .then(setUpgrades)
53
+ .catch(() => {});
54
+ }, [cart?.id, cart?.items.length, storeInfo?.upsell?.cartUpgradeBannerEnabled]);
55
+
56
+ // Load bundle offers when cart changes
57
+ useEffect(() => {
58
+ if (!cart?.id || cart.items.length === 0 || storeInfo?.upsell?.cartBundleEnabled === false) {
59
+ setBundles(null);
60
+ return;
61
+ }
62
+ const client = getClient();
63
+ client
64
+ .getCartBundles(cart.id)
65
+ .then(setBundles)
66
+ .catch(() => {});
67
+ }, [cart?.id, cart?.items.length, storeInfo?.upsell?.cartBundleEnabled]);
68
+
36
69
  if (cartLoading) {
37
70
  return (
38
71
  <div className="flex min-h-[60vh] items-center justify-center">
@@ -92,10 +125,30 @@ export default function CartPage() {
92
125
  {/* Cart items */}
93
126
  <div>
94
127
  {cart.items.map((item) => (
95
- <CartItem key={item.id} item={item} onUpdate={refreshCart} />
128
+ <div key={item.id}>
129
+ <CartItem item={item} onUpdate={refreshCart} />
130
+ {upgrades?.upgrades?.[item.productId] && (
131
+ <CartUpgradeBanner
132
+ suggestion={upgrades.upgrades[item.productId]}
133
+ cartItem={item}
134
+ onUpgrade={refreshCart}
135
+ className="mb-2 ms-24"
136
+ />
137
+ )}
138
+ </div>
96
139
  ))}
97
140
  </div>
98
141
 
142
+ {/* Bundle offers */}
143
+ {bundles?.bundles && bundles.bundles.length > 0 && (
144
+ <div className="mt-6 space-y-3">
145
+ <h3 className="text-foreground text-sm font-semibold">{t('bundleOffers')}</h3>
146
+ {bundles.bundles.map((offer) => (
147
+ <CartBundleOfferCard key={offer.id} offer={offer} onAdd={refreshCart} />
148
+ ))}
149
+ </div>
150
+ )}
151
+
99
152
  {/* Coupon input */}
100
153
  <div className="border-border mt-6 border-t pt-4">
101
154
  <CouponInput cart={cart} onUpdate={refreshCart} />
@@ -105,6 +158,7 @@ export default function CartPage() {
105
158
  {/* Summary sidebar */}
106
159
  <div className="lg:col-span-1">
107
160
  <div className="bg-muted/50 border-border sticky top-24 rounded-lg border p-6">
161
+ <FreeShippingBar className="mb-4" />
108
162
  <CartSummary />
109
163
 
110
164
  <Link
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { Suspense, useEffect, useState, useCallback } from 'react';
3
+ import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
4
4
  import { useSearchParams } from 'next/navigation';
5
5
  import Image from 'next/image';
6
6
  import Link from 'next/link';
@@ -10,6 +10,7 @@ import type {
10
10
  SetShippingAddressDto,
11
11
  ShippingDestinations,
12
12
  PickupLocation,
13
+ CheckoutBumpsResponse,
13
14
  } from 'brainerce';
14
15
  import { formatPrice } from 'brainerce';
15
16
  import { getClient } from '@/lib/brainerce';
@@ -20,6 +21,7 @@ import { PaymentStep } from '@/components/checkout/payment-step';
20
21
  import { DeliveryMethodStep } from '@/components/checkout/delivery-method-step';
21
22
  import { PickupStep } from '@/components/checkout/pickup-step';
22
23
  import { TaxDisplay } from '@/components/checkout/tax-display';
24
+ import { OrderBumpCard } from '@/components/checkout/order-bump-card';
23
25
  import { CouponInput } from '@/components/cart/coupon-input';
24
26
  import { ReservationCountdown } from '@/components/cart/reservation-countdown';
25
27
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
@@ -56,6 +58,9 @@ function CheckoutContent() {
56
58
  phone?: string;
57
59
  } | null>(null);
58
60
  const [hasSavedAddress, setHasSavedAddress] = useState(false);
61
+ const [orderBumps, setOrderBumps] = useState<CheckoutBumpsResponse | null>(null);
62
+ const [addedBumpIds, setAddedBumpIds] = useState<Set<string>>(new Set());
63
+ const [bumpLoading, setBumpLoading] = useState<string | null>(null);
59
64
 
60
65
  // Check for returning from canceled payment
61
66
  const canceled = searchParams.get('canceled') === 'true';
@@ -76,53 +81,62 @@ function CheckoutContent() {
76
81
  .catch(() => {});
77
82
  }, [isLoggedIn]);
78
83
 
79
- // Initialize or resume checkout
80
- const initCheckout = useCallback(async () => {
81
- try {
82
- setInitializing(true);
83
- setError(null);
84
- const client = getClient();
84
+ // Initialize or resume checkout (only once)
85
+ const checkoutInitRef = useRef(false);
86
+ const cartIdRef = useRef<string | null>(null);
85
87
 
86
- // Fetch shipping destinations and pickup locations in parallel
87
- client
88
- .getShippingDestinations()
89
- .then(setDestinations)
90
- .catch(() => {});
91
-
92
- const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]);
93
- setPickupLocations(locations);
88
+ useEffect(() => {
89
+ // Only init once, or if cart ID actually changed (e.g. cart was replaced)
90
+ if (!cart?.id) return;
91
+ if (checkoutInitRef.current && cartIdRef.current === cart.id) return;
92
+ checkoutInitRef.current = true;
93
+ cartIdRef.current = cart.id;
94
94
 
95
- // If returning with existing checkout ID, resume it
96
- if (existingCheckoutId) {
97
- const existing = await client.getCheckout(existingCheckoutId);
98
- setCheckout(existing);
95
+ const initCheckout = async () => {
96
+ try {
97
+ setInitializing(true);
98
+ setError(null);
99
+ const client = getClient();
99
100
 
100
- // Determine step based on checkout state
101
- const allDigital = existing.lineItems.every(
102
- (i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
103
- );
104
- setIsAllDigital(allDigital);
105
- if (allDigital) {
106
- // Digital products: show contact info step if email not set, else payment
107
- setStep(existing.email ? 'payment' : 'address');
108
- } else if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
109
- setDeliveryType('pickup');
110
- setStep('payment');
111
- } else if (existing.shippingAddress && existing.shippingRateId) {
112
- setStep('payment');
113
- } else if (existing.shippingAddress) {
114
- // Fetch shipping rates
115
- const rates = await client.getShippingRates(existing.id);
116
- setShippingRates(rates);
117
- setStep('shipping');
118
- } else if (locations.length > 0) {
119
- setStep('method');
101
+ // Fetch shipping destinations and pickup locations in parallel
102
+ client
103
+ .getShippingDestinations()
104
+ .then(setDestinations)
105
+ .catch(() => {});
106
+
107
+ const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]);
108
+ setPickupLocations(locations);
109
+
110
+ // If returning with existing checkout ID, resume it
111
+ if (existingCheckoutId) {
112
+ const existing = await client.getCheckout(existingCheckoutId);
113
+ setCheckout(existing);
114
+
115
+ // Determine step based on checkout state
116
+ const allDigital = existing.lineItems.every(
117
+ (i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
118
+ );
119
+ setIsAllDigital(allDigital);
120
+ if (allDigital) {
121
+ // Digital products: show contact info step if email not set, else payment
122
+ setStep(existing.email ? 'payment' : 'address');
123
+ } else if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
124
+ setDeliveryType('pickup');
125
+ setStep('payment');
126
+ } else if (existing.shippingAddress && existing.shippingRateId) {
127
+ setStep('payment');
128
+ } else if (existing.shippingAddress) {
129
+ // Fetch shipping rates
130
+ const rates = await client.getShippingRates(existing.id);
131
+ setShippingRates(rates);
132
+ setStep('shipping');
133
+ } else if (locations.length > 0) {
134
+ setStep('method');
135
+ }
136
+ return;
120
137
  }
121
- return;
122
- }
123
138
 
124
- // Create new checkout — cart is always server-side now
125
- if (cart && cart.id) {
139
+ // Create new checkout — cart is always server-side now
126
140
  const newCheckout = await client.createCheckout({ cartId: cart.id });
127
141
  setCheckout(newCheckout);
128
142
 
@@ -135,28 +149,72 @@ function CheckoutContent() {
135
149
  setStep('address');
136
150
  return;
137
151
  }
138
- } else {
139
- setError(t('cartIsEmpty'));
152
+
153
+ // If pickup locations exist, start with delivery method selection
154
+ if (locations.length > 0) {
155
+ setStep('method');
156
+ }
157
+ } catch (err) {
158
+ const message = err instanceof Error ? err.message : t('failedToInitCheckout');
159
+ setError(message);
160
+ } finally {
161
+ setInitializing(false);
140
162
  }
163
+ };
164
+
165
+ initCheckout();
166
+ }, [cart?.id, existingCheckoutId]);
141
167
 
142
- // If pickup locations exist, start with delivery method selection
143
- if (locations.length > 0) {
144
- setStep('method');
168
+ // Load order bumps when checkout is available
169
+ useEffect(() => {
170
+ if (!checkout?.id || storeInfo?.upsell?.checkoutOrderBumpEnabled === false) {
171
+ setOrderBumps(null);
172
+ return;
173
+ }
174
+ const client = getClient();
175
+ client
176
+ .getCheckoutBumps(checkout.id)
177
+ .then((data) => {
178
+ setOrderBumps(data);
179
+ // Detect already-added bumps from cart
180
+ if (cart?.items) {
181
+ const existingBumpIds = new Set<string>();
182
+ for (const item of cart.items) {
183
+ const meta = item.metadata as Record<string, unknown> | undefined;
184
+ if (meta?.isOrderBump && meta?.orderBumpId) {
185
+ existingBumpIds.add(meta.orderBumpId as string);
186
+ }
187
+ }
188
+ setAddedBumpIds(existingBumpIds);
189
+ }
190
+ })
191
+ .catch(() => {});
192
+ }, [checkout?.id, storeInfo?.upsell?.checkoutOrderBumpEnabled]);
193
+
194
+ // Handle bump toggle
195
+ async function handleBumpToggle(bumpId: string, add: boolean) {
196
+ if (!cart?.id || bumpLoading) return;
197
+ try {
198
+ setBumpLoading(bumpId);
199
+ const client = getClient();
200
+ if (add) {
201
+ await client.addOrderBump(cart.id, bumpId);
202
+ setAddedBumpIds((prev) => new Set([...prev, bumpId]));
203
+ } else {
204
+ await client.removeOrderBump(cart.id, bumpId);
205
+ setAddedBumpIds((prev) => {
206
+ const next = new Set(prev);
207
+ next.delete(bumpId);
208
+ return next;
209
+ });
145
210
  }
211
+ await refreshCart();
146
212
  } catch (err) {
147
- const message = err instanceof Error ? err.message : t('failedToInitCheckout');
148
- setError(message);
213
+ console.error('Failed to toggle order bump:', err);
149
214
  } finally {
150
- setInitializing(false);
151
- }
152
- }, [existingCheckoutId, cart]);
153
-
154
- const cartLoaded = cart !== null;
155
- useEffect(() => {
156
- if (cartLoaded) {
157
- initCheckout();
215
+ setBumpLoading(null);
158
216
  }
159
- }, [cartLoaded, initCheckout]);
217
+ }
160
218
 
161
219
  // Handle shipping address submission
162
220
  async function handleAddressSubmit(
@@ -660,6 +718,24 @@ function CheckoutContent() {
660
718
  )
661
719
  )}
662
720
 
721
+ {/* Order bumps */}
722
+ {orderBumps?.bumps && orderBumps.bumps.length > 0 && (
723
+ <div className="border-border space-y-2 border-t pt-4">
724
+ <p className="text-foreground text-xs font-semibold uppercase tracking-wide">
725
+ {t('addToYourOrder')}
726
+ </p>
727
+ {orderBumps.bumps.map((bump) => (
728
+ <OrderBumpCard
729
+ key={bump.id}
730
+ bump={bump}
731
+ isAdded={addedBumpIds.has(bump.id)}
732
+ onToggle={handleBumpToggle}
733
+ loading={bumpLoading === bump.id}
734
+ />
735
+ ))}
736
+ </div>
737
+ )}
738
+
663
739
  {/* Coupon input — show from shipping/pickup step onwards (or immediately if digital) */}
664
740
  {cart &&
665
741
  (isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
@@ -16,6 +16,7 @@ import { LoadingSpinner } from '@/components/shared/loading-spinner';
16
16
  import { VariantSelector } from '@/components/products/variant-selector';
17
17
  import { StockBadge } from '@/components/products/stock-badge';
18
18
  import { RecommendationSection } from '@/components/products/recommendation-section';
19
+ import { FrequentlyBoughtTogether } from '@/components/products/frequently-bought-together';
19
20
  import { useTranslations } from '@/lib/translations';
20
21
  import { cn } from '@/lib/utils';
21
22
 
@@ -454,6 +455,15 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
454
455
  </div>
455
456
  </div>
456
457
 
458
+ {/* Frequently Bought Together (cross-sells) */}
459
+ {recommendations?.crossSells && recommendations.crossSells.length > 0 && (
460
+ <FrequentlyBoughtTogether
461
+ items={recommendations.crossSells}
462
+ currentProduct={product}
463
+ className="mt-12"
464
+ />
465
+ )}
466
+
457
467
  {/* Upsells */}
458
468
  {recommendations?.upsells && recommendations.upsells.length > 0 && (
459
469
  <RecommendationSection
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import Image from 'next/image';
5
+ import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
6
+ import { formatPrice } from 'brainerce';
7
+ import { getClient } from '@/lib/brainerce';
8
+ import { useStoreInfo } from '@/providers/store-provider';
9
+ import { useTranslations } from '@/lib/translations';
10
+ import { cn } from '@/lib/utils';
11
+
12
+ interface CartBundleOfferCardProps {
13
+ offer: CartBundleOfferType;
14
+ onAdd: () => void;
15
+ className?: string;
16
+ }
17
+
18
+ export function CartBundleOfferCard({ offer, onAdd, className }: CartBundleOfferCardProps) {
19
+ const { storeInfo } = useStoreInfo();
20
+ const t = useTranslations('cart');
21
+ const currency = storeInfo?.currency || 'USD';
22
+ const [adding, setAdding] = useState(false);
23
+
24
+ const product = offer.bundleProduct;
25
+ const firstImage = product.images?.[0];
26
+ const imageUrl = firstImage ? (typeof firstImage === 'string' ? firstImage : firstImage.url) : null;
27
+ const originalPrice = parseFloat(offer.originalPrice);
28
+ const discountedPrice = parseFloat(offer.discountedPrice);
29
+ const discountLabel =
30
+ offer.discountType === 'PERCENTAGE'
31
+ ? `${offer.discountValue}%`
32
+ : formatPrice(parseFloat(offer.discountValue), { currency }) as string;
33
+
34
+ async function handleAdd() {
35
+ if (adding) return;
36
+ try {
37
+ setAdding(true);
38
+ const client = getClient();
39
+ await client.smartAddToCart({
40
+ productId: product.id,
41
+ variantId: offer.bundleVariantId || undefined,
42
+ quantity: 1,
43
+ });
44
+ onAdd();
45
+ } catch (err) {
46
+ console.error('Failed to add bundle item:', err);
47
+ } finally {
48
+ setAdding(false);
49
+ }
50
+ }
51
+
52
+ return (
53
+ <div
54
+ className={cn(
55
+ 'bg-background border-border flex items-center gap-4 rounded-lg border p-4',
56
+ className
57
+ )}
58
+ >
59
+ {/* Product image */}
60
+ <div className="bg-muted relative h-16 w-16 flex-shrink-0 overflow-hidden rounded">
61
+ {imageUrl ? (
62
+ <Image src={imageUrl} alt={product.name} fill sizes="64px" className="object-cover" />
63
+ ) : (
64
+ <div className="text-muted-foreground flex h-full w-full items-center justify-center">
65
+ <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
66
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
67
+ </svg>
68
+ </div>
69
+ )}
70
+ </div>
71
+
72
+ {/* Details */}
73
+ <div className="min-w-0 flex-1">
74
+ <p className="text-foreground text-sm font-medium">{offer.name}</p>
75
+ {offer.description && (
76
+ <p className="text-muted-foreground mt-0.5 text-xs">{offer.description}</p>
77
+ )}
78
+ <div className="mt-1 flex items-center gap-2">
79
+ <span className="text-muted-foreground text-sm line-through">
80
+ {formatPrice(originalPrice, { currency }) as string}
81
+ </span>
82
+ <span className="text-foreground text-sm font-semibold">
83
+ {formatPrice(discountedPrice, { currency }) as string}
84
+ </span>
85
+ <span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-xs font-medium">
86
+ -{discountLabel}
87
+ </span>
88
+ </div>
89
+ </div>
90
+
91
+ {/* Add button */}
92
+ <button
93
+ type="button"
94
+ onClick={handleAdd}
95
+ disabled={adding}
96
+ className={cn(
97
+ 'bg-primary text-primary-foreground flex-shrink-0 rounded px-4 py-2 text-xs font-medium transition-opacity hover:opacity-90',
98
+ 'disabled:cursor-not-allowed disabled:opacity-50'
99
+ )}
100
+ >
101
+ {adding ? t('addingBundle') : t('addBundleItem')}
102
+ </button>
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import Image from 'next/image';
5
+ import type { CartUpgradeSuggestion, CartItem as CartItemType } from 'brainerce';
6
+ import { formatPrice } from 'brainerce';
7
+ import { getClient } from '@/lib/brainerce';
8
+ import { useStoreInfo } from '@/providers/store-provider';
9
+ import { useTranslations } from '@/lib/translations';
10
+ import { cn } from '@/lib/utils';
11
+
12
+ interface CartUpgradeBannerProps {
13
+ suggestion: CartUpgradeSuggestion;
14
+ cartItem: CartItemType;
15
+ onUpgrade: () => void;
16
+ className?: string;
17
+ }
18
+
19
+ export function CartUpgradeBanner({
20
+ suggestion,
21
+ cartItem,
22
+ onUpgrade,
23
+ className,
24
+ }: CartUpgradeBannerProps) {
25
+ const { storeInfo } = useStoreInfo();
26
+ const t = useTranslations('cart');
27
+ const currency = storeInfo?.currency || 'USD';
28
+ const [upgrading, setUpgrading] = useState(false);
29
+ const [dismissed, setDismissed] = useState(false);
30
+
31
+ const storageKey = `dismissed_upgrade_${suggestion.sourceProductId}`;
32
+
33
+ useEffect(() => {
34
+ try {
35
+ if (sessionStorage.getItem(storageKey)) {
36
+ setDismissed(true);
37
+ }
38
+ } catch {}
39
+ }, [storageKey]);
40
+
41
+ if (dismissed) return null;
42
+
43
+ const target = suggestion.targetProduct;
44
+ const firstImage = target.images?.[0];
45
+ const imageUrl = firstImage ? (typeof firstImage === 'string' ? firstImage : firstImage.url) : null;
46
+ const formattedDelta = formatPrice(parseFloat(suggestion.priceDelta), { currency }) as string;
47
+
48
+ function handleDismiss() {
49
+ try {
50
+ sessionStorage.setItem(storageKey, '1');
51
+ } catch {}
52
+ setDismissed(true);
53
+ }
54
+
55
+ async function handleUpgrade() {
56
+ if (upgrading) return;
57
+ try {
58
+ setUpgrading(true);
59
+ const client = getClient();
60
+ await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
61
+ await client.smartAddToCart({ productId: target.id, quantity: cartItem.quantity });
62
+ onUpgrade();
63
+ } catch (err) {
64
+ console.error('Failed to upgrade cart item:', err);
65
+ } finally {
66
+ setUpgrading(false);
67
+ }
68
+ }
69
+
70
+ return (
71
+ <div
72
+ className={cn(
73
+ 'bg-primary/5 border-primary/20 relative flex items-center gap-3 rounded-lg border px-4 py-3',
74
+ className
75
+ )}
76
+ >
77
+ {/* Dismiss button */}
78
+ <button
79
+ type="button"
80
+ onClick={handleDismiss}
81
+ className="text-muted-foreground hover:text-foreground absolute end-2 top-2 text-xs"
82
+ aria-label={t('dismissUpgrade')}
83
+ >
84
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
85
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
86
+ </svg>
87
+ </button>
88
+
89
+ {/* Product image */}
90
+ <div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
91
+ {imageUrl ? (
92
+ <Image src={imageUrl} alt={target.name} fill sizes="48px" className="object-cover" />
93
+ ) : (
94
+ <div className="text-muted-foreground flex h-full w-full items-center justify-center">
95
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
96
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
97
+ </svg>
98
+ </div>
99
+ )}
100
+ </div>
101
+
102
+ {/* Text */}
103
+ <div className="min-w-0 flex-1">
104
+ <p className="text-foreground text-sm font-medium">
105
+ {t('upgradeFor', { name: target.name, amount: formattedDelta })}
106
+ </p>
107
+ </div>
108
+
109
+ {/* Upgrade button */}
110
+ <button
111
+ type="button"
112
+ onClick={handleUpgrade}
113
+ disabled={upgrading}
114
+ className={cn(
115
+ 'bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-opacity hover:opacity-90',
116
+ 'disabled:cursor-not-allowed disabled:opacity-50'
117
+ )}
118
+ >
119
+ {upgrading ? t('upgrading') : t('upgrade')}
120
+ </button>
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,64 @@
1
+ 'use client';
2
+
3
+ import { formatPrice } from 'brainerce';
4
+ import { useStoreInfo, useCart } from '@/providers/store-provider';
5
+ import { useTranslations } from '@/lib/translations';
6
+ import { cn } from '@/lib/utils';
7
+
8
+ interface FreeShippingBarProps {
9
+ className?: string;
10
+ }
11
+
12
+ export function FreeShippingBar({ className }: FreeShippingBarProps) {
13
+ const t = useTranslations('cart');
14
+ const { storeInfo } = useStoreInfo();
15
+ const { totals } = useCart();
16
+
17
+ const upsell = storeInfo?.upsell;
18
+ const threshold = upsell?.freeShippingThreshold;
19
+ const enabled = upsell?.freeShippingBarEnabled !== false;
20
+
21
+ // Don't render if disabled or no threshold configured
22
+ if (!enabled || !threshold || threshold <= 0) return null;
23
+
24
+ const subtotal = totals.subtotal;
25
+ const remaining = Math.max(0, threshold - subtotal);
26
+ const progress = Math.min(100, (subtotal / threshold) * 100);
27
+ const qualified = remaining <= 0;
28
+ const currency = storeInfo?.currency || 'USD';
29
+
30
+ // Don't show if already qualified
31
+ if (qualified) {
32
+ return (
33
+ <div className={cn('rounded-lg border border-green-200 bg-green-50 p-3', className)}>
34
+ <div className="flex items-center gap-2 text-sm font-medium text-green-700">
35
+ <svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
36
+ <path
37
+ strokeLinecap="round"
38
+ strokeLinejoin="round"
39
+ strokeWidth={2}
40
+ d="M5 13l4 4L19 7"
41
+ />
42
+ </svg>
43
+ {t('freeShippingQualified')}
44
+ </div>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ return (
50
+ <div className={cn('rounded-lg border border-amber-200 bg-amber-50 p-3', className)}>
51
+ <p className="mb-2 text-sm text-amber-800">
52
+ {t('freeShippingRemaining', {
53
+ amount: formatPrice(remaining, { currency }) as string,
54
+ })}
55
+ </p>
56
+ <div className="h-2 w-full overflow-hidden rounded-full bg-amber-200">
57
+ <div
58
+ className="h-full rounded-full bg-amber-500 transition-all duration-500 ease-out"
59
+ style={{ width: `${progress}%` }}
60
+ />
61
+ </div>
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,79 @@
1
+ 'use client';
2
+
3
+ import Image from 'next/image';
4
+ import type { OrderBump } from 'brainerce';
5
+ import { formatPrice } from 'brainerce';
6
+ import { useStoreInfo } from '@/providers/store-provider';
7
+ import { useTranslations } from '@/lib/translations';
8
+ import { cn } from '@/lib/utils';
9
+
10
+ interface OrderBumpCardProps {
11
+ bump: OrderBump;
12
+ isAdded: boolean;
13
+ onToggle: (bumpId: string, add: boolean) => void;
14
+ loading: boolean;
15
+ className?: string;
16
+ }
17
+
18
+ export function OrderBumpCard({ bump, isAdded, onToggle, loading, className }: OrderBumpCardProps) {
19
+ const { storeInfo } = useStoreInfo();
20
+ const t = useTranslations('checkout');
21
+ const currency = storeInfo?.currency || 'USD';
22
+
23
+ const product = bump.bumpProduct;
24
+ const firstImage = product.images?.[0];
25
+ const imageUrl = firstImage ? (typeof firstImage === 'string' ? firstImage : firstImage.url) : null;
26
+ const originalPrice = parseFloat(bump.originalPrice);
27
+ const hasDiscount = bump.discountedPrice != null;
28
+ const discountedPrice = hasDiscount ? parseFloat(bump.discountedPrice!) : null;
29
+
30
+ return (
31
+ <label
32
+ className={cn(
33
+ 'border-border hover:border-primary/50 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
34
+ isAdded && 'border-primary bg-primary/5',
35
+ loading && 'pointer-events-none opacity-60',
36
+ className
37
+ )}
38
+ >
39
+ <input
40
+ type="checkbox"
41
+ checked={isAdded}
42
+ onChange={() => onToggle(bump.id, !isAdded)}
43
+ disabled={loading}
44
+ className="mt-1 h-4 w-4 shrink-0 rounded"
45
+ />
46
+
47
+ {/* Image */}
48
+ {imageUrl && (
49
+ <div className="bg-muted relative h-10 w-10 shrink-0 overflow-hidden rounded">
50
+ <Image src={imageUrl} alt={product.name} fill sizes="40px" className="object-cover" />
51
+ </div>
52
+ )}
53
+
54
+ {/* Content */}
55
+ <div className="min-w-0 flex-1">
56
+ <p className="text-foreground text-sm font-medium">{bump.title}</p>
57
+ {bump.description && (
58
+ <p className="text-muted-foreground mt-0.5 text-xs">{bump.description}</p>
59
+ )}
60
+ <div className="mt-1 flex items-center gap-2">
61
+ {hasDiscount ? (
62
+ <>
63
+ <span className="text-muted-foreground text-xs line-through">
64
+ {formatPrice(originalPrice, { currency }) as string}
65
+ </span>
66
+ <span className="text-foreground text-sm font-semibold">
67
+ {formatPrice(discountedPrice!, { currency }) as string}
68
+ </span>
69
+ </>
70
+ ) : (
71
+ <span className="text-foreground text-sm font-semibold">
72
+ {formatPrice(originalPrice, { currency }) as string}
73
+ </span>
74
+ )}
75
+ </div>
76
+ </div>
77
+ </label>
78
+ );
79
+ }
@@ -0,0 +1,190 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import Image from 'next/image';
5
+ import type { Product, ProductRecommendation } from 'brainerce';
6
+ import { formatPrice } from 'brainerce';
7
+ import { useCart } from '@/providers/store-provider';
8
+ import { useStoreInfo } from '@/providers/store-provider';
9
+ import { useTranslations } from '@/lib/translations';
10
+ import { cn } from '@/lib/utils';
11
+
12
+ interface FrequentlyBoughtTogetherProps {
13
+ items: ProductRecommendation[];
14
+ currentProduct: Product;
15
+ className?: string;
16
+ }
17
+
18
+ function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
19
+ const sale = item.salePrice ? parseFloat(item.salePrice) : null;
20
+ const base = parseFloat(item.basePrice);
21
+ return sale != null && sale < base ? sale : base;
22
+ }
23
+
24
+ function ProductThumb({
25
+ name,
26
+ imageUrl,
27
+ price,
28
+ currency,
29
+ checked,
30
+ onToggle,
31
+ disabled,
32
+ }: {
33
+ name: string;
34
+ imageUrl: string | null;
35
+ price: number;
36
+ currency: string;
37
+ checked: boolean;
38
+ onToggle?: () => void;
39
+ disabled?: boolean;
40
+ }) {
41
+ return (
42
+ <label
43
+ className={cn(
44
+ 'border-border bg-background relative flex cursor-pointer flex-col items-center rounded-lg border p-3 transition-all',
45
+ checked ? 'ring-primary ring-2' : 'opacity-60',
46
+ disabled && 'pointer-events-none'
47
+ )}
48
+ >
49
+ {onToggle && (
50
+ <input
51
+ type="checkbox"
52
+ checked={checked}
53
+ onChange={onToggle}
54
+ className="absolute start-2 top-2 h-4 w-4 rounded"
55
+ />
56
+ )}
57
+ <div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
58
+ {imageUrl ? (
59
+ <Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
60
+ ) : (
61
+ <div className="flex h-full w-full items-center justify-center">
62
+ <svg className="text-muted-foreground h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
63
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
64
+ </svg>
65
+ </div>
66
+ )}
67
+ </div>
68
+ <span className="text-foreground line-clamp-2 text-center text-xs font-medium">{name}</span>
69
+ <span className="text-muted-foreground mt-1 text-xs">
70
+ {formatPrice(price, { currency }) as string}
71
+ </span>
72
+ </label>
73
+ );
74
+ }
75
+
76
+ export function FrequentlyBoughtTogether({
77
+ items,
78
+ currentProduct,
79
+ className,
80
+ }: FrequentlyBoughtTogetherProps) {
81
+ const { storeInfo } = useStoreInfo();
82
+ const { refreshCart } = useCart();
83
+ const t = useTranslations('productDetail');
84
+
85
+ // Only show up to 3 cross-sells
86
+ const crossSells = items.slice(0, 3);
87
+
88
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
89
+ const [adding, setAdding] = useState(false);
90
+
91
+ if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
92
+ if (crossSells.length === 0) return null;
93
+
94
+ const currency = storeInfo.currency || 'USD';
95
+
96
+ const currentPrice = getEffectivePrice(currentProduct);
97
+ const currentImage = currentProduct.images?.[0];
98
+ const currentImageUrl = currentImage
99
+ ? typeof currentImage === 'string'
100
+ ? currentImage
101
+ : currentImage.url
102
+ : null;
103
+
104
+ const totalPrice = crossSells
105
+ .filter((item) => selected.has(item.id))
106
+ .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
107
+
108
+ const toggleItem = (id: string) => {
109
+ setSelected((prev) => {
110
+ const next = new Set(prev);
111
+ if (next.has(id)) {
112
+ next.delete(id);
113
+ } else {
114
+ next.add(id);
115
+ }
116
+ return next;
117
+ });
118
+ };
119
+
120
+ async function handleAddAll() {
121
+ if (adding || selected.size === 0) return;
122
+ try {
123
+ setAdding(true);
124
+ const { getClient } = await import('@/lib/brainerce');
125
+ const client = getClient();
126
+ const selectedItems = crossSells.filter((item) => selected.has(item.id));
127
+ for (const item of selectedItems) {
128
+ await client.smartAddToCart({ productId: item.id, quantity: 1 });
129
+ }
130
+ await refreshCart();
131
+ } catch (err) {
132
+ console.error('Failed to add items to cart:', err);
133
+ } finally {
134
+ setAdding(false);
135
+ }
136
+ }
137
+
138
+ return (
139
+ <div className={cn('border-border rounded-lg border p-6', className)}>
140
+ <h2 className="text-foreground mb-4 text-xl font-semibold">{t('frequentlyBoughtTogether')}</h2>
141
+
142
+ <div className="flex flex-wrap items-center gap-3">
143
+ {/* Current product (always included, no checkbox) */}
144
+ <ProductThumb
145
+ name={currentProduct.name}
146
+ imageUrl={currentImageUrl}
147
+ price={currentPrice}
148
+ currency={currency}
149
+ checked={true}
150
+ disabled
151
+ />
152
+
153
+ {crossSells.map((item) => {
154
+ const img = item.images?.[0];
155
+ const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
156
+ return (
157
+ <div key={item.id} className="flex items-center gap-3">
158
+ <span className="text-muted-foreground text-lg font-light">+</span>
159
+ <ProductThumb
160
+ name={item.name}
161
+ imageUrl={imgUrl}
162
+ price={getEffectivePrice(item)}
163
+ currency={currency}
164
+ checked={selected.has(item.id)}
165
+ onToggle={() => toggleItem(item.id)}
166
+ />
167
+ </div>
168
+ );
169
+ })}
170
+ </div>
171
+
172
+ {/* Total + Add button */}
173
+ <div className="mt-4 flex flex-wrap items-center gap-4">
174
+ <span className="text-foreground text-lg font-semibold">
175
+ {t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
176
+ </span>
177
+ <button
178
+ onClick={handleAddAll}
179
+ disabled={adding || selected.size === 0}
180
+ className={cn(
181
+ 'bg-primary text-primary-foreground rounded px-5 py-2.5 text-sm font-medium transition-opacity hover:opacity-90',
182
+ 'disabled:cursor-not-allowed disabled:opacity-50'
183
+ )}
184
+ >
185
+ {adding ? t('addingAll') : t('addSelectedToCart')}
186
+ </button>
187
+ </div>
188
+ </div>
189
+ );
190
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { InventoryInfo } from 'brainerce';
4
4
  import { cn } from '@/lib/utils';
5
+ import { useTranslations } from '@/lib/translations';
5
6
 
6
7
  interface StockBadgeProps {
7
8
  inventory: InventoryInfo | null | undefined;
@@ -10,7 +11,8 @@ interface StockBadgeProps {
10
11
  }
11
12
 
12
13
  export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
13
- const label = getStockLabel(inventory, lowStockThreshold);
14
+ const t = useTranslations('productDetail');
15
+ const label = getStockLabel(inventory, lowStockThreshold, t);
14
16
  const color = getStockColor(inventory, lowStockThreshold);
15
17
 
16
18
  return (
@@ -28,21 +30,22 @@ export function StockBadge({ inventory, lowStockThreshold = 5, className }: Stoc
28
30
 
29
31
  function getStockLabel(
30
32
  inventory: InventoryInfo | null | undefined,
31
- lowStockThreshold: number
33
+ lowStockThreshold: number,
34
+ t: (key: string) => string
32
35
  ): string {
33
- if (!inventory) return 'Out of Stock';
36
+ if (!inventory) return t('outOfStock');
34
37
 
35
38
  const { trackingMode, inStock, available } = inventory;
36
39
 
37
- if (trackingMode === 'DISABLED') return 'Unavailable';
38
- if (!inStock) return 'Out of Stock';
39
- if (trackingMode === 'UNLIMITED') return 'In Stock';
40
+ if (trackingMode === 'DISABLED') return t('unavailable');
41
+ if (!inStock) return t('outOfStock');
42
+ if (trackingMode === 'UNLIMITED') return t('inStock');
40
43
 
41
44
  // TRACKED — show actual quantity
42
45
  if (available <= lowStockThreshold) {
43
- return `Only ${available} left`;
46
+ return t('onlyLeft').replace('{available}', String(available));
44
47
  }
45
- return `${available} in stock`;
48
+ return t('availableInStock').replace('{available}', String(available));
46
49
  }
47
50
 
48
51
  function getStockColor(
@@ -2,8 +2,10 @@
2
2
 
3
3
  import { useMemo } from 'react';
4
4
  import type { Product, ProductVariant } from 'brainerce';
5
- import { getVariantOptions, getProductSwatches, getStockStatus, formatPrice } from 'brainerce';
5
+ import { getVariantOptions, getProductSwatches, formatPrice } from 'brainerce';
6
+ import type { InventoryInfo } from 'brainerce';
6
7
  import { useStoreInfo } from '@/providers/store-provider';
8
+ import { useTranslations } from '@/lib/translations';
7
9
  import { cn } from '@/lib/utils';
8
10
 
9
11
  interface VariantSelectorProps {
@@ -32,6 +34,7 @@ export function VariantSelector({
32
34
  className,
33
35
  }: VariantSelectorProps) {
34
36
  const { storeInfo } = useStoreInfo();
37
+ const t = useTranslations('productDetail');
35
38
  const currency = storeInfo?.currency || 'USD';
36
39
  const variants = useMemo(() => product.variants || [], [product.variants]);
37
40
 
@@ -269,9 +272,21 @@ export function VariantSelector({
269
272
  }
270
273
  </span>
271
274
  )}
272
- <span>{getStockStatus(selectedVariant.inventory)}</span>
275
+ <span>{getTranslatedStockStatus(selectedVariant.inventory, t)}</span>
273
276
  </div>
274
277
  )}
275
278
  </div>
276
279
  );
277
280
  }
281
+
282
+ function getTranslatedStockStatus(
283
+ inventory: InventoryInfo | null | undefined,
284
+ t: (key: string) => string
285
+ ): string {
286
+ if (!inventory) return t('outOfStock');
287
+ const { trackingMode, inStock, available } = inventory;
288
+ if (trackingMode === 'DISABLED') return t('unavailable');
289
+ if (!inStock) return t('outOfStock');
290
+ if (trackingMode === 'UNLIMITED') return t('inStock');
291
+ return t('availableInStock').replace('{available}', String(available));
292
+ }