create-brainerce-store 1.72.0 → 1.74.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 +37 -4
- package/messages/en.json +30 -1
- package/messages/he.json +30 -1
- package/package.json +1 -1
- package/templates/nextjs/base/AGENTS.md.ejs +63 -16
- package/templates/nextjs/base/AI-GUIDE.md +17 -4
- package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
- package/templates/nextjs/base/TRANSLATIONS.md +207 -207
- package/templates/nextjs/base/src/app/account/page.tsx +168 -122
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
- package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
- package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
- package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
- package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
- package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
- package/templates/nextjs/base/src/core/lib/region.ts +112 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
- package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
- package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
- package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
- package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
- package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
- package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
- package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
|
@@ -1,243 +1,423 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
//
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
<%
|
|
243
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import type { Cart, CustomerProfile, PublicRegion } from 'brainerce';
|
|
6
|
+
import { getCartTotals } from 'brainerce';
|
|
7
|
+
import { getClient, initClient } from '@/core/lib/brainerce';
|
|
8
|
+
import { pickRegion, readRegionCookie, writeRegionCookie } from '@/core/lib/region';
|
|
9
|
+
import { pickPublicStoreInfo, type PublicStoreInfo } from '@/core/lib/store-info';
|
|
10
|
+
import {
|
|
11
|
+
pickPublicCapabilities,
|
|
12
|
+
type PublicStoreCapabilities,
|
|
13
|
+
} from '@/core/lib/capabilities';
|
|
14
|
+
import { checkAuthStatus, proxyLogout } from '@/core/lib/auth';
|
|
15
|
+
<% if (i18nEnabled) { %>
|
|
16
|
+
import { MessagesContext } from '@/core/lib/translations';
|
|
17
|
+
import { getMessages, defaultLocale } from '@/i18n';
|
|
18
|
+
<% } %>
|
|
19
|
+
|
|
20
|
+
// ---- Store Info Context ----
|
|
21
|
+
interface StoreInfoContextValue {
|
|
22
|
+
storeInfo: PublicStoreInfo | null;
|
|
23
|
+
loading: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const StoreInfoContext = createContext<StoreInfoContextValue>({
|
|
27
|
+
storeInfo: null,
|
|
28
|
+
loading: true,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export function useStoreInfo() {
|
|
32
|
+
return useContext(StoreInfoContext);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---- Store Capabilities Context ----
|
|
36
|
+
// What the merchant configured on this sales channel: low-stock warning and
|
|
37
|
+
// threshold, back-in-stock alerts, reservation timeout, and which optional
|
|
38
|
+
// features are switched on. Fetched ONCE for the whole app — it is per sales
|
|
39
|
+
// channel, not per product, so a page-level fetch would be repeated work.
|
|
40
|
+
interface StoreCapabilitiesContextValue {
|
|
41
|
+
capabilities: PublicStoreCapabilities | null;
|
|
42
|
+
capabilitiesLoading: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The default is a real value, not a throw: a component rendered during SSR or
|
|
46
|
+
// outside <StoreProvider> reads `null` and falls back to its own default rather
|
|
47
|
+
// than crashing the tree.
|
|
48
|
+
const StoreCapabilitiesContext = createContext<StoreCapabilitiesContextValue>({
|
|
49
|
+
capabilities: null,
|
|
50
|
+
capabilitiesLoading: true,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export function useStoreCapabilities() {
|
|
54
|
+
return useContext(StoreCapabilitiesContext);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- Region Context ----
|
|
58
|
+
// Which region prices this session. Seeded from the server (see RootLayout,
|
|
59
|
+
// which calls resolveRegion()) so SSR and the client agree from frame 0
|
|
60
|
+
// instead of the client flipping the currency after hydration.
|
|
61
|
+
//
|
|
62
|
+
// ⛔ A single-region store is a NO-OP by design. `regions.length <= 1` means
|
|
63
|
+
// the switcher renders nothing and `regionId` is the default the backend
|
|
64
|
+
// would have picked anyway. Nothing about this is conditional on a capability
|
|
65
|
+
// flag: there ISN'T one for regions, and a storefront that skips `regionId`
|
|
66
|
+
// does not hide a feature, it shows every shopper the default region's prices.
|
|
67
|
+
interface RegionContextValue {
|
|
68
|
+
/** Active regions, default first. Empty when the store has none. */
|
|
69
|
+
regions: PublicRegion[];
|
|
70
|
+
/** The region in force, or null when the store has no regions. */
|
|
71
|
+
region: PublicRegion | null;
|
|
72
|
+
/** Spread into SDK reads: `...(regionId ? { regionId } : {})`. */
|
|
73
|
+
regionId: string | undefined;
|
|
74
|
+
/** Buyer country from the edge, or null. Reused by the tax estimate. */
|
|
75
|
+
buyerCountry: string | null;
|
|
76
|
+
/** Switch region: persists the choice and re-fetches server + client data. */
|
|
77
|
+
setRegion: (regionId: string) => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const RegionContext = createContext<RegionContextValue>({
|
|
81
|
+
regions: [],
|
|
82
|
+
region: null,
|
|
83
|
+
regionId: undefined,
|
|
84
|
+
buyerCountry: null,
|
|
85
|
+
setRegion: () => {},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export function useRegion() {
|
|
89
|
+
return useContext(RegionContext);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- Auth Context ----
|
|
93
|
+
interface AuthContextValue {
|
|
94
|
+
isLoggedIn: boolean;
|
|
95
|
+
authLoading: boolean;
|
|
96
|
+
customer: CustomerProfile | null;
|
|
97
|
+
login: () => Promise<void>;
|
|
98
|
+
logout: () => Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const AuthContext = createContext<AuthContextValue>({
|
|
102
|
+
isLoggedIn: false,
|
|
103
|
+
authLoading: true,
|
|
104
|
+
customer: null,
|
|
105
|
+
login: async () => {},
|
|
106
|
+
logout: async () => {},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export function useAuth() {
|
|
110
|
+
return useContext(AuthContext);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- Cart Context ----
|
|
114
|
+
interface CartContextValue {
|
|
115
|
+
cart: Cart | null;
|
|
116
|
+
cartLoading: boolean;
|
|
117
|
+
refreshCart: () => Promise<void>;
|
|
118
|
+
itemCount: number;
|
|
119
|
+
totals: { subtotal: number; discount: number; shipping: number; total: number };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const CartContext = createContext<CartContextValue>({
|
|
123
|
+
cart: null,
|
|
124
|
+
cartLoading: true,
|
|
125
|
+
refreshCart: async () => {},
|
|
126
|
+
itemCount: 0,
|
|
127
|
+
totals: { subtotal: 0, discount: 0, shipping: 0, total: 0 },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export function useCart() {
|
|
131
|
+
return useContext(CartContext);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- Provider Component ----
|
|
135
|
+
<% if (i18nEnabled) { %>
|
|
136
|
+
export function StoreProvider({
|
|
137
|
+
children,
|
|
138
|
+
locale,
|
|
139
|
+
initialStoreInfo = null,
|
|
140
|
+
initialMessages,
|
|
141
|
+
initialRegions = [],
|
|
142
|
+
initialRegion = null,
|
|
143
|
+
initialCountry = null,
|
|
144
|
+
}: {
|
|
145
|
+
children: React.ReactNode;
|
|
146
|
+
locale?: string;
|
|
147
|
+
initialStoreInfo?: PublicStoreInfo | null;
|
|
148
|
+
initialMessages?: Record<string, Record<string, string>>;
|
|
149
|
+
initialRegions?: PublicRegion[];
|
|
150
|
+
initialRegion?: PublicRegion | null;
|
|
151
|
+
initialCountry?: string | null;
|
|
152
|
+
}) {
|
|
153
|
+
<% } else { %>
|
|
154
|
+
export function StoreProvider({
|
|
155
|
+
children,
|
|
156
|
+
initialStoreInfo = null,
|
|
157
|
+
initialRegions = [],
|
|
158
|
+
initialRegion = null,
|
|
159
|
+
initialCountry = null,
|
|
160
|
+
}: {
|
|
161
|
+
children: React.ReactNode;
|
|
162
|
+
initialStoreInfo?: PublicStoreInfo | null;
|
|
163
|
+
initialRegions?: PublicRegion[];
|
|
164
|
+
initialRegion?: PublicRegion | null;
|
|
165
|
+
initialCountry?: string | null;
|
|
166
|
+
}) {
|
|
167
|
+
<% } %>
|
|
168
|
+
// Seed from server-rendered data when available so SSR consumers (PriceDisplay,
|
|
169
|
+
// FreeShippingBar, locale-aware UI, etc.) render with real values from frame 0
|
|
170
|
+
// instead of falling back to USD/defaults until hydration completes.
|
|
171
|
+
const [storeInfo, setStoreInfo] = useState<PublicStoreInfo | null>(initialStoreInfo);
|
|
172
|
+
const [storeLoading, setStoreLoading] = useState(!initialStoreInfo);
|
|
173
|
+
const [capabilities, setCapabilities] = useState<PublicStoreCapabilities | null>(null);
|
|
174
|
+
const [capabilitiesLoading, setCapabilitiesLoading] = useState(true);
|
|
175
|
+
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
|
176
|
+
const [customer, setCustomer] = useState<CustomerProfile | null>(null);
|
|
177
|
+
const [authLoading, setAuthLoading] = useState(true);
|
|
178
|
+
const [cart, setCart] = useState<Cart | null>(null);
|
|
179
|
+
const [cartLoading, setCartLoading] = useState(true);
|
|
180
|
+
const [regions, setRegions] = useState<PublicRegion[]>(initialRegions);
|
|
181
|
+
const [region, setRegionState] = useState<PublicRegion | null>(initialRegion);
|
|
182
|
+
const router = useRouter();
|
|
183
|
+
<% if (i18nEnabled) { %>
|
|
184
|
+
// Seed from server-rendered messages (see RootLayout) so translated text
|
|
185
|
+
// renders at frame 0 instead of a window of raw "namespace.key" strings
|
|
186
|
+
// before the client-only fetch in the effect below resolves — this also
|
|
187
|
+
// closes the same gap on every locale switch, since RootLayout re-runs
|
|
188
|
+
// server-side per locale and StoreProvider remounts with fresh initial data.
|
|
189
|
+
const [messages, setMessages] = useState<Record<string, Record<string, string>>>(
|
|
190
|
+
initialMessages ?? {}
|
|
191
|
+
);
|
|
192
|
+
<% } %>
|
|
193
|
+
|
|
194
|
+
// Check auth status via httpOnly cookie (server-side validation)
|
|
195
|
+
const refreshAuth = useCallback(async () => {
|
|
196
|
+
try {
|
|
197
|
+
const status = await checkAuthStatus();
|
|
198
|
+
setIsLoggedIn(status.isLoggedIn);
|
|
199
|
+
setCustomer(status.isLoggedIn ? (status.customer as CustomerProfile) : null);
|
|
200
|
+
} catch {
|
|
201
|
+
setIsLoggedIn(false);
|
|
202
|
+
setCustomer(null);
|
|
203
|
+
} finally {
|
|
204
|
+
setAuthLoading(false);
|
|
205
|
+
}
|
|
206
|
+
}, []);
|
|
207
|
+
|
|
208
|
+
// Initialize client, check auth, and fetch store info
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const client = initClient();
|
|
211
|
+
<% if (i18nEnabled) { %>
|
|
212
|
+
|
|
213
|
+
// Set locale on SDK client for translated product content
|
|
214
|
+
if (locale) {
|
|
215
|
+
client.setLocale(locale);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Load UI message strings for current locale
|
|
219
|
+
getMessages(locale || defaultLocale).then(setMessages);
|
|
220
|
+
<% } %>
|
|
221
|
+
|
|
222
|
+
// Optimistic check: if brainerce_logged_in cookie exists, assume logged in
|
|
223
|
+
// while we validate the actual token server-side
|
|
224
|
+
if (typeof document !== 'undefined' && document.cookie.includes('brainerce_logged_in=1')) {
|
|
225
|
+
setIsLoggedIn(true);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Validate auth token server-side
|
|
229
|
+
refreshAuth();
|
|
230
|
+
|
|
231
|
+
// Fetch store info only when the server didn't already provide it. The
|
|
232
|
+
// happy path is that <RootLayout> SSR'd fetchStoreInfo() and seeded us via
|
|
233
|
+
// initialStoreInfo; this branch is the failure fallback (server fetch
|
|
234
|
+
// returned null) so user-facing components don't render forever as 'USD'.
|
|
235
|
+
if (!initialStoreInfo) {
|
|
236
|
+
client
|
|
237
|
+
.getStoreInfo()
|
|
238
|
+
.then((raw) => setStoreInfo(pickPublicStoreInfo(raw)))
|
|
239
|
+
.catch(console.error)
|
|
240
|
+
.finally(() => setStoreLoading(false));
|
|
241
|
+
}
|
|
242
|
+
<% if (i18nEnabled) { %>
|
|
243
|
+
}, [refreshAuth, locale, initialStoreInfo]);
|
|
244
|
+
<% } else { %>
|
|
245
|
+
}, [refreshAuth, initialStoreInfo]);
|
|
246
|
+
<% } %>
|
|
247
|
+
|
|
248
|
+
// Fetch the channel's capabilities once, on mount. Deliberately its own
|
|
249
|
+
// effect with no dependencies: the payload carries no translated content, so
|
|
250
|
+
// folding it into the effect above would refetch it on every locale switch
|
|
251
|
+
// for nothing.
|
|
252
|
+
//
|
|
253
|
+
// ⛔ This must never break the store. A brand-new store is empty and this
|
|
254
|
+
// call can fail (offline, a channel not yet live, a backend hiccup). On
|
|
255
|
+
// failure we keep `capabilities` at null and every consumer falls back to its
|
|
256
|
+
// own default, which is the behaviour the storefront had before capabilities
|
|
257
|
+
// existed. Nothing here throws and nothing renders an empty box.
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
let cancelled = false;
|
|
260
|
+
getClient()
|
|
261
|
+
.getStoreCapabilities()
|
|
262
|
+
.then((raw) => {
|
|
263
|
+
if (!cancelled) setCapabilities(pickPublicCapabilities(raw));
|
|
264
|
+
})
|
|
265
|
+
.catch(() => {
|
|
266
|
+
// Swallowed on purpose. See above. Consumers use their defaults.
|
|
267
|
+
})
|
|
268
|
+
.finally(() => {
|
|
269
|
+
if (!cancelled) setCapabilitiesLoading(false);
|
|
270
|
+
});
|
|
271
|
+
return () => {
|
|
272
|
+
cancelled = true;
|
|
273
|
+
};
|
|
274
|
+
}, []);
|
|
275
|
+
|
|
276
|
+
// Regions. The happy path is that RootLayout resolved them server-side and
|
|
277
|
+
// seeded us, so this effect does nothing at all. It is the fallback for a
|
|
278
|
+
// failed SSR resolve, and it deliberately mirrors the capabilities effect:
|
|
279
|
+
//
|
|
280
|
+
// ⛔ It must never break the store. An empty store, a channel that is not
|
|
281
|
+
// live yet and a backend hiccup all land in the catch, leave `regions` at
|
|
282
|
+
// `[]` and `region` at null, and every consumer then sends no `regionId`,
|
|
283
|
+
// exactly what this storefront did before regions existed. Nothing throws,
|
|
284
|
+
// and the switcher renders nothing rather than an empty dropdown.
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
if (initialRegions.length > 0) return;
|
|
287
|
+
let cancelled = false;
|
|
288
|
+
getClient()
|
|
289
|
+
.getStoreRegions()
|
|
290
|
+
.then((result) => {
|
|
291
|
+
if (cancelled) return;
|
|
292
|
+
const list = Array.isArray(result?.data) ? result.data : [];
|
|
293
|
+
if (list.length === 0) return;
|
|
294
|
+
setRegions(list);
|
|
295
|
+
// The cookie is the shopper's own choice and outranks the country
|
|
296
|
+
// guess. A stale id (region deleted or deactivated) is ignored.
|
|
297
|
+
const chosenId = readRegionCookie();
|
|
298
|
+
const chosen = chosenId ? list.find((r) => r.id === chosenId) : undefined;
|
|
299
|
+
setRegionState(chosen ?? pickRegion(list, initialCountry));
|
|
300
|
+
})
|
|
301
|
+
.catch(() => {
|
|
302
|
+
// Swallowed on purpose. See above.
|
|
303
|
+
});
|
|
304
|
+
return () => {
|
|
305
|
+
cancelled = true;
|
|
306
|
+
};
|
|
307
|
+
}, [initialRegions, initialCountry]);
|
|
308
|
+
|
|
309
|
+
// Switching region has to move BOTH halves of the app: the cookie so the
|
|
310
|
+
// next server render prices the same way, `router.refresh()` so the server
|
|
311
|
+
// components already on screen re-fetch, and local state so client hooks
|
|
312
|
+
// (product listing, home grid) refetch immediately instead of waiting.
|
|
313
|
+
const setRegion = useCallback(
|
|
314
|
+
(regionId: string) => {
|
|
315
|
+
const next = regions.find((r) => r.id === regionId);
|
|
316
|
+
if (!next) return;
|
|
317
|
+
writeRegionCookie(regionId);
|
|
318
|
+
setRegionState(next);
|
|
319
|
+
router.refresh();
|
|
320
|
+
},
|
|
321
|
+
[regions, router]
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// Cart management
|
|
325
|
+
const refreshCart = useCallback(async () => {
|
|
326
|
+
try {
|
|
327
|
+
setCartLoading(true);
|
|
328
|
+
const client = getClient();
|
|
329
|
+
// The SDK owns cart identity: smartGetCart() picks the guest, session or
|
|
330
|
+
// customer cart on its own. Never mirror the id into localStorage.
|
|
331
|
+
const c = await client.smartGetCart();
|
|
332
|
+
setCart(c);
|
|
333
|
+
} catch (err) {
|
|
334
|
+
console.error('Failed to load cart:', err);
|
|
335
|
+
} finally {
|
|
336
|
+
setCartLoading(false);
|
|
337
|
+
}
|
|
338
|
+
}, []);
|
|
339
|
+
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
refreshCart();
|
|
342
|
+
}, [refreshCart, isLoggedIn]);
|
|
343
|
+
|
|
344
|
+
// Called after successful login (cookie already set by proxy)
|
|
345
|
+
const login = useCallback(async () => {
|
|
346
|
+
// Refresh auth state from server (reads httpOnly cookie)
|
|
347
|
+
await refreshAuth();
|
|
348
|
+
|
|
349
|
+
// Merge guest session cart into customer cart
|
|
350
|
+
const client = getClient();
|
|
351
|
+
client.syncCartOnLogin().catch(console.error);
|
|
352
|
+
}, [refreshAuth]);
|
|
353
|
+
|
|
354
|
+
const logout = useCallback(async () => {
|
|
355
|
+
// Clear httpOnly cookie server-side
|
|
356
|
+
await proxyLogout();
|
|
357
|
+
|
|
358
|
+
const client = getClient();
|
|
359
|
+
client.onLogout();
|
|
360
|
+
setIsLoggedIn(false);
|
|
361
|
+
setCustomer(null);
|
|
362
|
+
setCart(null);
|
|
363
|
+
refreshCart();
|
|
364
|
+
}, [refreshCart]);
|
|
365
|
+
|
|
366
|
+
const itemCount = cart
|
|
367
|
+
? cart.items.reduce((sum, item) => sum + item.quantity, 0)
|
|
368
|
+
: 0;
|
|
369
|
+
|
|
370
|
+
const totals = cart ? getCartTotals(cart) : { subtotal: 0, discount: 0, shipping: 0, total: 0 };
|
|
371
|
+
|
|
372
|
+
<% if (i18nEnabled) { %>
|
|
373
|
+
return (
|
|
374
|
+
<MessagesContext.Provider value={messages}>
|
|
375
|
+
<StoreInfoContext.Provider value={{ storeInfo, loading: storeLoading }}>
|
|
376
|
+
<StoreCapabilitiesContext.Provider value={{ capabilities, capabilitiesLoading }}>
|
|
377
|
+
<RegionContext.Provider
|
|
378
|
+
value={{
|
|
379
|
+
regions,
|
|
380
|
+
region,
|
|
381
|
+
regionId: region?.id,
|
|
382
|
+
buyerCountry: initialCountry,
|
|
383
|
+
setRegion,
|
|
384
|
+
}}
|
|
385
|
+
>
|
|
386
|
+
<AuthContext.Provider value={{ isLoggedIn, authLoading, customer, login, logout }}>
|
|
387
|
+
<CartContext.Provider
|
|
388
|
+
value={{ cart, cartLoading, refreshCart, itemCount, totals }}
|
|
389
|
+
>
|
|
390
|
+
{children}
|
|
391
|
+
</CartContext.Provider>
|
|
392
|
+
</AuthContext.Provider>
|
|
393
|
+
</RegionContext.Provider>
|
|
394
|
+
</StoreCapabilitiesContext.Provider>
|
|
395
|
+
</StoreInfoContext.Provider>
|
|
396
|
+
</MessagesContext.Provider>
|
|
397
|
+
);
|
|
398
|
+
<% } else { %>
|
|
399
|
+
return (
|
|
400
|
+
<StoreInfoContext.Provider value={{ storeInfo, loading: storeLoading }}>
|
|
401
|
+
<StoreCapabilitiesContext.Provider value={{ capabilities, capabilitiesLoading }}>
|
|
402
|
+
<RegionContext.Provider
|
|
403
|
+
value={{
|
|
404
|
+
regions,
|
|
405
|
+
region,
|
|
406
|
+
regionId: region?.id,
|
|
407
|
+
buyerCountry: initialCountry,
|
|
408
|
+
setRegion,
|
|
409
|
+
}}
|
|
410
|
+
>
|
|
411
|
+
<AuthContext.Provider value={{ isLoggedIn, authLoading, customer, login, logout }}>
|
|
412
|
+
<CartContext.Provider
|
|
413
|
+
value={{ cart, cartLoading, refreshCart, itemCount, totals }}
|
|
414
|
+
>
|
|
415
|
+
{children}
|
|
416
|
+
</CartContext.Provider>
|
|
417
|
+
</AuthContext.Provider>
|
|
418
|
+
</RegionContext.Provider>
|
|
419
|
+
</StoreCapabilitiesContext.Provider>
|
|
420
|
+
</StoreInfoContext.Provider>
|
|
421
|
+
);
|
|
422
|
+
<% } %>
|
|
423
|
+
}
|