create-brainerce-store 1.43.0 → 1.43.1

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 (24) hide show
  1. package/dist/index.js +1 -1
  2. package/messages/en.json +1 -0
  3. package/messages/he.json +1 -0
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/next.config.ts +68 -69
  6. package/templates/nextjs/base/scripts/fetch-store-info.mjs +91 -93
  7. package/templates/nextjs/base/src/app/checkout/page.tsx +1004 -982
  8. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +118 -118
  9. package/templates/nextjs/base/src/components/account/order-history.tsx +368 -368
  10. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +111 -111
  11. package/templates/nextjs/base/src/components/cart/cart-item.tsx +152 -152
  12. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +108 -108
  13. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +141 -141
  14. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +62 -62
  15. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
  16. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
  17. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +109 -109
  18. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -64
  19. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +203 -203
  20. package/templates/nextjs/base/src/components/products/variant-selector.tsx +291 -291
  21. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +125 -129
  22. package/templates/nextjs/base/src/components/shared/price-display.tsx +61 -61
  23. package/templates/nextjs/base/src/lib/resolve-currency.ts +1 -6
  24. package/templates/nextjs/base/src/lib/use-currency.ts +1 -6
@@ -1,108 +1,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
+ '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,141 +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 { 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
+ '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,62 +1,62 @@
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
- }
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
+ }