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.
Files changed (22) hide show
  1. package/dist/index.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/nextjs/base/scripts/fetch-store-info.mjs +10 -4
  4. package/templates/nextjs/base/src/app/checkout/page.tsx +982 -981
  5. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +118 -117
  6. package/templates/nextjs/base/src/components/account/order-history.tsx +368 -367
  7. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +111 -112
  8. package/templates/nextjs/base/src/components/cart/cart-item.tsx +152 -153
  9. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +108 -108
  10. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +141 -142
  11. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +62 -59
  12. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -243
  13. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -199
  14. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +109 -110
  15. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +64 -65
  16. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +203 -202
  17. package/templates/nextjs/base/src/components/products/product-card.tsx +226 -226
  18. package/templates/nextjs/base/src/components/products/variant-selector.tsx +291 -292
  19. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +129 -125
  20. package/templates/nextjs/base/src/components/shared/price-display.tsx +61 -65
  21. package/templates/nextjs/base/src/lib/resolve-currency.ts +25 -0
  22. 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 { stripHtmlForSeo } from '@/lib/seo';
5
-
6
- interface ProductJsonLdProps {
7
- product: Product;
8
- url: string;
9
- currency?: string;
10
- }
11
-
12
- export async function ProductJsonLd({
13
- product,
14
- url,
15
- currency = process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD',
16
- }: ProductJsonLdProps) {
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: currency,
36
- availability,
37
- url,
38
- }
39
- : {
40
- '@type': 'Offer',
41
- price: priceInfo.price,
42
- priceCurrency: currency,
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
+ 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 { useStoreInfo } from '@/providers/store-provider';
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 { storeInfo } = useStoreInfo();
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';
33
-
34
- const basePrice = typeof price === 'string' ? parseFloat(price) : price;
35
- const sale =
36
- salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
37
- const isOnSale = sale !== null && sale < basePrice;
38
-
39
- const discountPercent =
40
- isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
41
-
42
- return (
43
- <span className={cn('inline-flex items-center gap-2', sizeClasses[size], className)}>
44
- {isOnSale ? (
45
- <>
46
- <span className="text-destructive font-medium">
47
- {formatPrice(sale!, { currency: currencyCode }) as string}
48
- </span>
49
- <span className="text-muted-foreground text-[0.85em] line-through">
50
- {formatPrice(basePrice, { currency: currencyCode }) as string}
51
- </span>
52
- {discountPercent > 0 && (
53
- <span className="bg-destructive text-destructive-foreground rounded px-1.5 py-0.5 text-xs font-medium">
54
- -{discountPercent}%
55
- </span>
56
- )}
57
- </>
58
- ) : (
59
- <span className="text-foreground font-medium">
60
- {formatPrice(basePrice, { currency: currencyCode }) as string}
61
- </span>
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
+ }