create-brainerce-store 1.86.1 → 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.
Files changed (44) hide show
  1. package/dist/index.js +9 -2
  2. package/messages/en.json +15 -2
  3. package/messages/he.json +15 -2
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +296 -255
  6. package/templates/nextjs/base/src/app/checkout/page.tsx +89 -6
  7. package/templates/nextjs/base/src/app/order-confirmation/layout.tsx +8 -0
  8. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +6 -0
  9. package/templates/nextjs/base/src/app/products/layout.tsx.ejs +18 -0
  10. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +37 -0
  11. package/templates/nextjs/base/src/components/auth/register-form.tsx +381 -375
  12. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +14 -8
  13. package/templates/nextjs/base/src/components/google-customer-reviews-optin.tsx +126 -0
  14. package/templates/nextjs/base/src/core/hooks/use-page-exists.ts +37 -0
  15. package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +27 -2
  16. package/templates/nextjs/base/src/core/lib/brainerce.server.ts +121 -81
  17. package/templates/nextjs/base/src/core/lib/coupon-error.ts +29 -0
  18. package/templates/nextjs/base/src/middleware.ts.ejs +22 -0
  19. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +6 -2
  20. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +6 -4
  21. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +138 -134
  22. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +10 -3
  23. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +5 -1
  24. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +2 -2
  25. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +6 -2
  26. package/templates/nextjs/base/src/ui/product/product-card.tsx +6 -2
  27. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +4 -36
  28. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +6 -2
  29. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +6 -4
  30. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +146 -142
  31. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +10 -3
  32. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +6 -2
  33. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +6 -2
  34. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +3 -28
  35. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +6 -2
  36. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +6 -4
  37. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +123 -119
  38. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +4 -3
  39. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +5 -1
  40. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
  41. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +6 -2
  42. package/templates/nextjs/ui-canvas/product/product-card.tsx +6 -2
  43. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +3 -28
  44. 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
- <a
572
- href="/privacy"
573
- target="_blank"
574
- rel="noopener noreferrer"
575
- className="text-primary underline underline-offset-2"
576
- >
577
- {t('privacyPolicyLink')}
578
- </a>{' '}
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,126 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { SALES_CHANNEL_ID } from '@/core/lib/brainerce';
5
+
6
+ /**
7
+ * Google Customer Reviews — Google's own post-purchase SURVEY opt-in module
8
+ * feeding the seller rating shown next to a merchant's ads/search listing.
9
+ * NOT Maps/Business reviews, and NOT Brainerce's own storefront product-
10
+ * review feature.
11
+ *
12
+ * Fetches this store's survey data directly rather than through the
13
+ * `brainerce` SDK: `create-brainerce-store` only ever imports an SDK version
14
+ * that has aged 7+ days on npm (see `packages/cli-shared/src/versions.ts`),
15
+ * so a brand-new SDK method is not importable from a freshly-scaffolded
16
+ * store yet — same reason `core/lib/tracking.ts` inlines
17
+ * `buildGa4CommercePayload`'s math instead of importing it. This mirrors the
18
+ * SDK's own `getOrderByCheckout` request shape exactly: the same
19
+ * possession-of-`checkoutId` trust model, routed through the same same-
20
+ * origin BFF proxy (`/api/store`) the rest of this storefront already uses.
21
+ *
22
+ * Renders no visible markup. The static `<meta>` marker the Readiness tab's
23
+ * embed-detection check looks for lives in `../app/order-confirmation/
24
+ * layout.tsx` instead — that one has to be server-rendered to be visible to
25
+ * a plain HTTP fetch; nothing this client component does after mount can be.
26
+ */
27
+
28
+ interface GoogleCustomerReviewsSurveyData {
29
+ enabled: boolean;
30
+ merchantId?: string;
31
+ orderId?: string;
32
+ email?: string;
33
+ deliveryCountry?: string;
34
+ estimatedDeliveryDate?: string;
35
+ lang?: string;
36
+ languageSupported?: boolean;
37
+ }
38
+
39
+ declare global {
40
+ interface Window {
41
+ gapi?: { load: (name: string, cb: () => void) => void; surveyoptin?: { render: (opts: Record<string, string>) => void } };
42
+ renderOptIn?: () => void;
43
+ ___gcfg?: { lang?: string };
44
+ }
45
+ }
46
+
47
+ // Module-level, not component state: survives this component unmounting and
48
+ // remounting on the SAME checkout id (React Strict Mode's double-invoke,
49
+ // or navigating away and back within one browser session) without opening
50
+ // Google's dialog twice. A full page refresh naturally resets this, which is
51
+ // correct — the whole point is a fresh render for a fresh page load.
52
+ let renderedForCheckoutId: string | null = null;
53
+
54
+ export function GoogleCustomerReviewsOptIn({ checkoutId }: { checkoutId: string }) {
55
+ useEffect(() => {
56
+ if (!checkoutId || renderedForCheckoutId === checkoutId) return;
57
+
58
+ let cancelled = false;
59
+
60
+ async function run() {
61
+ try {
62
+ const res = await fetch(
63
+ `/api/store/api/vc/${encodeURIComponent(SALES_CHANNEL_ID)}/checkout/${encodeURIComponent(checkoutId)}/google-customer-reviews`
64
+ );
65
+ if (!res.ok || cancelled) return;
66
+ const survey = (await res.json()) as GoogleCustomerReviewsSurveyData;
67
+ if (!survey?.enabled || cancelled) return;
68
+ if (
69
+ !survey.merchantId ||
70
+ !survey.orderId ||
71
+ !survey.email ||
72
+ !survey.deliveryCountry ||
73
+ !survey.estimatedDeliveryDate
74
+ ) {
75
+ // A Google-side or Brainerce-side gap must never break order
76
+ // confirmation — skip silently rather than call render() with
77
+ // missing required fields.
78
+ return;
79
+ }
80
+
81
+ renderedForCheckoutId = checkoutId;
82
+
83
+ // Optional language hint MUST be set before platform.js loads.
84
+ if (survey.languageSupported && survey.lang) {
85
+ window.___gcfg = { lang: survey.lang };
86
+ }
87
+
88
+ window.renderOptIn = function renderOptIn() {
89
+ window.gapi?.load('surveyoptin', function surveyOptinReady() {
90
+ window.gapi?.surveyoptin?.render({
91
+ merchant_id: survey.merchantId!,
92
+ order_id: survey.orderId!,
93
+ email: survey.email!,
94
+ delivery_country: survey.deliveryCountry!,
95
+ estimated_delivery_date: survey.estimatedDeliveryDate!,
96
+ });
97
+ });
98
+ };
99
+
100
+ // Google's own required embed: a script tag loading platform.js with
101
+ // onload=renderOptIn. Explicitly NOT delivered through Google Tag
102
+ // Manager — Google does not support that for this integration.
103
+ // Created via the DOM API (no nonce) rather than `next/script`,
104
+ // mirroring how the SDK's own `loadGoogleAnalytics` loads gtag.js:
105
+ // under this storefront's `script-src 'strict-dynamic'` CSP, a
106
+ // script inserted by an already-trusted script (this bundle) is
107
+ // trusted too, regardless of its own attributes.
108
+ const script = document.createElement('script');
109
+ script.src = 'https://apis.google.com/js/platform.js?onload=renderOptIn';
110
+ script.async = true;
111
+ script.defer = true;
112
+ document.body.appendChild(script);
113
+ } catch (err) {
114
+ // Never break order confirmation over this.
115
+ console.warn('Google Customer Reviews survey not loaded:', err);
116
+ }
117
+ }
118
+
119
+ run();
120
+ return () => {
121
+ cancelled = true;
122
+ };
123
+ }, [checkoutId]);
124
+
125
+ return null;
126
+ }
@@ -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
- if (getErrorCode(err) === 'CART_LINE_LIMIT_REACHED') return 'CART_FULL';
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,81 +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
- * Server-side cached fetch of store info, used to seed `<StoreProvider>` at
47
- * SSR so client components don't render with `null` storeInfo and bake the
48
- * wrong currency symbol into the HTML Googlebot indexes.
49
- *
50
- * Two cache layers:
51
- * - React `cache()` dedupes calls within a single request render so the
52
- * layout + `generateMetadata` share one fetch.
53
- * - Next.js `fetch({ next: { revalidate, tags } })` caches the response
54
- * across requests for ~60s and lets the dashboard bust the cache via
55
- * `revalidateTag('store-info')` on store-config save.
56
- *
57
- * Returns `null` on hard failure so the provider's client-side `useEffect`
58
- * fallback can take over — behaviour identical to pre-SSR-hydration.
59
- *
60
- * Bypasses {@link getServerClient} on purpose: the SDK's internal `fetch`
61
- * does not forward `next:` options, so it cannot participate in the Next.js
62
- * Data Cache today. Switch back to the SDK once it accepts `fetchOptions`.
63
- */
64
- export const fetchStoreInfo = cache(async (locale?: string): Promise<PublicStoreInfo | null> => {
65
- const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
66
- const siteOrigin = await getRequestOrigin();
67
- const url = `${apiUrl}/api/vc/${encodeURIComponent(SALES_CHANNEL_ID)}/info`;
68
- try {
69
- const res = await fetch(url, {
70
- headers: {
71
- Origin: siteOrigin,
72
- ...(locale ? { 'Accept-Language': locale } : {}),
73
- },
74
- next: { revalidate: 60, tags: ['store-info'] },
75
- });
76
- if (!res.ok) return null;
77
- return pickPublicStoreInfo(await res.json());
78
- } catch {
79
- return null;
80
- }
81
- });
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
+ }
@@ -49,6 +49,10 @@ const MARKETING_TAG_ORIGINS = [
49
49
  'https://*.analytics.google.com',
50
50
  'https://stats.g.doubleclick.net',
51
51
  'https://www.google.com',
52
+ // Google Customer Reviews survey opt-in module (gapi's own bootstrap
53
+ // requests, loaded from apis.google.com/js/platform.js). Only relevant if
54
+ // the merchant enabled it in Apps → Google.
55
+ 'https://apis.google.com',
52
56
  // Meta pixel
53
57
  'https://connect.facebook.net',
54
58
  'https://www.facebook.com',
@@ -69,6 +73,13 @@ function buildCsp(nonce: string): string {
69
73
  "style-src 'self' 'unsafe-inline' https://cdn.meshulam.co.il",
70
74
  "img-src 'self' data: blob: https:",
71
75
  "font-src 'self' data:",
76
+ // ⛔ TODO if you enabled Google Customer Reviews: the survey dialog opens
77
+ // in an iframe from a Google domain that has NOT been verified against a
78
+ // live embed here (see the WP4 growth-plan writeup). Open your real
79
+ // order-confirmation page with the widget enabled, watch DevTools →
80
+ // Network for the frame request Google makes, and add that exact host
81
+ // here. Do not guess a broad wildcard; a wrong or missing entry either
82
+ // silently breaks the survey dialog or over-widens this policy.
72
83
  "frame-src 'self' https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://grow.security https://*.grow.security https://creditguard.co.il https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions https://checkout.stripe.com https://www.paypal.com https://www.sandbox.paypal.com https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://*.brainerce.com",
73
84
  `connect-src 'self' ${brainerceApiOrigin()} https://api.brainerce.com https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://pay.google.com https://*.stripe.com https://*.creditguard.co.il ${MARKETING_TAG_ORIGINS}`,
74
85
  "worker-src 'self' blob:",
@@ -203,6 +214,10 @@ const MARKETING_TAG_ORIGINS = [
203
214
  'https://*.analytics.google.com',
204
215
  'https://stats.g.doubleclick.net',
205
216
  'https://www.google.com',
217
+ // Google Customer Reviews survey opt-in module (gapi's own bootstrap
218
+ // requests, loaded from apis.google.com/js/platform.js). Only relevant if
219
+ // the merchant enabled it in Apps → Google.
220
+ 'https://apis.google.com',
206
221
  // Meta pixel
207
222
  'https://connect.facebook.net',
208
223
  'https://www.facebook.com',
@@ -223,6 +238,13 @@ function buildCsp(nonce: string): string {
223
238
  "style-src 'self' 'unsafe-inline' https://cdn.meshulam.co.il",
224
239
  "img-src 'self' data: blob: https:",
225
240
  "font-src 'self' data:",
241
+ // ⛔ TODO if you enabled Google Customer Reviews: the survey dialog opens
242
+ // in an iframe from a Google domain that has NOT been verified against a
243
+ // live embed here (see the WP4 growth-plan writeup). Open your real
244
+ // order-confirmation page with the widget enabled, watch DevTools →
245
+ // Network for the frame request Google makes, and add that exact host
246
+ // here. Do not guess a broad wildcard; a wrong or missing entry either
247
+ // silently breaks the survey dialog or over-widens this policy.
226
248
  "frame-src 'self' https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://grow.security https://*.grow.security https://creditguard.co.il https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions https://checkout.stripe.com https://www.paypal.com https://www.sandbox.paypal.com https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://*.brainerce.com",
227
249
  `connect-src 'self' ${brainerceApiOrigin()} https://api.brainerce.com https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://pay.google.com https://*.stripe.com https://*.creditguard.co.il ${MARKETING_TAG_ORIGINS}`,
228
250
  "worker-src 'self' blob:",
@@ -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 { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
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
- {addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
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 { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
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 === 'CART_FULL'
202
- ? tp('cartFull')
203
- : tp('addToCartFailed')}
205
+ : tp(addToCartErrorMessageKey(upgradeError))}
204
206
  </p>
205
207
  )}
206
208
  </div>