create-brainerce-store 1.84.0 → 1.86.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 (52) hide show
  1. package/README.md +130 -92
  2. package/dist/index.js +39 -17
  3. package/messages/en.json +9 -2
  4. package/messages/he.json +9 -2
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.env.local.ejs +10 -1
  7. package/templates/nextjs/base/AGENTS.md.ejs +229 -207
  8. package/templates/nextjs/base/AI-GUIDE.md +4 -0
  9. package/templates/nextjs/base/CLAUDE.md.ejs +240 -218
  10. package/templates/nextjs/base/scripts/connect.mjs +115 -18
  11. package/templates/nextjs/base/scripts/env-file.mjs +93 -0
  12. package/templates/nextjs/base/scripts/fetch-store-info.mjs +109 -83
  13. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +2 -1
  14. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +2 -0
  15. package/templates/nextjs/base/src/app/checkout/page.tsx +1325 -1302
  16. package/templates/nextjs/base/src/app/forgot-password/page.tsx +3 -2
  17. package/templates/nextjs/base/src/app/icon.svg +11 -0
  18. package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
  19. package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
  20. package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
  21. package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
  22. package/templates/nextjs/base/src/components/account/order-payment-block.tsx +17 -2
  23. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
  24. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +130 -12
  25. package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
  26. package/templates/nextjs/base/src/core/lib/tracking.ts +25 -14
  27. package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
  28. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  29. package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
  30. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
  31. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
  32. package/templates/nextjs/base/src/ui/product/price-display.tsx +85 -62
  33. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +539 -537
  34. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
  35. package/templates/nextjs/designs/atelier/globals.css +408 -402
  36. package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
  37. package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
  38. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
  39. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
  40. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
  41. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
  42. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
  43. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
  44. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
  45. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
  46. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +90 -69
  47. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
  48. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +560 -553
  49. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
  50. package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
  51. package/templates/nextjs/ui-canvas/product/price-display.tsx +73 -52
  52. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +428 -426
@@ -1,137 +1,174 @@
1
- 'use client';
2
-
3
- import { useState, useEffect } from 'react';
4
- import type { CustomerOAuthProvider } from 'brainerce';
5
- import { getClient } from '@/core/lib/brainerce';
6
- import { useTranslations } from '@/core/lib/translations';
7
- import { cn } from '@/core/lib/utils';
8
- import { LoadingSpinner } from '@/ui/shared/loading-spinner';
9
-
10
- const PROVIDER_CONFIG: Record<CustomerOAuthProvider, { labelKey: string; icon: React.ReactNode }> =
11
- {
12
- GOOGLE: {
13
- labelKey: 'google',
14
- icon: (
15
- <svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
16
- <path
17
- d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
18
- fill="#4285F4"
19
- />
20
- <path
21
- d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
22
- fill="#34A853"
23
- />
24
- <path
25
- d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
26
- fill="#FBBC05"
27
- />
28
- <path
29
- d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
30
- fill="#EA4335"
31
- />
32
- </svg>
33
- ),
34
- },
35
- FACEBOOK: {
36
- labelKey: 'facebook',
37
- icon: (
38
- <svg className="h-5 w-5" viewBox="0 0 24 24" fill="#1877F2">
39
- <path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
40
- </svg>
41
- ),
42
- },
43
- GITHUB: {
44
- labelKey: 'github',
45
- icon: (
46
- <svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
47
- <path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z" />
48
- </svg>
49
- ),
50
- },
51
- };
52
-
53
- interface OAuthButtonsProps {
54
- className?: string;
55
- }
56
-
57
- export function OAuthButtons({ className }: OAuthButtonsProps) {
58
- const t = useTranslations('auth');
59
- const [providers, setProviders] = useState<CustomerOAuthProvider[]>([]);
60
- const [loading, setLoading] = useState(true);
61
- const [redirecting, setRedirecting] = useState<CustomerOAuthProvider | null>(null);
62
-
63
- useEffect(() => {
64
- async function fetchProviders() {
65
- try {
66
- const client = getClient();
67
- const result = await client.getAvailableOAuthProviders();
68
- setProviders(result.providers);
69
- } catch {
70
- // OAuth not available - silently hide buttons
71
- setProviders([]);
72
- } finally {
73
- setLoading(false);
74
- }
75
- }
76
-
77
- fetchProviders();
78
- }, []);
79
-
80
- async function handleOAuthClick(provider: CustomerOAuthProvider) {
81
- if (redirecting) return;
82
-
83
- try {
84
- setRedirecting(provider);
85
- const client = getClient();
86
- const redirectUrl = window.location.origin + '/api/auth/oauth-callback';
87
- const result = await client.getOAuthAuthorizeUrl(provider, { redirectUrl });
88
- window.location.href = result.authorizationUrl;
89
- } catch (err) {
90
- console.error('OAuth redirect failed:', err);
91
- setRedirecting(null);
92
- }
93
- }
94
-
95
- if (loading) {
96
- return null;
97
- }
98
-
99
- if (providers.length === 0) {
100
- return null;
101
- }
102
-
103
- return (
104
- <div className={cn('space-y-3', className)}>
105
- <div className="relative">
106
- <div className="absolute inset-0 flex items-center">
107
- <div className="border-border w-full border-t" />
108
- </div>
109
- <div className="relative flex justify-center text-xs uppercase">
110
- <span className="bg-background text-muted-foreground px-2">{t('orContinueWith')}</span>
111
- </div>
112
- </div>
113
-
114
- <div className="grid gap-2">
115
- {providers.map((provider) => {
116
- const config = PROVIDER_CONFIG[provider];
117
- if (!config) return null;
118
-
119
- const isRedirecting = redirecting === provider;
120
-
121
- return (
122
- <button
123
- key={provider}
124
- type="button"
125
- onClick={() => handleOAuthClick(provider)}
126
- disabled={!!redirecting}
127
- className="border-border text-foreground bg-background hover:bg-muted flex h-10 w-full items-center justify-center gap-2 rounded border text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
128
- >
129
- {isRedirecting ? <LoadingSpinner size="sm" /> : config.icon}
130
- {t(config.labelKey as 'google' | 'facebook' | 'github')}
131
- </button>
132
- );
133
- })}
134
- </div>
135
- </div>
136
- );
137
- }
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import type { CustomerOAuthProvider } from 'brainerce';
5
+ import { getClient } from '@/core/lib/brainerce';
6
+ import { useTranslations } from '@/core/lib/translations';
7
+ import { cn } from '@/core/lib/utils';
8
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
9
+
10
+ const PROVIDER_CONFIG: Record<CustomerOAuthProvider, { labelKey: string; icon: React.ReactNode }> =
11
+ {
12
+ GOOGLE: {
13
+ labelKey: 'google',
14
+ icon: (
15
+ <svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
16
+ <path
17
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
18
+ fill="#4285F4"
19
+ />
20
+ <path
21
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
22
+ fill="#34A853"
23
+ />
24
+ <path
25
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
26
+ fill="#FBBC05"
27
+ />
28
+ <path
29
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
30
+ fill="#EA4335"
31
+ />
32
+ </svg>
33
+ ),
34
+ },
35
+ FACEBOOK: {
36
+ labelKey: 'facebook',
37
+ icon: (
38
+ <svg className="h-5 w-5" viewBox="0 0 24 24" fill="#1877F2">
39
+ <path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
40
+ </svg>
41
+ ),
42
+ },
43
+ GITHUB: {
44
+ labelKey: 'github',
45
+ icon: (
46
+ <svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
47
+ <path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z" />
48
+ </svg>
49
+ ),
50
+ },
51
+ };
52
+
53
+ interface OAuthButtonsProps {
54
+ className?: string;
55
+ }
56
+
57
+ export function OAuthButtons({ className }: OAuthButtonsProps) {
58
+ const t = useTranslations('auth');
59
+ const [providers, setProviders] = useState<CustomerOAuthProvider[]>([]);
60
+ const [loading, setLoading] = useState(true);
61
+ const [redirecting, setRedirecting] = useState<CustomerOAuthProvider | null>(null);
62
+ const [failedProvider, setFailedProvider] = useState<CustomerOAuthProvider | null>(null);
63
+
64
+ useEffect(() => {
65
+ async function fetchProviders() {
66
+ try {
67
+ const client = getClient();
68
+ const result = await client.getAvailableOAuthProviders();
69
+ // `redirectReady` is per storefront address: a provider can be enabled
70
+ // while the sales channel has nowhere to send the session back to
71
+ // (TEST channel, no domain, this preview URL not registered). The
72
+ // authorize call would 400 on click, so render nothing — and tell the
73
+ // builder why, in dev only, since only the merchant can fix it.
74
+ // Read through a local shape rather than the SDK type: the scaffold pins
75
+ // an SDK that may predate the field, and `=== false` keeps an older
76
+ // backend (which omits it) behaving exactly as before.
77
+ const readiness = result as { redirectReady?: boolean; redirectBlockedReason?: string };
78
+ if (readiness.redirectReady === false) {
79
+ if (process.env.NODE_ENV !== 'production') {
80
+ console.warn(
81
+ `[brainerce] Social login hidden: this storefront's address (${window.location.origin}) ` +
82
+ `is not registered on the sales channel (reason: ${readiness.redirectBlockedReason}). ` +
83
+ 'Add it under Dashboard → Sales Channels → Domains.'
84
+ );
85
+ }
86
+ setProviders([]);
87
+ return;
88
+ }
89
+ setProviders(result.providers);
90
+ } catch {
91
+ // OAuth not available - silently hide buttons
92
+ setProviders([]);
93
+ } finally {
94
+ setLoading(false);
95
+ }
96
+ }
97
+
98
+ fetchProviders();
99
+ }, []);
100
+
101
+ async function handleOAuthClick(provider: CustomerOAuthProvider) {
102
+ if (redirecting) return;
103
+
104
+ try {
105
+ setRedirecting(provider);
106
+ setFailedProvider(null);
107
+ const client = getClient();
108
+ const redirectUrl = window.location.origin + '/api/auth/oauth-callback';
109
+ const result = await client.getOAuthAuthorizeUrl(provider, { redirectUrl });
110
+ window.location.href = result.authorizationUrl;
111
+ } catch (err) {
112
+ // The 400 body is a rule for the merchant (`redirect_not_allowed` and
113
+ // which channel setting to fix) — the shopper gets store-language copy,
114
+ // never the English policy text.
115
+ console.error('OAuth redirect failed:', err);
116
+ setFailedProvider(provider);
117
+ setRedirecting(null);
118
+ }
119
+ }
120
+
121
+ if (loading) {
122
+ return null;
123
+ }
124
+
125
+ if (providers.length === 0) {
126
+ return null;
127
+ }
128
+
129
+ return (
130
+ <div className={cn('space-y-3', className)}>
131
+ <div className="relative">
132
+ <div className="absolute inset-0 flex items-center">
133
+ <div className="border-border w-full border-t" />
134
+ </div>
135
+ <div className="relative flex justify-center text-xs uppercase">
136
+ <span className="bg-background text-muted-foreground px-2">{t('orContinueWith')}</span>
137
+ </div>
138
+ </div>
139
+
140
+ {failedProvider && (
141
+ <div
142
+ role="alert"
143
+ className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm"
144
+ >
145
+ {t('socialLoginUnavailable', {
146
+ provider: t(PROVIDER_CONFIG[failedProvider].labelKey as 'google' | 'facebook' | 'github'),
147
+ })}
148
+ </div>
149
+ )}
150
+
151
+ <div className="grid gap-2">
152
+ {providers.map((provider) => {
153
+ const config = PROVIDER_CONFIG[provider];
154
+ if (!config) return null;
155
+
156
+ const isRedirecting = redirecting === provider;
157
+
158
+ return (
159
+ <button
160
+ key={provider}
161
+ type="button"
162
+ onClick={() => handleOAuthClick(provider)}
163
+ disabled={!!redirecting}
164
+ className="border-border text-foreground bg-background hover:bg-muted flex h-10 w-full items-center justify-center gap-2 rounded border text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
165
+ >
166
+ {isRedirecting ? <LoadingSpinner size="sm" /> : config.icon}
167
+ {t(config.labelKey as 'google' | 'facebook' | 'github')}
168
+ </button>
169
+ );
170
+ })}
171
+ </div>
172
+ </div>
173
+ );
174
+ }
@@ -73,6 +73,18 @@ function resolveClientSdk(
73
73
  return { renderType: 'redirect' };
74
74
  }
75
75
 
76
+ /**
77
+ * Brainerce-hosted embed (Cardcom OpenFields) vs a provider-hosted page.
78
+ * Path-based so it holds across localhost/staging/prod without a domain list.
79
+ */
80
+ function isBrainerceEmbedUrl(url: string): boolean {
81
+ try {
82
+ return new URL(url).pathname.includes('/embed/');
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
76
88
  function extractMessage(response: unknown): string {
77
89
  if (typeof response === 'string') return response;
78
90
  return (response as { message?: string })?.message || '';
@@ -88,7 +100,14 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
88
100
  // is only a fallback for providers that duplicate it there. Reading
89
101
  // clientSecret alone breaks providers that return a real identifier (MAX,
90
102
  // Takbull) — the iframe/link silently points at an id instead of a page.
103
+ //
104
+ // Every allowlist check and embed-detection below must read THIS value, not
105
+ // `paymentIntent.clientSecret` directly — that was the bug: two checks kept
106
+ // reading clientSecret straight off the intent after this fallback was
107
+ // introduced, so a MAX/Takbull-style provider failed `new URL()` on a bare
108
+ // id and the payment step silently rendered nothing.
91
109
  const paymentIntentUrl = paymentIntent?.clientSdk?.renderArg || paymentIntent?.clientSecret || '';
110
+ const isBrainerceEmbed = isBrainerceEmbedUrl(paymentIntentUrl);
92
111
  const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
93
112
  const [loading, setLoading] = useState(true);
94
113
  const [error, setError] = useState<string | null>(null);
@@ -98,9 +117,60 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
98
117
  // from a provider's hosted page — used to narrow the modal + auto-size the
99
118
  // iframe instead of reserving the tall LowProfile footprint.
100
119
  const [embeddedIframeHeight, setEmbeddedIframeHeight] = useState<number | null>(null);
120
+ // Set when the inline embed never posts its first `brainerce:resize` within
121
+ // the timeout below — e.g. the parent's CSP blocks the iframe from framing
122
+ // at all. Without this the shopper stares at a permanently blank/broken box
123
+ // with no error and no way forward.
124
+ const [iframeLoadFailed, setIframeLoadFailed] = useState(false);
125
+ // Provider-hosted iframe modal, closed by the shopper. Without this the
126
+ // modal had no closed state at all: the X did a full navigation to
127
+ // `?canceled=true`, the page resumed on the payment step, a fresh intent
128
+ // re-opened the modal over the "payment canceled" banner, and browser Back
129
+ // did the same — the shopper could never get back to the checkout. Starts
130
+ // closed when the URL already says the previous attempt was canceled.
131
+ const [modalDismissed, setModalDismissed] = useState<boolean>(
132
+ () =>
133
+ typeof window !== 'undefined' &&
134
+ new URLSearchParams(window.location.search).get('canceled') === 'true'
135
+ );
101
136
  const walletOpenRef = useRef(false);
102
137
  const initialized = useRef(false);
103
138
 
139
+ // Whether the provider-hosted modal is on screen right now. Derived, not
140
+ // stored, so every branch below agrees on it.
141
+ const providerModalOpen =
142
+ !modalDismissed &&
143
+ !!paymentIntent &&
144
+ resolveClientSdk(paymentIntent, preloadedSdk).renderType === 'iframe' &&
145
+ !isBrainerceEmbed;
146
+
147
+ // Back closes the modal instead of leaving the shopper inside it. Opening
148
+ // pushes one history entry tagged as ours; popping it (Back, or the X below
149
+ // calling history.back()) dismisses. A provider page that navigates inside
150
+ // the iframe adds entries of its own on top, so Back may need a press per
151
+ // in-frame navigation first — that is the browser's rule, not ours, and it
152
+ // still ends on the checkout rather than looping through the payment page.
153
+ useEffect(() => {
154
+ if (!providerModalOpen) return;
155
+ const marker = { brainercePaymentModal: true };
156
+ window.history.pushState(marker, '');
157
+ const onPop = () => setModalDismissed(true);
158
+ window.addEventListener('popstate', onPop);
159
+ return () => window.removeEventListener('popstate', onPop);
160
+ }, [providerModalOpen]);
161
+
162
+ const closeProviderModal = useCallback(() => {
163
+ if (
164
+ (window.history.state as { brainercePaymentModal?: boolean } | null)?.brainercePaymentModal
165
+ ) {
166
+ // Our entry is on top: popping it fires popstate, which dismisses and
167
+ // leaves the history as it was before the modal opened.
168
+ window.history.back();
169
+ } else {
170
+ setModalDismissed(true);
171
+ }
172
+ }, []);
173
+
104
174
  // Stable refs for SDK event callbacks (avoids stale closures in onload)
105
175
  const cbRef = useRef({
106
176
  onSuccess: (_r: unknown) => {},
@@ -519,6 +589,26 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
519
589
  return () => cleanups.forEach((fn) => fn());
520
590
  }, [checkoutId]);
521
591
 
592
+ // Brainerce-hosted embed pages announce themselves immediately via the
593
+ // `brainerce:resize` postMessage handled above (it sets
594
+ // `embeddedIframeHeight`). If that never arrives — most commonly the
595
+ // parent origin isn't in the embed page's `frame-ancestors` allowlist and
596
+ // the browser refuses to frame it at all — there is otherwise no signal:
597
+ // cross-origin framing failures are invisible to the parent window, so the
598
+ // shopper is left staring at a blank box forever with no error and no way
599
+ // forward.
600
+ useEffect(() => {
601
+ if (!isBrainerceEmbed || !paymentIntentUrl) return;
602
+ setIframeLoadFailed(false);
603
+ const timer = setTimeout(() => {
604
+ setEmbeddedIframeHeight((h) => {
605
+ if (h === null) setIframeLoadFailed(true);
606
+ return h;
607
+ });
608
+ }, 10000);
609
+ return () => clearTimeout(timer);
610
+ }, [isBrainerceEmbed, paymentIntentUrl]);
611
+
522
612
  // --- UI ---
523
613
 
524
614
  // Invalid checkout id — render the error box instead of ever spinning.
@@ -636,23 +726,31 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
636
726
  }
637
727
 
638
728
  if (sdk.renderType === 'iframe') {
639
- if (!isAllowedPaymentUrl(paymentIntent.clientSecret)) return null;
729
+ if (!isAllowedPaymentUrl(paymentIntentUrl)) return null;
640
730
 
641
731
  // Detect Brainerce-hosted embed (path-based — works across localhost/
642
732
  // staging/prod without a domain list) vs. a provider-hosted page. The
643
733
  // embed page is already brand-styled and compact → render inline in the
644
734
  // checkout flow. Provider-hosted pages carry their own branding/chrome →
645
735
  // keep the modal overlay so they don't fight the checkout layout.
646
- const iframeUrlObj = (() => {
647
- try {
648
- return new URL(paymentIntent.clientSecret);
649
- } catch {
650
- return null;
736
+ if (isBrainerceEmbed) {
737
+ if (iframeLoadFailed) {
738
+ return (
739
+ <div className={cn('border-border rounded-lg border px-4 py-6 text-center', className)}>
740
+ <p className="text-foreground text-sm font-medium">{t('paymentError')}</p>
741
+ <p className="text-muted-foreground mt-1 text-sm">{t('paymentLoadFailed')}</p>
742
+ <a
743
+ href={paymentIntentUrl}
744
+ target="_blank"
745
+ rel="noopener noreferrer"
746
+ className="bg-primary text-primary-foreground mt-4 inline-flex items-center rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90"
747
+ >
748
+ {t('openPaymentModal')}
749
+ </a>
750
+ </div>
751
+ );
651
752
  }
652
- })();
653
- const isBrainerceEmbed = iframeUrlObj?.pathname.includes('/embed/') ?? false;
654
753
 
655
- if (isBrainerceEmbed) {
656
754
  // Inline: default to a reasonable height until the embed posts its real
657
755
  // height via `brainerce:resize`. Transition smooths the resize into the
658
756
  // final measurement.
@@ -674,6 +772,27 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
674
772
  );
675
773
  }
676
774
 
775
+ // Closed by the shopper (X, browser Back, or arriving with
776
+ // `?canceled=true`): hold the payment step with a way back in. The intent
777
+ // is kept; re-opening reuses it (the core hands back the same intent for
778
+ // an unchanged charge).
779
+ if (modalDismissed) {
780
+ return (
781
+ <div
782
+ className={cn('flex flex-col items-center justify-center py-12 text-center', className)}
783
+ >
784
+ <p className="text-muted-foreground mb-4 text-sm">{t('paymentModalClosed')}</p>
785
+ <button
786
+ type="button"
787
+ onClick={() => setModalDismissed(false)}
788
+ className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-6 py-2.5 text-sm font-medium transition-colors"
789
+ >
790
+ {t('openPaymentModal')}
791
+ </button>
792
+ </div>
793
+ );
794
+ }
795
+
677
796
  // Provider-hosted page (e.g. Cardcom LowProfile with full merchant
678
797
  // branding) — modal overlay keeps it visually contained.
679
798
  const formattedAmount = formatPrice((Number(paymentIntent.amount) || 0) / 100, {
@@ -702,9 +821,8 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
702
821
  </span>
703
822
  </div>
704
823
  <button
705
- onClick={() => {
706
- window.location.href = `/checkout?checkout_id=${checkoutId}&canceled=true`;
707
- }}
824
+ type="button"
825
+ onClick={closeProviderModal}
708
826
  className="text-muted-foreground hover:bg-secondary hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors"
709
827
  aria-label="Close"
710
828
  >
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Single source of truth for "what is this store called?" — the twin of
3
+ * `resolve-currency.ts` / `use-currency.ts`, and the ONLY file that carries
4
+ * the name the scaffolder knew at generation time.
5
+ *
6
+ * The chain, strongest first:
7
+ * 1. `storeInfo.name` — live, from `/api/vc/<id>/info`. Server components
8
+ * and `generateMetadata` get it from `fetchStoreInfo()`; client
9
+ * components from `useStoreInfo()`. Always right, and free wherever the
10
+ * layout already fetched it.
11
+ * 2. `process.env.NEXT_PUBLIC_STORE_NAME` — written to `.env.local` by
12
+ * `npm run setup` (which `npm run connect` runs for you) and inlined at
13
+ * BUILD time. It is what the client bundle carries before the live data
14
+ * arrives, and what a page renders when the API is unreachable. Refresh
15
+ * it with `npm run setup`, then rebuild.
16
+ * 3. `SCAFFOLD_STORE_NAME` — the literal below, baked when the project was
17
+ * generated. Under `--defer-connection` the scaffolder knew no store, so
18
+ * this is just the project directory name: a placeholder, never a brand.
19
+ * It must never be the only source anywhere (it once was, and a Hebrew
20
+ * sports store went live titled "storefront").
21
+ *
22
+ * ⛔ No imports here, on purpose: this file is used by server components,
23
+ * `generateMetadata`, route handlers, the OG image and `'use client'` hooks
24
+ * alike, so it must never pull in `next/headers` or React.
25
+ */
26
+
27
+ /** The name the scaffolder knew — see (3) above. Rendered by the scaffolder
28
+ * as a complete JS string literal (`storeNameJs`), so quotes, backticks and
29
+ * `</script>` inside a real store name are already escaped here; every
30
+ * consumer still escapes for ITS context (React text, JSON-LD, `<title>`). */
31
+ export const SCAFFOLD_STORE_NAME: string = <%- storeNameJs %>;
32
+
33
+ /**
34
+ * Resolve the display name for this store.
35
+ *
36
+ * @param storeInfo result of `fetchStoreInfo()` / `useStoreInfo()` — may be
37
+ * null when the fetch failed or has not resolved yet
38
+ * @param override pass when the caller already has a more specific name
39
+ * (a sales-channel label, an order's captured store name)
40
+ */
41
+ export function resolveStoreName(
42
+ storeInfo: { name?: string | null } | null | undefined,
43
+ override?: string | null
44
+ ): string {
45
+ return (
46
+ override?.trim() ||
47
+ storeInfo?.name?.trim() ||
48
+ process.env.NEXT_PUBLIC_STORE_NAME?.trim() ||
49
+ SCAFFOLD_STORE_NAME
50
+ );
51
+ }
52
+
53
+ /** `<title>` template for pages that set their own title: "Page | Store". */
54
+ export function buildTitleTemplate(storeName: string): string {
55
+ return `%s | ${storeName}`;
56
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Cart, Order, Product, ProductVariant, TrackingEventItem } from 'brainerce';
2
- import { getCartTotals } from 'brainerce';
2
+ import { getCartTotals, buildGa4CommercePayload } from 'brainerce';
3
3
 
4
4
  import { getClient } from './brainerce';
5
5
 
@@ -16,12 +16,14 @@ import { getClient } from './brainerce';
16
16
  *
17
17
  * ## Item ids
18
18
  *
19
- * `itemId` is always the **SKU**. That is the id Brainerce publishes to the
20
- * Google Merchant Center and Meta catalog feeds, so it is the only id the ad
21
- * platforms can match a pixel event against. Sending a product or variant id
22
- * instead breaks attribution and dynamic remarketing silently — the events
23
- * arrive, the platform reports an id its catalog has never seen, and the
24
- * remarketing audience never builds.
19
+ * `itemId` is the SKU-preferring id Meta/TikTok pixels match against (Meta
20
+ * deliberately uses the bare internal id instead, since SKU is nullable
21
+ * there — see `apps/meta-connector/src/lib/mappers.ts`). For GA4/GTM, the SDK
22
+ * builds a SEPARATE id from `productId`/`variantId` (`prod_<id>`/`var_<id>`)
23
+ * matching the Google Merchant Center feed's `offerId` — neither SKU nor a
24
+ * bare unprefixed id matches that feed, which silently breaks Shopping ads /
25
+ * dynamic remarketing item matching. Always pass `productId`/`variantId`
26
+ * alongside `itemId` so both platforms attribute correctly.
25
27
  */
26
28
 
27
29
  /** Parse a Brainerce decimal string (`"29.99"`) into a number. */
@@ -54,6 +56,8 @@ export function trackProductView(
54
56
  items: [
55
57
  {
56
58
  itemId: resolveItemId(product, variant),
59
+ productId: product.id,
60
+ variantId: variant?.id,
57
61
  itemName: product.name,
58
62
  price,
59
63
  quantity: 1,
@@ -77,6 +81,8 @@ export function trackAddToCart(
77
81
  items: [
78
82
  {
79
83
  itemId: resolveItemId(product, variant),
84
+ productId: product.id,
85
+ variantId: variant?.id,
80
86
  itemName: product.name,
81
87
  price,
82
88
  quantity,
@@ -92,6 +98,8 @@ function cartItems(cart: Cart): TrackingEventItem[] {
92
98
  // The cart line carries product + variant snapshots; prefer the variant's
93
99
  // SKU for the same catalog-parity reason as `resolveItemId`.
94
100
  itemId: item.variant?.sku || item.product?.sku || item.variantId || item.productId,
101
+ productId: item.productId,
102
+ variantId: item.variantId ?? undefined,
95
103
  itemName: item.product?.name,
96
104
  price: toNumber(item.currentUnitPrice ?? item.unitPrice),
97
105
  quantity: item.quantity,
@@ -151,18 +159,21 @@ export function trackPurchase(order: Order): void {
151
159
  // vendor-side transaction id de-dupe rather than skipping the conversion.
152
160
  }
153
161
 
162
+ // GA4 spec: `value` is net item revenue only — `tax`/`shipping` are
163
+ // reported alongside it, never folded in. See `buildGa4CommercePayload`.
164
+ const commerce = buildGa4CommercePayload(order);
165
+
154
166
  getClient().trackMarketingEvent('purchase', {
155
167
  transactionId: order.id,
156
- currency: order.currency,
157
- value: toNumber(order.total ?? order.totalAmount),
158
- shipping: toNumber(order.shippingAmount),
159
- // In inclusive (VAT) pricing `taxAmount` is "0" and the real VAT lives in
160
- // the frozen breakdown — reporting the 0 would understate tax on every
161
- // order for every VAT-region merchant.
162
- tax: toNumber(order.taxBreakdown?.totalTax) ?? toNumber(order.taxAmount),
168
+ currency: commerce.currency,
169
+ value: commerce.value,
170
+ shipping: commerce.shipping,
171
+ tax: commerce.tax,
163
172
  coupon: order.couponCode ?? undefined,
164
173
  items: (order.items ?? []).map((item) => ({
165
174
  itemId: item.sku || item.variantId || item.productId,
175
+ productId: item.productId,
176
+ variantId: item.variantId,
166
177
  itemName: item.name,
167
178
  price: toNumber(item.price),
168
179
  quantity: item.quantity,