create-brainerce-store 1.42.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.
- package/dist/index.js +1 -1
- package/messages/en.json +1 -0
- package/messages/he.json +1 -0
- package/package.json +1 -1
- package/templates/nextjs/base/next.config.ts +68 -69
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +91 -87
- package/templates/nextjs/base/src/app/checkout/page.tsx +120 -97
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +5 -4
- package/templates/nextjs/base/src/components/account/order-history.tsx +2 -1
- package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +2 -3
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +2 -3
- package/templates/nextjs/base/src/components/cart/cart-summary.tsx +3 -3
- package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +2 -3
- package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +4 -1
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +2 -3
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +2 -3
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +2 -3
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +37 -28
- package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +5 -4
- package/templates/nextjs/base/src/components/products/product-card.tsx +226 -226
- package/templates/nextjs/base/src/components/products/variant-selector.tsx +2 -3
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +7 -7
- package/templates/nextjs/base/src/components/shared/price-display.tsx +2 -6
- package/templates/nextjs/base/src/lib/resolve-currency.ts +20 -0
- package/templates/nextjs/base/src/lib/use-currency.ts +19 -0
|
@@ -1,226 +1,226 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import { Link, useRouter } from '@/lib/navigation';
|
|
5
|
-
import Image from 'next/image';
|
|
6
|
-
import type { Product } from 'brainerce';
|
|
7
|
-
import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
|
|
8
|
-
import { useTranslations } from '@/lib/translations';
|
|
9
|
-
import { PriceDisplay } from '@/components/shared/price-display';
|
|
10
|
-
import { StockBadge } from '@/components/products/stock-badge';
|
|
11
|
-
import { DiscountBadge } from '@/components/products/discount-badge';
|
|
12
|
-
import { useCart
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const currency =
|
|
23
|
-
|
|
24
|
-
let min: number;
|
|
25
|
-
let max: number;
|
|
26
|
-
if (product.priceMin && product.priceMax) {
|
|
27
|
-
min = parseFloat(product.priceMin);
|
|
28
|
-
max = parseFloat(product.priceMax);
|
|
29
|
-
} else {
|
|
30
|
-
const variants = product.variants ?? [];
|
|
31
|
-
if (variants.length === 0) return null;
|
|
32
|
-
const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
|
|
33
|
-
min = Math.min(...prices);
|
|
34
|
-
max = Math.max(...prices);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (isNaN(min) || isNaN(max)) return null;
|
|
38
|
-
|
|
39
|
-
return (
|
|
40
|
-
<span className="text-foreground text-sm font-medium">
|
|
41
|
-
{min === max
|
|
42
|
-
? (formatPrice(min, { currency }) as string)
|
|
43
|
-
: `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`}
|
|
44
|
-
</span>
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function ProductCard({ product, className }: ProductCardProps) {
|
|
49
|
-
const t = useTranslations('common');
|
|
50
|
-
const tp = useTranslations('productDetail');
|
|
51
|
-
const tProd = useTranslations('products');
|
|
52
|
-
const router = useRouter();
|
|
53
|
-
const { refreshCart } = useCart();
|
|
54
|
-
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
55
|
-
const mainImage = product.images?.[0];
|
|
56
|
-
const imageUrl = mainImage?.url || null;
|
|
57
|
-
const slug = product.slug || product.id;
|
|
58
|
-
const isVariable = product.type === 'VARIABLE';
|
|
59
|
-
|
|
60
|
-
const [adding, setAdding] = useState(false);
|
|
61
|
-
const [added, setAdded] = useState(false);
|
|
62
|
-
|
|
63
|
-
const canPurchase = product.inventory?.canPurchase !== false;
|
|
64
|
-
|
|
65
|
-
async function handleAddToCart(e: React.MouseEvent) {
|
|
66
|
-
e.preventDefault();
|
|
67
|
-
e.stopPropagation();
|
|
68
|
-
|
|
69
|
-
if (isVariable) {
|
|
70
|
-
router.push(`/products/${slug}`);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (adding || !canPurchase) return;
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
setAdding(true);
|
|
78
|
-
const { getClient } = await import('@/lib/brainerce');
|
|
79
|
-
const client = getClient();
|
|
80
|
-
await client.smartAddToCart({ productId: product.id, quantity: 1 });
|
|
81
|
-
await refreshCart();
|
|
82
|
-
setAdded(true);
|
|
83
|
-
setTimeout(() => setAdded(false), 2000);
|
|
84
|
-
} catch (err) {
|
|
85
|
-
console.error('Failed to add to cart:', err);
|
|
86
|
-
} finally {
|
|
87
|
-
setAdding(false);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return (
|
|
92
|
-
<div
|
|
93
|
-
className={cn(
|
|
94
|
-
'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
|
|
95
|
-
className
|
|
96
|
-
)}
|
|
97
|
-
>
|
|
98
|
-
{/* Image — clickable */}
|
|
99
|
-
<Link href={`/products/${slug}`} className="block">
|
|
100
|
-
<div className="bg-muted relative aspect-square overflow-hidden">
|
|
101
|
-
{imageUrl ? (
|
|
102
|
-
<Image
|
|
103
|
-
src={imageUrl}
|
|
104
|
-
alt={mainImage?.alt || product.name}
|
|
105
|
-
fill
|
|
106
|
-
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
107
|
-
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
|
108
|
-
/>
|
|
109
|
-
) : (
|
|
110
|
-
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
111
|
-
<svg className="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
112
|
-
<path
|
|
113
|
-
strokeLinecap="round"
|
|
114
|
-
strokeLinejoin="round"
|
|
115
|
-
strokeWidth={1.5}
|
|
116
|
-
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"
|
|
117
|
-
/>
|
|
118
|
-
</svg>
|
|
119
|
-
</div>
|
|
120
|
-
)}
|
|
121
|
-
|
|
122
|
-
{/* Badges */}
|
|
123
|
-
<div className="absolute start-2 top-2 flex flex-col gap-1">
|
|
124
|
-
{isOnSale && (
|
|
125
|
-
<span className="bg-destructive text-destructive-foreground rounded px-2 py-1 text-xs font-bold">
|
|
126
|
-
{t('sale')}
|
|
127
|
-
</span>
|
|
128
|
-
)}
|
|
129
|
-
<DiscountBadge discount={product.discount} />
|
|
130
|
-
{product.isDownloadable && (
|
|
131
|
-
<span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
|
|
132
|
-
{tp('digitalProduct')}
|
|
133
|
-
</span>
|
|
134
|
-
)}
|
|
135
|
-
</div>
|
|
136
|
-
|
|
137
|
-
{/* Add to cart overlay button */}
|
|
138
|
-
{(isVariable || canPurchase) && (
|
|
139
|
-
<button
|
|
140
|
-
onClick={handleAddToCart}
|
|
141
|
-
disabled={adding}
|
|
142
|
-
aria-label={isVariable ? tProd('selectOptions') : tp('addToCart')}
|
|
143
|
-
className={cn(
|
|
144
|
-
'absolute bottom-2 end-2 flex h-8 w-8 items-center justify-center rounded-full shadow-md transition-all',
|
|
145
|
-
'translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100',
|
|
146
|
-
added
|
|
147
|
-
? 'bg-green-500 text-white'
|
|
148
|
-
: 'bg-primary text-primary-foreground hover:opacity-90'
|
|
149
|
-
)}
|
|
150
|
-
>
|
|
151
|
-
{added ? (
|
|
152
|
-
<svg
|
|
153
|
-
className="h-4 w-4"
|
|
154
|
-
fill="none"
|
|
155
|
-
viewBox="0 0 24 24"
|
|
156
|
-
stroke="currentColor"
|
|
157
|
-
strokeWidth={2.5}
|
|
158
|
-
>
|
|
159
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
160
|
-
</svg>
|
|
161
|
-
) : isVariable ? (
|
|
162
|
-
<svg
|
|
163
|
-
className="h-4 w-4"
|
|
164
|
-
fill="none"
|
|
165
|
-
viewBox="0 0 24 24"
|
|
166
|
-
stroke="currentColor"
|
|
167
|
-
strokeWidth={2}
|
|
168
|
-
>
|
|
169
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
|
170
|
-
</svg>
|
|
171
|
-
) : (
|
|
172
|
-
<svg
|
|
173
|
-
className="h-4 w-4"
|
|
174
|
-
fill="none"
|
|
175
|
-
viewBox="0 0 24 24"
|
|
176
|
-
stroke="currentColor"
|
|
177
|
-
strokeWidth={2}
|
|
178
|
-
>
|
|
179
|
-
<path
|
|
180
|
-
strokeLinecap="round"
|
|
181
|
-
strokeLinejoin="round"
|
|
182
|
-
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
|
|
183
|
-
/>
|
|
184
|
-
</svg>
|
|
185
|
-
)}
|
|
186
|
-
</button>
|
|
187
|
-
)}
|
|
188
|
-
</div>
|
|
189
|
-
</Link>
|
|
190
|
-
|
|
191
|
-
{/* Content */}
|
|
192
|
-
<div className="space-y-2 p-3">
|
|
193
|
-
{/* Categories */}
|
|
194
|
-
{product.categories && product.categories.length > 0 && (
|
|
195
|
-
<div className="flex flex-wrap gap-1">
|
|
196
|
-
{product.categories.slice(0, 2).map((cat) => (
|
|
197
|
-
<span
|
|
198
|
-
key={cat.id}
|
|
199
|
-
className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px]"
|
|
200
|
-
>
|
|
201
|
-
{cat.name}
|
|
202
|
-
</span>
|
|
203
|
-
))}
|
|
204
|
-
</div>
|
|
205
|
-
)}
|
|
206
|
-
|
|
207
|
-
{/* Name — clickable */}
|
|
208
|
-
<Link href={`/products/${slug}`}>
|
|
209
|
-
<h3 className="text-foreground hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
|
|
210
|
-
{product.name}
|
|
211
|
-
</h3>
|
|
212
|
-
</Link>
|
|
213
|
-
|
|
214
|
-
{/* Price */}
|
|
215
|
-
{isVariable ? (
|
|
216
|
-
<VariantPriceRange product={product} />
|
|
217
|
-
) : (
|
|
218
|
-
<PriceDisplay price={originalPrice} salePrice={isOnSale ? price : undefined} size="sm" />
|
|
219
|
-
)}
|
|
220
|
-
|
|
221
|
-
{/* Stock */}
|
|
222
|
-
<StockBadge inventory={product.inventory} />
|
|
223
|
-
</div>
|
|
224
|
-
</div>
|
|
225
|
-
);
|
|
226
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Link, useRouter } from '@/lib/navigation';
|
|
5
|
+
import Image from 'next/image';
|
|
6
|
+
import type { Product } from 'brainerce';
|
|
7
|
+
import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
|
|
8
|
+
import { useTranslations } from '@/lib/translations';
|
|
9
|
+
import { PriceDisplay } from '@/components/shared/price-display';
|
|
10
|
+
import { StockBadge } from '@/components/products/stock-badge';
|
|
11
|
+
import { DiscountBadge } from '@/components/products/discount-badge';
|
|
12
|
+
import { useCart } from '@/providers/store-provider';
|
|
13
|
+
import { useCurrency } from '@/lib/use-currency';
|
|
14
|
+
import { cn } from '@/lib/utils';
|
|
15
|
+
|
|
16
|
+
interface ProductCardProps {
|
|
17
|
+
product: Product;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function VariantPriceRange({ product }: { product: Product }) {
|
|
22
|
+
const currency = useCurrency();
|
|
23
|
+
|
|
24
|
+
let min: number;
|
|
25
|
+
let max: number;
|
|
26
|
+
if (product.priceMin && product.priceMax) {
|
|
27
|
+
min = parseFloat(product.priceMin);
|
|
28
|
+
max = parseFloat(product.priceMax);
|
|
29
|
+
} else {
|
|
30
|
+
const variants = product.variants ?? [];
|
|
31
|
+
if (variants.length === 0) return null;
|
|
32
|
+
const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
|
|
33
|
+
min = Math.min(...prices);
|
|
34
|
+
max = Math.max(...prices);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (isNaN(min) || isNaN(max)) return null;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<span className="text-foreground text-sm font-medium">
|
|
41
|
+
{min === max
|
|
42
|
+
? (formatPrice(min, { currency }) as string)
|
|
43
|
+
: `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`}
|
|
44
|
+
</span>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ProductCard({ product, className }: ProductCardProps) {
|
|
49
|
+
const t = useTranslations('common');
|
|
50
|
+
const tp = useTranslations('productDetail');
|
|
51
|
+
const tProd = useTranslations('products');
|
|
52
|
+
const router = useRouter();
|
|
53
|
+
const { refreshCart } = useCart();
|
|
54
|
+
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
55
|
+
const mainImage = product.images?.[0];
|
|
56
|
+
const imageUrl = mainImage?.url || null;
|
|
57
|
+
const slug = product.slug || product.id;
|
|
58
|
+
const isVariable = product.type === 'VARIABLE';
|
|
59
|
+
|
|
60
|
+
const [adding, setAdding] = useState(false);
|
|
61
|
+
const [added, setAdded] = useState(false);
|
|
62
|
+
|
|
63
|
+
const canPurchase = product.inventory?.canPurchase !== false;
|
|
64
|
+
|
|
65
|
+
async function handleAddToCart(e: React.MouseEvent) {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
e.stopPropagation();
|
|
68
|
+
|
|
69
|
+
if (isVariable) {
|
|
70
|
+
router.push(`/products/${slug}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (adding || !canPurchase) return;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
setAdding(true);
|
|
78
|
+
const { getClient } = await import('@/lib/brainerce');
|
|
79
|
+
const client = getClient();
|
|
80
|
+
await client.smartAddToCart({ productId: product.id, quantity: 1 });
|
|
81
|
+
await refreshCart();
|
|
82
|
+
setAdded(true);
|
|
83
|
+
setTimeout(() => setAdded(false), 2000);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error('Failed to add to cart:', err);
|
|
86
|
+
} finally {
|
|
87
|
+
setAdding(false);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
className={cn(
|
|
94
|
+
'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
|
|
95
|
+
className
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
{/* Image — clickable */}
|
|
99
|
+
<Link href={`/products/${slug}`} className="block">
|
|
100
|
+
<div className="bg-muted relative aspect-square overflow-hidden">
|
|
101
|
+
{imageUrl ? (
|
|
102
|
+
<Image
|
|
103
|
+
src={imageUrl}
|
|
104
|
+
alt={mainImage?.alt || product.name}
|
|
105
|
+
fill
|
|
106
|
+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
107
|
+
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
|
108
|
+
/>
|
|
109
|
+
) : (
|
|
110
|
+
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
111
|
+
<svg className="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
112
|
+
<path
|
|
113
|
+
strokeLinecap="round"
|
|
114
|
+
strokeLinejoin="round"
|
|
115
|
+
strokeWidth={1.5}
|
|
116
|
+
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"
|
|
117
|
+
/>
|
|
118
|
+
</svg>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{/* Badges */}
|
|
123
|
+
<div className="absolute start-2 top-2 flex flex-col gap-1">
|
|
124
|
+
{isOnSale && (
|
|
125
|
+
<span className="bg-destructive text-destructive-foreground rounded px-2 py-1 text-xs font-bold">
|
|
126
|
+
{t('sale')}
|
|
127
|
+
</span>
|
|
128
|
+
)}
|
|
129
|
+
<DiscountBadge discount={product.discount} />
|
|
130
|
+
{product.isDownloadable && (
|
|
131
|
+
<span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
|
|
132
|
+
{tp('digitalProduct')}
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{/* Add to cart overlay button */}
|
|
138
|
+
{(isVariable || canPurchase) && (
|
|
139
|
+
<button
|
|
140
|
+
onClick={handleAddToCart}
|
|
141
|
+
disabled={adding}
|
|
142
|
+
aria-label={isVariable ? tProd('selectOptions') : tp('addToCart')}
|
|
143
|
+
className={cn(
|
|
144
|
+
'absolute bottom-2 end-2 flex h-8 w-8 items-center justify-center rounded-full shadow-md transition-all',
|
|
145
|
+
'translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100',
|
|
146
|
+
added
|
|
147
|
+
? 'bg-green-500 text-white'
|
|
148
|
+
: 'bg-primary text-primary-foreground hover:opacity-90'
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
{added ? (
|
|
152
|
+
<svg
|
|
153
|
+
className="h-4 w-4"
|
|
154
|
+
fill="none"
|
|
155
|
+
viewBox="0 0 24 24"
|
|
156
|
+
stroke="currentColor"
|
|
157
|
+
strokeWidth={2.5}
|
|
158
|
+
>
|
|
159
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
160
|
+
</svg>
|
|
161
|
+
) : isVariable ? (
|
|
162
|
+
<svg
|
|
163
|
+
className="h-4 w-4"
|
|
164
|
+
fill="none"
|
|
165
|
+
viewBox="0 0 24 24"
|
|
166
|
+
stroke="currentColor"
|
|
167
|
+
strokeWidth={2}
|
|
168
|
+
>
|
|
169
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
|
170
|
+
</svg>
|
|
171
|
+
) : (
|
|
172
|
+
<svg
|
|
173
|
+
className="h-4 w-4"
|
|
174
|
+
fill="none"
|
|
175
|
+
viewBox="0 0 24 24"
|
|
176
|
+
stroke="currentColor"
|
|
177
|
+
strokeWidth={2}
|
|
178
|
+
>
|
|
179
|
+
<path
|
|
180
|
+
strokeLinecap="round"
|
|
181
|
+
strokeLinejoin="round"
|
|
182
|
+
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
|
|
183
|
+
/>
|
|
184
|
+
</svg>
|
|
185
|
+
)}
|
|
186
|
+
</button>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
</Link>
|
|
190
|
+
|
|
191
|
+
{/* Content */}
|
|
192
|
+
<div className="space-y-2 p-3">
|
|
193
|
+
{/* Categories */}
|
|
194
|
+
{product.categories && product.categories.length > 0 && (
|
|
195
|
+
<div className="flex flex-wrap gap-1">
|
|
196
|
+
{product.categories.slice(0, 2).map((cat) => (
|
|
197
|
+
<span
|
|
198
|
+
key={cat.id}
|
|
199
|
+
className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px]"
|
|
200
|
+
>
|
|
201
|
+
{cat.name}
|
|
202
|
+
</span>
|
|
203
|
+
))}
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{/* Name — clickable */}
|
|
208
|
+
<Link href={`/products/${slug}`}>
|
|
209
|
+
<h3 className="text-foreground hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
|
|
210
|
+
{product.name}
|
|
211
|
+
</h3>
|
|
212
|
+
</Link>
|
|
213
|
+
|
|
214
|
+
{/* Price */}
|
|
215
|
+
{isVariable ? (
|
|
216
|
+
<VariantPriceRange product={product} />
|
|
217
|
+
) : (
|
|
218
|
+
<PriceDisplay price={originalPrice} salePrice={isOnSale ? price : undefined} size="sm" />
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{/* Stock */}
|
|
222
|
+
<StockBadge inventory={product.inventory} />
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -4,7 +4,7 @@ import { useMemo } from 'react';
|
|
|
4
4
|
import type { Product, ProductVariant } from 'brainerce';
|
|
5
5
|
import { getVariantOptions, getProductSwatches, formatPrice } from 'brainerce';
|
|
6
6
|
import type { InventoryInfo } from 'brainerce';
|
|
7
|
-
import {
|
|
7
|
+
import { useCurrency } from '@/lib/use-currency';
|
|
8
8
|
import { useTranslations } from '@/lib/translations';
|
|
9
9
|
import { cn } from '@/lib/utils';
|
|
10
10
|
|
|
@@ -33,9 +33,8 @@ export function VariantSelector({
|
|
|
33
33
|
onVariantChange,
|
|
34
34
|
className,
|
|
35
35
|
}: VariantSelectorProps) {
|
|
36
|
-
const { storeInfo } = useStoreInfo();
|
|
37
36
|
const t = useTranslations('productDetail');
|
|
38
|
-
const currency =
|
|
37
|
+
const currency = useCurrency();
|
|
39
38
|
const variants = useMemo(() => product.variants || [], [product.variants]);
|
|
40
39
|
|
|
41
40
|
// Get swatch metadata from product attribute options
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Product } from 'brainerce';
|
|
2
2
|
import { getProductPriceInfo } from 'brainerce';
|
|
3
3
|
import { getNonce } from '@/lib/nonce';
|
|
4
|
+
import { resolveCurrency } from '@/lib/resolve-currency';
|
|
4
5
|
import { stripHtmlForSeo } from '@/lib/seo';
|
|
5
6
|
|
|
6
7
|
interface ProductJsonLdProps {
|
|
@@ -9,11 +10,10 @@ interface ProductJsonLdProps {
|
|
|
9
10
|
currency?: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
export async function ProductJsonLd({
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}: ProductJsonLdProps) {
|
|
13
|
+
export async function ProductJsonLd({ product, url, currency }: ProductJsonLdProps) {
|
|
14
|
+
// Defer to the shared server helper so the env-var + USD fallback chain
|
|
15
|
+
// lives in exactly one place across all server-side currency reads.
|
|
16
|
+
const resolvedCurrency = resolveCurrency(null, currency);
|
|
17
17
|
const nonce = await getNonce();
|
|
18
18
|
const priceInfo = getProductPriceInfo(product);
|
|
19
19
|
const imageUrl = product.images?.[0]?.url;
|
|
@@ -32,14 +32,14 @@ export async function ProductJsonLd({
|
|
|
32
32
|
lowPrice: product.priceMin,
|
|
33
33
|
highPrice: product.priceMax ?? product.priceMin,
|
|
34
34
|
offerCount: product.variants?.length ?? 1,
|
|
35
|
-
priceCurrency:
|
|
35
|
+
priceCurrency: resolvedCurrency,
|
|
36
36
|
availability,
|
|
37
37
|
url,
|
|
38
38
|
}
|
|
39
39
|
: {
|
|
40
40
|
'@type': 'Offer',
|
|
41
41
|
price: priceInfo.price,
|
|
42
|
-
priceCurrency:
|
|
42
|
+
priceCurrency: resolvedCurrency,
|
|
43
43
|
availability,
|
|
44
44
|
url,
|
|
45
45
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { formatPrice } from 'brainerce';
|
|
4
|
-
import {
|
|
4
|
+
import { useCurrency } from '@/lib/use-currency';
|
|
5
5
|
import { cn } from '@/lib/utils';
|
|
6
6
|
|
|
7
7
|
interface PriceDisplayProps {
|
|
@@ -25,11 +25,7 @@ export function PriceDisplay({
|
|
|
25
25
|
className,
|
|
26
26
|
size = 'md',
|
|
27
27
|
}: PriceDisplayProps) {
|
|
28
|
-
const
|
|
29
|
-
// SSR-safe fallback: storeInfo is hydrated client-side, so without the build-time env var
|
|
30
|
-
// the server renders 'USD' and search engines index a wrong currency symbol (e.g. $ for an ILS store).
|
|
31
|
-
const currencyCode =
|
|
32
|
-
currency || storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
|
|
28
|
+
const currencyCode = useCurrency(currency);
|
|
33
29
|
|
|
34
30
|
const basePrice = typeof price === 'string' ? parseFloat(price) : price;
|
|
35
31
|
const sale =
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PublicStoreInfo } from '@/lib/store-info';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server-side twin of `useCurrency()` — call from Server Components,
|
|
5
|
+
* `generateMetadata`, route handlers, and anywhere outside React render
|
|
6
|
+
* (where the client `useStoreInfo()` hook isn't available).
|
|
7
|
+
*
|
|
8
|
+
* Keeps the fallback chain in one place across both client and server.
|
|
9
|
+
*
|
|
10
|
+
* @param storeInfo result of `fetchStoreInfo()` — may be null if the SSR
|
|
11
|
+
* fetch failed transiently
|
|
12
|
+
* @param override pass when the caller already has a specific currency
|
|
13
|
+
* (e.g. order-level currency that should win over store)
|
|
14
|
+
*/
|
|
15
|
+
export function resolveCurrency(
|
|
16
|
+
storeInfo: PublicStoreInfo | null | undefined,
|
|
17
|
+
override?: string | null
|
|
18
|
+
): string {
|
|
19
|
+
return override || storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Single source of truth for "which currency code do I format prices with?"
|
|
7
|
+
* in client components. Collapses the historical three-line fallback chain
|
|
8
|
+
* (`storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD'`)
|
|
9
|
+
* into one call so the chain lives in exactly one place — and so the day
|
|
10
|
+
* we drop the env var or the USD last-resort, we only touch this file.
|
|
11
|
+
*
|
|
12
|
+
* @param override pass when the caller already has a specific currency
|
|
13
|
+
* (e.g. an `Order` is denominated in its own currency,
|
|
14
|
+
* not the live store currency). Wins over `storeInfo`.
|
|
15
|
+
*/
|
|
16
|
+
export function useCurrency(override?: string | null): string {
|
|
17
|
+
const { storeInfo } = useStoreInfo();
|
|
18
|
+
return override || storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
|
|
19
|
+
}
|