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.
Files changed (88) hide show
  1. package/dist/index.js +37 -4
  2. package/messages/en.json +30 -1
  3. package/messages/he.json +30 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/AGENTS.md.ejs +63 -16
  6. package/templates/nextjs/base/AI-GUIDE.md +17 -4
  7. package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
  8. package/templates/nextjs/base/TRANSLATIONS.md +207 -207
  9. package/templates/nextjs/base/src/app/account/page.tsx +168 -122
  10. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
  11. package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
  12. package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
  13. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
  14. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  15. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
  16. package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
  17. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
  18. package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
  19. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
  20. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
  21. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
  22. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
  23. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
  24. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
  25. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
  26. package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
  27. package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
  28. package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
  29. package/templates/nextjs/base/src/core/lib/region.ts +112 -0
  30. package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
  31. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
  32. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
  33. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
  34. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
  35. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
  37. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
  38. package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
  39. package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
  40. package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
  41. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
  42. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
  43. package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
  44. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
  45. package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
  46. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
  47. package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
  48. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
  49. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
  50. package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
  51. package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
  52. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
  53. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
  54. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
  55. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
  56. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
  57. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
  58. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
  59. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
  60. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
  61. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
  62. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
  63. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
  64. package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
  65. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
  66. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
  67. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
  68. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
  69. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
  70. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
  71. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
  72. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
  73. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
  74. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
  75. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
  76. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
  77. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
  78. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
  79. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
  80. package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
  81. package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
  82. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
  83. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
  84. package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
  85. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
  86. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
  87. package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
  88. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
@@ -4,10 +4,12 @@ import type { Metadata } from 'next';
4
4
  import { StoreProvider } from '@/core/providers/store-provider';
5
5
  import { AnnouncementBar } from '@/ui/layout/announcement-bar';
6
6
  import { SiteHeader } from '@/ui/layout/site-header';
7
+ import { DiscountBannerStrip } from '@/ui/home/discount-banner-strip';
7
8
  import { SiteFooter } from '@/ui/layout/site-footer';
8
9
  import { BrainerceBotWidget } from '@/components/brainerce-bot';
9
10
  import { TrackingBootstrap } from '@/components/tracking-bootstrap';
10
11
  import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
12
+ import { resolveRegion } from '@/core/lib/region.server';
11
13
  import { getDirection, getMessages, supportedLocales } from '@/i18n';
12
14
  import { getNonce } from '@/core/lib/nonce';
13
15
  import { getCanonicalSiteUrl } from '@/core/lib/site-url';
@@ -71,7 +73,8 @@ export default async function RootLayout({
71
73
  // seeded a particular content type yet. New stores ship with default rows
72
74
  // seeded by the backend (StoresService.seedDefaultContent).
73
75
  const client = await getServerClient(locale);
74
- const [announcements, siteHeader, siteFooter, storeInfo, messages] = await Promise.all([
76
+ const [announcements, siteHeader, siteFooter, storeInfo, messages, discountBanners, regionInfo] =
77
+ await Promise.all([
75
78
  client.content.announcement.list(locale).catch(() => []),
76
79
  client.content.header.get('main', locale).catch(() => null),
77
80
  client.content.footer.get('main', locale).catch(() => null),
@@ -83,6 +86,18 @@ export default async function RootLayout({
83
86
  // without this, every page (and every locale switch) shows raw
84
87
  // "namespace.key" strings until the client-only fetch resolves.
85
88
  getMessages(locale),
89
+ // Store-wide discount banners. Fetched here rather than on the home
90
+ // page so they render below the header on EVERY page. `.catch(() => [])`
91
+ // keeps a store with no active discounts (and an API hiccup) silent:
92
+ // the strip itself returns null on an empty array.
93
+ client.getDiscountBanners().catch(() => []),
94
+ // Which region prices this request: the shopper's cookie, else the edge
95
+ // geo header, else the store default. Resolved here so SSR and the client
96
+ // agree from frame 0 and every page shares one round trip (it is
97
+ // `cache()`-wrapped). Never throws: a store with no regions answers
98
+ // `{ regions: [], region: null }` and every consumer then sends no
99
+ // `regionId`, which is what this storefront did before regions existed.
100
+ resolveRegion(),
86
101
  ]);
87
102
 
88
103
  return (
@@ -123,7 +138,14 @@ export default async function RootLayout({
123
138
  />
124
139
  </head>
125
140
  <body className={font.className}>
126
- <StoreProvider locale={locale} initialStoreInfo={storeInfo} initialMessages={messages}>
141
+ <StoreProvider
142
+ locale={locale}
143
+ initialStoreInfo={storeInfo}
144
+ initialMessages={messages}
145
+ initialRegions={regionInfo.regions}
146
+ initialRegion={regionInfo.region}
147
+ initialCountry={regionInfo.country}
148
+ >
127
149
  {/* Mounted ahead of the page tree on purpose: React runs effects in
128
150
  tree order, so booting the tags here guarantees fbq/ttq exist by
129
151
  the time a page component fires its first e-commerce event. */}
@@ -131,6 +153,7 @@ export default async function RootLayout({
131
153
  <div className="min-h-screen flex flex-col">
132
154
  <AnnouncementBar announcements={announcements} />
133
155
  <SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
156
+ <DiscountBannerStrip banners={discountBanners} />
134
157
  <main className="flex-1">{children}</main>
135
158
  <SiteFooter footer={siteFooter} storeName={<%- storeNameJs %>} />
136
159
  </div>
@@ -146,10 +169,12 @@ import type { Metadata } from 'next';
146
169
  import { StoreProvider } from '@/core/providers/store-provider';
147
170
  import { AnnouncementBar } from '@/ui/layout/announcement-bar';
148
171
  import { SiteHeader } from '@/ui/layout/site-header';
172
+ import { DiscountBannerStrip } from '@/ui/home/discount-banner-strip';
149
173
  import { SiteFooter } from '@/ui/layout/site-footer';
150
174
  import { BrainerceBotWidget } from '@/components/brainerce-bot';
151
175
  import { TrackingBootstrap } from '@/components/tracking-bootstrap';
152
176
  import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
177
+ import { resolveRegion } from '@/core/lib/region.server';
153
178
  import { getNonce } from '@/core/lib/nonce';
154
179
  import { getCanonicalSiteUrl } from '@/core/lib/site-url';
155
180
  import './globals.css';
@@ -205,7 +230,8 @@ export default async function RootLayout({
205
230
  // seeded a particular content type yet. New stores ship with default rows
206
231
  // seeded by the backend (StoresService.seedDefaultContent).
207
232
  const client = await getServerClient();
208
- const [announcements, siteHeader, siteFooter, storeInfo] = await Promise.all([
233
+ const [announcements, siteHeader, siteFooter, storeInfo, discountBanners, regionInfo] =
234
+ await Promise.all([
209
235
  client.content.announcement.list().catch(() => []),
210
236
  client.content.header.get('main').catch(() => null),
211
237
  client.content.footer.get('main').catch(() => null),
@@ -213,6 +239,18 @@ export default async function RootLayout({
213
239
  // render with the real currency, feature flags, and i18n config at frame 0
214
240
  // — without this, Googlebot sees USD/defaults baked into the HTML.
215
241
  fetchStoreInfo(),
242
+ // Store-wide discount banners. Fetched here rather than on the home
243
+ // page so they render below the header on EVERY page. `.catch(() => [])`
244
+ // keeps a store with no active discounts (and an API hiccup) silent:
245
+ // the strip itself returns null on an empty array.
246
+ client.getDiscountBanners().catch(() => []),
247
+ // Which region prices this request: the shopper's cookie, else the edge
248
+ // geo header, else the store default. Resolved here so SSR and the client
249
+ // agree from frame 0 and every page shares one round trip (it is
250
+ // `cache()`-wrapped). Never throws: a store with no regions answers
251
+ // `{ regions: [], region: null }` and every consumer then sends no
252
+ // `regionId`, which is what this storefront did before regions existed.
253
+ resolveRegion(),
216
254
  ]);
217
255
 
218
256
  return (
@@ -253,7 +291,12 @@ export default async function RootLayout({
253
291
  />
254
292
  </head>
255
293
  <body className={font.className}>
256
- <StoreProvider initialStoreInfo={storeInfo}>
294
+ <StoreProvider
295
+ initialStoreInfo={storeInfo}
296
+ initialRegions={regionInfo.regions}
297
+ initialRegion={regionInfo.region}
298
+ initialCountry={regionInfo.country}
299
+ >
257
300
  {/* Mounted ahead of the page tree on purpose: React runs effects in
258
301
  tree order, so booting the tags here guarantees fbq/ttq exist by
259
302
  the time a page component fires its first e-commerce event. */}
@@ -261,6 +304,7 @@ export default async function RootLayout({
261
304
  <div className="min-h-screen flex flex-col">
262
305
  <AnnouncementBar announcements={announcements} />
263
306
  <SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
307
+ <DiscountBannerStrip banners={discountBanners} />
264
308
  <main className="flex-1">{children}</main>
265
309
  <SiteFooter footer={siteFooter} storeName={<%- storeNameJs %>} />
266
310
  </div>
@@ -2,10 +2,14 @@
2
2
 
3
3
  import { Suspense, useEffect, useState } from 'react';
4
4
  import { useSearchParams } from 'next/navigation';
5
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
6
  import { Link } from '@/core/lib/navigation';
6
- import type { WaitForOrderResult, OrderDownloadLink } from 'brainerce';
7
+ import type { Order, WaitForOrderResult, OrderDownloadLink } from 'brainerce';
8
+ import { formatPrice } from 'brainerce';
7
9
  import { getClient } from '@/core/lib/brainerce';
8
10
  import { useCart } from '@/core/providers/store-provider';
11
+ import { useCurrency } from '@/core/lib/use-currency';
12
+ import { OrderCustomizations } from '@/components/account/order-customizations';
9
13
  import { LoadingSpinner } from '@/ui/shared/loading-spinner';
10
14
  import { useTranslations } from '@/core/lib/translations';
11
15
  import { trackPurchase } from '@/core/lib/tracking';
@@ -18,6 +22,10 @@ function OrderConfirmationContent() {
18
22
  const t = useTranslations('orderConfirmation');
19
23
  const tc = useTranslations('common');
20
24
  const [result, setResult] = useState<WaitForOrderResult | null>(null);
25
+ // The real Order behind the checkout. `waitForOrder` resolves to a status
26
+ // envelope that carries no line items at all, so this second fetch is the
27
+ // only way this page can show what was actually bought.
28
+ const [order, setOrder] = useState<Order | null>(null);
21
29
  const [loading, setLoading] = useState(true);
22
30
  const [error, setError] = useState<string | null>(null);
23
31
 
@@ -88,10 +96,17 @@ function OrderConfirmationContent() {
88
96
  // Read the real order rather than the pre-payment cart so value, tax,
89
97
  // shipping and coupon match what was actually charged; `trackPurchase`
90
98
  // de-duplicates on the order id, so a refresh cannot double-count it.
99
+ //
100
+ // The same Order is what this page renders its line items from, so
101
+ // keep it rather than discarding it after the conversion call.
102
+ // `getOrderByCheckout` is typed `Promise<Order>` and REJECTS on 404 —
103
+ // it never resolves to null — so the catch below is what degrades this
104
+ // page to the order-number-only view instead of breaking it.
91
105
  if (orderResult.success) {
92
106
  try {
93
- const order = await client.getOrderByCheckout(checkoutId!);
94
- trackPurchase(order);
107
+ const fetchedOrder = await client.getOrderByCheckout(checkoutId!);
108
+ setOrder(fetchedOrder);
109
+ trackPurchase(fetchedOrder);
95
110
  } catch (err) {
96
111
  // A conversion we failed to report must never break the page the
97
112
  // shopper is looking at — but it should be visible, because a
@@ -179,6 +194,11 @@ function OrderConfirmationContent() {
179
194
 
180
195
  <p className="text-muted-foreground mt-2">{t('confirmationEmail')}</p>
181
196
 
197
+ {/* What was actually bought. Renders nothing at all when the order
198
+ fetch failed or came back with no lines, leaving the number-only
199
+ view above intact. */}
200
+ <ConfirmationLineItems order={order} />
201
+
182
202
  {result.status.orderId && (
183
203
  <ConfirmationDownloads orderId={result.status.orderId} checkoutId={checkoutId!} />
184
204
  )}
@@ -242,6 +262,81 @@ function OrderConfirmationContent() {
242
262
  );
243
263
  }
244
264
 
265
+ /**
266
+ * Line items on the confirmation page.
267
+ *
268
+ * `OrderItem` is FLAT — `item.name`, `item.price`, `item.image` — unlike the
269
+ * nested `CartItem` / `CheckoutLineItem` shapes used elsewhere in this
270
+ * storefront. Do not copy a cart row renderer here; `item.product.name` does
271
+ * not exist on an order.
272
+ *
273
+ * Auto-hides: returns null when the order fetch failed (`order === null`) or
274
+ * the order carries no lines, so the number-only confirmation still renders.
275
+ */
276
+ function ConfirmationLineItems({ order }: { order: Order | null }) {
277
+ // Named `tch` rather than `t` on purpose: the other components in this file
278
+ // bind `t` to the `orderConfirmation` namespace, and a same-named alias on a
279
+ // different namespace is how a wrong-namespace key slips through review.
280
+ const tch = useTranslations('checkout');
281
+ const ta = useTranslations('account');
282
+ const tc = useTranslations('common');
283
+ const currency = useCurrency(order?.currency);
284
+
285
+ if (!order || !order.items || order.items.length === 0) return null;
286
+
287
+ const totalAmount = order.totalAmount || order.total || '0';
288
+
289
+ return (
290
+ <div className="border-border bg-muted/30 mx-auto mt-8 max-w-md rounded-lg border p-6 text-start">
291
+ <h3 className="text-foreground mb-3 text-sm font-semibold">{tch('orderSummary')}</h3>
292
+
293
+ <div className="space-y-3">
294
+ {order.items.map((item, index) => (
295
+ <div key={`${item.productId}-${index}`} className="space-y-1">
296
+ <div className="flex items-center gap-3">
297
+ <div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
298
+ {item.image ? (
299
+ <Image
300
+ src={item.image}
301
+ alt={item.name || ta('productFallback')}
302
+ fill
303
+ sizes="48px"
304
+ className="object-cover"
305
+ />
306
+ ) : null}
307
+ </div>
308
+
309
+ <div className="min-w-0 flex-1">
310
+ <p className="text-foreground truncate text-sm">
311
+ {item.name || ta('productFallback')}
312
+ </p>
313
+ <p className="text-muted-foreground text-xs">
314
+ {tc('qty')} {item.quantity}
315
+ </p>
316
+ </div>
317
+
318
+ <span className="text-foreground flex-shrink-0 text-sm">
319
+ {formatPrice(parseFloat(item.price), { currency }) as string}
320
+ </span>
321
+ </div>
322
+
323
+ {/* What the buyer typed / uploaded / picked, with the merchant's
324
+ own labels. Renders nothing when the line has none. */}
325
+ {item.customizations && <OrderCustomizations customizations={item.customizations} />}
326
+ </div>
327
+ ))}
328
+ </div>
329
+
330
+ <div className="border-border mt-3 flex items-center justify-between border-t pt-3">
331
+ <span className="text-foreground text-sm font-medium">{tc('total')}</span>
332
+ <span className="text-foreground text-sm font-semibold">
333
+ {formatPrice(parseFloat(totalAmount), { currency }) as string}
334
+ </span>
335
+ </div>
336
+ </div>
337
+ );
338
+ }
339
+
245
340
  function ConfirmationDownloads({ orderId, checkoutId }: { orderId: string; checkoutId: string }) {
246
341
  const t = useTranslations('orderConfirmation');
247
342
  const [downloads, setDownloads] = useState<OrderDownloadLink[] | null>(null);
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
2
2
  import { notFound, permanentRedirect } from 'next/navigation';
3
3
  import { BrainerceError, getProductPriceInfo } from 'brainerce';
4
4
  import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
5
+ import { getRegionId } from '@/core/lib/region.server';
5
6
  import { resolveCurrency } from '@/core/lib/resolve-currency';
6
7
  import { buildMetaDescription } from '@/core/lib/seo';
7
8
  import { decodeSlug } from '@/core/lib/utils';
@@ -43,8 +44,16 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
43
44
  // Fetch product + store info in parallel; storeInfo gives us the live
44
45
  // currency for OG price tags (with env-var + USD fallbacks so we never
45
46
  // silently emit a wrong currency when the API is briefly unreachable).
47
+ // ⛔ `regionId` belongs on EVERY product read. Without it the backend
48
+ // prices in the default region, so a shopper elsewhere gets the wrong
49
+ // currency in the OG price meta that link previews and Merchant Center
50
+ // read. `getRegionId()` is `cache()`-wrapped and shared with the layout
51
+ // and the page body below, so this is not an extra round trip. It
52
+ // resolves to `undefined` on a store with no regions, and the SDK then
53
+ // omits the query param entirely.
54
+ const regionId = await getRegionId();
46
55
  const [product, storeInfo] = await Promise.all([
47
- client.getProductBySlug(slug),
56
+ client.getProductBySlug(slug, { regionId }),
48
57
  fetchStoreInfo(locale),
49
58
  ]);
50
59
  const imageUrl = product.images?.[0]?.url;
@@ -138,9 +147,12 @@ export default async function ProductDetailPage({ params }: Props) {
138
147
  const slug = decodeSlug(rawSlug);
139
148
 
140
149
  const client = await getServerClient(locale);
150
+ // Same region as the metadata above (one shared `cache()`d resolve), so the
151
+ // price in the <head> and the price on the page cannot disagree.
152
+ const regionId = await getRegionId();
141
153
  let product;
142
154
  try {
143
- product = await client.getProductBySlug(slug);
155
+ product = await client.getProductBySlug(slug, { regionId });
144
156
  } catch (err) {
145
157
  // Only a genuine 404 may become a storefront 404. Anything else — a 403
146
158
  // from the channel's origin check, a network failure, a 5xx — is a
@@ -0,0 +1,226 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Loyalty — points balance, tier progress, and the reward catalogue.
5
+ *
6
+ * ⛔ REDEEMING DOES NOT DISCOUNT ANYTHING BY ITSELF. `redeemLoyaltyReward()`
7
+ * spends the points and mints a ONE-TIME COUPON CODE, which the shopper still
8
+ * has to apply at checkout. If the panel says "redeemed!" and stops there, the
9
+ * points are gone and the discount never happens — so the code is shown, kept
10
+ * on screen, and copyable. Points are the shopper's money-equivalent and the
11
+ * spend is not reversible from the storefront.
12
+ *
13
+ * ⛔ IT AUTO-HIDES, AND THAT IS THE POINT. `status.program` is null for a store
14
+ * with no loyalty program, which is most stores on day one. Render nothing
15
+ * then — never an empty "0 points" card, which reads as a broken feature rather
16
+ * than an absent one. Build the panel anyway: the merchant turns the program on
17
+ * from the dashboard without touching this code.
18
+ *
19
+ * ⛔ `pointsBalance` EXCLUDES PENDING POINTS. Points from a recent order sit
20
+ * pending for a merchant-set number of days before they can be spent, so a
21
+ * shopper who just bought something sees a balance that does not include it.
22
+ * That is correct, and it is why the copy says "ready to spend" rather than
23
+ * "earned".
24
+ *
25
+ * ⛔ A REWARD CAN BE VISIBLE AND UNREDEEMABLE. Two independent gates:
26
+ * `pointsCost` above the balance, and `minTierLevel` above the shopper's tier.
27
+ * Both are enforced server-side, so a button that ignores them produces a
28
+ * failed call, not a discount. They are shown disabled with the reason instead
29
+ * of hidden — a locked reward is the thing that makes the next tier worth
30
+ * reaching.
31
+ *
32
+ * ⛔ `program.status` DRAFT / PAUSED both stop EARNING. An existing balance is
33
+ * still spendable, so the panel keeps working; it just stops promising that the
34
+ * next order adds anything.
35
+ *
36
+ * Requires a logged-in customer — the calls go through the BFF proxy, which
37
+ * attaches the httpOnly cookie. Mount it inside the account page, never on a
38
+ * public route.
39
+ */
40
+
41
+ import { useEffect, useState } from 'react';
42
+ import type { LoyaltyStatus, LoyaltyReward } from 'brainerce';
43
+ import { getClient } from '@/core/lib/brainerce';
44
+ import { useTranslations } from '@/core/lib/translations';
45
+ import { cn } from '@/core/lib/utils';
46
+
47
+ interface LoyaltyPanelProps {
48
+ className?: string;
49
+ }
50
+
51
+ interface RedeemedCoupon {
52
+ rewardId: string;
53
+ couponCode: string;
54
+ }
55
+
56
+ export function LoyaltyPanel({ className }: LoyaltyPanelProps) {
57
+ const t = useTranslations('loyalty');
58
+ const [status, setStatus] = useState<LoyaltyStatus | null>(null);
59
+ const [rewards, setRewards] = useState<LoyaltyReward[]>([]);
60
+ const [loading, setLoading] = useState(true);
61
+ const [redeemingId, setRedeemingId] = useState<string | null>(null);
62
+ const [redeemed, setRedeemed] = useState<RedeemedCoupon | null>(null);
63
+ const [error, setError] = useState<string | null>(null);
64
+
65
+ useEffect(() => {
66
+ let cancelled = false;
67
+
68
+ async function load() {
69
+ const client = getClient();
70
+ // Settled, not all: a store with rewards configured and none active
71
+ // still has a balance worth showing, and vice versa.
72
+ const [statusResult, rewardsResult] = await Promise.allSettled([
73
+ client.getLoyaltyStatus(),
74
+ client.getAvailableRewards(),
75
+ ]);
76
+ if (cancelled) return;
77
+ if (statusResult.status === 'fulfilled') setStatus(statusResult.value);
78
+ if (rewardsResult.status === 'fulfilled') setRewards(rewardsResult.value);
79
+ setLoading(false);
80
+ }
81
+
82
+ load();
83
+ return () => {
84
+ cancelled = true;
85
+ };
86
+ }, []);
87
+
88
+ // No program on this store, or the status call failed: render nothing at all.
89
+ // An empty card is worse than no card.
90
+ if (loading || !status?.program) return null;
91
+
92
+ const pointsName = status.program.pointsName || t('pointsFallback');
93
+ const earningPaused = status.program.status !== 'ACTIVE';
94
+ const tierLevel = status.tier?.level ?? 0;
95
+
96
+ async function redeem(reward: LoyaltyReward) {
97
+ setRedeemingId(reward.id);
98
+ setError(null);
99
+ try {
100
+ const result = await getClient().redeemLoyaltyReward(reward.id);
101
+ setRedeemed({ rewardId: reward.id, couponCode: result.couponCode });
102
+ // The server is the authority on the new balance — take it from the
103
+ // response rather than subtracting pointsCost locally, which drifts the
104
+ // moment a reward is repriced between load and redeem.
105
+ setStatus((prev) => (prev ? { ...prev, pointsBalance: result.pointsBalance } : prev));
106
+ } catch (err) {
107
+ setError(err instanceof Error ? err.message : t('redeemError'));
108
+ } finally {
109
+ setRedeemingId(null);
110
+ }
111
+ }
112
+
113
+ return (
114
+ <section className={cn('border-border rounded-lg border p-6', className)}>
115
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
116
+ <h2 className="text-foreground text-lg font-semibold">{t('title')}</h2>
117
+ <p className="text-foreground text-2xl font-bold">
118
+ {status.pointsBalance.toLocaleString()}{' '}
119
+ <span className="text-muted-foreground text-sm font-normal">{pointsName}</span>
120
+ </p>
121
+ </div>
122
+ <p className="text-muted-foreground mt-1 text-sm">
123
+ {earningPaused ? t('earningPaused') : t('readyToSpend')}
124
+ </p>
125
+
126
+ {/* Tier progress. Rendered only when the store configured tiers — the
127
+ whole block is absent otherwise, rather than a full bar with no name. */}
128
+ {status.tier && (
129
+ <div className="mt-5">
130
+ <div className="flex items-baseline justify-between text-sm">
131
+ <span className="text-foreground font-medium">{status.tier.name}</span>
132
+ {status.nextTier && (
133
+ // `pointsToNextTier` is named for points and is NOT always points:
134
+ // it is whatever the next tier qualifies on, so a SPEND tier
135
+ // reports a currency amount. Saying "points" over a spend
136
+ // threshold tells the shopper to earn the wrong thing.
137
+ <span className="text-muted-foreground">
138
+ {t(
139
+ status.nextTier.qualificationType === 'SPEND'
140
+ ? 'toNextTierSpend'
141
+ : 'toNextTierPoints',
142
+ {
143
+ amount: (status.pointsToNextTier ?? 0).toLocaleString(),
144
+ pointsName,
145
+ tier: status.nextTier.name,
146
+ }
147
+ )}
148
+ </span>
149
+ )}
150
+ </div>
151
+ <div className="bg-muted mt-2 h-2 w-full overflow-hidden rounded-full">
152
+ <div
153
+ className="bg-primary h-full rounded-full transition-[width]"
154
+ style={{ width: `${Math.round(Math.min(1, status.progressToNextTier) * 100)}%` }}
155
+ />
156
+ </div>
157
+ </div>
158
+ )}
159
+
160
+ {/* Rewards. An empty catalogue is a normal state — the merchant has not
161
+ created any yet — so the list simply does not render. */}
162
+ {rewards.length > 0 && (
163
+ <div className="mt-6">
164
+ <h3 className="text-foreground mb-3 text-sm font-semibold">{t('rewardsTitle')}</h3>
165
+ <ul className="space-y-3">
166
+ {rewards.map((reward) => {
167
+ const tooExpensive = reward.pointsCost > status.pointsBalance;
168
+ const tierLocked =
169
+ reward.minTierLevel !== null && tierLevel < (reward.minTierLevel ?? 0);
170
+ const justRedeemed = redeemed?.rewardId === reward.id;
171
+
172
+ return (
173
+ <li
174
+ key={reward.id}
175
+ className="border-border flex flex-wrap items-center justify-between gap-3 rounded border p-3"
176
+ >
177
+ <div className="min-w-0">
178
+ <p className="text-foreground font-medium">{reward.name}</p>
179
+ {reward.description && (
180
+ <p className="text-muted-foreground text-sm">{reward.description}</p>
181
+ )}
182
+ <p className="text-muted-foreground mt-1 text-xs">
183
+ {t('pointsCost', {
184
+ points: reward.pointsCost.toLocaleString(),
185
+ pointsName,
186
+ })}
187
+ {tierLocked && ` · ${t('tierLocked')}`}
188
+ </p>
189
+ </div>
190
+
191
+ {justRedeemed ? (
192
+ // The code IS the reward. It stays on screen; the shopper
193
+ // has to carry it to checkout, and the points are already
194
+ // spent either way.
195
+ <div className="text-right">
196
+ <p className="text-muted-foreground text-xs">{t('yourCode')}</p>
197
+ <code className="bg-muted text-foreground rounded px-2 py-1 font-mono text-sm">
198
+ {redeemed.couponCode}
199
+ </code>
200
+ <p className="text-muted-foreground mt-1 text-xs">{t('applyAtCheckout')}</p>
201
+ </div>
202
+ ) : (
203
+ <button
204
+ type="button"
205
+ disabled={tooExpensive || tierLocked || redeemingId === reward.id}
206
+ onClick={() => redeem(reward)}
207
+ className="bg-primary text-primary-foreground rounded px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
208
+ >
209
+ {redeemingId === reward.id
210
+ ? t('redeeming')
211
+ : tooExpensive
212
+ ? t('notEnoughPoints')
213
+ : t('redeem')}
214
+ </button>
215
+ )}
216
+ </li>
217
+ );
218
+ })}
219
+ </ul>
220
+ </div>
221
+ )}
222
+
223
+ {error && <p className="text-destructive mt-4 text-sm">{error}</p>}
224
+ </section>
225
+ );
226
+ }