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.
- package/dist/index.js +16 -11
- package/package.json +1 -1
- package/templates/nextjs/base/TRANSLATIONS.md +200 -0
- package/templates/nextjs/base/next.config.ts +22 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +1 -1
- package/templates/nextjs/base/src/app/layout.tsx.ejs +14 -6
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +40 -3
- package/templates/nextjs/base/src/components/account/order-history.tsx +367 -367
- package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +112 -112
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
- package/templates/nextjs/base/src/components/cart/cart-summary.tsx +108 -108
- package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -142
- package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -59
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +243 -243
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +199 -199
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +110 -110
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +65 -65
- package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -202
- package/templates/nextjs/base/src/components/products/product-card.tsx +226 -226
- package/templates/nextjs/base/src/components/products/variant-selector.tsx +292 -292
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +5 -1
- package/templates/nextjs/base/src/components/shared/price-display.tsx +65 -62
- package/templates/nextjs/base/src/lib/brainerce.ts.ejs +42 -0
- package/templates/nextjs/base/src/lib/store-info.ts +48 -0
- 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
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
<span className="text-
|
|
47
|
-
{formatPrice(
|
|
48
|
-
</span>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 {
|
|
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:
|
|
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({
|
|
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({
|
|
83
|
+
export function StoreProvider({
|
|
84
|
+
children,
|
|
85
|
+
initialStoreInfo = null,
|
|
86
|
+
}: {
|
|
87
|
+
children: React.ReactNode;
|
|
88
|
+
initialStoreInfo?: PublicStoreInfo | null;
|
|
89
|
+
}) {
|
|
75
90
|
<% } %>
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|