create-brainerce-store 1.86.1 → 1.87.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 CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "create-brainerce-store",
34
- version: "1.86.1",
34
+ version: "1.87.0",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
36
  homepage: "https://brainerce.com",
37
37
  repository: {
package/messages/en.json CHANGED
@@ -610,7 +610,11 @@
610
610
  "notEnoughPoints": "Not enough points",
611
611
  "yourCode": "Your code",
612
612
  "applyAtCheckout": "Enter it at checkout to get the discount.",
613
- "redeemError": "We could not redeem that. Please try again."
613
+ "redeemError": "We could not redeem that. Please try again.",
614
+ "referralSectionTitle": "Invite friends",
615
+ "referralShareHint": "Share your link. When a friend's first order goes through, you earn points.",
616
+ "referralCopyLink": "Copy link",
617
+ "referralLinkCopied": "Copied!"
614
618
  },
615
619
  "content": {
616
620
  "faqTitle": "Frequently Asked Questions",
package/messages/he.json CHANGED
@@ -610,7 +610,11 @@
610
610
  "notEnoughPoints": "אין מספיק נקודות",
611
611
  "yourCode": "הקוד שלך",
612
612
  "applyAtCheckout": "הזינו אותו בצ׳קאאוט כדי לקבל את ההנחה.",
613
- "redeemError": "לא הצלחנו לממש. נסו שוב."
613
+ "redeemError": "לא הצלחנו לממש. נסו שוב.",
614
+ "referralSectionTitle": "הזמינו חברים",
615
+ "referralShareHint": "שתפו את הקישור שלכם. כשההזמנה הראשונה של חבר תושלם, תצברו נקודות.",
616
+ "referralCopyLink": "העתקת קישור",
617
+ "referralLinkCopied": "הועתק!"
614
618
  },
615
619
  "content": {
616
620
  "faqTitle": "שאלות נפוצות",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.86.1",
3
+ "version": "1.87.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "homepage": "https://brainerce.com",
6
6
  "repository": {
@@ -38,6 +38,43 @@ function isSecure(): boolean {
38
38
  return process.env.NODE_ENV === 'production';
39
39
  }
40
40
 
41
+ /**
42
+ * Reads a fetch `Response` body as text, self-healing an observed runtime bug:
43
+ * on Next.js 15.5.26, a `fetch()` made from a Route Handler or Server
44
+ * Component can return a Response whose body is still gzip-compressed bytes,
45
+ * even though an identical request (same URL, same headers) made via plain
46
+ * Node `fetch()` outside Next decompresses correctly — confirmed by diffing
47
+ * the raw response bytes both ways. The exact point inside Next's
48
+ * server-side fetch instrumentation that causes this isn't confirmed, only
49
+ * the externally observable symptom is. Calling `.text()` on a Response like
50
+ * that UTF-8-decodes still-gzipped bytes — lossy (replacement characters)
51
+ * and unparseable as JSON.
52
+ *
53
+ * A correctly-behaving fetch (browsers; plain Node; this bug's absence) never
54
+ * hands back bytes that still start with gzip's magic number (`1f 8b`) — real
55
+ * compression has already been undone by the time a body reaches here — so
56
+ * checking for it before ever decoding as text can't misfire into
57
+ * double-decompressing an already-plain body.
58
+ *
59
+ * This only works because the backend request below pins `Accept-Encoding:
60
+ * gzip` instead of the runtime default (which also offers Brotli) — Brotli
61
+ * has no reliable magic-number signature and no Web Streams API decoder, so
62
+ * this self-healing has no fallback for it. That header is
63
+ * correctness-critical here, not a tuning choice: reverting it to the
64
+ * runtime default reintroduces this exact corruption, just for
65
+ * Brotli-compressed responses instead of gzip ones.
66
+ */
67
+ async function readBackendBody(response: Response): Promise<string> {
68
+ const buffer = new Uint8Array(await response.arrayBuffer());
69
+ if (buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b) {
70
+ const decompressed = new Blob([buffer])
71
+ .stream()
72
+ .pipeThrough(new DecompressionStream('gzip'));
73
+ return new TextDecoder('utf-8').decode(await new Response(decompressed).arrayBuffer());
74
+ }
75
+ return new TextDecoder('utf-8').decode(buffer);
76
+ }
77
+
41
78
  function setAuthCookies(response: NextResponse, token: string): void {
42
79
  response.cookies.set(TOKEN_COOKIE, token, {
43
80
  httpOnly: true,
@@ -93,6 +130,10 @@ async function proxyRequest(
93
130
  const incomingContentType = request.headers.get('content-type');
94
131
  const headers: Record<string, string> = {
95
132
  'Content-Type': incomingContentType || 'application/json',
133
+ // Pinned to gzip (not the runtime default `gzip, deflate, br`) so
134
+ // readBackendBody()'s self-healing only has to reason about one wire
135
+ // format — see the comment there.
136
+ 'Accept-Encoding': 'gzip',
96
137
  };
97
138
 
98
139
  // Send the proxy's own origin, never the client-supplied Origin header —
@@ -166,7 +207,7 @@ async function proxyRequest(
166
207
  }
167
208
 
168
209
  // Read response body
169
- const responseText = await backendResponse.text();
210
+ const responseText = await readBackendBody(backendResponse);
170
211
 
171
212
  // For auth endpoints: intercept token, set cookie, strip token from response
172
213
  if (backendResponse.ok && method === 'POST' && isAuthEndpoint(pathSegments)) {
@@ -2,6 +2,14 @@ import type { Metadata } from 'next';
2
2
 
3
3
  export const metadata: Metadata = {
4
4
  robots: { index: false, follow: false },
5
+ // Static, server-rendered marker for Brainerce's Google Customer Reviews
6
+ // readiness check (Apps → Google → Readiness tab): it fetches this route
7
+ // with a plain HTTP GET, no JS execution, so the signal that the survey
8
+ // component is wired up has to live in server-rendered HTML, not in
9
+ // anything the client component below renders after mount. Keep this in
10
+ // place whenever `<GoogleCustomerReviewsOptIn />` is used on this route —
11
+ // removing one without the other makes the readiness check lie.
12
+ other: { 'brainerce-google-customer-reviews': '1' },
5
13
  };
6
14
 
7
15
  export default function Layout({ children }: { children: React.ReactNode }) {
@@ -13,6 +13,7 @@ import { OrderCustomizations } from '@/components/account/order-customizations';
13
13
  import { LoadingSpinner } from '@/ui/shared/loading-spinner';
14
14
  import { useTranslations } from '@/core/lib/translations';
15
15
  import { trackPurchase } from '@/core/lib/tracking';
16
+ import { GoogleCustomerReviewsOptIn } from '@/components/google-customer-reviews-optin';
16
17
 
17
18
  function OrderConfirmationContent() {
18
19
  const searchParams = useSearchParams();
@@ -203,6 +204,11 @@ function OrderConfirmationContent() {
203
204
  <ConfirmationDownloads orderId={result.status.orderId} checkoutId={checkoutId!} />
204
205
  )}
205
206
 
207
+ {/* Renders no visible markup. Self-gates entirely on whether the
208
+ merchant enabled Google Customer Reviews and this order has the
209
+ data the survey needs — see the component's own doc comment. */}
210
+ <GoogleCustomerReviewsOptIn checkoutId={checkoutId!} />
211
+
206
212
  <div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
207
213
  <Link
208
214
  href="/products"
@@ -87,6 +87,7 @@ export function LoyaltyPanel({ className }: LoyaltyPanelProps) {
87
87
  const [redeemingId, setRedeemingId] = useState<string | null>(null);
88
88
  const [redeemed, setRedeemed] = useState<RedeemedCoupon | null>(null);
89
89
  const [error, setError] = useState<string | null>(null);
90
+ const [linkCopied, setLinkCopied] = useState(false);
90
91
 
91
92
  useEffect(() => {
92
93
  let cancelled = false;
@@ -144,6 +145,18 @@ export function LoyaltyPanel({ className }: LoyaltyPanelProps) {
144
145
  const earningPaused = status.program.status !== 'ACTIVE';
145
146
  const tierLevel = status.tier?.level ?? 0;
146
147
 
148
+ async function copyReferralLink(code: string) {
149
+ const link = `${window.location.origin}/?ref=${code}`;
150
+ try {
151
+ await navigator.clipboard.writeText(link);
152
+ setLinkCopied(true);
153
+ setTimeout(() => setLinkCopied(false), 2000);
154
+ } catch {
155
+ // Clipboard access denied (permissions, insecure context) — the code is
156
+ // still visible on screen for the shopper to select and copy by hand.
157
+ }
158
+ }
159
+
147
160
  async function redeem(reward: LoyaltyReward) {
148
161
  setRedeemingId(reward.id);
149
162
  setError(null);
@@ -229,6 +242,30 @@ export function LoyaltyPanel({ className }: LoyaltyPanelProps) {
229
242
  </div>
230
243
  )}
231
244
 
245
+ {/* Referral share code. Null when the store has referrals disabled or
246
+ this customer isn't enrolled yet — absent entirely rather than an
247
+ empty block, same pattern as the tier/rewards sections above. */}
248
+ {status.referralCode && (
249
+ <div className="border-border mt-6 border-t pt-5">
250
+ <h3 className="text-foreground mb-1 text-sm font-semibold">
251
+ {t('referralSectionTitle')}
252
+ </h3>
253
+ <p className="text-muted-foreground text-sm">{t('referralShareHint')}</p>
254
+ <div className="mt-3 flex flex-wrap items-center gap-2">
255
+ <code className="bg-muted text-foreground rounded px-2 py-1 font-mono text-sm">
256
+ {status.referralCode}
257
+ </code>
258
+ <button
259
+ type="button"
260
+ onClick={() => copyReferralLink(status.referralCode as string)}
261
+ className="border-border rounded border px-3 py-1.5 text-sm font-medium transition-opacity hover:opacity-80"
262
+ >
263
+ {linkCopied ? t('referralLinkCopied') : t('referralCopyLink')}
264
+ </button>
265
+ </div>
266
+ </div>
267
+ )}
268
+
232
269
  {/* Rewards. An empty catalogue is a normal state — the merchant has not
233
270
  created any yet — so the list simply does not render. */}
234
271
  {rewards.length > 0 && (
@@ -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
+ }
@@ -42,6 +42,43 @@ export async function getServerClient(locale?: string): Promise<BrainerceClient>
42
42
  return client;
43
43
  }
44
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
+
45
82
  /**
46
83
  * Server-side cached fetch of store info, used to seed `<StoreProvider>` at
47
84
  * SSR so client components don't render with `null` storeInfo and bake the
@@ -69,12 +106,15 @@ export const fetchStoreInfo = cache(async (locale?: string): Promise<PublicStore
69
106
  const res = await fetch(url, {
70
107
  headers: {
71
108
  Origin: siteOrigin,
109
+ // Pinned to gzip — see readBackendBody() for why.
110
+ 'Accept-Encoding': 'gzip',
72
111
  ...(locale ? { 'Accept-Language': locale } : {}),
73
112
  },
74
113
  next: { revalidate: 60, tags: ['store-info'] },
75
114
  });
76
115
  if (!res.ok) return null;
77
- return pickPublicStoreInfo(await res.json());
116
+ const text = await readBackendBody(res);
117
+ return pickPublicStoreInfo(text ? JSON.parse(text) : {});
78
118
  } catch {
79
119
  return null;
80
120
  }
@@ -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:",