create-brainerce-store 1.72.0 → 1.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +37 -4
- package/messages/en.json +30 -1
- package/messages/he.json +30 -1
- package/package.json +1 -1
- package/templates/nextjs/base/AGENTS.md.ejs +63 -16
- package/templates/nextjs/base/AI-GUIDE.md +17 -4
- package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
- package/templates/nextjs/base/TRANSLATIONS.md +207 -207
- package/templates/nextjs/base/src/app/account/page.tsx +168 -122
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
- package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
- package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
- package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
- package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
- package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
- package/templates/nextjs/base/src/core/lib/region.ts +112 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
- package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
- package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
- package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
- package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
- package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
- package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
- package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
|
@@ -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] =
|
|
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
|
|
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] =
|
|
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
|
|
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
|
|
94
|
-
|
|
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
|
+
}
|