create-brainerce-store 1.41.1 → 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.
- package/dist/index.js +16 -11
- package/package.json +1 -1
- package/templates/nextjs/base/TRANSLATIONS.md +200 -0
- package/templates/nextjs/base/next.config.ts +22 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +1 -1
- package/templates/nextjs/base/src/app/layout.tsx.ejs +14 -6
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +40 -3
- package/templates/nextjs/base/src/components/account/order-history.tsx +367 -367
- package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +112 -112
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
- package/templates/nextjs/base/src/components/cart/cart-summary.tsx +108 -108
- package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -142
- package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -59
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +243 -243
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +199 -199
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +110 -110
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +65 -65
- package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -202
- package/templates/nextjs/base/src/components/products/product-card.tsx +226 -226
- package/templates/nextjs/base/src/components/products/variant-selector.tsx +292 -292
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +5 -1
- package/templates/nextjs/base/src/components/shared/price-display.tsx +65 -62
- package/templates/nextjs/base/src/lib/brainerce.ts.ejs +42 -0
- package/templates/nextjs/base/src/lib/store-info.ts +48 -0
- 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
|
+
}
|