create-brainerce-store 1.84.0 → 1.85.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 (42) hide show
  1. package/README.md +130 -92
  2. package/dist/index.js +39 -17
  3. package/messages/en.json +3 -0
  4. package/messages/he.json +3 -0
  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/forgot-password/page.tsx +3 -2
  15. package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
  16. package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
  17. package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
  18. package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
  19. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
  20. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +82 -11
  21. package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
  22. package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
  23. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  24. package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
  25. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
  26. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
  27. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
  28. package/templates/nextjs/designs/atelier/globals.css +408 -402
  29. package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
  30. package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
  31. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
  32. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
  33. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
  34. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
  35. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
  36. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
  37. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
  38. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
  39. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
  40. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +558 -553
  41. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
  42. package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
@@ -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 || '';
@@ -98,9 +110,55 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
98
110
  // from a provider's hosted page — used to narrow the modal + auto-size the
99
111
  // iframe instead of reserving the tall LowProfile footprint.
100
112
  const [embeddedIframeHeight, setEmbeddedIframeHeight] = useState<number | null>(null);
113
+ // Provider-hosted iframe modal, closed by the shopper. Without this the
114
+ // modal had no closed state at all: the X did a full navigation to
115
+ // `?canceled=true`, the page resumed on the payment step, a fresh intent
116
+ // re-opened the modal over the "payment canceled" banner, and browser Back
117
+ // did the same — the shopper could never get back to the checkout. Starts
118
+ // closed when the URL already says the previous attempt was canceled.
119
+ const [modalDismissed, setModalDismissed] = useState<boolean>(
120
+ () =>
121
+ typeof window !== 'undefined' &&
122
+ new URLSearchParams(window.location.search).get('canceled') === 'true'
123
+ );
101
124
  const walletOpenRef = useRef(false);
102
125
  const initialized = useRef(false);
103
126
 
127
+ // Whether the provider-hosted modal is on screen right now. Derived, not
128
+ // stored, so every branch below agrees on it.
129
+ const providerModalOpen =
130
+ !modalDismissed &&
131
+ !!paymentIntent &&
132
+ resolveClientSdk(paymentIntent, preloadedSdk).renderType === 'iframe' &&
133
+ !isBrainerceEmbedUrl(paymentIntent.clientSecret);
134
+
135
+ // Back closes the modal instead of leaving the shopper inside it. Opening
136
+ // pushes one history entry tagged as ours; popping it (Back, or the X below
137
+ // calling history.back()) dismisses. A provider page that navigates inside
138
+ // the iframe adds entries of its own on top, so Back may need a press per
139
+ // in-frame navigation first — that is the browser's rule, not ours, and it
140
+ // still ends on the checkout rather than looping through the payment page.
141
+ useEffect(() => {
142
+ if (!providerModalOpen) return;
143
+ const marker = { brainercePaymentModal: true };
144
+ window.history.pushState(marker, '');
145
+ const onPop = () => setModalDismissed(true);
146
+ window.addEventListener('popstate', onPop);
147
+ return () => window.removeEventListener('popstate', onPop);
148
+ }, [providerModalOpen]);
149
+
150
+ const closeProviderModal = useCallback(() => {
151
+ if (
152
+ (window.history.state as { brainercePaymentModal?: boolean } | null)?.brainercePaymentModal
153
+ ) {
154
+ // Our entry is on top: popping it fires popstate, which dismisses and
155
+ // leaves the history as it was before the modal opened.
156
+ window.history.back();
157
+ } else {
158
+ setModalDismissed(true);
159
+ }
160
+ }, []);
161
+
104
162
  // Stable refs for SDK event callbacks (avoids stale closures in onload)
105
163
  const cbRef = useRef({
106
164
  onSuccess: (_r: unknown) => {},
@@ -643,14 +701,7 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
643
701
  // embed page is already brand-styled and compact → render inline in the
644
702
  // checkout flow. Provider-hosted pages carry their own branding/chrome →
645
703
  // 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;
651
- }
652
- })();
653
- const isBrainerceEmbed = iframeUrlObj?.pathname.includes('/embed/') ?? false;
704
+ const isBrainerceEmbed = isBrainerceEmbedUrl(paymentIntent.clientSecret);
654
705
 
655
706
  if (isBrainerceEmbed) {
656
707
  // Inline: default to a reasonable height until the embed posts its real
@@ -674,6 +725,27 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
674
725
  );
675
726
  }
676
727
 
728
+ // Closed by the shopper (X, browser Back, or arriving with
729
+ // `?canceled=true`): hold the payment step with a way back in. The intent
730
+ // is kept; re-opening reuses it (the core hands back the same intent for
731
+ // an unchanged charge).
732
+ if (modalDismissed) {
733
+ return (
734
+ <div
735
+ className={cn('flex flex-col items-center justify-center py-12 text-center', className)}
736
+ >
737
+ <p className="text-muted-foreground mb-4 text-sm">{t('paymentModalClosed')}</p>
738
+ <button
739
+ type="button"
740
+ onClick={() => setModalDismissed(false)}
741
+ className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-6 py-2.5 text-sm font-medium transition-colors"
742
+ >
743
+ {t('openPaymentModal')}
744
+ </button>
745
+ </div>
746
+ );
747
+ }
748
+
677
749
  // Provider-hosted page (e.g. Cardcom LowProfile with full merchant
678
750
  // branding) — modal overlay keeps it visually contained.
679
751
  const formattedAmount = formatPrice((Number(paymentIntent.amount) || 0) / 100, {
@@ -702,9 +774,8 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
702
774
  </span>
703
775
  </div>
704
776
  <button
705
- onClick={() => {
706
- window.location.href = `/checkout?checkout_id=${checkoutId}&canceled=true`;
707
- }}
777
+ type="button"
778
+ onClick={closeProviderModal}
708
779
  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
780
  aria-label="Close"
710
781
  >
@@ -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
+ }
@@ -0,0 +1,18 @@
1
+ 'use client';
2
+
3
+ import { useStoreInfo } from '@/core/providers/store-provider';
4
+ import { resolveStoreName } from '@/core/lib/store-name';
5
+
6
+ /**
7
+ * Client twin of `resolveStoreName()` — the one call for "what is this store
8
+ * called?" inside `'use client'` components, exactly as `useCurrency()` is
9
+ * for the currency code. Live `storeInfo.name` wins; the build-time
10
+ * `NEXT_PUBLIC_STORE_NAME` and then the scaffold literal fill in before the
11
+ * provider has resolved. See `store-name.ts` for the chain.
12
+ *
13
+ * @param override pass when the caller already has a more specific name.
14
+ */
15
+ export function useStoreName(override?: string | null): string {
16
+ const { storeInfo } = useStoreInfo();
17
+ return resolveStoreName(storeInfo, override);
18
+ }
@@ -1,27 +1,27 @@
1
- 'use client';
2
-
3
- import { Link } from '@/core/lib/navigation';
4
- import { useTranslations } from '@/core/lib/translations';
5
- import { Button } from '@/components/ui/button';
6
-
7
- /** Homepage hero: welcome headline, subtitle, and shop-now CTA. */
8
- export function HeroSection({ storeName }: { storeName?: string | null }) {
9
- const t = useTranslations('home');
10
- const tc = useTranslations('common');
11
-
12
- return (
13
- <section className="bg-muted">
14
- <div className="mx-auto max-w-7xl px-4 py-20 text-center sm:px-6 lg:px-8">
15
- <h1 className="text-foreground text-4xl font-bold tracking-tight sm:text-5xl">
16
- {t('welcomeTo')} {storeName || tc('store')}
17
- </h1>
18
- <p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
19
- {t('heroSubtitle')}
20
- </p>
21
- <Button asChild size="lg" className="mt-8 rounded px-6 text-base">
22
- <Link href="/products">{tc('shopNow')}</Link>
23
- </Button>
24
- </div>
25
- </section>
26
- );
27
- }
1
+ 'use client';
2
+
3
+ import { Link } from '@/core/lib/navigation';
4
+ import { useTranslations } from '@/core/lib/translations';
5
+ import { Button } from '@/components/ui/button';
6
+
7
+ /** Homepage hero: welcome headline, subtitle, and shop-now CTA. */
8
+ export function HeroSection({ storeName }: { storeName?: string | null }) {
9
+ const t = useTranslations('home');
10
+ const tc = useTranslations('common');
11
+
12
+ return (
13
+ <section className="bg-muted">
14
+ <div className="mx-auto max-w-7xl px-4 py-20 text-center sm:px-6 lg:px-8">
15
+ <h1 className="text-foreground text-4xl font-bold ltr:tracking-tight sm:text-5xl">
16
+ {t('welcomeTo')} {storeName || tc('store')}
17
+ </h1>
18
+ <p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
19
+ {t('heroSubtitle')}
20
+ </p>
21
+ <Button asChild size="lg" className="mt-8 rounded px-6 text-base">
22
+ <Link href="/products">{tc('shopNow')}</Link>
23
+ </Button>
24
+ </div>
25
+ </section>
26
+ );
27
+ }
@@ -205,7 +205,7 @@ export function HeaderSearch({ className }: HeaderSearchProps) {
205
205
  {index === 0 && productCount > 0 && (
206
206
  <li
207
207
  role="presentation"
208
- className="text-muted-foreground px-4 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide"
208
+ className="text-muted-foreground px-4 pb-1 pt-2 text-[11px] font-semibold ltr:uppercase ltr:tracking-wide"
209
209
  >
210
210
  {tp('pageTitle')}
211
211
  </li>
@@ -213,7 +213,7 @@ export function HeaderSearch({ className }: HeaderSearchProps) {
213
213
  {index === productCount && option.kind === 'category' && (
214
214
  <li
215
215
  role="presentation"
216
- className="text-muted-foreground px-4 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide"
216
+ className="text-muted-foreground px-4 pb-1 pt-2 text-[11px] font-semibold ltr:uppercase ltr:tracking-wide"
217
217
  >
218
218
  {t('categories')}
219
219
  </li>