create-brainerce-store 1.43.0 → 1.43.2
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 +11 -8
- 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 +98 -93
- package/templates/nextjs/base/src/app/checkout/page.tsx +1004 -982
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +118 -118
- package/templates/nextjs/base/src/components/account/order-history.tsx +368 -368
- package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +111 -111
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +152 -152
- package/templates/nextjs/base/src/components/cart/cart-summary.tsx +108 -108
- package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +141 -141
- package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +62 -62
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +109 -109
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -64
- package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +203 -203
- package/templates/nextjs/base/src/components/products/product-card.tsx +46 -1
- package/templates/nextjs/base/src/components/products/variant-selector.tsx +291 -291
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +125 -129
- package/templates/nextjs/base/src/components/shared/price-display.tsx +61 -61
- package/templates/nextjs/base/src/lib/resolve-currency.ts +1 -6
- package/templates/nextjs/base/src/lib/use-currency.ts +1 -6
|
@@ -1,129 +1,125 @@
|
|
|
1
|
-
import type { Product } from 'brainerce';
|
|
2
|
-
import { getProductPriceInfo } from 'brainerce';
|
|
3
|
-
import { getNonce } from '@/lib/nonce';
|
|
4
|
-
import { resolveCurrency } from '@/lib/resolve-currency';
|
|
5
|
-
import { stripHtmlForSeo } from '@/lib/seo';
|
|
6
|
-
|
|
7
|
-
interface ProductJsonLdProps {
|
|
8
|
-
product: Product;
|
|
9
|
-
url: string;
|
|
10
|
-
currency?: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export async function ProductJsonLd({
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
currency
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
.replace(/</g, '\\u003c')
|
|
127
|
-
.replace(/>/g, '\\u003e')
|
|
128
|
-
.replace(/&/g, '\\u0026');
|
|
129
|
-
}
|
|
1
|
+
import type { Product } from 'brainerce';
|
|
2
|
+
import { getProductPriceInfo } from 'brainerce';
|
|
3
|
+
import { getNonce } from '@/lib/nonce';
|
|
4
|
+
import { resolveCurrency } from '@/lib/resolve-currency';
|
|
5
|
+
import { stripHtmlForSeo } from '@/lib/seo';
|
|
6
|
+
|
|
7
|
+
interface ProductJsonLdProps {
|
|
8
|
+
product: Product;
|
|
9
|
+
url: string;
|
|
10
|
+
currency?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
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
|
+
const nonce = await getNonce();
|
|
18
|
+
const priceInfo = getProductPriceInfo(product);
|
|
19
|
+
const imageUrl = product.images?.[0]?.url;
|
|
20
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
21
|
+
|
|
22
|
+
const availability =
|
|
23
|
+
product.inventory?.canPurchase !== false
|
|
24
|
+
? 'https://schema.org/InStock'
|
|
25
|
+
: 'https://schema.org/OutOfStock';
|
|
26
|
+
|
|
27
|
+
const isVariable = product.type === 'VARIABLE';
|
|
28
|
+
const offers =
|
|
29
|
+
isVariable && product.priceMin
|
|
30
|
+
? {
|
|
31
|
+
'@type': 'AggregateOffer',
|
|
32
|
+
lowPrice: product.priceMin,
|
|
33
|
+
highPrice: product.priceMax ?? product.priceMin,
|
|
34
|
+
offerCount: product.variants?.length ?? 1,
|
|
35
|
+
priceCurrency: resolvedCurrency,
|
|
36
|
+
availability,
|
|
37
|
+
url,
|
|
38
|
+
}
|
|
39
|
+
: {
|
|
40
|
+
'@type': 'Offer',
|
|
41
|
+
price: priceInfo.price,
|
|
42
|
+
priceCurrency: resolvedCurrency,
|
|
43
|
+
availability,
|
|
44
|
+
url,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// schema.org/Product.description must be plain text — Google's Rich Results
|
|
48
|
+
// and AI Overviews ingest this field directly and reject markup. Strip HTML
|
|
49
|
+
// first; if the description is empty after stripping, fall back to the name.
|
|
50
|
+
const cleanDescription = stripHtmlForSeo(product.description) || product.name;
|
|
51
|
+
const productJsonLd: Record<string, unknown> = {
|
|
52
|
+
'@context': 'https://schema.org',
|
|
53
|
+
'@type': 'Product',
|
|
54
|
+
name: product.name,
|
|
55
|
+
description: cleanDescription,
|
|
56
|
+
image: imageUrl,
|
|
57
|
+
url,
|
|
58
|
+
sku: product.sku || product.id,
|
|
59
|
+
offers,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Emit aggregateRating only when there is real review data — Google rejects
|
|
63
|
+
// self-serving / faked ratings and stores with 0 reviews shouldn't claim any.
|
|
64
|
+
if (product.reviewCount && product.reviewCount > 0) {
|
|
65
|
+
productJsonLd.aggregateRating = {
|
|
66
|
+
'@type': 'AggregateRating',
|
|
67
|
+
ratingValue: product.avgRating,
|
|
68
|
+
reviewCount: product.reviewCount,
|
|
69
|
+
bestRating: 5,
|
|
70
|
+
worstRating: 1,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const breadcrumbJsonLd = {
|
|
75
|
+
'@context': 'https://schema.org',
|
|
76
|
+
'@type': 'BreadcrumbList',
|
|
77
|
+
itemListElement: [
|
|
78
|
+
{
|
|
79
|
+
'@type': 'ListItem',
|
|
80
|
+
position: 1,
|
|
81
|
+
name: 'Home',
|
|
82
|
+
item: baseUrl || '/',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
'@type': 'ListItem',
|
|
86
|
+
position: 2,
|
|
87
|
+
name: 'Products',
|
|
88
|
+
item: `${baseUrl}/products`,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
'@type': 'ListItem',
|
|
92
|
+
position: 3,
|
|
93
|
+
name: product.name,
|
|
94
|
+
item: url,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
<script
|
|
102
|
+
type="application/ld+json"
|
|
103
|
+
nonce={nonce}
|
|
104
|
+
suppressHydrationWarning
|
|
105
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(productJsonLd) }}
|
|
106
|
+
/>
|
|
107
|
+
<script
|
|
108
|
+
type="application/ld+json"
|
|
109
|
+
nonce={nonce}
|
|
110
|
+
suppressHydrationWarning
|
|
111
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(breadcrumbJsonLd) }}
|
|
112
|
+
/>
|
|
113
|
+
</>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Serialize a JSON-LD object for embedding in a <script> tag. Escapes
|
|
118
|
+
// `<`, `>`, and `&` to \uXXXX so seller-controlled product fields cannot
|
|
119
|
+
// break out of the script element with `</script>` or inject HTML.
|
|
120
|
+
function serializeJsonLd(value: unknown): string {
|
|
121
|
+
return JSON.stringify(value)
|
|
122
|
+
.replace(/</g, '\\u003c')
|
|
123
|
+
.replace(/>/g, '\\u003e')
|
|
124
|
+
.replace(/&/g, '\\u0026');
|
|
125
|
+
}
|
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { formatPrice } from 'brainerce';
|
|
4
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
5
|
-
import { cn } from '@/lib/utils';
|
|
6
|
-
|
|
7
|
-
interface PriceDisplayProps {
|
|
8
|
-
price: string | number;
|
|
9
|
-
salePrice?: string | number | null;
|
|
10
|
-
currency?: string;
|
|
11
|
-
className?: string;
|
|
12
|
-
size?: 'sm' | 'md' | 'lg';
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const sizeClasses = {
|
|
16
|
-
sm: 'text-sm',
|
|
17
|
-
md: 'text-base',
|
|
18
|
-
lg: 'text-xl font-semibold',
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export function PriceDisplay({
|
|
22
|
-
price,
|
|
23
|
-
salePrice,
|
|
24
|
-
currency,
|
|
25
|
-
className,
|
|
26
|
-
size = 'md',
|
|
27
|
-
}: PriceDisplayProps) {
|
|
28
|
-
const currencyCode = useCurrency(currency);
|
|
29
|
-
|
|
30
|
-
const basePrice = typeof price === 'string' ? parseFloat(price) : price;
|
|
31
|
-
const sale =
|
|
32
|
-
salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
|
|
33
|
-
const isOnSale = sale !== null && sale < basePrice;
|
|
34
|
-
|
|
35
|
-
const discountPercent =
|
|
36
|
-
isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
|
|
37
|
-
|
|
38
|
-
return (
|
|
39
|
-
<span className={cn('inline-flex items-center gap-2', sizeClasses[size], className)}>
|
|
40
|
-
{isOnSale ? (
|
|
41
|
-
<>
|
|
42
|
-
<span className="text-destructive font-medium">
|
|
43
|
-
{formatPrice(sale!, { currency: currencyCode }) as string}
|
|
44
|
-
</span>
|
|
45
|
-
<span className="text-muted-foreground text-[0.85em] line-through">
|
|
46
|
-
{formatPrice(basePrice, { currency: currencyCode }) as string}
|
|
47
|
-
</span>
|
|
48
|
-
{discountPercent > 0 && (
|
|
49
|
-
<span className="bg-destructive text-destructive-foreground rounded px-1.5 py-0.5 text-xs font-medium">
|
|
50
|
-
-{discountPercent}%
|
|
51
|
-
</span>
|
|
52
|
-
)}
|
|
53
|
-
</>
|
|
54
|
-
) : (
|
|
55
|
-
<span className="text-foreground font-medium">
|
|
56
|
-
{formatPrice(basePrice, { currency: currencyCode }) as string}
|
|
57
|
-
</span>
|
|
58
|
-
)}
|
|
59
|
-
</span>
|
|
60
|
-
);
|
|
61
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { formatPrice } from 'brainerce';
|
|
4
|
+
import { useCurrency } from '@/lib/use-currency';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
interface PriceDisplayProps {
|
|
8
|
+
price: string | number;
|
|
9
|
+
salePrice?: string | number | null;
|
|
10
|
+
currency?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
size?: 'sm' | 'md' | 'lg';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const sizeClasses = {
|
|
16
|
+
sm: 'text-sm',
|
|
17
|
+
md: 'text-base',
|
|
18
|
+
lg: 'text-xl font-semibold',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function PriceDisplay({
|
|
22
|
+
price,
|
|
23
|
+
salePrice,
|
|
24
|
+
currency,
|
|
25
|
+
className,
|
|
26
|
+
size = 'md',
|
|
27
|
+
}: PriceDisplayProps) {
|
|
28
|
+
const currencyCode = useCurrency(currency);
|
|
29
|
+
|
|
30
|
+
const basePrice = typeof price === 'string' ? parseFloat(price) : price;
|
|
31
|
+
const sale =
|
|
32
|
+
salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
|
|
33
|
+
const isOnSale = sale !== null && sale < basePrice;
|
|
34
|
+
|
|
35
|
+
const discountPercent =
|
|
36
|
+
isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<span className={cn('inline-flex items-center gap-2', sizeClasses[size], className)}>
|
|
40
|
+
{isOnSale ? (
|
|
41
|
+
<>
|
|
42
|
+
<span className="text-destructive font-medium">
|
|
43
|
+
{formatPrice(sale!, { currency: currencyCode }) as string}
|
|
44
|
+
</span>
|
|
45
|
+
<span className="text-muted-foreground text-[0.85em] line-through">
|
|
46
|
+
{formatPrice(basePrice, { currency: currencyCode }) as string}
|
|
47
|
+
</span>
|
|
48
|
+
{discountPercent > 0 && (
|
|
49
|
+
<span className="bg-destructive text-destructive-foreground rounded px-1.5 py-0.5 text-xs font-medium">
|
|
50
|
+
-{discountPercent}%
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
</>
|
|
54
|
+
) : (
|
|
55
|
+
<span className="text-foreground font-medium">
|
|
56
|
+
{formatPrice(basePrice, { currency: currencyCode }) as string}
|
|
57
|
+
</span>
|
|
58
|
+
)}
|
|
59
|
+
</span>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -16,10 +16,5 @@ export function resolveCurrency(
|
|
|
16
16
|
storeInfo: PublicStoreInfo | null | undefined,
|
|
17
17
|
override?: string | null
|
|
18
18
|
): string {
|
|
19
|
-
return
|
|
20
|
-
override ||
|
|
21
|
-
storeInfo?.currency ||
|
|
22
|
-
process.env.NEXT_PUBLIC_STORE_CURRENCY ||
|
|
23
|
-
'USD'
|
|
24
|
-
);
|
|
19
|
+
return override || storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
|
|
25
20
|
}
|
|
@@ -15,10 +15,5 @@ import { useStoreInfo } from '@/providers/store-provider';
|
|
|
15
15
|
*/
|
|
16
16
|
export function useCurrency(override?: string | null): string {
|
|
17
17
|
const { storeInfo } = useStoreInfo();
|
|
18
|
-
return
|
|
19
|
-
override ||
|
|
20
|
-
storeInfo?.currency ||
|
|
21
|
-
process.env.NEXT_PUBLIC_STORE_CURRENCY ||
|
|
22
|
-
'USD'
|
|
23
|
-
);
|
|
18
|
+
return override || storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
|
|
24
19
|
}
|