create-brainerce-store 1.41.1 → 1.42.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 (25) 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/src/app/checkout/page.tsx +1 -1
  6. package/templates/nextjs/base/src/app/layout.tsx.ejs +14 -6
  7. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +40 -3
  8. package/templates/nextjs/base/src/components/account/order-history.tsx +367 -367
  9. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +112 -112
  10. package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
  11. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +108 -108
  12. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -142
  13. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -59
  14. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +243 -243
  15. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +199 -199
  16. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +110 -110
  17. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +65 -65
  18. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -202
  19. package/templates/nextjs/base/src/components/products/product-card.tsx +226 -226
  20. package/templates/nextjs/base/src/components/products/variant-selector.tsx +292 -292
  21. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +5 -1
  22. package/templates/nextjs/base/src/components/shared/price-display.tsx +65 -62
  23. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +42 -0
  24. package/templates/nextjs/base/src/lib/store-info.ts +48 -0
  25. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +37 -14
@@ -1,62 +1,65 @@
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
- const currencyCode = currency || storeInfo?.currency || 'USD';
30
-
31
- const basePrice = typeof price === 'string' ? parseFloat(price) : price;
32
- const sale =
33
- salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
34
- const isOnSale = sale !== null && sale < basePrice;
35
-
36
- const discountPercent =
37
- isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
38
-
39
- return (
40
- <span className={cn('inline-flex items-center gap-2', sizeClasses[size], className)}>
41
- {isOnSale ? (
42
- <>
43
- <span className="text-destructive font-medium">
44
- {formatPrice(sale!, { currency: currencyCode }) as string}
45
- </span>
46
- <span className="text-muted-foreground text-[0.85em] line-through">
47
- {formatPrice(basePrice, { currency: currencyCode }) as string}
48
- </span>
49
- {discountPercent > 0 && (
50
- <span className="bg-destructive text-destructive-foreground rounded px-1.5 py-0.5 text-xs font-medium">
51
- -{discountPercent}%
52
- </span>
53
- )}
54
- </>
55
- ) : (
56
- <span className="text-foreground font-medium">
57
- {formatPrice(basePrice, { currency: currencyCode }) as string}
58
- </span>
59
- )}
60
- </span>
61
- );
62
- }
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,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,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
+ }
@@ -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