create-brainerce-store 1.87.0 → 1.88.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 +9 -2
- package/messages/en.json +10 -1
- package/messages/he.json +10 -1
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +296 -296
- package/templates/nextjs/base/src/app/checkout/page.tsx +89 -6
- package/templates/nextjs/base/src/app/products/layout.tsx.ejs +18 -0
- package/templates/nextjs/base/src/components/auth/register-form.tsx +381 -375
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +14 -8
- package/templates/nextjs/base/src/core/hooks/use-page-exists.ts +37 -0
- package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +27 -2
- package/templates/nextjs/base/src/core/lib/brainerce.server.ts +121 -121
- package/templates/nextjs/base/src/core/lib/coupon-error.ts +29 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +6 -2
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +6 -4
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +138 -134
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +10 -3
- package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +5 -1
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +6 -2
- package/templates/nextjs/base/src/ui/product/product-card.tsx +6 -2
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +4 -36
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +6 -2
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +6 -4
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +146 -142
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +10 -3
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +6 -2
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +6 -2
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +3 -28
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +6 -2
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +6 -4
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +123 -119
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +4 -3
- package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +5 -1
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +6 -2
- package/templates/nextjs/ui-canvas/product/product-card.tsx +6 -2
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +3 -28
- package/templates/nextjs/base/src/app/products/layout.tsx +0 -18
|
@@ -7,6 +7,7 @@ import { cn } from '@/core/lib/utils';
|
|
|
7
7
|
import { isValidEmail } from '@/core/lib/validation';
|
|
8
8
|
import { getClient } from '@/core/lib/brainerce';
|
|
9
9
|
import { useRegion } from '@/core/providers/store-provider';
|
|
10
|
+
import { usePageExists } from '@/core/hooks/use-page-exists';
|
|
10
11
|
|
|
11
12
|
// Debounce address-suggestion calls rather than firing on every keystroke —
|
|
12
13
|
// both to avoid a jumpy dropdown and to keep autocomplete-session call volume
|
|
@@ -65,6 +66,7 @@ export function CheckoutForm({
|
|
|
65
66
|
});
|
|
66
67
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
67
68
|
const [privacyAccepted, setPrivacyAccepted] = useState(false);
|
|
69
|
+
const hasPrivacyPage = usePageExists('privacy');
|
|
68
70
|
const [acceptsMarketing, setAcceptsMarketing] = useState(false);
|
|
69
71
|
const [saveDetails, setSaveDetails] = useState(true);
|
|
70
72
|
const t = useTranslations('checkoutForm');
|
|
@@ -568,14 +570,18 @@ export function CheckoutForm({
|
|
|
568
570
|
/>
|
|
569
571
|
<span className="text-muted-foreground text-sm">
|
|
570
572
|
{t('privacyAcceptPrefix')}{' '}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
573
|
+
{hasPrivacyPage === false ? (
|
|
574
|
+
<span className="text-foreground">{t('privacyPolicyLink')}</span>
|
|
575
|
+
) : (
|
|
576
|
+
<a
|
|
577
|
+
href="/pages/privacy"
|
|
578
|
+
target="_blank"
|
|
579
|
+
rel="noopener noreferrer"
|
|
580
|
+
className="text-primary underline underline-offset-2"
|
|
581
|
+
>
|
|
582
|
+
{t('privacyPolicyLink')}
|
|
583
|
+
</a>
|
|
584
|
+
)}{' '}
|
|
579
585
|
<span className="text-destructive">*</span>
|
|
580
586
|
</span>
|
|
581
587
|
</label>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether the merchant has published a PAGE with this slug (Content → Page).
|
|
8
|
+
*
|
|
9
|
+
* `false` only on a genuine 404. While loading, or if the lookup fails for any
|
|
10
|
+
* other reason, it stays `null`, so callers keep rendering the link: a flaky
|
|
11
|
+
* network must not strip the policy link off a consent checkbox.
|
|
12
|
+
*
|
|
13
|
+
* Why it exists: a brand-new store has no `privacy` page, and the required
|
|
14
|
+
* privacy checkbox at register and checkout linked straight to a 404
|
|
15
|
+
* (2026-10-04 QA). Callers show the policy name as plain text until the
|
|
16
|
+
* merchant creates the page.
|
|
17
|
+
*/
|
|
18
|
+
export function usePageExists(slug: string): boolean | null {
|
|
19
|
+
const [exists, setExists] = useState<boolean | null>(null);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
getClient()
|
|
24
|
+
.content.page.getBySlug(slug)
|
|
25
|
+
.then((page) => {
|
|
26
|
+
if (!cancelled) setExists(page !== null);
|
|
27
|
+
})
|
|
28
|
+
.catch(() => {
|
|
29
|
+
// Not a 404: unknown, keep the link.
|
|
30
|
+
});
|
|
31
|
+
return () => {
|
|
32
|
+
cancelled = true;
|
|
33
|
+
};
|
|
34
|
+
}, [slug]);
|
|
35
|
+
|
|
36
|
+
return exists;
|
|
37
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* each design maps this to its own `productDetail.*` string rather than showing
|
|
6
6
|
* the raw API sentence. Shared by the product page hook and the grid quick-add.
|
|
7
7
|
*/
|
|
8
|
-
export type AddToCartError = 'CART_FULL' | 'FAILED';
|
|
8
|
+
export type AddToCartError = 'CART_FULL' | 'NOT_ENOUGH_STOCK' | 'ITEM_UNAVAILABLE' | 'FAILED';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The API's stable, machine-readable error code for a failed call, or
|
|
@@ -43,7 +43,32 @@ export function getErrorCode(err: unknown): string | undefined {
|
|
|
43
43
|
* message rather than the silence this replaced.
|
|
44
44
|
*/
|
|
45
45
|
export function toAddToCartError(err: unknown): AddToCartError {
|
|
46
|
-
|
|
46
|
+
const code = getErrorCode(err);
|
|
47
|
+
if (code === 'CART_LINE_LIMIT_REACHED') return 'CART_FULL';
|
|
48
|
+
// Two PERMANENT refusals: "please try again" would send the shopper round a
|
|
49
|
+
// loop that cannot succeed, so each gets its own sentence.
|
|
50
|
+
if (code === 'INSUFFICIENT_STOCK') return 'NOT_ENOUGH_STOCK';
|
|
51
|
+
if (code === 'PRODUCT_UNAVAILABLE') return 'ITEM_UNAVAILABLE';
|
|
47
52
|
const message = (err as { message?: string } | null)?.message ?? '';
|
|
48
53
|
return /cart can hold at most/i.test(message) ? 'CART_FULL' : 'FAILED';
|
|
49
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The `productDetail.*` message key for an add-to-cart refusal. Every surface
|
|
58
|
+
* renders `t(addToCartErrorMessageKey(error))`, so a new member of
|
|
59
|
+
* {@link AddToCartError} cannot silently fall through to "please try again".
|
|
60
|
+
*/
|
|
61
|
+
export function addToCartErrorMessageKey(
|
|
62
|
+
error: AddToCartError
|
|
63
|
+
): 'cartFull' | 'addNotEnoughStock' | 'addItemUnavailable' | 'addToCartFailed' {
|
|
64
|
+
switch (error) {
|
|
65
|
+
case 'CART_FULL':
|
|
66
|
+
return 'cartFull';
|
|
67
|
+
case 'NOT_ENOUGH_STOCK':
|
|
68
|
+
return 'addNotEnoughStock';
|
|
69
|
+
case 'ITEM_UNAVAILABLE':
|
|
70
|
+
return 'addItemUnavailable';
|
|
71
|
+
default:
|
|
72
|
+
return 'addToCartFailed';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -1,121 +1,121 @@
|
|
|
1
|
-
import { BrainerceClient } from 'brainerce';
|
|
2
|
-
import { cache } from 'react';
|
|
3
|
-
import { SALES_CHANNEL_ID } from '@/core/lib/brainerce';
|
|
4
|
-
import { pickPublicStoreInfo, type PublicStoreInfo } from '@/core/lib/store-info';
|
|
5
|
-
import { getRequestOrigin } from '@/core/lib/site-url';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* SERVER-ONLY half of the SDK wiring. Kept apart from `brainerce.ts` on
|
|
9
|
-
* purpose: everything here reaches `next/headers` (to resolve the request's
|
|
10
|
-
* origin), and ~30 client components import `getClient` from `brainerce.ts`.
|
|
11
|
-
* A single shared module would drag `next/headers` into the client bundle and
|
|
12
|
-
* fail the build outright — which is exactly what happened when these two
|
|
13
|
-
* functions still lived there.
|
|
14
|
-
*
|
|
15
|
-
* Import from here in Server Components, `generateMetadata`, route handlers,
|
|
16
|
-
* `sitemap.ts` / `robots.ts`. Never from a `'use client'` file.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Server-side client — calls the backend directly (no proxy needed for public
|
|
21
|
-
* data). Used by Server Components for SSR data fetching.
|
|
22
|
-
*
|
|
23
|
-
* ⛔ ASYNC on purpose. The backend compares the `Origin` we send against the
|
|
24
|
-
* domain configured on the sales channel, and a LIVE channel rejects both a
|
|
25
|
-
* mismatched Origin AND a missing one with 403. The correct value is only
|
|
26
|
-
* knowable from the request when no `SITE_URL` is configured — the norm for
|
|
27
|
-
* AI-builder deploys — so resolving it needs an await. This previously read
|
|
28
|
-
* `process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'`, which sent
|
|
29
|
-
* localhost from every such deploy, so every SSR call failed the moment the
|
|
30
|
-
* channel went Live.
|
|
31
|
-
*/
|
|
32
|
-
export async function getServerClient(locale?: string): Promise<BrainerceClient> {
|
|
33
|
-
const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
|
|
34
|
-
const client = new BrainerceClient({
|
|
35
|
-
salesChannelId: SALES_CHANNEL_ID,
|
|
36
|
-
baseUrl: apiUrl,
|
|
37
|
-
origin: await getRequestOrigin(),
|
|
38
|
-
});
|
|
39
|
-
if (locale) {
|
|
40
|
-
client.setLocale(locale);
|
|
41
|
-
}
|
|
42
|
-
return client;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Reads a fetch `Response` body as text, self-healing an observed runtime bug:
|
|
47
|
-
* on Next.js 15.5.26, a `fetch()` made from a Route Handler or Server
|
|
48
|
-
* Component can return a Response whose body is still gzip-compressed bytes,
|
|
49
|
-
* even though an identical request (same URL, same headers) made via plain
|
|
50
|
-
* Node `fetch()` outside Next decompresses correctly — confirmed by diffing
|
|
51
|
-
* the raw response bytes both ways. The exact point inside Next's
|
|
52
|
-
* server-side fetch instrumentation that causes this isn't confirmed, only
|
|
53
|
-
* the externally observable symptom is. Calling `.text()`/`.json()` on a
|
|
54
|
-
* Response like that UTF-8-decodes still-gzipped bytes — lossy (replacement
|
|
55
|
-
* characters) and unparseable as JSON.
|
|
56
|
-
*
|
|
57
|
-
* A correctly-behaving fetch (browsers; plain Node; this bug's absence) never
|
|
58
|
-
* hands back bytes that still start with gzip's magic number (`1f 8b`) — real
|
|
59
|
-
* compression has already been undone by the time a body reaches here — so
|
|
60
|
-
* checking for it before ever decoding as text can't misfire into
|
|
61
|
-
* double-decompressing an already-plain body.
|
|
62
|
-
*
|
|
63
|
-
* This only works because the fetch below pins `Accept-Encoding: gzip`
|
|
64
|
-
* instead of the runtime default (which also offers Brotli) — Brotli has no
|
|
65
|
-
* reliable magic-number signature and no Web Streams API decoder, so this
|
|
66
|
-
* self-healing has no fallback for it. That header is correctness-critical
|
|
67
|
-
* here, not a tuning choice: reverting it to the runtime default reintroduces
|
|
68
|
-
* this exact corruption, just for Brotli-compressed responses instead of
|
|
69
|
-
* gzip ones.
|
|
70
|
-
*/
|
|
71
|
-
async function readBackendBody(response: Response): Promise<string> {
|
|
72
|
-
const buffer = new Uint8Array(await response.arrayBuffer());
|
|
73
|
-
if (buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b) {
|
|
74
|
-
const decompressed = new Blob([buffer])
|
|
75
|
-
.stream()
|
|
76
|
-
.pipeThrough(new DecompressionStream('gzip'));
|
|
77
|
-
return new TextDecoder('utf-8').decode(await new Response(decompressed).arrayBuffer());
|
|
78
|
-
}
|
|
79
|
-
return new TextDecoder('utf-8').decode(buffer);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Server-side cached fetch of store info, used to seed `<StoreProvider>` at
|
|
84
|
-
* SSR so client components don't render with `null` storeInfo and bake the
|
|
85
|
-
* wrong currency symbol into the HTML Googlebot indexes.
|
|
86
|
-
*
|
|
87
|
-
* Two cache layers:
|
|
88
|
-
* - React `cache()` dedupes calls within a single request render so the
|
|
89
|
-
* layout + `generateMetadata` share one fetch.
|
|
90
|
-
* - Next.js `fetch({ next: { revalidate, tags } })` caches the response
|
|
91
|
-
* across requests for ~60s and lets the dashboard bust the cache via
|
|
92
|
-
* `revalidateTag('store-info')` on store-config save.
|
|
93
|
-
*
|
|
94
|
-
* Returns `null` on hard failure so the provider's client-side `useEffect`
|
|
95
|
-
* fallback can take over — behaviour identical to pre-SSR-hydration.
|
|
96
|
-
*
|
|
97
|
-
* Bypasses {@link getServerClient} on purpose: the SDK's internal `fetch`
|
|
98
|
-
* does not forward `next:` options, so it cannot participate in the Next.js
|
|
99
|
-
* Data Cache today. Switch back to the SDK once it accepts `fetchOptions`.
|
|
100
|
-
*/
|
|
101
|
-
export const fetchStoreInfo = cache(async (locale?: string): Promise<PublicStoreInfo | null> => {
|
|
102
|
-
const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
|
|
103
|
-
const siteOrigin = await getRequestOrigin();
|
|
104
|
-
const url = `${apiUrl}/api/vc/${encodeURIComponent(SALES_CHANNEL_ID)}/info`;
|
|
105
|
-
try {
|
|
106
|
-
const res = await fetch(url, {
|
|
107
|
-
headers: {
|
|
108
|
-
Origin: siteOrigin,
|
|
109
|
-
// Pinned to gzip — see readBackendBody() for why.
|
|
110
|
-
'Accept-Encoding': 'gzip',
|
|
111
|
-
...(locale ? { 'Accept-Language': locale } : {}),
|
|
112
|
-
},
|
|
113
|
-
next: { revalidate: 60, tags: ['store-info'] },
|
|
114
|
-
});
|
|
115
|
-
if (!res.ok) return null;
|
|
116
|
-
const text = await readBackendBody(res);
|
|
117
|
-
return pickPublicStoreInfo(text ? JSON.parse(text) : {});
|
|
118
|
-
} catch {
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
});
|
|
1
|
+
import { BrainerceClient } from 'brainerce';
|
|
2
|
+
import { cache } from 'react';
|
|
3
|
+
import { SALES_CHANNEL_ID } from '@/core/lib/brainerce';
|
|
4
|
+
import { pickPublicStoreInfo, type PublicStoreInfo } from '@/core/lib/store-info';
|
|
5
|
+
import { getRequestOrigin } from '@/core/lib/site-url';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SERVER-ONLY half of the SDK wiring. Kept apart from `brainerce.ts` on
|
|
9
|
+
* purpose: everything here reaches `next/headers` (to resolve the request's
|
|
10
|
+
* origin), and ~30 client components import `getClient` from `brainerce.ts`.
|
|
11
|
+
* A single shared module would drag `next/headers` into the client bundle and
|
|
12
|
+
* fail the build outright — which is exactly what happened when these two
|
|
13
|
+
* functions still lived there.
|
|
14
|
+
*
|
|
15
|
+
* Import from here in Server Components, `generateMetadata`, route handlers,
|
|
16
|
+
* `sitemap.ts` / `robots.ts`. Never from a `'use client'` file.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Server-side client — calls the backend directly (no proxy needed for public
|
|
21
|
+
* data). Used by Server Components for SSR data fetching.
|
|
22
|
+
*
|
|
23
|
+
* ⛔ ASYNC on purpose. The backend compares the `Origin` we send against the
|
|
24
|
+
* domain configured on the sales channel, and a LIVE channel rejects both a
|
|
25
|
+
* mismatched Origin AND a missing one with 403. The correct value is only
|
|
26
|
+
* knowable from the request when no `SITE_URL` is configured — the norm for
|
|
27
|
+
* AI-builder deploys — so resolving it needs an await. This previously read
|
|
28
|
+
* `process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'`, which sent
|
|
29
|
+
* localhost from every such deploy, so every SSR call failed the moment the
|
|
30
|
+
* channel went Live.
|
|
31
|
+
*/
|
|
32
|
+
export async function getServerClient(locale?: string): Promise<BrainerceClient> {
|
|
33
|
+
const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
|
|
34
|
+
const client = new BrainerceClient({
|
|
35
|
+
salesChannelId: SALES_CHANNEL_ID,
|
|
36
|
+
baseUrl: apiUrl,
|
|
37
|
+
origin: await getRequestOrigin(),
|
|
38
|
+
});
|
|
39
|
+
if (locale) {
|
|
40
|
+
client.setLocale(locale);
|
|
41
|
+
}
|
|
42
|
+
return client;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reads a fetch `Response` body as text, self-healing an observed runtime bug:
|
|
47
|
+
* on Next.js 15.5.26, a `fetch()` made from a Route Handler or Server
|
|
48
|
+
* Component can return a Response whose body is still gzip-compressed bytes,
|
|
49
|
+
* even though an identical request (same URL, same headers) made via plain
|
|
50
|
+
* Node `fetch()` outside Next decompresses correctly — confirmed by diffing
|
|
51
|
+
* the raw response bytes both ways. The exact point inside Next's
|
|
52
|
+
* server-side fetch instrumentation that causes this isn't confirmed, only
|
|
53
|
+
* the externally observable symptom is. Calling `.text()`/`.json()` on a
|
|
54
|
+
* Response like that UTF-8-decodes still-gzipped bytes — lossy (replacement
|
|
55
|
+
* characters) and unparseable as JSON.
|
|
56
|
+
*
|
|
57
|
+
* A correctly-behaving fetch (browsers; plain Node; this bug's absence) never
|
|
58
|
+
* hands back bytes that still start with gzip's magic number (`1f 8b`) — real
|
|
59
|
+
* compression has already been undone by the time a body reaches here — so
|
|
60
|
+
* checking for it before ever decoding as text can't misfire into
|
|
61
|
+
* double-decompressing an already-plain body.
|
|
62
|
+
*
|
|
63
|
+
* This only works because the fetch below pins `Accept-Encoding: gzip`
|
|
64
|
+
* instead of the runtime default (which also offers Brotli) — Brotli has no
|
|
65
|
+
* reliable magic-number signature and no Web Streams API decoder, so this
|
|
66
|
+
* self-healing has no fallback for it. That header is correctness-critical
|
|
67
|
+
* here, not a tuning choice: reverting it to the runtime default reintroduces
|
|
68
|
+
* this exact corruption, just for Brotli-compressed responses instead of
|
|
69
|
+
* gzip ones.
|
|
70
|
+
*/
|
|
71
|
+
async function readBackendBody(response: Response): Promise<string> {
|
|
72
|
+
const buffer = new Uint8Array(await response.arrayBuffer());
|
|
73
|
+
if (buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b) {
|
|
74
|
+
const decompressed = new Blob([buffer])
|
|
75
|
+
.stream()
|
|
76
|
+
.pipeThrough(new DecompressionStream('gzip'));
|
|
77
|
+
return new TextDecoder('utf-8').decode(await new Response(decompressed).arrayBuffer());
|
|
78
|
+
}
|
|
79
|
+
return new TextDecoder('utf-8').decode(buffer);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Server-side cached fetch of store info, used to seed `<StoreProvider>` at
|
|
84
|
+
* SSR so client components don't render with `null` storeInfo and bake the
|
|
85
|
+
* wrong currency symbol into the HTML Googlebot indexes.
|
|
86
|
+
*
|
|
87
|
+
* Two cache layers:
|
|
88
|
+
* - React `cache()` dedupes calls within a single request render so the
|
|
89
|
+
* layout + `generateMetadata` share one fetch.
|
|
90
|
+
* - Next.js `fetch({ next: { revalidate, tags } })` caches the response
|
|
91
|
+
* across requests for ~60s and lets the dashboard bust the cache via
|
|
92
|
+
* `revalidateTag('store-info')` on store-config save.
|
|
93
|
+
*
|
|
94
|
+
* Returns `null` on hard failure so the provider's client-side `useEffect`
|
|
95
|
+
* fallback can take over — behaviour identical to pre-SSR-hydration.
|
|
96
|
+
*
|
|
97
|
+
* Bypasses {@link getServerClient} on purpose: the SDK's internal `fetch`
|
|
98
|
+
* does not forward `next:` options, so it cannot participate in the Next.js
|
|
99
|
+
* Data Cache today. Switch back to the SDK once it accepts `fetchOptions`.
|
|
100
|
+
*/
|
|
101
|
+
export const fetchStoreInfo = cache(async (locale?: string): Promise<PublicStoreInfo | null> => {
|
|
102
|
+
const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
|
|
103
|
+
const siteOrigin = await getRequestOrigin();
|
|
104
|
+
const url = `${apiUrl}/api/vc/${encodeURIComponent(SALES_CHANNEL_ID)}/info`;
|
|
105
|
+
try {
|
|
106
|
+
const res = await fetch(url, {
|
|
107
|
+
headers: {
|
|
108
|
+
Origin: siteOrigin,
|
|
109
|
+
// Pinned to gzip — see readBackendBody() for why.
|
|
110
|
+
'Accept-Encoding': 'gzip',
|
|
111
|
+
...(locale ? { 'Accept-Language': locale } : {}),
|
|
112
|
+
},
|
|
113
|
+
next: { revalidate: 60, tags: ['store-info'] },
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok) return null;
|
|
116
|
+
const text = await readBackendBody(res);
|
|
117
|
+
return pickPublicStoreInfo(text ? JSON.parse(text) : {});
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getErrorCode } from '@/core/lib/add-to-cart-error';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Message keys (under `coupon.*`) for the stable codes `applyCoupon` /
|
|
5
|
+
* `applyCheckoutCoupon` return. The API's `message` is English whatever the
|
|
6
|
+
* shopper's language, so a known code is always shown from our own messages.
|
|
7
|
+
* A refusal with no code (a checkout past its editable stage, say) returns
|
|
8
|
+
* `null` and the caller falls back to the API's message.
|
|
9
|
+
*/
|
|
10
|
+
const COUPON_ERROR_KEYS = {
|
|
11
|
+
COUPON_NOT_FOUND: 'invalidCode',
|
|
12
|
+
COUPON_NOT_ACTIVE: 'notActive',
|
|
13
|
+
COUPON_EXPIRED: 'expired',
|
|
14
|
+
COUPON_USAGE_LIMIT_REACHED: 'usageLimitReached',
|
|
15
|
+
COUPON_PER_CUSTOMER_LIMIT_REACHED: 'perCustomerLimitReached',
|
|
16
|
+
COUPON_MINIMUM_NOT_MET: 'minimumNotMet',
|
|
17
|
+
COUPON_REGION_RESTRICTED: 'regionRestricted',
|
|
18
|
+
COUPON_NO_ELIGIBLE_ITEMS: 'noEligibleItems',
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export type CouponErrorKey = (typeof COUPON_ERROR_KEYS)[keyof typeof COUPON_ERROR_KEYS];
|
|
22
|
+
|
|
23
|
+
export function couponErrorKey(err: unknown): CouponErrorKey | null {
|
|
24
|
+
const code = getErrorCode(err);
|
|
25
|
+
if (code && code in COUPON_ERROR_KEYS) {
|
|
26
|
+
return COUPON_ERROR_KEYS[code as keyof typeof COUPON_ERROR_KEYS];
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
@@ -6,7 +6,11 @@ import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
|
|
|
6
6
|
import { formatPrice } from 'brainerce';
|
|
7
7
|
import { useCurrency } from '@/core/lib/use-currency';
|
|
8
8
|
import { useTranslations } from '@/core/lib/translations';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
addToCartErrorMessageKey,
|
|
11
|
+
toAddToCartError,
|
|
12
|
+
type AddToCartError,
|
|
13
|
+
} from '@/core/lib/add-to-cart-error';
|
|
10
14
|
import { Badge } from '@/components/ui/badge';
|
|
11
15
|
import { Button } from '@/components/ui/button';
|
|
12
16
|
import { Card } from '@/components/ui/card';
|
|
@@ -127,7 +131,7 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
|
|
|
127
131
|
*/}
|
|
128
132
|
{addError && (
|
|
129
133
|
<p role="alert" className="text-destructive mt-2 text-xs">
|
|
130
|
-
{
|
|
134
|
+
{tp(addToCartErrorMessageKey(addError))}
|
|
131
135
|
</p>
|
|
132
136
|
)}
|
|
133
137
|
</Card>
|
|
@@ -8,7 +8,11 @@ import { formatPrice } from 'brainerce';
|
|
|
8
8
|
import { getClient } from '@/core/lib/brainerce';
|
|
9
9
|
import { useCurrency } from '@/core/lib/use-currency';
|
|
10
10
|
import { useTranslations } from '@/core/lib/translations';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
addToCartErrorMessageKey,
|
|
13
|
+
toAddToCartError,
|
|
14
|
+
type AddToCartError,
|
|
15
|
+
} from '@/core/lib/add-to-cart-error';
|
|
12
16
|
import { Button } from '@/components/ui/button';
|
|
13
17
|
import { cn } from '@/core/lib/utils';
|
|
14
18
|
|
|
@@ -198,9 +202,7 @@ export function CartUpgradeBanner({
|
|
|
198
202
|
<p role="alert" className="text-destructive mt-1 text-xs">
|
|
199
203
|
{upgradeError === 'ORIGINAL_NOT_REMOVED'
|
|
200
204
|
? t('upgradeOriginalNotRemoved')
|
|
201
|
-
: upgradeError
|
|
202
|
-
? tp('cartFull')
|
|
203
|
-
: tp('addToCartFailed')}
|
|
205
|
+
: tp(addToCartErrorMessageKey(upgradeError))}
|
|
204
206
|
</p>
|
|
205
207
|
)}
|
|
206
208
|
</div>
|