create-brainerce-store 1.41.0 → 1.42.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 (30) hide show
  1. package/dist/index.js +41 -12
  2. package/messages/en.json +441 -441
  3. package/messages/he.json +441 -441
  4. package/package.json +2 -2
  5. package/templates/nextjs/base/TRANSLATIONS.md +200 -0
  6. package/templates/nextjs/base/next.config.ts +22 -0
  7. package/templates/nextjs/base/package.json.ejs +3 -0
  8. package/templates/nextjs/base/src/app/checkout/page.tsx +1 -1
  9. package/templates/nextjs/base/src/app/layout.tsx.ejs +14 -6
  10. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +9 -4
  11. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +45 -5
  12. package/templates/nextjs/base/src/components/account/order-history.tsx +367 -367
  13. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +112 -112
  14. package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
  15. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +108 -108
  16. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -142
  17. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -59
  18. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +243 -243
  19. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +199 -199
  20. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +110 -110
  21. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +65 -65
  22. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -202
  23. package/templates/nextjs/base/src/components/products/product-card.tsx +226 -226
  24. package/templates/nextjs/base/src/components/products/variant-selector.tsx +292 -292
  25. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +5 -1
  26. package/templates/nextjs/base/src/components/shared/price-display.tsx +65 -62
  27. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +42 -0
  28. package/templates/nextjs/base/src/lib/store-info.ts +48 -0
  29. package/templates/nextjs/base/src/lib/utils.ts +21 -6
  30. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +37 -14
@@ -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 || '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 { 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,142 +1,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 { 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
- /* 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 { 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,59 +1,59 @@
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 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 { 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
+ }