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.
Files changed (50) hide show
  1. package/README.md +31 -10
  2. package/dist/index.js +197 -105
  3. package/messages/en.json +63 -3
  4. package/messages/he.json +63 -3
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/TRANSLATIONS.md +14 -7
  7. package/templates/nextjs/base/src/app/checkout/page.tsx +1074 -1017
  8. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +21 -2
  9. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +1 -1
  10. package/templates/nextjs/base/src/app/register/page.tsx +67 -64
  11. package/templates/nextjs/base/src/components/account/order-history.tsx +25 -39
  12. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +30 -11
  13. package/templates/nextjs/base/src/components/account/profile-section.tsx +303 -226
  14. package/templates/nextjs/base/src/components/auth/register-form.tsx +326 -245
  15. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +306 -294
  16. package/templates/nextjs/base/src/components/checkout/date-picker.tsx +13 -1
  17. package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +61 -21
  18. package/templates/nextjs/base/src/components/shared/birthday-picker.tsx +258 -0
  19. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +71 -2
  20. package/templates/nextjs/base/src/core/lib/auth.ts +155 -154
  21. package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
  22. package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +5 -16
  23. package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
  24. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +3 -6
  25. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +19 -1
  26. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +42 -6
  27. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +52 -10
  28. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +143 -0
  29. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +18 -2
  30. package/templates/nextjs/base/src/ui/product/back-in-stock-form.tsx +173 -0
  31. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +484 -455
  32. package/templates/nextjs/base/src/ui/product/review-form.tsx +136 -12
  33. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +139 -108
  34. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +21 -3
  35. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +19 -1
  36. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +44 -7
  37. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +52 -10
  38. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +155 -142
  39. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +500 -477
  40. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +135 -11
  41. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +179 -148
  42. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +15 -1
  43. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +38 -4
  44. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +54 -11
  45. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +122 -0
  46. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +87 -83
  47. package/templates/nextjs/ui-canvas/product/back-in-stock-form.tsx +151 -0
  48. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +373 -352
  49. package/templates/nextjs/ui-canvas/product/review-form.tsx +129 -11
  50. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +127 -96
@@ -1,154 +1,155 @@
1
- /**
2
- * Client-side auth helpers that call the BFF proxy API routes.
3
- * All mutating requests include the CSRF header.
4
- * The token is managed server-side via httpOnly cookies never exposed to JS.
5
- */
6
-
7
- // Read either env var name. The new one is preferred; the old one is a soft
8
- // alias kept for backwards compatibility both are accepted by the SDK.
9
- const CONNECTION_ID =
10
- process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID ||
11
- process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID ||
12
- '';
13
-
14
- const CSRF_HEADERS: Record<string, string> = {
15
- 'Content-Type': 'application/json',
16
- 'X-Requested-With': 'brainerce',
17
- };
18
-
19
- interface LoginResult {
20
- customer: {
21
- id: string;
22
- email: string;
23
- firstName?: string;
24
- lastName?: string;
25
- emailVerified: boolean;
26
- };
27
- expiresAt: string;
28
- requiresVerification?: boolean;
29
- }
30
-
31
- interface RegisterResult {
32
- customer: {
33
- id: string;
34
- email: string;
35
- firstName?: string;
36
- lastName?: string;
37
- emailVerified: boolean;
38
- };
39
- expiresAt: string;
40
- requiresVerification?: boolean;
41
- }
42
-
43
- interface AuthStatus {
44
- isLoggedIn: boolean;
45
- customer?: {
46
- id: string;
47
- email: string;
48
- firstName?: string;
49
- lastName?: string;
50
- phone?: string;
51
- emailVerified: boolean;
52
- };
53
- error?: string;
54
- }
55
-
56
- interface VerifyEmailResult {
57
- verified: boolean;
58
- message?: string;
59
- }
60
-
61
- async function handleResponse<T>(response: Response): Promise<T> {
62
- const data = await response.json();
63
- if (!response.ok) {
64
- throw new Error(data.message || data.error || `Request failed (${response.status})`);
65
- }
66
- return data as T;
67
- }
68
-
69
- /**
70
- * Login via BFF proxy. The proxy sets the httpOnly cookie on success.
71
- */
72
- export async function proxyLogin(email: string, password: string): Promise<LoginResult> {
73
- const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/login`, {
74
- method: 'POST',
75
- headers: CSRF_HEADERS,
76
- body: JSON.stringify({ email, password }),
77
- });
78
- return handleResponse<LoginResult>(response);
79
- }
80
-
81
- /**
82
- * Register via BFF proxy. The proxy sets the httpOnly cookie on success.
83
- */
84
- export async function proxyRegister(data: {
85
- firstName: string;
86
- lastName: string;
87
- email: string;
88
- password: string;
89
- acceptsMarketing?: boolean;
90
- }): Promise<RegisterResult> {
91
- const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/register`, {
92
- method: 'POST',
93
- headers: CSRF_HEADERS,
94
- body: JSON.stringify(data),
95
- });
96
- return handleResponse<RegisterResult>(response);
97
- }
98
-
99
- /**
100
- * Check auth status. Reads httpOnly cookie server-side and validates with backend.
101
- */
102
- export async function checkAuthStatus(): Promise<AuthStatus> {
103
- const response = await fetch('/api/auth/me');
104
- return response.json();
105
- }
106
-
107
- /**
108
- * Logout. Clears httpOnly auth cookies server-side.
109
- */
110
- export async function proxyLogout(): Promise<void> {
111
- await fetch('/api/auth/logout', {
112
- method: 'POST',
113
- headers: { 'X-Requested-With': 'brainerce' },
114
- });
115
- }
116
-
117
- /**
118
- * Verify email via BFF proxy. The auth token is in the httpOnly cookie (set during login/register).
119
- * The proxy adds the Authorization header automatically.
120
- */
121
- export async function proxyVerifyEmail(code: string): Promise<VerifyEmailResult> {
122
- const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/verify-email`, {
123
- method: 'POST',
124
- headers: CSRF_HEADERS,
125
- body: JSON.stringify({ code }),
126
- });
127
- return handleResponse<VerifyEmailResult>(response);
128
- }
129
-
130
- /**
131
- * Resend verification email via BFF proxy.
132
- * Uses the auth token from the httpOnly cookie.
133
- */
134
- export async function proxyResendVerification(): Promise<{ message: string }> {
135
- const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/resend-verification`, {
136
- method: 'POST',
137
- headers: CSRF_HEADERS,
138
- });
139
- return handleResponse<{ message: string }>(response);
140
- }
141
-
142
- /**
143
- * Reset password via BFF proxy.
144
- * The reset token is in an httpOnly cookie (set by /api/auth/reset-callback when the user
145
- * clicked the email link). The proxy reads it server-side the token never reaches client JS.
146
- */
147
- export async function proxyResetPassword(newPassword: string): Promise<{ message: string }> {
148
- const response = await fetch('/api/auth/reset-password', {
149
- method: 'POST',
150
- headers: CSRF_HEADERS,
151
- body: JSON.stringify({ newPassword }),
152
- });
153
- return handleResponse<{ message: string }>(response);
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 ID helpers (not a security token safe in localStorage)
41
- const CART_ID_KEY = 'brainerce_cart_id';
42
-
43
- export function getStoredCartId(): string | null {
44
- if (typeof window === 'undefined') return null;
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, setStoredCartId } from '@/core/lib/brainerce';
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 { cart, cartLoading, refreshCart, itemCount, cartRecs, upgrades, bundles } = useCartPage();
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 reservation={cart.reservation} className="mb-6" />
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
- <Button asChild size="lg" className="mt-6 w-full rounded px-6 text-sm">
117
- <Link href="/checkout">{t('proceedToCheckout')}</Link>
118
- </Button>
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"