create-brainerce-store 1.42.0 → 1.43.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 (22) hide show
  1. package/dist/index.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/nextjs/base/scripts/fetch-store-info.mjs +10 -4
  4. package/templates/nextjs/base/src/app/checkout/page.tsx +982 -981
  5. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +118 -117
  6. package/templates/nextjs/base/src/components/account/order-history.tsx +368 -367
  7. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +111 -112
  8. package/templates/nextjs/base/src/components/cart/cart-item.tsx +152 -153
  9. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +108 -108
  10. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +141 -142
  11. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +62 -59
  12. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -243
  13. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -199
  14. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +109 -110
  15. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +64 -65
  16. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +203 -202
  17. package/templates/nextjs/base/src/components/products/product-card.tsx +226 -226
  18. package/templates/nextjs/base/src/components/products/variant-selector.tsx +291 -292
  19. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +129 -125
  20. package/templates/nextjs/base/src/components/shared/price-display.tsx +61 -65
  21. package/templates/nextjs/base/src/lib/resolve-currency.ts +25 -0
  22. package/templates/nextjs/base/src/lib/use-currency.ts +24 -0
@@ -1,108 +1,108 @@
1
- 'use client';
2
-
3
- import { formatPrice } from 'brainerce';
4
- import { useTranslations } from '@/lib/translations';
5
- import { useStoreInfo, useCart } from '@/providers/store-provider';
6
- import { cn } from '@/lib/utils';
7
-
8
- interface CartSummaryProps {
9
- className?: string;
10
- }
11
-
12
- export function CartSummary({ className }: CartSummaryProps) {
13
- const t = useTranslations('cart');
14
- const tc = useTranslations('common');
15
- const { storeInfo } = useStoreInfo();
16
- const { totals, cart } = useCart();
17
- const currency = storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
18
-
19
- const rules = cart?.appliedDiscounts;
20
- const ruleAmt = cart?.ruleDiscountAmount ? parseFloat(cart.ruleDiscountAmount) : 0;
21
- const couponAmt = totals.discount - ruleAmt;
22
-
23
- return (
24
- <div className={cn('space-y-3', className)}>
25
- <h3 className="text-foreground text-lg font-semibold">{t('orderSummary')}</h3>
26
-
27
- <div className="space-y-2 text-sm">
28
- {/* Subtotal */}
29
- <div className="flex items-center justify-between">
30
- <span className="text-muted-foreground">{tc('subtotal')}</span>
31
- <span className="text-foreground font-medium">
32
- {formatPrice(totals.subtotal, { currency }) as string}
33
- </span>
34
- </div>
35
-
36
- {/* Rule discounts - show each rule by name */}
37
- {rules && rules.length > 0
38
- ? rules.map((rule) => (
39
- <div key={rule.ruleId} className="flex items-center justify-between">
40
- <span className="text-muted-foreground">{rule.ruleName}</span>
41
- <span className="text-destructive font-medium">
42
- -{formatPrice(parseFloat(rule.discountAmount), { currency }) as string}
43
- </span>
44
- </div>
45
- ))
46
- : ruleAmt > 0 && (
47
- <div className="flex items-center justify-between">
48
- <span className="text-muted-foreground">{tc('generalDiscount')}</span>
49
- <span className="text-destructive font-medium">
50
- -{formatPrice(ruleAmt, { currency }) as string}
51
- </span>
52
- </div>
53
- )}
54
-
55
- {/* Coupon discount */}
56
- {cart?.couponCode && couponAmt > 0 && (
57
- <div className="flex items-center justify-between">
58
- <span className="text-muted-foreground">
59
- {tc('couponDiscount')} ({cart.couponCode})
60
- </span>
61
- <span className="text-destructive font-medium">
62
- -{formatPrice(couponAmt, { currency }) as string}
63
- </span>
64
- </div>
65
- )}
66
-
67
- {/* Fallback: generic discount when no breakdown available */}
68
- {totals.discount > 0 &&
69
- ruleAmt <= 0 &&
70
- !cart?.couponCode &&
71
- (!rules || rules.length === 0) && (
72
- <div className="flex items-center justify-between">
73
- <span className="text-muted-foreground">{tc('discount')}</span>
74
- <span className="text-destructive font-medium">
75
- -{formatPrice(totals.discount, { currency }) as string}
76
- </span>
77
- </div>
78
- )}
79
-
80
- {/* Shipping */}
81
- {totals.shipping > 0 && (
82
- <div className="flex items-center justify-between">
83
- <span className="text-muted-foreground">{tc('shipping')}</span>
84
- <span className="text-foreground font-medium">
85
- {formatPrice(totals.shipping, { currency }) as string}
86
- </span>
87
- </div>
88
- )}
89
-
90
- {/* Tax */}
91
- <div className="flex items-center justify-between">
92
- <span className="text-muted-foreground">{tc('tax')}</span>
93
- <span className="text-muted-foreground text-xs">{t('taxAtCheckout')}</span>
94
- </div>
95
-
96
- {/* Divider */}
97
- <div className="border-border mt-2 border-t pt-2">
98
- <div className="flex items-center justify-between">
99
- <span className="text-foreground font-semibold">{tc('total')}</span>
100
- <span className="text-foreground text-base font-semibold">
101
- {formatPrice(totals.total, { currency }) as string}
102
- </span>
103
- </div>
104
- </div>
105
- </div>
106
- </div>
107
- );
108
- }
1
+ 'use client';
2
+
3
+ import { formatPrice } from 'brainerce';
4
+ import { useTranslations } from '@/lib/translations';
5
+ import { useCart } from '@/providers/store-provider';
6
+ import { useCurrency } from '@/lib/use-currency';
7
+ import { cn } from '@/lib/utils';
8
+
9
+ interface CartSummaryProps {
10
+ className?: string;
11
+ }
12
+
13
+ export function CartSummary({ className }: CartSummaryProps) {
14
+ const t = useTranslations('cart');
15
+ const tc = useTranslations('common');
16
+ const { totals, cart } = useCart();
17
+ const currency = useCurrency();
18
+
19
+ const rules = cart?.appliedDiscounts;
20
+ const ruleAmt = cart?.ruleDiscountAmount ? parseFloat(cart.ruleDiscountAmount) : 0;
21
+ const couponAmt = totals.discount - ruleAmt;
22
+
23
+ return (
24
+ <div className={cn('space-y-3', className)}>
25
+ <h3 className="text-foreground text-lg font-semibold">{t('orderSummary')}</h3>
26
+
27
+ <div className="space-y-2 text-sm">
28
+ {/* Subtotal */}
29
+ <div className="flex items-center justify-between">
30
+ <span className="text-muted-foreground">{tc('subtotal')}</span>
31
+ <span className="text-foreground font-medium">
32
+ {formatPrice(totals.subtotal, { currency }) as string}
33
+ </span>
34
+ </div>
35
+
36
+ {/* Rule discounts - show each rule by name */}
37
+ {rules && rules.length > 0
38
+ ? rules.map((rule) => (
39
+ <div key={rule.ruleId} className="flex items-center justify-between">
40
+ <span className="text-muted-foreground">{rule.ruleName}</span>
41
+ <span className="text-destructive font-medium">
42
+ -{formatPrice(parseFloat(rule.discountAmount), { currency }) as string}
43
+ </span>
44
+ </div>
45
+ ))
46
+ : ruleAmt > 0 && (
47
+ <div className="flex items-center justify-between">
48
+ <span className="text-muted-foreground">{tc('generalDiscount')}</span>
49
+ <span className="text-destructive font-medium">
50
+ -{formatPrice(ruleAmt, { currency }) as string}
51
+ </span>
52
+ </div>
53
+ )}
54
+
55
+ {/* Coupon discount */}
56
+ {cart?.couponCode && couponAmt > 0 && (
57
+ <div className="flex items-center justify-between">
58
+ <span className="text-muted-foreground">
59
+ {tc('couponDiscount')} ({cart.couponCode})
60
+ </span>
61
+ <span className="text-destructive font-medium">
62
+ -{formatPrice(couponAmt, { currency }) as string}
63
+ </span>
64
+ </div>
65
+ )}
66
+
67
+ {/* Fallback: generic discount when no breakdown available */}
68
+ {totals.discount > 0 &&
69
+ ruleAmt <= 0 &&
70
+ !cart?.couponCode &&
71
+ (!rules || rules.length === 0) && (
72
+ <div className="flex items-center justify-between">
73
+ <span className="text-muted-foreground">{tc('discount')}</span>
74
+ <span className="text-destructive font-medium">
75
+ -{formatPrice(totals.discount, { currency }) as string}
76
+ </span>
77
+ </div>
78
+ )}
79
+
80
+ {/* Shipping */}
81
+ {totals.shipping > 0 && (
82
+ <div className="flex items-center justify-between">
83
+ <span className="text-muted-foreground">{tc('shipping')}</span>
84
+ <span className="text-foreground font-medium">
85
+ {formatPrice(totals.shipping, { currency }) as string}
86
+ </span>
87
+ </div>
88
+ )}
89
+
90
+ {/* Tax */}
91
+ <div className="flex items-center justify-between">
92
+ <span className="text-muted-foreground">{tc('tax')}</span>
93
+ <span className="text-muted-foreground text-xs">{t('taxAtCheckout')}</span>
94
+ </div>
95
+
96
+ {/* Divider */}
97
+ <div className="border-border mt-2 border-t pt-2">
98
+ <div className="flex items-center justify-between">
99
+ <span className="text-foreground font-semibold">{tc('total')}</span>
100
+ <span className="text-foreground text-base font-semibold">
101
+ {formatPrice(totals.total, { currency }) as string}
102
+ </span>
103
+ </div>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ );
108
+ }
@@ -1,142 +1,141 @@
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 || process.env.NEXT_PUBLIC_STORE_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
- /* ignore */
40
- }
41
- }, [storageKey]);
42
-
43
- if (dismissed) return null;
44
-
45
- const target = suggestion.targetProduct;
46
- const firstImage = target.images?.[0];
47
- const imageUrl = firstImage
48
- ? typeof firstImage === 'string'
49
- ? firstImage
50
- : firstImage.url
51
- : null;
52
- const formattedDelta = formatPrice(parseFloat(suggestion.priceDelta), { currency }) as string;
53
-
54
- function handleDismiss() {
55
- try {
56
- sessionStorage.setItem(storageKey, '1');
57
- } catch {
58
- /* ignore */
59
- }
60
- setDismissed(true);
61
- }
62
-
63
- async function handleUpgrade() {
64
- if (upgrading) return;
65
- try {
66
- setUpgrading(true);
67
- const client = getClient();
68
- await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
69
- await client.smartAddToCart({ productId: target.id, quantity: cartItem.quantity });
70
- onUpgrade();
71
- } catch (err) {
72
- console.error('Failed to upgrade cart item:', err);
73
- } finally {
74
- setUpgrading(false);
75
- }
76
- }
77
-
78
- return (
79
- <div
80
- className={cn(
81
- 'bg-primary/5 border-primary/20 relative flex items-center gap-3 rounded-lg border px-4 py-3',
82
- className
83
- )}
84
- >
85
- {/* Dismiss button */}
86
- <button
87
- type="button"
88
- onClick={handleDismiss}
89
- className="text-muted-foreground hover:text-foreground absolute end-2 top-2 text-xs"
90
- aria-label={t('dismissUpgrade')}
91
- >
92
- <svg
93
- className="h-4 w-4"
94
- fill="none"
95
- viewBox="0 0 24 24"
96
- stroke="currentColor"
97
- strokeWidth={2}
98
- >
99
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
100
- </svg>
101
- </button>
102
-
103
- {/* Product image */}
104
- <div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
105
- {imageUrl ? (
106
- <Image src={imageUrl} alt={target.name} fill sizes="48px" className="object-cover" />
107
- ) : (
108
- <div className="text-muted-foreground flex h-full w-full items-center justify-center">
109
- <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
110
- <path
111
- strokeLinecap="round"
112
- strokeLinejoin="round"
113
- strokeWidth={1.5}
114
- 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"
115
- />
116
- </svg>
117
- </div>
118
- )}
119
- </div>
120
-
121
- {/* Text */}
122
- <div className="min-w-0 flex-1">
123
- <p className="text-foreground text-sm font-medium">
124
- {t('upgradeFor', { name: target.name, amount: formattedDelta })}
125
- </p>
126
- </div>
127
-
128
- {/* Upgrade button */}
129
- <button
130
- type="button"
131
- onClick={handleUpgrade}
132
- disabled={upgrading}
133
- className={cn(
134
- 'bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-opacity hover:opacity-90',
135
- 'disabled:cursor-not-allowed disabled:opacity-50'
136
- )}
137
- >
138
- {upgrading ? t('upgrading') : t('upgrade')}
139
- </button>
140
- </div>
141
- );
142
- }
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 { useCurrency } from '@/lib/use-currency';
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 t = useTranslations('cart');
26
+ const currency = useCurrency();
27
+ const [upgrading, setUpgrading] = useState(false);
28
+ const [dismissed, setDismissed] = useState(false);
29
+
30
+ const storageKey = `dismissed_upgrade_${suggestion.sourceProductId}`;
31
+
32
+ useEffect(() => {
33
+ try {
34
+ if (sessionStorage.getItem(storageKey)) {
35
+ setDismissed(true);
36
+ }
37
+ } catch {
38
+ /* ignore */
39
+ }
40
+ }, [storageKey]);
41
+
42
+ if (dismissed) return null;
43
+
44
+ const target = suggestion.targetProduct;
45
+ const firstImage = target.images?.[0];
46
+ const imageUrl = firstImage
47
+ ? typeof firstImage === 'string'
48
+ ? firstImage
49
+ : firstImage.url
50
+ : null;
51
+ const formattedDelta = formatPrice(parseFloat(suggestion.priceDelta), { currency }) as string;
52
+
53
+ function handleDismiss() {
54
+ try {
55
+ sessionStorage.setItem(storageKey, '1');
56
+ } catch {
57
+ /* ignore */
58
+ }
59
+ setDismissed(true);
60
+ }
61
+
62
+ async function handleUpgrade() {
63
+ if (upgrading) return;
64
+ try {
65
+ setUpgrading(true);
66
+ const client = getClient();
67
+ await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
68
+ await client.smartAddToCart({ productId: target.id, quantity: cartItem.quantity });
69
+ onUpgrade();
70
+ } catch (err) {
71
+ console.error('Failed to upgrade cart item:', err);
72
+ } finally {
73
+ setUpgrading(false);
74
+ }
75
+ }
76
+
77
+ return (
78
+ <div
79
+ className={cn(
80
+ 'bg-primary/5 border-primary/20 relative flex items-center gap-3 rounded-lg border px-4 py-3',
81
+ className
82
+ )}
83
+ >
84
+ {/* Dismiss button */}
85
+ <button
86
+ type="button"
87
+ onClick={handleDismiss}
88
+ className="text-muted-foreground hover:text-foreground absolute end-2 top-2 text-xs"
89
+ aria-label={t('dismissUpgrade')}
90
+ >
91
+ <svg
92
+ className="h-4 w-4"
93
+ fill="none"
94
+ viewBox="0 0 24 24"
95
+ stroke="currentColor"
96
+ strokeWidth={2}
97
+ >
98
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
99
+ </svg>
100
+ </button>
101
+
102
+ {/* Product image */}
103
+ <div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
104
+ {imageUrl ? (
105
+ <Image src={imageUrl} alt={target.name} fill sizes="48px" className="object-cover" />
106
+ ) : (
107
+ <div className="text-muted-foreground flex h-full w-full items-center justify-center">
108
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
109
+ <path
110
+ strokeLinecap="round"
111
+ strokeLinejoin="round"
112
+ strokeWidth={1.5}
113
+ 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"
114
+ />
115
+ </svg>
116
+ </div>
117
+ )}
118
+ </div>
119
+
120
+ {/* Text */}
121
+ <div className="min-w-0 flex-1">
122
+ <p className="text-foreground text-sm font-medium">
123
+ {t('upgradeFor', { name: target.name, amount: formattedDelta })}
124
+ </p>
125
+ </div>
126
+
127
+ {/* Upgrade button */}
128
+ <button
129
+ type="button"
130
+ onClick={handleUpgrade}
131
+ disabled={upgrading}
132
+ className={cn(
133
+ 'bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-opacity hover:opacity-90',
134
+ 'disabled:cursor-not-allowed disabled:opacity-50'
135
+ )}
136
+ >
137
+ {upgrading ? t('upgrading') : t('upgrade')}
138
+ </button>
139
+ </div>
140
+ );
141
+ }
@@ -1,59 +1,62 @@
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 || process.env.NEXT_PUBLIC_STORE_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 strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
37
- </svg>
38
- {t('freeShippingQualified')}
39
- </div>
40
- </div>
41
- );
42
- }
43
-
44
- return (
45
- <div className={cn('rounded-lg border border-amber-200 bg-amber-50 p-3', className)}>
46
- <p className="mb-2 text-sm text-amber-800">
47
- {t('freeShippingRemaining', {
48
- amount: formatPrice(remaining, { currency }) as string,
49
- })}
50
- </p>
51
- <div className="h-2 w-full overflow-hidden rounded-full bg-amber-200">
52
- <div
53
- className="h-full rounded-full bg-amber-500 transition-all duration-500 ease-out"
54
- style={{ width: `${progress}%` }}
55
- />
56
- </div>
57
- </div>
58
- );
59
- }
1
+ 'use client';
2
+
3
+ import { formatPrice } from 'brainerce';
4
+ import { useStoreInfo, useCart } from '@/providers/store-provider';
5
+ import { useCurrency } from '@/lib/use-currency';
6
+ import { useTranslations } from '@/lib/translations';
7
+ import { cn } from '@/lib/utils';
8
+
9
+ interface FreeShippingBarProps {
10
+ className?: string;
11
+ }
12
+
13
+ export function FreeShippingBar({ className }: FreeShippingBarProps) {
14
+ // Hooks must be called unconditionally and in the same order on every
15
+ // render keep all of them above any early `return null` branch.
16
+ const t = useTranslations('cart');
17
+ const { storeInfo } = useStoreInfo();
18
+ const { totals } = useCart();
19
+ const currency = useCurrency();
20
+
21
+ const upsell = storeInfo?.upsell;
22
+ const threshold = upsell?.freeShippingThreshold;
23
+ const enabled = upsell?.freeShippingBarEnabled !== false;
24
+
25
+ // Don't render if disabled or no threshold configured
26
+ if (!enabled || !threshold || threshold <= 0) return null;
27
+
28
+ const subtotal = totals.subtotal;
29
+ const remaining = Math.max(0, threshold - subtotal);
30
+ const progress = Math.min(100, (subtotal / threshold) * 100);
31
+ const qualified = remaining <= 0;
32
+
33
+ // Don't show if already qualified
34
+ if (qualified) {
35
+ return (
36
+ <div className={cn('rounded-lg border border-green-200 bg-green-50 p-3', className)}>
37
+ <div className="flex items-center gap-2 text-sm font-medium text-green-700">
38
+ <svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
39
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
40
+ </svg>
41
+ {t('freeShippingQualified')}
42
+ </div>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ return (
48
+ <div className={cn('rounded-lg border border-amber-200 bg-amber-50 p-3', className)}>
49
+ <p className="mb-2 text-sm text-amber-800">
50
+ {t('freeShippingRemaining', {
51
+ amount: formatPrice(remaining, { currency }) as string,
52
+ })}
53
+ </p>
54
+ <div className="h-2 w-full overflow-hidden rounded-full bg-amber-200">
55
+ <div
56
+ className="h-full rounded-full bg-amber-500 transition-all duration-500 ease-out"
57
+ style={{ width: `${progress}%` }}
58
+ />
59
+ </div>
60
+ </div>
61
+ );
62
+ }