create-brainerce-store 1.68.0 → 1.72.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/README.md +31 -10
- package/dist/index.js +197 -105
- package/messages/en.json +63 -3
- package/messages/he.json +63 -3
- package/package.json +1 -1
- package/templates/nextjs/base/TRANSLATIONS.md +14 -7
- package/templates/nextjs/base/src/app/checkout/page.tsx +1074 -1017
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +21 -2
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +1 -1
- package/templates/nextjs/base/src/app/register/page.tsx +67 -64
- package/templates/nextjs/base/src/components/account/order-history.tsx +25 -39
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +30 -11
- package/templates/nextjs/base/src/components/account/profile-section.tsx +303 -226
- package/templates/nextjs/base/src/components/auth/register-form.tsx +326 -245
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +306 -294
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +13 -1
- package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +61 -21
- package/templates/nextjs/base/src/components/shared/birthday-picker.tsx +258 -0
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +71 -2
- package/templates/nextjs/base/src/core/lib/auth.ts +155 -154
- package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
- package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +5 -16
- package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +3 -6
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +19 -1
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +42 -6
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +52 -10
- package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +143 -0
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +18 -2
- package/templates/nextjs/base/src/ui/product/back-in-stock-form.tsx +173 -0
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +484 -455
- package/templates/nextjs/base/src/ui/product/review-form.tsx +136 -12
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +139 -108
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +21 -3
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +19 -1
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +44 -7
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +52 -10
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +155 -142
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +500 -477
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +135 -11
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +179 -148
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +15 -1
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +38 -4
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +54 -11
- package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +122 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +87 -83
- package/templates/nextjs/ui-canvas/product/back-in-stock-form.tsx +151 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +373 -352
- package/templates/nextjs/ui-canvas/product/review-form.tsx +129 -11
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +127 -96
|
@@ -1,154 +1,155 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client-side auth helpers
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Client-side auth helpers.
|
|
3
|
+
*
|
|
4
|
+
* Everything the SDK covers goes through an SDK method. Never hand-build a
|
|
5
|
+
* REST path: the SDK owns the URL shape, and a string built here silently
|
|
6
|
+
* rots the day a route moves. `getClient()` is configured with
|
|
7
|
+
* `baseUrl: '/api/store'` + `proxyMode`, so each call still lands on the
|
|
8
|
+
* same-origin BFF proxy, which adds the Authorization header from the
|
|
9
|
+
* httpOnly cookie and strips the token out of the response. The SDK adds the
|
|
10
|
+
* CSRF header the proxy requires on every non-GET request.
|
|
11
|
+
*
|
|
12
|
+
* Only the routes with no SDK equivalent are plain fetches: `/api/auth/me`,
|
|
13
|
+
* `/api/auth/logout` and `/api/auth/reset-password` are this app's own Next
|
|
14
|
+
* route handlers, not Brainerce endpoints.
|
|
15
|
+
*
|
|
16
|
+
* The token is managed server-side via httpOnly cookies, never exposed to JS.
|
|
17
|
+
*/
|
|
18
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
19
|
+
|
|
20
|
+
const CSRF_HEADERS: Record<string, string> = {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
'X-Requested-With': 'brainerce',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
interface LoginResult {
|
|
26
|
+
customer: {
|
|
27
|
+
id: string;
|
|
28
|
+
email: string;
|
|
29
|
+
firstName?: string;
|
|
30
|
+
lastName?: string;
|
|
31
|
+
emailVerified: boolean;
|
|
32
|
+
};
|
|
33
|
+
expiresAt: string;
|
|
34
|
+
requiresVerification?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface RegisterResult {
|
|
38
|
+
customer: {
|
|
39
|
+
id: string;
|
|
40
|
+
email: string;
|
|
41
|
+
firstName?: string;
|
|
42
|
+
lastName?: string;
|
|
43
|
+
emailVerified: boolean;
|
|
44
|
+
};
|
|
45
|
+
expiresAt: string;
|
|
46
|
+
requiresVerification?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface AuthStatus {
|
|
50
|
+
isLoggedIn: boolean;
|
|
51
|
+
customer?: {
|
|
52
|
+
id: string;
|
|
53
|
+
email: string;
|
|
54
|
+
firstName?: string;
|
|
55
|
+
lastName?: string;
|
|
56
|
+
phone?: string;
|
|
57
|
+
emailVerified: boolean;
|
|
58
|
+
};
|
|
59
|
+
error?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface VerifyEmailResult {
|
|
63
|
+
verified: boolean;
|
|
64
|
+
message?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function handleResponse<T>(response: Response): Promise<T> {
|
|
68
|
+
const data = await response.json();
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new Error(data.message || data.error || `Request failed (${response.status})`);
|
|
71
|
+
}
|
|
72
|
+
return data as T;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Login via the SDK, routed through the BFF proxy, which sets the httpOnly
|
|
77
|
+
* cookie on success and strips the token from the response. The narrow
|
|
78
|
+
* `LoginResult` return type is deliberate: the SDK's own response type
|
|
79
|
+
* declares a `token`, but the proxy removes it before it reaches the browser,
|
|
80
|
+
* so nothing here should read one.
|
|
81
|
+
*/
|
|
82
|
+
export async function proxyLogin(email: string, password: string): Promise<LoginResult> {
|
|
83
|
+
return getClient().loginCustomer(email, password);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Register via the SDK, routed through the BFF proxy, which sets the httpOnly
|
|
88
|
+
* cookie on success.
|
|
89
|
+
*/
|
|
90
|
+
export async function proxyRegister(data: {
|
|
91
|
+
firstName: string;
|
|
92
|
+
lastName: string;
|
|
93
|
+
email: string;
|
|
94
|
+
password: string;
|
|
95
|
+
acceptsMarketing?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Birthday month (1-12) and day (1-31), never a year. Powers the loyalty
|
|
98
|
+
* birthday gift. Send both or neither: one without the other is rejected
|
|
99
|
+
* with HTTP 400, and so is a day the month does not have. Required only when
|
|
100
|
+
* `getStoreInfo().requireBirthday` is true for this sales channel.
|
|
101
|
+
*/
|
|
102
|
+
birthMonth?: number;
|
|
103
|
+
birthDay?: number;
|
|
104
|
+
}): Promise<RegisterResult> {
|
|
105
|
+
return getClient().registerCustomer(data);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check auth status. Reads httpOnly cookie server-side and validates with backend.
|
|
110
|
+
*/
|
|
111
|
+
export async function checkAuthStatus(): Promise<AuthStatus> {
|
|
112
|
+
const response = await fetch('/api/auth/me');
|
|
113
|
+
return response.json();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Logout. Clears httpOnly auth cookies server-side.
|
|
118
|
+
*/
|
|
119
|
+
export async function proxyLogout(): Promise<void> {
|
|
120
|
+
await fetch('/api/auth/logout', {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { 'X-Requested-With': 'brainerce' },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Verify email via the SDK. No token argument is passed: the auth token lives
|
|
128
|
+
* in the httpOnly cookie (set during login/register) and the proxy attaches
|
|
129
|
+
* the Authorization header. The SDK skips its own token check in proxy mode.
|
|
130
|
+
*/
|
|
131
|
+
export async function proxyVerifyEmail(code: string): Promise<VerifyEmailResult> {
|
|
132
|
+
return getClient().verifyEmail(code);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resend the verification email via the SDK. Uses the auth token from the
|
|
137
|
+
* httpOnly cookie, added by the proxy. Rate limited to 3 requests per hour.
|
|
138
|
+
*/
|
|
139
|
+
export async function proxyResendVerification(): Promise<{ message: string }> {
|
|
140
|
+
return getClient().resendVerificationEmail();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Reset password via BFF proxy.
|
|
145
|
+
* The reset token is in an httpOnly cookie (set by /api/auth/reset-callback when the user
|
|
146
|
+
* clicked the email link). The proxy reads it server-side — the token never reaches client JS.
|
|
147
|
+
*/
|
|
148
|
+
export async function proxyResetPassword(newPassword: string): Promise<{ message: string }> {
|
|
149
|
+
const response = await fetch('/api/auth/reset-password', {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: CSRF_HEADERS,
|
|
152
|
+
body: JSON.stringify({ newPassword }),
|
|
153
|
+
});
|
|
154
|
+
return handleResponse<{ message: string }>(response);
|
|
155
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Birthday helpers for the loyalty birthday gift.
|
|
3
|
+
*
|
|
4
|
+
* Brainerce stores a birthday as a MONTH and a DAY and never a year, so there
|
|
5
|
+
* is no age on file and nothing here needs a date library or a date picker.
|
|
6
|
+
* The platform mints a one-time coupon and emails it ahead of the day, which
|
|
7
|
+
* only works if the storefront actually collects the two values.
|
|
8
|
+
*
|
|
9
|
+
* Both `updateMyProfile()` and `registerCustomer()` reject a month sent without
|
|
10
|
+
* a day (and the reverse) with HTTP 400, and reject a day the month does not
|
|
11
|
+
* have, so every form that collects a birthday validates with these helpers
|
|
12
|
+
* before it submits.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Translation keys for the month names, index 0 = January. They live in the
|
|
17
|
+
* `common` namespace so the account form and the signup form share one list
|
|
18
|
+
* instead of each carrying its own twelve keys.
|
|
19
|
+
*/
|
|
20
|
+
export const BIRTH_MONTH_KEYS = [
|
|
21
|
+
'monthJanuary',
|
|
22
|
+
'monthFebruary',
|
|
23
|
+
'monthMarch',
|
|
24
|
+
'monthApril',
|
|
25
|
+
'monthMay',
|
|
26
|
+
'monthJune',
|
|
27
|
+
'monthJuly',
|
|
28
|
+
'monthAugust',
|
|
29
|
+
'monthSeptember',
|
|
30
|
+
'monthOctober',
|
|
31
|
+
'monthNovember',
|
|
32
|
+
'monthDecember',
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How many days each month offers.
|
|
37
|
+
*
|
|
38
|
+
* February gets 29, not 28: the 29th IS a valid birthday and the platform
|
|
39
|
+
* celebrates it on 28 February in years that do not have one. No year is ever
|
|
40
|
+
* stored, so there is no leap-year arithmetic to do here.
|
|
41
|
+
*/
|
|
42
|
+
const DAYS_IN_BIRTH_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
43
|
+
|
|
44
|
+
/** Highest valid day for a birthday month (1-12). */
|
|
45
|
+
export function daysInBirthMonth(month: number): number {
|
|
46
|
+
return DAYS_IN_BIRTH_MONTH[month - 1] ?? 31;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Day numbers to render in the day grid. With no month chosen yet the full
|
|
51
|
+
* 1-31 range is offered; picking a month narrows it right away.
|
|
52
|
+
*/
|
|
53
|
+
export function birthDayOptions(month: number | null): number[] {
|
|
54
|
+
const count = month ? daysInBirthMonth(month) : 31;
|
|
55
|
+
return Array.from({ length: count }, (_, index) => index + 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Translation key for a stored month, or null when the value is outside 1-12.
|
|
60
|
+
* Guards the display path so a bad value renders nothing rather than a raw
|
|
61
|
+
* `common.` key path.
|
|
62
|
+
*/
|
|
63
|
+
export function birthMonthKey(month: number): string | null {
|
|
64
|
+
return BIRTH_MONTH_KEYS[month - 1] ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read a stored form value into the number the API wants, or null when no
|
|
69
|
+
* birthday is set.
|
|
70
|
+
*/
|
|
71
|
+
export function toBirthdayNumber(value: string): number | null {
|
|
72
|
+
const parsed = Number(value);
|
|
73
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
74
|
+
}
|
|
@@ -37,22 +37,11 @@ export function initClientWithLocale(locale: string): BrainerceClient {
|
|
|
37
37
|
}
|
|
38
38
|
<% } %>
|
|
39
39
|
|
|
40
|
-
// Cart
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return localStorage.getItem(CART_ID_KEY);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function setStoredCartId(cartId: string | null): void {
|
|
49
|
-
if (typeof window === 'undefined') return;
|
|
50
|
-
if (cartId) {
|
|
51
|
-
localStorage.setItem(CART_ID_KEY, cartId);
|
|
52
|
-
} else {
|
|
53
|
-
localStorage.removeItem(CART_ID_KEY);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
40
|
+
// Cart identity belongs to the SDK. It owns the guest cart id, the session
|
|
41
|
+
// cart and the logged-in cart cache, and `smartGetCart()` resolves the right
|
|
42
|
+
// one. Do not mirror a cart id into localStorage here: a second copy drifts
|
|
43
|
+
// out of step on login, cart merge and post-payment clear, and the SDK will
|
|
44
|
+
// not read it back.
|
|
56
45
|
|
|
57
46
|
// Initialize client (no token hydration — auth handled by httpOnly cookie)
|
|
58
47
|
export function initClient(): BrainerceClient {
|
|
@@ -21,6 +21,15 @@ export interface PublicStoreInfo {
|
|
|
21
21
|
contactPhone?: string | null;
|
|
22
22
|
socialLinks?: Record<string, string> | null;
|
|
23
23
|
requireEmailVerification?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Whether the merchant made the birthday mandatory at registration on this
|
|
26
|
+
* sales channel. Render the signup form's month and day fields as required
|
|
27
|
+
* when true and block the submit while either is empty, because the backend
|
|
28
|
+
* rejects a register call without both with HTTP 400. Absent means optional.
|
|
29
|
+
* Nothing else in the storefront enforces it, so treat it purely as a
|
|
30
|
+
* rendering hint.
|
|
31
|
+
*/
|
|
32
|
+
requireBirthday?: boolean;
|
|
24
33
|
upsell?: StoreInfo['upsell'];
|
|
25
34
|
i18n?: StoreInfo['i18n'];
|
|
26
35
|
/** Real flat-rate/free shipping zones — feeds Product JSON-LD `shippingDetails`. Public by design (merchants display shipping rates openly). */
|
|
@@ -59,6 +68,7 @@ export function pickPublicStoreInfo(raw: StoreInfo): PublicStoreInfo {
|
|
|
59
68
|
contactPhone: raw.contactPhone ?? null,
|
|
60
69
|
socialLinks: raw.socialLinks ?? null,
|
|
61
70
|
requireEmailVerification: raw.requireEmailVerification,
|
|
71
|
+
requireBirthday: raw.requireBirthday,
|
|
62
72
|
upsell: raw.upsell,
|
|
63
73
|
i18n: raw.i18n,
|
|
64
74
|
shipping: raw.shipping,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
|
4
4
|
import type { Cart, CustomerProfile } from 'brainerce';
|
|
5
5
|
import { getCartTotals } from 'brainerce';
|
|
6
|
-
import { getClient, initClient
|
|
6
|
+
import { getClient, initClient } from '@/core/lib/brainerce';
|
|
7
7
|
import { pickPublicStoreInfo, type PublicStoreInfo } from '@/core/lib/store-info';
|
|
8
8
|
import { checkAuthStatus, proxyLogout } from '@/core/lib/auth';
|
|
9
9
|
<% if (i18nEnabled) { %>
|
|
@@ -170,13 +170,10 @@ export function StoreProvider({
|
|
|
170
170
|
try {
|
|
171
171
|
setCartLoading(true);
|
|
172
172
|
const client = getClient();
|
|
173
|
+
// The SDK owns cart identity: smartGetCart() picks the guest, session or
|
|
174
|
+
// customer cart on its own. Never mirror the id into localStorage.
|
|
173
175
|
const c = await client.smartGetCart();
|
|
174
176
|
setCart(c);
|
|
175
|
-
|
|
176
|
-
// Persist server cart ID
|
|
177
|
-
if (c && c.id) {
|
|
178
|
-
setStoredCartId(c.id);
|
|
179
|
-
}
|
|
180
177
|
} catch (err) {
|
|
181
178
|
console.error('Failed to load cart:', err);
|
|
182
179
|
} finally {
|
|
@@ -30,6 +30,17 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
30
30
|
const unitPrice = parseFloat(item.unitPrice);
|
|
31
31
|
const lineTotal = unitPrice * item.quantity;
|
|
32
32
|
|
|
33
|
+
// The server decides purchasability, per line. `isAvailable === false` means
|
|
34
|
+
// this line blocks checkout until it is removed, whether the stock ran out
|
|
35
|
+
// (a released reservation is the common cause) or the product/variant was
|
|
36
|
+
// withdrawn from sale. `unavailableReason` separates the two for the label.
|
|
37
|
+
const isUnavailable = item.isAvailable === false;
|
|
38
|
+
const unavailableLabel = isUnavailable
|
|
39
|
+
? item.unavailableReason === 'OUT_OF_STOCK'
|
|
40
|
+
? td('outOfStock')
|
|
41
|
+
: td('unavailable')
|
|
42
|
+
: null;
|
|
43
|
+
|
|
33
44
|
async function handleQuantityChange(newQuantity: number) {
|
|
34
45
|
if (newQuantity < 1 || updating) return;
|
|
35
46
|
|
|
@@ -83,6 +94,13 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
83
94
|
<div className="min-w-0 flex-1">
|
|
84
95
|
<h3 className="text-foreground truncate text-sm font-medium">{productName}</h3>
|
|
85
96
|
|
|
97
|
+
{/* Availability badge. This line blocks checkout while it shows. */}
|
|
98
|
+
{unavailableLabel && (
|
|
99
|
+
<span className="bg-destructive/10 text-destructive mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium">
|
|
100
|
+
{unavailableLabel}
|
|
101
|
+
</span>
|
|
102
|
+
)}
|
|
103
|
+
|
|
86
104
|
{/* Variant name */}
|
|
87
105
|
{variantName && <p className="text-muted-foreground mt-1 text-xs">{variantName}</p>}
|
|
88
106
|
|
|
@@ -116,7 +134,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
116
134
|
<button
|
|
117
135
|
type="button"
|
|
118
136
|
onClick={() => handleQuantityChange(item.quantity + 1)}
|
|
119
|
-
disabled={updating}
|
|
137
|
+
disabled={updating || isUnavailable}
|
|
120
138
|
className="text-foreground hover:bg-muted px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
|
121
139
|
aria-label={td('increaseQuantity')}
|
|
122
140
|
>
|
|
@@ -21,7 +21,20 @@ import { useTranslations } from '@/core/lib/translations';
|
|
|
21
21
|
export function CartView() {
|
|
22
22
|
const t = useTranslations('cart');
|
|
23
23
|
const tc = useTranslations('common');
|
|
24
|
-
const
|
|
24
|
+
const tr = useTranslations('reservation');
|
|
25
|
+
const {
|
|
26
|
+
cart,
|
|
27
|
+
cartLoading,
|
|
28
|
+
refreshCart,
|
|
29
|
+
itemCount,
|
|
30
|
+
cartRecs,
|
|
31
|
+
upgrades,
|
|
32
|
+
bundles,
|
|
33
|
+
reservationExpired,
|
|
34
|
+
unavailableItems,
|
|
35
|
+
canProceedToCheckout,
|
|
36
|
+
onReservationExpired,
|
|
37
|
+
} = useCartPage();
|
|
25
38
|
|
|
26
39
|
if (cartLoading) {
|
|
27
40
|
return (
|
|
@@ -55,9 +68,14 @@ export function CartView() {
|
|
|
55
68
|
{t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
|
|
56
69
|
</h1>
|
|
57
70
|
|
|
58
|
-
{/* Reservation countdown
|
|
71
|
+
{/* Reservation countdown. onExpire refreshes the cart and closes the
|
|
72
|
+
checkout gate below. Passing it is what makes expiry mean anything. */}
|
|
59
73
|
{cart.reservation?.hasReservation && (
|
|
60
|
-
<ReservationCountdown
|
|
74
|
+
<ReservationCountdown
|
|
75
|
+
reservation={cart.reservation}
|
|
76
|
+
onExpire={onReservationExpired}
|
|
77
|
+
className="mb-6"
|
|
78
|
+
/>
|
|
61
79
|
)}
|
|
62
80
|
|
|
63
81
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
@@ -113,9 +131,27 @@ export function CartView() {
|
|
|
113
131
|
<FreeShippingBar className="mb-4" />
|
|
114
132
|
<CartSummary />
|
|
115
133
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
134
|
+
{/* Proceed to checkout. When the gate is closed this must be a
|
|
135
|
+
real disabled control, never a styled-down link: an anchor
|
|
136
|
+
ignores `disabled` and would still navigate. */}
|
|
137
|
+
{canProceedToCheckout ? (
|
|
138
|
+
<Button asChild size="lg" className="mt-6 w-full rounded px-6 text-sm">
|
|
139
|
+
<Link href="/checkout">{t('proceedToCheckout')}</Link>
|
|
140
|
+
</Button>
|
|
141
|
+
) : (
|
|
142
|
+
<>
|
|
143
|
+
<Button disabled size="lg" className="mt-6 w-full rounded px-6 text-sm">
|
|
144
|
+
{t('proceedToCheckout')}
|
|
145
|
+
</Button>
|
|
146
|
+
<p className="text-destructive mt-2 text-xs">
|
|
147
|
+
{unavailableItems.length > 0
|
|
148
|
+
? t('unavailableItemsHint')
|
|
149
|
+
: reservationExpired
|
|
150
|
+
? tr('expiredHint')
|
|
151
|
+
: null}
|
|
152
|
+
</p>
|
|
153
|
+
</>
|
|
154
|
+
)}
|
|
119
155
|
|
|
120
156
|
<Link
|
|
121
157
|
href="/products"
|