create-brainerce-store 1.41.1 → 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 (28) hide show
  1. package/dist/index.js +16 -11
  2. package/package.json +1 -1
  3. package/templates/nextjs/base/TRANSLATIONS.md +200 -0
  4. package/templates/nextjs/base/next.config.ts +22 -0
  5. package/templates/nextjs/base/scripts/fetch-store-info.mjs +10 -4
  6. package/templates/nextjs/base/src/app/checkout/page.tsx +982 -981
  7. package/templates/nextjs/base/src/app/layout.tsx.ejs +14 -6
  8. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +118 -80
  9. package/templates/nextjs/base/src/components/account/order-history.tsx +2 -1
  10. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +2 -3
  11. package/templates/nextjs/base/src/components/cart/cart-item.tsx +2 -3
  12. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +3 -3
  13. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +2 -3
  14. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +4 -1
  15. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +2 -3
  16. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +2 -3
  17. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +2 -3
  18. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +2 -3
  19. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +5 -4
  20. package/templates/nextjs/base/src/components/products/product-card.tsx +3 -3
  21. package/templates/nextjs/base/src/components/products/variant-selector.tsx +2 -3
  22. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +129 -121
  23. package/templates/nextjs/base/src/components/shared/price-display.tsx +2 -3
  24. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +42 -0
  25. package/templates/nextjs/base/src/lib/resolve-currency.ts +25 -0
  26. package/templates/nextjs/base/src/lib/store-info.ts +48 -0
  27. package/templates/nextjs/base/src/lib/use-currency.ts +24 -0
  28. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +37 -14
@@ -1,121 +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({ product, url, currency = 'USD' }: ProductJsonLdProps) {
13
- const nonce = await getNonce();
14
- const priceInfo = getProductPriceInfo(product);
15
- const imageUrl = product.images?.[0]?.url;
16
- const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
17
-
18
- const availability =
19
- product.inventory?.canPurchase !== false
20
- ? 'https://schema.org/InStock'
21
- : 'https://schema.org/OutOfStock';
22
-
23
- const isVariable = product.type === 'VARIABLE';
24
- const offers =
25
- isVariable && product.priceMin
26
- ? {
27
- '@type': 'AggregateOffer',
28
- lowPrice: product.priceMin,
29
- highPrice: product.priceMax ?? product.priceMin,
30
- offerCount: product.variants?.length ?? 1,
31
- priceCurrency: currency,
32
- availability,
33
- url,
34
- }
35
- : {
36
- '@type': 'Offer',
37
- price: priceInfo.price,
38
- priceCurrency: currency,
39
- availability,
40
- url,
41
- };
42
-
43
- // schema.org/Product.description must be plain text — Google's Rich Results
44
- // and AI Overviews ingest this field directly and reject markup. Strip HTML
45
- // first; if the description is empty after stripping, fall back to the name.
46
- const cleanDescription = stripHtmlForSeo(product.description) || product.name;
47
- const productJsonLd: Record<string, unknown> = {
48
- '@context': 'https://schema.org',
49
- '@type': 'Product',
50
- name: product.name,
51
- description: cleanDescription,
52
- image: imageUrl,
53
- url,
54
- sku: product.sku || product.id,
55
- offers,
56
- };
57
-
58
- // Emit aggregateRating only when there is real review data — Google rejects
59
- // self-serving / faked ratings and stores with 0 reviews shouldn't claim any.
60
- if (product.reviewCount && product.reviewCount > 0) {
61
- productJsonLd.aggregateRating = {
62
- '@type': 'AggregateRating',
63
- ratingValue: product.avgRating,
64
- reviewCount: product.reviewCount,
65
- bestRating: 5,
66
- worstRating: 1,
67
- };
68
- }
69
-
70
- const breadcrumbJsonLd = {
71
- '@context': 'https://schema.org',
72
- '@type': 'BreadcrumbList',
73
- itemListElement: [
74
- {
75
- '@type': 'ListItem',
76
- position: 1,
77
- name: 'Home',
78
- item: baseUrl || '/',
79
- },
80
- {
81
- '@type': 'ListItem',
82
- position: 2,
83
- name: 'Products',
84
- item: `${baseUrl}/products`,
85
- },
86
- {
87
- '@type': 'ListItem',
88
- position: 3,
89
- name: product.name,
90
- item: url,
91
- },
92
- ],
93
- };
94
-
95
- return (
96
- <>
97
- <script
98
- type="application/ld+json"
99
- nonce={nonce}
100
- suppressHydrationWarning
101
- dangerouslySetInnerHTML={{ __html: serializeJsonLd(productJsonLd) }}
102
- />
103
- <script
104
- type="application/ld+json"
105
- nonce={nonce}
106
- suppressHydrationWarning
107
- dangerouslySetInnerHTML={{ __html: serializeJsonLd(breadcrumbJsonLd) }}
108
- />
109
- </>
110
- );
111
- }
112
-
113
- // Serialize a JSON-LD object for embedding in a <script> tag. Escapes
114
- // `<`, `>`, and `&` to \uXXXX so seller-controlled product fields cannot
115
- // break out of the script element with `</script>` or inject HTML.
116
- function serializeJsonLd(value: unknown): string {
117
- return JSON.stringify(value)
118
- .replace(/</g, '\\u003c')
119
- .replace(/>/g, '\\u003e')
120
- .replace(/&/g, '\\u0026');
121
- }
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,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { formatPrice } from 'brainerce';
4
- import { useStoreInfo } from '@/providers/store-provider';
4
+ import { useCurrency } from '@/lib/use-currency';
5
5
  import { cn } from '@/lib/utils';
6
6
 
7
7
  interface PriceDisplayProps {
@@ -25,8 +25,7 @@ export function PriceDisplay({
25
25
  className,
26
26
  size = 'md',
27
27
  }: PriceDisplayProps) {
28
- const { storeInfo } = useStoreInfo();
29
- const currencyCode = currency || storeInfo?.currency || 'USD';
28
+ const currencyCode = useCurrency(currency);
30
29
 
31
30
  const basePrice = typeof price === 'string' ? parseFloat(price) : price;
32
31
  const sale =
@@ -1,4 +1,6 @@
1
1
  import { BrainerceClient } from 'brainerce';
2
+ import { cache } from 'react';
3
+ import { pickPublicStoreInfo, type PublicStoreInfo } from '@/lib/store-info';
2
4
 
3
5
  // Read either env var name. The new one is preferred; the old one is a soft
4
6
  // alias kept for backwards compatibility — both are accepted by the SDK.
@@ -66,3 +68,43 @@ export function getServerClient(locale?: string): BrainerceClient {
66
68
  }
67
69
  return client;
68
70
  }
71
+
72
+ /**
73
+ * Server-side cached fetch of store info, used to seed `<StoreProvider>` at
74
+ * SSR so client components don't render with `null` storeInfo and bake the
75
+ * wrong currency symbol into the HTML Googlebot indexes.
76
+ *
77
+ * Two cache layers:
78
+ * - React `cache()` dedupes calls within a single request render so the
79
+ * layout + `generateMetadata` share one fetch.
80
+ * - Next.js `fetch({ next: { revalidate, tags } })` caches the response
81
+ * across requests for ~60s and lets the dashboard bust the cache via
82
+ * `revalidateTag('store-info')` on store-config save.
83
+ *
84
+ * Returns `null` on hard failure so the provider's client-side `useEffect`
85
+ * fallback can take over — behaviour identical to pre-SSR-hydration.
86
+ *
87
+ * Bypasses {@link getServerClient} on purpose: the SDK's internal `fetch`
88
+ * does not forward `next:` options, so it cannot participate in the Next.js
89
+ * Data Cache today. Switch back to the SDK once it accepts `fetchOptions`.
90
+ */
91
+ export const fetchStoreInfo = cache(
92
+ async (locale?: string): Promise<PublicStoreInfo | null> => {
93
+ const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
94
+ const siteOrigin = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
95
+ const url = `${apiUrl}/api/vc/${encodeURIComponent(SALES_CHANNEL_ID)}/info`;
96
+ try {
97
+ const res = await fetch(url, {
98
+ headers: {
99
+ Origin: siteOrigin,
100
+ ...(locale ? { 'Accept-Language': locale } : {}),
101
+ },
102
+ next: { revalidate: 60, tags: ['store-info'] },
103
+ });
104
+ if (!res.ok) return null;
105
+ return pickPublicStoreInfo(await res.json());
106
+ } catch {
107
+ return null;
108
+ }
109
+ },
110
+ );
@@ -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,48 @@
1
+ import type { StoreInfo } from 'brainerce';
2
+
3
+ /**
4
+ * The subset of {@link StoreInfo} that is safe to serialize into the
5
+ * server-rendered HTML / RSC payload. Sales-channel-only operational fields
6
+ * (channel name, connection status, allowed API scopes, sandbox flags,
7
+ * internal cuids) are intentionally omitted — they leak operational state to
8
+ * crawlers and have no role in storefront rendering.
9
+ *
10
+ * Uses indexed access on `StoreInfo` for nested types (`upsell`, `i18n`) so
11
+ * this stays automatically in sync with the SDK without depending on those
12
+ * sub-types being exported individually.
13
+ */
14
+ export interface PublicStoreInfo {
15
+ name: string;
16
+ currency: string;
17
+ language: string;
18
+ metaDescription?: string | null;
19
+ logo?: string | null;
20
+ contactEmail?: string | null;
21
+ contactPhone?: string | null;
22
+ socialLinks?: Record<string, string> | null;
23
+ requireEmailVerification?: boolean;
24
+ upsell?: StoreInfo['upsell'];
25
+ i18n?: StoreInfo['i18n'];
26
+ }
27
+
28
+ /**
29
+ * Project a raw {@link StoreInfo} response from the backend onto the
30
+ * {@link PublicStoreInfo} shape. Anything not listed here never reaches the
31
+ * browser. Add a field here only after confirming it is non-sensitive and
32
+ * required by a storefront-side consumer.
33
+ */
34
+ export function pickPublicStoreInfo(raw: StoreInfo): PublicStoreInfo {
35
+ return {
36
+ name: raw.name,
37
+ currency: raw.currency,
38
+ language: raw.language,
39
+ metaDescription: raw.metaDescription ?? null,
40
+ logo: raw.logo ?? null,
41
+ contactEmail: raw.contactEmail ?? null,
42
+ contactPhone: raw.contactPhone ?? null,
43
+ socialLinks: raw.socialLinks ?? null,
44
+ requireEmailVerification: raw.requireEmailVerification,
45
+ upsell: raw.upsell,
46
+ i18n: raw.i18n,
47
+ };
48
+ }
@@ -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
+ }
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
 
3
3
  import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
4
- import type { StoreInfo, Cart, CustomerProfile } from 'brainerce';
4
+ import type { Cart, CustomerProfile } from 'brainerce';
5
5
  import { getCartTotals } from 'brainerce';
6
6
  import { getClient, initClient, setStoredCartId } from '@/lib/brainerce';
7
+ import { pickPublicStoreInfo, type PublicStoreInfo } from '@/lib/store-info';
7
8
  import { checkAuthStatus, proxyLogout } from '@/lib/auth';
8
9
  <% if (i18nEnabled) { %>
9
10
  import { MessagesContext } from '@/lib/translations';
@@ -12,7 +13,7 @@ import { getMessages, defaultLocale } from '@/i18n';
12
13
 
13
14
  // ---- Store Info Context ----
14
15
  interface StoreInfoContextValue {
15
- storeInfo: StoreInfo | null;
16
+ storeInfo: PublicStoreInfo | null;
16
17
  loading: boolean;
17
18
  }
18
19
 
@@ -69,12 +70,29 @@ export function useCart() {
69
70
 
70
71
  // ---- Provider Component ----
71
72
  <% if (i18nEnabled) { %>
72
- export function StoreProvider({ children, locale }: { children: React.ReactNode; locale?: string }) {
73
+ export function StoreProvider({
74
+ children,
75
+ locale,
76
+ initialStoreInfo = null,
77
+ }: {
78
+ children: React.ReactNode;
79
+ locale?: string;
80
+ initialStoreInfo?: PublicStoreInfo | null;
81
+ }) {
73
82
  <% } else { %>
74
- export function StoreProvider({ children }: { children: React.ReactNode }) {
83
+ export function StoreProvider({
84
+ children,
85
+ initialStoreInfo = null,
86
+ }: {
87
+ children: React.ReactNode;
88
+ initialStoreInfo?: PublicStoreInfo | null;
89
+ }) {
75
90
  <% } %>
76
- const [storeInfo, setStoreInfo] = useState<StoreInfo | null>(null);
77
- const [storeLoading, setStoreLoading] = useState(true);
91
+ // Seed from server-rendered data when available so SSR consumers (PriceDisplay,
92
+ // FreeShippingBar, locale-aware UI, etc.) render with real values from frame 0
93
+ // instead of falling back to USD/defaults until hydration completes.
94
+ const [storeInfo, setStoreInfo] = useState<PublicStoreInfo | null>(initialStoreInfo);
95
+ const [storeLoading, setStoreLoading] = useState(!initialStoreInfo);
78
96
  const [isLoggedIn, setIsLoggedIn] = useState(false);
79
97
  const [customer, setCustomer] = useState<CustomerProfile | null>(null);
80
98
  const [authLoading, setAuthLoading] = useState(true);
@@ -121,16 +139,21 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
121
139
  // Validate auth token server-side
122
140
  refreshAuth();
123
141
 
124
- // Fetch store info (public, no auth needed)
125
- client
126
- .getStoreInfo()
127
- .then(setStoreInfo)
128
- .catch(console.error)
129
- .finally(() => setStoreLoading(false));
142
+ // Fetch store info only when the server didn't already provide it. The
143
+ // happy path is that <RootLayout> SSR'd fetchStoreInfo() and seeded us via
144
+ // initialStoreInfo; this branch is the failure fallback (server fetch
145
+ // returned null) so user-facing components don't render forever as 'USD'.
146
+ if (!initialStoreInfo) {
147
+ client
148
+ .getStoreInfo()
149
+ .then((raw) => setStoreInfo(pickPublicStoreInfo(raw)))
150
+ .catch(console.error)
151
+ .finally(() => setStoreLoading(false));
152
+ }
130
153
  <% if (i18nEnabled) { %>
131
- }, [refreshAuth, locale]);
154
+ }, [refreshAuth, locale, initialStoreInfo]);
132
155
  <% } else { %>
133
- }, [refreshAuth]);
156
+ }, [refreshAuth, initialStoreInfo]);
134
157
  <% } %>
135
158
 
136
159
  // Cart management