create-brainerce-store 1.52.4 → 1.53.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/LICENSE +21 -0
- package/dist/index.js +4 -2
- package/messages/en.json +2 -0
- package/messages/he.json +2 -0
- package/package.json +1 -1
- package/templates/nextjs/base/next.config.ts +86 -76
- package/templates/nextjs/base/src/app/layout.tsx.ejs +227 -223
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +118 -4
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +10 -1
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +255 -251
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -269
|
@@ -1,223 +1,227 @@
|
|
|
1
|
-
<% if (i18nEnabled) { %>
|
|
2
|
-
import type { Metadata } from 'next';
|
|
3
|
-
<%- fontImport %>
|
|
4
|
-
import { StoreProvider } from '@/core/providers/store-provider';
|
|
5
|
-
import { AnnouncementBar } from '@/ui/layout/announcement-bar';
|
|
6
|
-
import { SiteHeader } from '@/ui/layout/site-header';
|
|
7
|
-
import { SiteFooter } from '@/ui/layout/site-footer';
|
|
8
|
-
import { BrainerceBotWidget } from '@/components/brainerce-bot';
|
|
9
|
-
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce';
|
|
10
|
-
import { getDirection, supportedLocales } from '@/i18n';
|
|
11
|
-
import { getNonce } from '@/core/lib/nonce';
|
|
12
|
-
import '../globals.css';
|
|
13
|
-
|
|
14
|
-
<%- fontVariable %>
|
|
15
|
-
|
|
16
|
-
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
17
|
-
|
|
18
|
-
export const metadata: Metadata = {
|
|
19
|
-
metadataBase: new URL(baseUrl),
|
|
20
|
-
title: {
|
|
21
|
-
default: <%- storeNameJs %>,
|
|
22
|
-
template: <%- titleTemplateJs %>,
|
|
23
|
-
},
|
|
24
|
-
description: <%- storeNameJs %>,
|
|
25
|
-
alternates: {
|
|
26
|
-
canonical: '/',
|
|
27
|
-
},
|
|
28
|
-
openGraph: {
|
|
29
|
-
siteName: <%- storeNameJs %>,
|
|
30
|
-
type: 'website',
|
|
31
|
-
},
|
|
32
|
-
robots: {
|
|
33
|
-
index: true,
|
|
34
|
-
follow: true,
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const organizationJsonLd = {
|
|
39
|
-
'@context': 'https://schema.org',
|
|
40
|
-
'@type': 'Organization',
|
|
41
|
-
name: <%- storeNameJs %>,
|
|
42
|
-
url: baseUrl,
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export function generateStaticParams() {
|
|
46
|
-
return supportedLocales.map((locale) => ({ locale }));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export default async function RootLayout({
|
|
50
|
-
children,
|
|
51
|
-
params,
|
|
52
|
-
}: {
|
|
53
|
-
children: React.ReactNode;
|
|
54
|
-
params: Promise<{ locale: string }>;
|
|
55
|
-
}) {
|
|
56
|
-
const { locale } = await params;
|
|
57
|
-
const dir = getDirection(locale);
|
|
58
|
-
const nonce = await getNonce();
|
|
59
|
-
|
|
60
|
-
// Merchant-driven layout chrome — fetched server-side. Each call falls back
|
|
61
|
-
// to null/[] on 404 so the layout never crashes when the merchant hasn't
|
|
62
|
-
// seeded a particular content type yet. New stores ship with default rows
|
|
63
|
-
// seeded by the backend (StoresService.seedDefaultContent).
|
|
64
|
-
const client = getServerClient(locale);
|
|
65
|
-
const [announcements, siteHeader, siteFooter, storeInfo] = await Promise.all([
|
|
66
|
-
client.content.announcement.list(locale).catch(() => []),
|
|
67
|
-
client.content.header.get('main', locale).catch(() => null),
|
|
68
|
-
client.content.footer.get('main', locale).catch(() => null),
|
|
69
|
-
// SSR-fetch store config so PriceDisplay / FreeShippingBar / upsell UI
|
|
70
|
-
// render with the real currency, feature flags, and i18n config at frame 0
|
|
71
|
-
// — without this, Googlebot sees USD/defaults baked into the HTML.
|
|
72
|
-
fetchStoreInfo(locale),
|
|
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
|
-
import {
|
|
122
|
-
|
|
123
|
-
import {
|
|
124
|
-
import {
|
|
125
|
-
import {
|
|
126
|
-
import '
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
},
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
},
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
1
|
+
<% if (i18nEnabled) { %>
|
|
2
|
+
import type { Metadata } from 'next';
|
|
3
|
+
<%- fontImport %>
|
|
4
|
+
import { StoreProvider } from '@/core/providers/store-provider';
|
|
5
|
+
import { AnnouncementBar } from '@/ui/layout/announcement-bar';
|
|
6
|
+
import { SiteHeader } from '@/ui/layout/site-header';
|
|
7
|
+
import { SiteFooter } from '@/ui/layout/site-footer';
|
|
8
|
+
import { BrainerceBotWidget } from '@/components/brainerce-bot';
|
|
9
|
+
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce';
|
|
10
|
+
import { getDirection, getMessages, supportedLocales } from '@/i18n';
|
|
11
|
+
import { getNonce } from '@/core/lib/nonce';
|
|
12
|
+
import '../globals.css';
|
|
13
|
+
|
|
14
|
+
<%- fontVariable %>
|
|
15
|
+
|
|
16
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
17
|
+
|
|
18
|
+
export const metadata: Metadata = {
|
|
19
|
+
metadataBase: new URL(baseUrl),
|
|
20
|
+
title: {
|
|
21
|
+
default: <%- storeNameJs %>,
|
|
22
|
+
template: <%- titleTemplateJs %>,
|
|
23
|
+
},
|
|
24
|
+
description: <%- storeNameJs %>,
|
|
25
|
+
alternates: {
|
|
26
|
+
canonical: '/',
|
|
27
|
+
},
|
|
28
|
+
openGraph: {
|
|
29
|
+
siteName: <%- storeNameJs %>,
|
|
30
|
+
type: 'website',
|
|
31
|
+
},
|
|
32
|
+
robots: {
|
|
33
|
+
index: true,
|
|
34
|
+
follow: true,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const organizationJsonLd = {
|
|
39
|
+
'@context': 'https://schema.org',
|
|
40
|
+
'@type': 'Organization',
|
|
41
|
+
name: <%- storeNameJs %>,
|
|
42
|
+
url: baseUrl,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function generateStaticParams() {
|
|
46
|
+
return supportedLocales.map((locale) => ({ locale }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default async function RootLayout({
|
|
50
|
+
children,
|
|
51
|
+
params,
|
|
52
|
+
}: {
|
|
53
|
+
children: React.ReactNode;
|
|
54
|
+
params: Promise<{ locale: string }>;
|
|
55
|
+
}) {
|
|
56
|
+
const { locale } = await params;
|
|
57
|
+
const dir = getDirection(locale);
|
|
58
|
+
const nonce = await getNonce();
|
|
59
|
+
|
|
60
|
+
// Merchant-driven layout chrome — fetched server-side. Each call falls back
|
|
61
|
+
// to null/[] on 404 so the layout never crashes when the merchant hasn't
|
|
62
|
+
// seeded a particular content type yet. New stores ship with default rows
|
|
63
|
+
// seeded by the backend (StoresService.seedDefaultContent).
|
|
64
|
+
const client = getServerClient(locale);
|
|
65
|
+
const [announcements, siteHeader, siteFooter, storeInfo, messages] = await Promise.all([
|
|
66
|
+
client.content.announcement.list(locale).catch(() => []),
|
|
67
|
+
client.content.header.get('main', locale).catch(() => null),
|
|
68
|
+
client.content.footer.get('main', locale).catch(() => null),
|
|
69
|
+
// SSR-fetch store config so PriceDisplay / FreeShippingBar / upsell UI
|
|
70
|
+
// render with the real currency, feature flags, and i18n config at frame 0
|
|
71
|
+
// — without this, Googlebot sees USD/defaults baked into the HTML.
|
|
72
|
+
fetchStoreInfo(locale),
|
|
73
|
+
// SSR-fetch UI message strings so translated text renders at frame 0 —
|
|
74
|
+
// without this, every page (and every locale switch) shows raw
|
|
75
|
+
// "namespace.key" strings until the client-only fetch resolves.
|
|
76
|
+
getMessages(locale),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<html lang={locale} dir={dir}>
|
|
81
|
+
<head>
|
|
82
|
+
<script
|
|
83
|
+
type="application/ld+json"
|
|
84
|
+
nonce={nonce}
|
|
85
|
+
suppressHydrationWarning
|
|
86
|
+
dangerouslySetInnerHTML={{
|
|
87
|
+
__html: JSON.stringify(organizationJsonLd)
|
|
88
|
+
.replace(/</g, '\\u003c')
|
|
89
|
+
.replace(/>/g, '\\u003e')
|
|
90
|
+
.replace(/&/g, '\\u0026'),
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
{/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
|
|
94
|
+
SPA route changes. Cookieless (no consent banner needed); the
|
|
95
|
+
merchant can toggle it per sales channel in the dashboard. */}
|
|
96
|
+
<script
|
|
97
|
+
defer
|
|
98
|
+
src={`${process.env.NEXT_PUBLIC_BRAINERCE_PIXEL_URL || 'https://api.brainerce.com'}/t.js`}
|
|
99
|
+
data-channel={
|
|
100
|
+
process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID || '<%= connectionId %>'
|
|
101
|
+
}
|
|
102
|
+
nonce={nonce}
|
|
103
|
+
suppressHydrationWarning
|
|
104
|
+
/>
|
|
105
|
+
</head>
|
|
106
|
+
<body className={font.className}>
|
|
107
|
+
<StoreProvider locale={locale} initialStoreInfo={storeInfo} initialMessages={messages}>
|
|
108
|
+
<div className="min-h-screen flex flex-col">
|
|
109
|
+
<AnnouncementBar announcements={announcements} />
|
|
110
|
+
<SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
|
|
111
|
+
<main className="flex-1">{children}</main>
|
|
112
|
+
<SiteFooter footer={siteFooter} storeName={<%- storeNameJs %>} />
|
|
113
|
+
</div>
|
|
114
|
+
<BrainerceBotWidget />
|
|
115
|
+
</StoreProvider>
|
|
116
|
+
</body>
|
|
117
|
+
</html>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
<% } else { %>
|
|
121
|
+
import type { Metadata } from 'next';
|
|
122
|
+
<%- fontImport %>
|
|
123
|
+
import { StoreProvider } from '@/core/providers/store-provider';
|
|
124
|
+
import { AnnouncementBar } from '@/ui/layout/announcement-bar';
|
|
125
|
+
import { SiteHeader } from '@/ui/layout/site-header';
|
|
126
|
+
import { SiteFooter } from '@/ui/layout/site-footer';
|
|
127
|
+
import { BrainerceBotWidget } from '@/components/brainerce-bot';
|
|
128
|
+
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce';
|
|
129
|
+
import { getNonce } from '@/core/lib/nonce';
|
|
130
|
+
import './globals.css';
|
|
131
|
+
|
|
132
|
+
<%- fontVariable %>
|
|
133
|
+
|
|
134
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
135
|
+
|
|
136
|
+
export const metadata: Metadata = {
|
|
137
|
+
metadataBase: new URL(baseUrl),
|
|
138
|
+
title: {
|
|
139
|
+
default: <%- storeNameJs %>,
|
|
140
|
+
template: <%- titleTemplateJs %>,
|
|
141
|
+
},
|
|
142
|
+
description: <%- storeNameJs %>,
|
|
143
|
+
alternates: {
|
|
144
|
+
canonical: '/',
|
|
145
|
+
},
|
|
146
|
+
openGraph: {
|
|
147
|
+
siteName: <%- storeNameJs %>,
|
|
148
|
+
locale: '<%= ogLocale %>',
|
|
149
|
+
type: 'website',
|
|
150
|
+
},
|
|
151
|
+
robots: {
|
|
152
|
+
index: true,
|
|
153
|
+
follow: true,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const organizationJsonLd = {
|
|
158
|
+
'@context': 'https://schema.org',
|
|
159
|
+
'@type': 'Organization',
|
|
160
|
+
name: <%- storeNameJs %>,
|
|
161
|
+
url: baseUrl,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export default async function RootLayout({
|
|
165
|
+
children,
|
|
166
|
+
}: {
|
|
167
|
+
children: React.ReactNode;
|
|
168
|
+
}) {
|
|
169
|
+
const nonce = await getNonce();
|
|
170
|
+
|
|
171
|
+
// Merchant-driven layout chrome — fetched server-side. Each call falls back
|
|
172
|
+
// to null/[] on 404 so the layout never crashes when the merchant hasn't
|
|
173
|
+
// seeded a particular content type yet. New stores ship with default rows
|
|
174
|
+
// seeded by the backend (StoresService.seedDefaultContent).
|
|
175
|
+
const client = getServerClient();
|
|
176
|
+
const [announcements, siteHeader, siteFooter, storeInfo] = await Promise.all([
|
|
177
|
+
client.content.announcement.list().catch(() => []),
|
|
178
|
+
client.content.header.get('main').catch(() => null),
|
|
179
|
+
client.content.footer.get('main').catch(() => null),
|
|
180
|
+
// SSR-fetch store config so PriceDisplay / FreeShippingBar / upsell UI
|
|
181
|
+
// render with the real currency, feature flags, and i18n config at frame 0
|
|
182
|
+
// — without this, Googlebot sees USD/defaults baked into the HTML.
|
|
183
|
+
fetchStoreInfo(),
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<html lang="<%= language %>" dir="<%= direction %>">
|
|
188
|
+
<head>
|
|
189
|
+
<script
|
|
190
|
+
type="application/ld+json"
|
|
191
|
+
nonce={nonce}
|
|
192
|
+
suppressHydrationWarning
|
|
193
|
+
dangerouslySetInnerHTML={{
|
|
194
|
+
__html: JSON.stringify(organizationJsonLd)
|
|
195
|
+
.replace(/</g, '\\u003c')
|
|
196
|
+
.replace(/>/g, '\\u003e')
|
|
197
|
+
.replace(/&/g, '\\u0026'),
|
|
198
|
+
}}
|
|
199
|
+
/>
|
|
200
|
+
{/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
|
|
201
|
+
SPA route changes. Cookieless (no consent banner needed); the
|
|
202
|
+
merchant can toggle it per sales channel in the dashboard. */}
|
|
203
|
+
<script
|
|
204
|
+
defer
|
|
205
|
+
src={`${process.env.NEXT_PUBLIC_BRAINERCE_PIXEL_URL || 'https://api.brainerce.com'}/t.js`}
|
|
206
|
+
data-channel={
|
|
207
|
+
process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID || '<%= connectionId %>'
|
|
208
|
+
}
|
|
209
|
+
nonce={nonce}
|
|
210
|
+
suppressHydrationWarning
|
|
211
|
+
/>
|
|
212
|
+
</head>
|
|
213
|
+
<body className={font.className}>
|
|
214
|
+
<StoreProvider initialStoreInfo={storeInfo}>
|
|
215
|
+
<div className="min-h-screen flex flex-col">
|
|
216
|
+
<AnnouncementBar announcements={announcements} />
|
|
217
|
+
<SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
|
|
218
|
+
<main className="flex-1">{children}</main>
|
|
219
|
+
<SiteFooter footer={siteFooter} storeName={<%- storeNameJs %>} />
|
|
220
|
+
</div>
|
|
221
|
+
<BrainerceBotWidget />
|
|
222
|
+
</StoreProvider>
|
|
223
|
+
</body>
|
|
224
|
+
</html>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
<% } %>
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useRef } from 'react';
|
|
4
|
-
import type { SetShippingAddressDto, ShippingDestinations } from 'brainerce';
|
|
4
|
+
import type { SetShippingAddressDto, ShippingDestinations, AddressSuggestion } from 'brainerce';
|
|
5
5
|
import { useTranslations } from '@/core/lib/translations';
|
|
6
6
|
import { cn } from '@/core/lib/utils';
|
|
7
7
|
import { isValidEmail } from '@/core/lib/validation';
|
|
8
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
9
|
+
|
|
10
|
+
// Debounce address-suggestion calls rather than firing on every keystroke —
|
|
11
|
+
// both to avoid a jumpy dropdown and to keep autocomplete-session call volume
|
|
12
|
+
// reasonable (see `getAddressSuggestions` doc in the SDK).
|
|
13
|
+
const ADDRESS_SEARCH_DEBOUNCE_MS = 300;
|
|
14
|
+
const ADDRESS_SEARCH_MIN_CHARS = 3;
|
|
8
15
|
|
|
9
16
|
interface CheckoutConsent {
|
|
10
17
|
acceptsMarketing: boolean;
|
|
@@ -51,6 +58,84 @@ export function CheckoutForm({
|
|
|
51
58
|
const tc = useTranslations('common');
|
|
52
59
|
const hasAppliedPrefill = useRef(!!initialValues);
|
|
53
60
|
|
|
61
|
+
// Address-autocomplete state — groups one address-entry attempt for
|
|
62
|
+
// session-token billing (see `getAddressSuggestions` in the SDK). A fresh
|
|
63
|
+
// token is minted whenever a suggestion is picked, ending that session.
|
|
64
|
+
const [addressSuggestions, setAddressSuggestions] = useState<AddressSuggestion[]>([]);
|
|
65
|
+
const [showAddressSuggestions, setShowAddressSuggestions] = useState(false);
|
|
66
|
+
const [isSearchingAddress, setIsSearchingAddress] = useState(false);
|
|
67
|
+
const [outsideDeliveryZone, setOutsideDeliveryZone] = useState(false);
|
|
68
|
+
const addressSessionToken = useRef(
|
|
69
|
+
typeof crypto !== 'undefined' ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`
|
|
70
|
+
);
|
|
71
|
+
const addressSearchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
return () => {
|
|
75
|
+
if (addressSearchTimer.current) clearTimeout(addressSearchTimer.current);
|
|
76
|
+
};
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
function handleLine1Change(value: string) {
|
|
80
|
+
updateField('line1', value);
|
|
81
|
+
setOutsideDeliveryZone(false);
|
|
82
|
+
|
|
83
|
+
if (addressSearchTimer.current) clearTimeout(addressSearchTimer.current);
|
|
84
|
+
|
|
85
|
+
const query = value.trim();
|
|
86
|
+
if (query.length < ADDRESS_SEARCH_MIN_CHARS) {
|
|
87
|
+
setAddressSuggestions([]);
|
|
88
|
+
setShowAddressSuggestions(false);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
addressSearchTimer.current = setTimeout(async () => {
|
|
93
|
+
setIsSearchingAddress(true);
|
|
94
|
+
try {
|
|
95
|
+
const results = await getClient().getAddressSuggestions(
|
|
96
|
+
query,
|
|
97
|
+
addressSessionToken.current
|
|
98
|
+
);
|
|
99
|
+
setAddressSuggestions(results);
|
|
100
|
+
setShowAddressSuggestions(results.length > 0);
|
|
101
|
+
} catch {
|
|
102
|
+
// Autocomplete is a soft enhancement — a failed lookup just means no
|
|
103
|
+
// suggestions this keystroke; the shopper can keep typing manually.
|
|
104
|
+
setAddressSuggestions([]);
|
|
105
|
+
setShowAddressSuggestions(false);
|
|
106
|
+
} finally {
|
|
107
|
+
setIsSearchingAddress(false);
|
|
108
|
+
}
|
|
109
|
+
}, ADDRESS_SEARCH_DEBOUNCE_MS);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function handleSelectAddressSuggestion(suggestion: AddressSuggestion) {
|
|
113
|
+
setShowAddressSuggestions(false);
|
|
114
|
+
try {
|
|
115
|
+
const { address, inZone } = await getClient().getAddressDetails(
|
|
116
|
+
suggestion.placeId,
|
|
117
|
+
addressSessionToken.current
|
|
118
|
+
);
|
|
119
|
+
setFormData((prev) => ({
|
|
120
|
+
...prev,
|
|
121
|
+
line1: address.line1 || prev.line1,
|
|
122
|
+
city: address.city || prev.city,
|
|
123
|
+
region: address.region || prev.region,
|
|
124
|
+
postalCode: address.postalCode || prev.postalCode,
|
|
125
|
+
country: address.country || prev.country,
|
|
126
|
+
}));
|
|
127
|
+
setOutsideDeliveryZone(!inZone);
|
|
128
|
+
} catch {
|
|
129
|
+
// Resolution failed — keep whatever the shopper had typed/selected as
|
|
130
|
+
// the visible text; they can still fill in the rest manually.
|
|
131
|
+
} finally {
|
|
132
|
+
// New session for the next address-entry attempt.
|
|
133
|
+
addressSessionToken.current =
|
|
134
|
+
typeof crypto !== 'undefined' ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
|
135
|
+
setAddressSuggestions([]);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
54
139
|
// Sync prefill data when it arrives async (e.g. from getCheckoutPrefillData)
|
|
55
140
|
useEffect(() => {
|
|
56
141
|
if (!initialValues || hasAppliedPrefill.current) return;
|
|
@@ -261,20 +346,49 @@ export function CheckoutForm({
|
|
|
261
346
|
</div>
|
|
262
347
|
</div>
|
|
263
348
|
|
|
264
|
-
{/* Address line 1 */}
|
|
265
|
-
<div>
|
|
349
|
+
{/* Address line 1 — autocomplete typeahead */}
|
|
350
|
+
<div className="relative">
|
|
266
351
|
<label htmlFor="line1" className="text-foreground mb-1 block text-sm font-medium">
|
|
267
352
|
{t('address')} <span className="text-destructive">*</span>
|
|
268
353
|
</label>
|
|
269
354
|
<input
|
|
270
355
|
id="line1"
|
|
271
356
|
type="text"
|
|
357
|
+
autoComplete="off"
|
|
272
358
|
value={formData.line1}
|
|
273
|
-
onChange={(e) =>
|
|
359
|
+
onChange={(e) => handleLine1Change(e.target.value)}
|
|
360
|
+
onFocus={() => setShowAddressSuggestions(addressSuggestions.length > 0)}
|
|
361
|
+
onBlur={() => setTimeout(() => setShowAddressSuggestions(false), 150)}
|
|
274
362
|
className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
|
|
275
363
|
placeholder={t('streetAddress')}
|
|
276
364
|
/>
|
|
277
365
|
{errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
|
|
366
|
+
|
|
367
|
+
{showAddressSuggestions && addressSuggestions.length > 0 && (
|
|
368
|
+
<ul className="bg-background border-border absolute z-10 mt-1 max-h-60 w-full overflow-y-auto rounded border shadow-lg">
|
|
369
|
+
{addressSuggestions.map((suggestion) => (
|
|
370
|
+
<li key={suggestion.placeId}>
|
|
371
|
+
<button
|
|
372
|
+
type="button"
|
|
373
|
+
// onMouseDown fires before the input's onBlur, so the
|
|
374
|
+
// click registers before the dropdown closes.
|
|
375
|
+
onMouseDown={() => handleSelectAddressSuggestion(suggestion)}
|
|
376
|
+
className="hover:bg-muted w-full px-3 py-2 text-start text-sm"
|
|
377
|
+
>
|
|
378
|
+
{suggestion.description}
|
|
379
|
+
</button>
|
|
380
|
+
</li>
|
|
381
|
+
))}
|
|
382
|
+
</ul>
|
|
383
|
+
)}
|
|
384
|
+
{isSearchingAddress && (
|
|
385
|
+
<p className="text-muted-foreground mt-1 text-xs">{t('searchingAddress')}</p>
|
|
386
|
+
)}
|
|
387
|
+
{outsideDeliveryZone && (
|
|
388
|
+
<p className="mt-2 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
|
|
389
|
+
{t('outsideDeliveryZone')}
|
|
390
|
+
</p>
|
|
391
|
+
)}
|
|
278
392
|
</div>
|
|
279
393
|
|
|
280
394
|
{/* Address line 2 */}
|
|
@@ -74,10 +74,12 @@ export function StoreProvider({
|
|
|
74
74
|
children,
|
|
75
75
|
locale,
|
|
76
76
|
initialStoreInfo = null,
|
|
77
|
+
initialMessages,
|
|
77
78
|
}: {
|
|
78
79
|
children: React.ReactNode;
|
|
79
80
|
locale?: string;
|
|
80
81
|
initialStoreInfo?: PublicStoreInfo | null;
|
|
82
|
+
initialMessages?: Record<string, Record<string, string>>;
|
|
81
83
|
}) {
|
|
82
84
|
<% } else { %>
|
|
83
85
|
export function StoreProvider({
|
|
@@ -99,7 +101,14 @@ export function StoreProvider({
|
|
|
99
101
|
const [cart, setCart] = useState<Cart | null>(null);
|
|
100
102
|
const [cartLoading, setCartLoading] = useState(true);
|
|
101
103
|
<% if (i18nEnabled) { %>
|
|
102
|
-
|
|
104
|
+
// Seed from server-rendered messages (see RootLayout) so translated text
|
|
105
|
+
// renders at frame 0 instead of a window of raw "namespace.key" strings
|
|
106
|
+
// before the client-only fetch in the effect below resolves — this also
|
|
107
|
+
// closes the same gap on every locale switch, since RootLayout re-runs
|
|
108
|
+
// server-side per locale and StoreProvider remounts with fresh initial data.
|
|
109
|
+
const [messages, setMessages] = useState<Record<string, Record<string, string>>>(
|
|
110
|
+
initialMessages ?? {}
|
|
111
|
+
);
|
|
103
112
|
<% } %>
|
|
104
113
|
|
|
105
114
|
// Check auth status via httpOnly cookie (server-side validation)
|