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.
- package/README.md +130 -92
- package/dist/index.js +39 -17
- package/messages/en.json +9 -2
- package/messages/he.json +9 -2
- package/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +10 -1
- package/templates/nextjs/base/AGENTS.md.ejs +229 -207
- package/templates/nextjs/base/AI-GUIDE.md +4 -0
- package/templates/nextjs/base/CLAUDE.md.ejs +240 -218
- package/templates/nextjs/base/scripts/connect.mjs +115 -18
- package/templates/nextjs/base/scripts/env-file.mjs +93 -0
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +109 -83
- package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +2 -1
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +2 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +1325 -1302
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +3 -2
- package/templates/nextjs/base/src/app/icon.svg +11 -0
- package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
- package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
- package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
- package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
- package/templates/nextjs/base/src/components/account/order-payment-block.tsx +17 -2
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +130 -12
- package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
- package/templates/nextjs/base/src/core/lib/tracking.ts +25 -14
- package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
- package/templates/nextjs/base/src/ui/product/price-display.tsx +85 -62
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +539 -537
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
- package/templates/nextjs/designs/atelier/globals.css +408 -402
- package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
- package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +90 -69
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +560 -553
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
- package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +73 -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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(
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
return
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
706
|
-
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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:
|
|
157
|
-
value:
|
|
158
|
-
shipping:
|
|
159
|
-
|
|
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,
|