create-brainerce-store 1.71.0 → 1.73.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 (27) hide show
  1. package/README.md +31 -10
  2. package/dist/index.js +179 -107
  3. package/messages/en.json +14 -1
  4. package/messages/he.json +14 -1
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/TRANSLATIONS.md +207 -200
  7. package/templates/nextjs/base/src/app/checkout/page.tsx +1074 -1018
  8. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +21 -2
  9. package/templates/nextjs/base/src/components/account/order-history.tsx +371 -385
  10. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -66
  11. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +127 -58
  12. package/templates/nextjs/base/src/core/lib/auth.ts +32 -39
  13. package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +5 -16
  14. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +3 -6
  15. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +164 -146
  16. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +176 -140
  17. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -95
  18. package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
  19. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -163
  20. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +158 -140
  21. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +184 -147
  22. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -89
  23. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
  24. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -123
  25. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -106
  26. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -81
  27. package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
@@ -1,66 +1,85 @@
1
- 'use client';
2
-
3
- import type { Order, OrderStatus } from 'brainerce';
4
- import { useTranslations } from '@/core/lib/translations';
5
- import { cn } from '@/core/lib/utils';
6
-
7
- const STATUS_KEYS: Record<
8
- OrderStatus,
9
- | 'statusPending'
10
- | 'statusProcessing'
11
- | 'statusShipped'
12
- | 'statusDelivered'
13
- | 'statusCancelled'
14
- | 'statusRefunded'
15
- > = {
16
- pending: 'statusPending',
17
- processing: 'statusProcessing',
18
- shipped: 'statusShipped',
19
- delivered: 'statusDelivered',
20
- cancelled: 'statusCancelled',
21
- refunded: 'statusRefunded',
22
- };
23
-
24
- interface OrderStatusTimelineProps {
25
- history: Order['statusHistory'];
26
- className?: string;
27
- }
28
-
29
- export function OrderStatusTimeline({ history, className }: OrderStatusTimelineProps) {
30
- const t = useTranslations('account');
31
- if (!history || history.length === 0) return null;
32
-
33
- return (
34
- <div className={cn('border-border border-t pt-2', className)}>
35
- <p className="text-foreground mb-2 text-sm font-medium">{t('statusTimeline')}</p>
36
- <ol className="space-y-1.5">
37
- {history.map((entry, idx) => {
38
- const key = STATUS_KEYS[entry.status] || 'statusPending';
39
- const when = new Date(entry.at);
40
- const whenStr = isNaN(when.getTime())
41
- ? entry.at
42
- : when.toLocaleString(undefined, {
43
- year: 'numeric',
44
- month: 'short',
45
- day: 'numeric',
46
- hour: '2-digit',
47
- minute: '2-digit',
48
- });
49
- return (
50
- <li key={`${entry.status}-${idx}`} className="flex items-center gap-2 text-xs">
51
- <span
52
- className={cn(
53
- 'bg-primary inline-block h-2 w-2 flex-shrink-0 rounded-full',
54
- idx === history.length - 1 ? 'opacity-100' : 'opacity-60'
55
- )}
56
- />
57
- <span className="text-foreground font-medium">{t(key)}</span>
58
- <span className="text-muted-foreground">· {whenStr}</span>
59
- {entry.note && <span className="text-muted-foreground truncate">— {entry.note}</span>}
60
- </li>
61
- );
62
- })}
63
- </ol>
64
- </div>
65
- );
66
- }
1
+ 'use client';
2
+
3
+ import type { Order, OrderStatus } from 'brainerce';
4
+ import { useTranslations } from '@/core/lib/translations';
5
+ import { cn } from '@/core/lib/utils';
6
+
7
+ /**
8
+ * Message key for every order status the API can return.
9
+ *
10
+ * The API sends these values UPPERCASE and verbatim (`PENDING`, `ON_HOLD`,
11
+ * ...). Never lowercase them before the lookup: a lowercase key silently
12
+ * misses and every row falls through to the default label.
13
+ */
14
+ export type OrderStatusLabelKey =
15
+ | 'statusDraft'
16
+ | 'statusPending'
17
+ | 'statusProcessing'
18
+ | 'statusOnHold'
19
+ | 'statusPaid'
20
+ | 'statusShipped'
21
+ | 'statusDelivered'
22
+ | 'statusCompleted'
23
+ | 'statusFulfilled'
24
+ | 'statusCancelled'
25
+ | 'statusRefunded'
26
+ | 'statusPartiallyRefunded';
27
+
28
+ export const ORDER_STATUS_LABEL_KEYS: Record<OrderStatus, OrderStatusLabelKey> = {
29
+ DRAFT: 'statusDraft',
30
+ PENDING: 'statusPending',
31
+ PROCESSING: 'statusProcessing',
32
+ ON_HOLD: 'statusOnHold',
33
+ PAID: 'statusPaid',
34
+ SHIPPED: 'statusShipped',
35
+ DELIVERED: 'statusDelivered',
36
+ COMPLETED: 'statusCompleted',
37
+ FULFILLED: 'statusFulfilled',
38
+ CANCELLED: 'statusCancelled',
39
+ REFUNDED: 'statusRefunded',
40
+ PARTIALLY_REFUNDED: 'statusPartiallyRefunded',
41
+ };
42
+
43
+ interface OrderStatusTimelineProps {
44
+ history: Order['statusHistory'];
45
+ className?: string;
46
+ }
47
+
48
+ export function OrderStatusTimeline({ history, className }: OrderStatusTimelineProps) {
49
+ const t = useTranslations('account');
50
+ if (!history || history.length === 0) return null;
51
+
52
+ return (
53
+ <div className={cn('border-border border-t pt-2', className)}>
54
+ <p className="text-foreground mb-2 text-sm font-medium">{t('statusTimeline')}</p>
55
+ <ol className="space-y-1.5">
56
+ {history.map((entry, idx) => {
57
+ const key = ORDER_STATUS_LABEL_KEYS[entry.status] || 'statusPending';
58
+ const when = new Date(entry.at);
59
+ const whenStr = isNaN(when.getTime())
60
+ ? entry.at
61
+ : when.toLocaleString(undefined, {
62
+ year: 'numeric',
63
+ month: 'short',
64
+ day: 'numeric',
65
+ hour: '2-digit',
66
+ minute: '2-digit',
67
+ });
68
+ return (
69
+ <li key={`${entry.status}-${idx}`} className="flex items-center gap-2 text-xs">
70
+ <span
71
+ className={cn(
72
+ 'bg-primary inline-block h-2 w-2 flex-shrink-0 rounded-full',
73
+ idx === history.length - 1 ? 'opacity-100' : 'opacity-60'
74
+ )}
75
+ />
76
+ <span className="text-foreground font-medium">{t(key)}</span>
77
+ <span className="text-muted-foreground">· {whenStr}</span>
78
+ {entry.note && <span className="text-muted-foreground truncate">({entry.note})</span>}
79
+ </li>
80
+ );
81
+ })}
82
+ </ol>
83
+ </div>
84
+ );
85
+ }
@@ -1,58 +1,127 @@
1
- 'use client';
2
-
3
- import { useEffect, useState } from 'react';
4
- import type {
5
- Cart,
6
- CartRecommendationsResponse,
7
- CartUpgradesResponse,
8
- CartBundlesResponse,
9
- } from 'brainerce';
10
- import { getClient } from '@/core/lib/brainerce';
11
- import { useCart } from '@/core/providers/store-provider';
12
-
13
- export interface UseCartPageResult {
14
- cart: Cart | null;
15
- cartLoading: boolean;
16
- refreshCart: () => Promise<void>;
17
- itemCount: number;
18
- /** Cross-sell recommendations for the current cart contents. */
19
- cartRecs: CartRecommendationsResponse | null;
20
- /** Per-item upgrade suggestions keyed by productId. */
21
- upgrades: CartUpgradesResponse | null;
22
- /** Bundle offers matching the current cart. */
23
- bundles: CartBundlesResponse | null;
24
- }
25
-
26
- /**
27
- * Cart-page behavior: the shared cart state (from StoreProvider) plus the
28
- * enrichment fetch (recommendations / upgrades / bundles) that runs whenever
29
- * the cart contents change. Pure data/behavior — rendering lives in
30
- * `ui/cart/cart-view.tsx`.
31
- */
32
- export function useCartPage(): UseCartPageResult {
33
- const { cart, cartLoading, refreshCart, itemCount } = useCart();
34
- const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
35
- const [upgrades, setUpgrades] = useState<CartUpgradesResponse | null>(null);
36
- const [bundles, setBundles] = useState<CartBundlesResponse | null>(null);
37
-
38
- // Load recommendations, upgrades, and bundles in a single request
39
- useEffect(() => {
40
- if (!cart?.id || cart.items.length === 0) {
41
- setCartRecs(null);
42
- setUpgrades(null);
43
- setBundles(null);
44
- return;
45
- }
46
- const client = getClient();
47
- client
48
- .getCart(cart.id, { include: ['recommendations', 'upgrades', 'bundles'] })
49
- .then((enriched) => {
50
- setCartRecs(enriched.recommendations ?? null);
51
- setUpgrades(enriched.upgrades ?? null);
52
- setBundles(enriched.bundles ?? null);
53
- })
54
- .catch(() => {});
55
- }, [cart?.id, cart?.items.length]);
56
-
57
- return { cart, cartLoading, refreshCart, itemCount, cartRecs, upgrades, bundles };
58
- }
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import type {
5
+ Cart,
6
+ CartItem,
7
+ CartRecommendationsResponse,
8
+ CartUpgradesResponse,
9
+ CartBundlesResponse,
10
+ } from 'brainerce';
11
+ import { getClient } from '@/core/lib/brainerce';
12
+ import { useCart } from '@/core/providers/store-provider';
13
+
14
+ export interface UseCartPageResult {
15
+ cart: Cart | null;
16
+ cartLoading: boolean;
17
+ refreshCart: () => Promise<void>;
18
+ itemCount: number;
19
+ /** Cross-sell recommendations for the current cart contents. */
20
+ cartRecs: CartRecommendationsResponse | null;
21
+ /** Per-item upgrade suggestions keyed by productId. */
22
+ upgrades: CartUpgradesResponse | null;
23
+ /** Bundle offers matching the current cart. */
24
+ bundles: CartBundlesResponse | null;
25
+ /**
26
+ * True once the stock reservation on this cart has run out and the refreshed
27
+ * cart still carries the same expired window. Goes back to false on its own
28
+ * if the server hands back a fresh reservation.
29
+ */
30
+ reservationExpired: boolean;
31
+ /** Lines the server says cannot be bought right now (`isAvailable === false`). */
32
+ unavailableItems: CartItem[];
33
+ /**
34
+ * False while the reservation is expired or any line is unavailable. The
35
+ * proceed-to-checkout action must be genuinely disabled on false, not just
36
+ * styled as disabled.
37
+ */
38
+ canProceedToCheckout: boolean;
39
+ /**
40
+ * Hand this to `<ReservationCountdown onExpire={...}>`. It refreshes the
41
+ * cart once per expiry window: the server, not the client timer, decides
42
+ * what is still purchasable.
43
+ */
44
+ onReservationExpired: () => void;
45
+ }
46
+
47
+ /**
48
+ * Cart-page behavior: the shared cart state (from StoreProvider) plus the
49
+ * enrichment fetch (recommendations / upgrades / bundles) that runs whenever
50
+ * the cart contents change. Pure data/behavior — rendering lives in
51
+ * `ui/cart/cart-view.tsx`.
52
+ */
53
+ export function useCartPage(): UseCartPageResult {
54
+ const { cart, cartLoading, refreshCart, itemCount } = useCart();
55
+ const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
56
+ const [upgrades, setUpgrades] = useState<CartUpgradesResponse | null>(null);
57
+ const [bundles, setBundles] = useState<CartBundlesResponse | null>(null);
58
+
59
+ // Load recommendations, upgrades, and bundles in a single request
60
+ useEffect(() => {
61
+ if (!cart?.id || cart.items.length === 0) {
62
+ setCartRecs(null);
63
+ setUpgrades(null);
64
+ setBundles(null);
65
+ return;
66
+ }
67
+ const client = getClient();
68
+ client
69
+ .getCart(cart.id, { include: ['recommendations', 'upgrades', 'bundles'] })
70
+ .then((enriched) => {
71
+ setCartRecs(enriched.recommendations ?? null);
72
+ setUpgrades(enriched.upgrades ?? null);
73
+ setBundles(enriched.bundles ?? null);
74
+ })
75
+ .catch(() => {});
76
+ }, [cart?.id, cart?.items.length]);
77
+
78
+ // ---- Reservation expiry ----
79
+ //
80
+ // The countdown component only ticks. Expiry is handled here, once, so all
81
+ // three design variants share one behaviour and a user redesigning the
82
+ // countdown cannot delete the gate along with the markup.
83
+ //
84
+ // Both pieces of state key on the reservation's `expiresAt`, which is the
85
+ // only stable identity a reservation window has:
86
+ // - `handledExpiryRef` stops the refresh loop (refresh remounts the
87
+ // countdown, which would otherwise report the same expiry again).
88
+ // - comparing `expiredWindow` to the CURRENT `expiresAt` means a server
89
+ // that renews the reservation clears the expired state by itself.
90
+ const reservationExpiresAt = cart?.reservation?.expiresAt ?? null;
91
+ const handledExpiryRef = useRef<string | null>(null);
92
+ const [expiredWindow, setExpiredWindow] = useState<string | null>(null);
93
+
94
+ const onReservationExpired = useCallback(() => {
95
+ if (!reservationExpiresAt) return;
96
+ setExpiredWindow(reservationExpiresAt);
97
+ if (handledExpiryRef.current === reservationExpiresAt) return;
98
+ handledExpiryRef.current = reservationExpiresAt;
99
+ // Re-read from the server: it is the source of truth for what survived
100
+ // the released reservation. `isAvailable` on each line comes back updated.
101
+ void refreshCart();
102
+ }, [reservationExpiresAt, refreshCart]);
103
+
104
+ const reservationExpired =
105
+ expiredWindow !== null &&
106
+ expiredWindow === reservationExpiresAt &&
107
+ cart?.reservation?.hasReservation === true;
108
+
109
+ const unavailableItems = cart?.items.filter((item) => item.isAvailable === false) ?? [];
110
+
111
+ const canProceedToCheckout =
112
+ !!cart && cart.items.length > 0 && !reservationExpired && unavailableItems.length === 0;
113
+
114
+ return {
115
+ cart,
116
+ cartLoading,
117
+ refreshCart,
118
+ itemCount,
119
+ cartRecs,
120
+ upgrades,
121
+ bundles,
122
+ reservationExpired,
123
+ unavailableItems,
124
+ canProceedToCheckout,
125
+ onReservationExpired,
126
+ };
127
+ }
@@ -1,15 +1,21 @@
1
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.
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.
5
17
  */
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
- '';
18
+ import { getClient } from '@/core/lib/brainerce';
13
19
 
14
20
  const CSRF_HEADERS: Record<string, string> = {
15
21
  'Content-Type': 'application/json',
@@ -67,19 +73,19 @@ async function handleResponse<T>(response: Response): Promise<T> {
67
73
  }
68
74
 
69
75
  /**
70
- * Login via BFF proxy. The proxy sets the httpOnly cookie on success.
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.
71
81
  */
72
82
  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);
83
+ return getClient().loginCustomer(email, password);
79
84
  }
80
85
 
81
86
  /**
82
- * Register via BFF proxy. The proxy sets the httpOnly cookie on success.
87
+ * Register via the SDK, routed through the BFF proxy, which sets the httpOnly
88
+ * cookie on success.
83
89
  */
84
90
  export async function proxyRegister(data: {
85
91
  firstName: string;
@@ -96,12 +102,7 @@ export async function proxyRegister(data: {
96
102
  birthMonth?: number;
97
103
  birthDay?: number;
98
104
  }): Promise<RegisterResult> {
99
- const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/register`, {
100
- method: 'POST',
101
- headers: CSRF_HEADERS,
102
- body: JSON.stringify(data),
103
- });
104
- return handleResponse<RegisterResult>(response);
105
+ return getClient().registerCustomer(data);
105
106
  }
106
107
 
107
108
  /**
@@ -123,28 +124,20 @@ export async function proxyLogout(): Promise<void> {
123
124
  }
124
125
 
125
126
  /**
126
- * Verify email via BFF proxy. The auth token is in the httpOnly cookie (set during login/register).
127
- * The proxy adds the Authorization header automatically.
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.
128
130
  */
129
131
  export async function proxyVerifyEmail(code: string): Promise<VerifyEmailResult> {
130
- const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/verify-email`, {
131
- method: 'POST',
132
- headers: CSRF_HEADERS,
133
- body: JSON.stringify({ code }),
134
- });
135
- return handleResponse<VerifyEmailResult>(response);
132
+ return getClient().verifyEmail(code);
136
133
  }
137
134
 
138
135
  /**
139
- * Resend verification email via BFF proxy.
140
- * Uses the auth token from the httpOnly cookie.
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.
141
138
  */
142
139
  export async function proxyResendVerification(): Promise<{ message: string }> {
143
- const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/resend-verification`, {
144
- method: 'POST',
145
- headers: CSRF_HEADERS,
146
- });
147
- return handleResponse<{ message: string }>(response);
140
+ return getClient().resendVerificationEmail();
148
141
  }
149
142
 
150
143
  /**
@@ -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 {
@@ -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 {