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