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
|
@@ -21,8 +21,9 @@ export default function ForgotPasswordPage() {
|
|
|
21
21
|
setLoading(true);
|
|
22
22
|
setError(null);
|
|
23
23
|
const client = getClient();
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// The reset link's host is chosen by the server (the channel's domain),
|
|
25
|
+
// never by this page; the email lands on /reset-password?token=...
|
|
26
|
+
await client.forgotPassword(email);
|
|
26
27
|
setSent(true);
|
|
27
28
|
} catch (err) {
|
|
28
29
|
const message =
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
|
2
|
+
<rect width="32" height="32" rx="7" fill="#71717a" />
|
|
3
|
+
<path
|
|
4
|
+
d="M11 13V10.5a5 5 0 0 1 10 0V13M8.5 13h15l-1.1 12.1a2 2 0 0 1-2 1.9h-8.8a2 2 0 0 1-2-1.9L8.5 13Z"
|
|
5
|
+
fill="none"
|
|
6
|
+
stroke="#fafafa"
|
|
7
|
+
stroke-width="2"
|
|
8
|
+
stroke-linecap="round"
|
|
9
|
+
stroke-linejoin="round"
|
|
10
|
+
/>
|
|
11
|
+
</svg>
|
|
@@ -13,6 +13,8 @@ import { resolveRegion } from '@/core/lib/region.server';
|
|
|
13
13
|
import { getDirection, getMessages, supportedLocales } from '@/i18n';
|
|
14
14
|
import { getNonce } from '@/core/lib/nonce';
|
|
15
15
|
import { getCanonicalSiteUrl } from '@/core/lib/site-url';
|
|
16
|
+
import { resolveStoreName, buildTitleTemplate } from '@/core/lib/store-name';
|
|
17
|
+
import { buildMetaDescription } from '@/core/lib/seo';
|
|
16
18
|
import '../globals.css';
|
|
17
19
|
|
|
18
20
|
<%- fontVariable %>
|
|
@@ -20,20 +22,32 @@ import '../globals.css';
|
|
|
20
22
|
// Resolved per request rather than read from a hardcoded env default: the
|
|
21
23
|
// scaffolder cannot know the deploy domain, so `metadataBase` is the one
|
|
22
24
|
// value that must never be guessed. See core/lib/site-url.ts.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
//
|
|
26
|
+
// The store NAME is resolved the same way, and for the same reason: under
|
|
27
|
+
// `--defer-connection` the scaffolder only knew the directory name. Live
|
|
28
|
+
// `storeInfo.name` wins (`fetchStoreInfo` is `cache()`-wrapped, so this shares
|
|
29
|
+
// the layout's fetch), then the build-time NEXT_PUBLIC_STORE_NAME, then the
|
|
30
|
+
// scaffold literal — see core/lib/store-name.ts.
|
|
31
|
+
export async function generateMetadata({
|
|
32
|
+
params,
|
|
33
|
+
}: {
|
|
34
|
+
params: Promise<{ locale: string }>;
|
|
35
|
+
}): Promise<Metadata> {
|
|
36
|
+
const { locale } = await params;
|
|
37
|
+
const [baseUrl, storeInfo] = await Promise.all([getCanonicalSiteUrl(), fetchStoreInfo(locale)]);
|
|
38
|
+
const storeName = resolveStoreName(storeInfo);
|
|
25
39
|
return {
|
|
26
40
|
metadataBase: new URL(baseUrl),
|
|
27
41
|
title: {
|
|
28
|
-
default:
|
|
29
|
-
template:
|
|
42
|
+
default: storeName,
|
|
43
|
+
template: buildTitleTemplate(storeName),
|
|
30
44
|
},
|
|
31
|
-
description:
|
|
45
|
+
description: buildMetaDescription(storeInfo?.metaDescription) || storeName,
|
|
32
46
|
alternates: {
|
|
33
47
|
canonical: '/',
|
|
34
48
|
},
|
|
35
49
|
openGraph: {
|
|
36
|
-
siteName:
|
|
50
|
+
siteName: storeName,
|
|
37
51
|
type: 'website',
|
|
38
52
|
},
|
|
39
53
|
robots: {
|
|
@@ -43,11 +57,11 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
43
57
|
};
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
function buildOrganizationJsonLd(baseUrl: string) {
|
|
60
|
+
function buildOrganizationJsonLd(baseUrl: string, storeName: string) {
|
|
47
61
|
return {
|
|
48
62
|
'@context': 'https://schema.org',
|
|
49
63
|
'@type': 'Organization',
|
|
50
|
-
name:
|
|
64
|
+
name: storeName,
|
|
51
65
|
url: baseUrl,
|
|
52
66
|
};
|
|
53
67
|
}
|
|
@@ -99,6 +113,9 @@ export default async function RootLayout({
|
|
|
99
113
|
// `regionId`, which is what this storefront did before regions existed.
|
|
100
114
|
resolveRegion(),
|
|
101
115
|
]);
|
|
116
|
+
// One resolved name for the JSON-LD, the header and the footer. Live data
|
|
117
|
+
// first; the scaffold-time literal is only the last fallback.
|
|
118
|
+
const storeName = resolveStoreName(storeInfo);
|
|
102
119
|
|
|
103
120
|
return (
|
|
104
121
|
<html lang={locale} dir={dir}>
|
|
@@ -108,7 +125,7 @@ export default async function RootLayout({
|
|
|
108
125
|
nonce={nonce}
|
|
109
126
|
suppressHydrationWarning
|
|
110
127
|
dangerouslySetInnerHTML={{
|
|
111
|
-
__html: JSON.stringify(buildOrganizationJsonLd(baseUrl))
|
|
128
|
+
__html: JSON.stringify(buildOrganizationJsonLd(baseUrl, storeName))
|
|
112
129
|
.replace(/</g, '\\u003c')
|
|
113
130
|
.replace(/>/g, '\\u003e')
|
|
114
131
|
.replace(/&/g, '\\u0026'),
|
|
@@ -152,10 +169,10 @@ export default async function RootLayout({
|
|
|
152
169
|
<TrackingBootstrap />
|
|
153
170
|
<div className="min-h-screen flex flex-col">
|
|
154
171
|
<AnnouncementBar announcements={announcements} />
|
|
155
|
-
<SiteHeader header={siteHeader} storeName={
|
|
172
|
+
<SiteHeader header={siteHeader} storeName={storeName} />
|
|
156
173
|
<DiscountBannerStrip banners={discountBanners} />
|
|
157
174
|
<main className="flex-1">{children}</main>
|
|
158
|
-
<SiteFooter footer={siteFooter} storeName={
|
|
175
|
+
<SiteFooter footer={siteFooter} storeName={storeName} />
|
|
159
176
|
</div>
|
|
160
177
|
<BrainerceBotWidget />
|
|
161
178
|
</StoreProvider>
|
|
@@ -177,6 +194,8 @@ import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
|
|
|
177
194
|
import { resolveRegion } from '@/core/lib/region.server';
|
|
178
195
|
import { getNonce } from '@/core/lib/nonce';
|
|
179
196
|
import { getCanonicalSiteUrl } from '@/core/lib/site-url';
|
|
197
|
+
import { resolveStoreName, buildTitleTemplate } from '@/core/lib/store-name';
|
|
198
|
+
import { buildMetaDescription } from '@/core/lib/seo';
|
|
180
199
|
import './globals.css';
|
|
181
200
|
|
|
182
201
|
<%- fontVariable %>
|
|
@@ -184,20 +203,27 @@ import './globals.css';
|
|
|
184
203
|
// Resolved per request rather than read from a hardcoded env default: the
|
|
185
204
|
// scaffolder cannot know the deploy domain, so `metadataBase` is the one
|
|
186
205
|
// value that must never be guessed. See core/lib/site-url.ts.
|
|
206
|
+
//
|
|
207
|
+
// The store NAME is resolved the same way, and for the same reason: under
|
|
208
|
+
// `--defer-connection` the scaffolder only knew the directory name. Live
|
|
209
|
+
// `storeInfo.name` wins (`fetchStoreInfo` is `cache()`-wrapped, so this shares
|
|
210
|
+
// the layout's fetch), then the build-time NEXT_PUBLIC_STORE_NAME, then the
|
|
211
|
+
// scaffold literal — see core/lib/store-name.ts.
|
|
187
212
|
export async function generateMetadata(): Promise<Metadata> {
|
|
188
|
-
const baseUrl = await getCanonicalSiteUrl();
|
|
213
|
+
const [baseUrl, storeInfo] = await Promise.all([getCanonicalSiteUrl(), fetchStoreInfo()]);
|
|
214
|
+
const storeName = resolveStoreName(storeInfo);
|
|
189
215
|
return {
|
|
190
216
|
metadataBase: new URL(baseUrl),
|
|
191
217
|
title: {
|
|
192
|
-
default:
|
|
193
|
-
template:
|
|
218
|
+
default: storeName,
|
|
219
|
+
template: buildTitleTemplate(storeName),
|
|
194
220
|
},
|
|
195
|
-
description:
|
|
221
|
+
description: buildMetaDescription(storeInfo?.metaDescription) || storeName,
|
|
196
222
|
alternates: {
|
|
197
223
|
canonical: '/',
|
|
198
224
|
},
|
|
199
225
|
openGraph: {
|
|
200
|
-
siteName:
|
|
226
|
+
siteName: storeName,
|
|
201
227
|
locale: '<%= ogLocale %>',
|
|
202
228
|
type: 'website',
|
|
203
229
|
},
|
|
@@ -208,11 +234,11 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
208
234
|
};
|
|
209
235
|
}
|
|
210
236
|
|
|
211
|
-
function buildOrganizationJsonLd(baseUrl: string) {
|
|
237
|
+
function buildOrganizationJsonLd(baseUrl: string, storeName: string) {
|
|
212
238
|
return {
|
|
213
239
|
'@context': 'https://schema.org',
|
|
214
240
|
'@type': 'Organization',
|
|
215
|
-
name:
|
|
241
|
+
name: storeName,
|
|
216
242
|
url: baseUrl,
|
|
217
243
|
};
|
|
218
244
|
}
|
|
@@ -252,6 +278,9 @@ export default async function RootLayout({
|
|
|
252
278
|
// `regionId`, which is what this storefront did before regions existed.
|
|
253
279
|
resolveRegion(),
|
|
254
280
|
]);
|
|
281
|
+
// One resolved name for the JSON-LD, the header and the footer. Live data
|
|
282
|
+
// first; the scaffold-time literal is only the last fallback.
|
|
283
|
+
const storeName = resolveStoreName(storeInfo);
|
|
255
284
|
|
|
256
285
|
return (
|
|
257
286
|
<html lang="<%= language %>" dir="<%= direction %>">
|
|
@@ -261,7 +290,7 @@ export default async function RootLayout({
|
|
|
261
290
|
nonce={nonce}
|
|
262
291
|
suppressHydrationWarning
|
|
263
292
|
dangerouslySetInnerHTML={{
|
|
264
|
-
__html: JSON.stringify(buildOrganizationJsonLd(baseUrl))
|
|
293
|
+
__html: JSON.stringify(buildOrganizationJsonLd(baseUrl, storeName))
|
|
265
294
|
.replace(/</g, '\\u003c')
|
|
266
295
|
.replace(/>/g, '\\u003e')
|
|
267
296
|
.replace(/&/g, '\\u0026'),
|
|
@@ -303,10 +332,10 @@ export default async function RootLayout({
|
|
|
303
332
|
<TrackingBootstrap />
|
|
304
333
|
<div className="min-h-screen flex flex-col">
|
|
305
334
|
<AnnouncementBar announcements={announcements} />
|
|
306
|
-
<SiteHeader header={siteHeader} storeName={
|
|
335
|
+
<SiteHeader header={siteHeader} storeName={storeName} />
|
|
307
336
|
<DiscountBannerStrip banners={discountBanners} />
|
|
308
337
|
<main className="flex-1">{children}</main>
|
|
309
|
-
<SiteFooter footer={siteFooter} storeName={
|
|
338
|
+
<SiteFooter footer={siteFooter} storeName={storeName} />
|
|
310
339
|
</div>
|
|
311
340
|
<BrainerceBotWidget />
|
|
312
341
|
</StoreProvider>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ImageResponse } from 'next/og';
|
|
2
2
|
import { fetchStoreInfo } from '@/core/lib/brainerce.server';
|
|
3
|
+
import { resolveStoreName } from '@/core/lib/store-name';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Default Open Graph card for every page that doesn't set its own og:image
|
|
@@ -41,7 +42,10 @@ const LATIN_ONLY = /^[ -ɏ\s\p{P}\p{N}]*$/u;
|
|
|
41
42
|
|
|
42
43
|
export default async function OpenGraphImage() {
|
|
43
44
|
const info = await fetchStoreInfo();
|
|
44
|
-
|
|
45
|
+
// Live name first, then the build-time NEXT_PUBLIC_STORE_NAME, then the
|
|
46
|
+
// scaffold literal — the card must never read a stale baked name while the
|
|
47
|
+
// <title> next to it reads the live one.
|
|
48
|
+
const name = resolveStoreName(info);
|
|
45
49
|
const description = info?.metaDescription?.trim() || '';
|
|
46
50
|
const logo = info?.logo || null;
|
|
47
51
|
|
|
@@ -1,138 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
setError(t(pwCode));
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
if (newPassword !== confirmPassword) {
|
|
31
|
-
setError(t('passwordsMustMatch'));
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
setLoading(true);
|
|
37
|
-
setError(null);
|
|
38
|
-
await proxyResetPassword(newPassword);
|
|
39
|
-
setSuccess(true);
|
|
40
|
-
setTimeout(() => router.push('/login'), 2000);
|
|
41
|
-
} catch (err) {
|
|
42
|
-
const message =
|
|
43
|
-
err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
|
44
|
-
setError(message);
|
|
45
|
-
} finally {
|
|
46
|
-
setLoading(false);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return (
|
|
51
|
-
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
52
|
-
<div className="w-full max-w-md space-y-6">
|
|
53
|
-
<div className="text-center">
|
|
54
|
-
<h1 className="text-foreground text-2xl font-bold">{t('resetPasswordTitle')}</h1>
|
|
55
|
-
<p className="text-muted-foreground mt-1 text-sm">{t('resetPasswordSubtitle')}</p>
|
|
56
|
-
</div>
|
|
57
|
-
|
|
58
|
-
{error && (
|
|
59
|
-
<div className="bg-destructive/10 border-destructive/20 text-destructive space-y-2 rounded-lg border px-4 py-3 text-sm">
|
|
60
|
-
<p>{error}</p>
|
|
61
|
-
<Link
|
|
62
|
-
href="/forgot-password"
|
|
63
|
-
className="text-primary block font-medium hover:underline"
|
|
64
|
-
>
|
|
65
|
-
{t('sendResetLink')}
|
|
66
|
-
</Link>
|
|
67
|
-
</div>
|
|
68
|
-
)}
|
|
69
|
-
|
|
70
|
-
{success ? (
|
|
71
|
-
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300">
|
|
72
|
-
{t('passwordResetSuccess')}
|
|
73
|
-
</div>
|
|
74
|
-
) : (
|
|
75
|
-
<form onSubmit={handleSubmit} className="space-y-4">
|
|
76
|
-
<div>
|
|
77
|
-
<label
|
|
78
|
-
htmlFor="new-password"
|
|
79
|
-
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
80
|
-
>
|
|
81
|
-
{t('newPassword')}
|
|
82
|
-
</label>
|
|
83
|
-
<input
|
|
84
|
-
id="new-password"
|
|
85
|
-
type="password"
|
|
86
|
-
required
|
|
87
|
-
minLength={8}
|
|
88
|
-
value={newPassword}
|
|
89
|
-
onChange={(e) => setNewPassword(e.target.value)}
|
|
90
|
-
placeholder={t('newPasswordPlaceholder')}
|
|
91
|
-
autoComplete="new-password"
|
|
92
|
-
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2"
|
|
93
|
-
/>
|
|
94
|
-
</div>
|
|
95
|
-
|
|
96
|
-
<div>
|
|
97
|
-
<label
|
|
98
|
-
htmlFor="confirm-password"
|
|
99
|
-
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
100
|
-
>
|
|
101
|
-
{t('confirmPassword')}
|
|
102
|
-
</label>
|
|
103
|
-
<input
|
|
104
|
-
id="confirm-password"
|
|
105
|
-
type="password"
|
|
106
|
-
required
|
|
107
|
-
minLength={8}
|
|
108
|
-
value={confirmPassword}
|
|
109
|
-
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
110
|
-
placeholder={t('confirmPasswordPlaceholder')}
|
|
111
|
-
autoComplete="new-password"
|
|
112
|
-
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2"
|
|
113
|
-
/>
|
|
114
|
-
</div>
|
|
115
|
-
|
|
116
|
-
<button
|
|
117
|
-
type="submit"
|
|
118
|
-
disabled={loading}
|
|
119
|
-
className="bg-primary text-primary-foreground flex h-10 w-full items-center justify-center gap-2 rounded text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
120
|
-
>
|
|
121
|
-
{loading ? (
|
|
122
|
-
<>
|
|
123
|
-
<LoadingSpinner
|
|
124
|
-
size="sm"
|
|
125
|
-
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
126
|
-
/>
|
|
127
|
-
{t('resettingPassword')}
|
|
128
|
-
</>
|
|
129
|
-
) : (
|
|
130
|
-
t('resetPassword')
|
|
131
|
-
)}
|
|
132
|
-
</button>
|
|
133
|
-
</form>
|
|
134
|
-
)}
|
|
135
|
-
</div>
|
|
136
|
-
</div>
|
|
137
|
-
);
|
|
138
|
-
}
|
|
1
|
+
import { redirect } from 'next/navigation';
|
|
2
|
+
import { ResetPasswordForm } from './reset-password-form';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The email Brainerce sends links to `<store domain>/reset-password?token=...`.
|
|
6
|
+
* That token must never reach client JavaScript, so this server component hands
|
|
7
|
+
* it to `/api/auth/reset-callback`, which stores it in an httpOnly cookie and
|
|
8
|
+
* returns here with a clean URL. The form then posts the new password through
|
|
9
|
+
* `/api/auth/reset-password`, which reads the cookie server-side.
|
|
10
|
+
*
|
|
11
|
+
* Without this hop the page rendered with no cookie and every reset failed with
|
|
12
|
+
* "No reset token found", because the callback route was only ever reached by a
|
|
13
|
+
* `resetUrl` option the SDK does not send.
|
|
14
|
+
*/
|
|
15
|
+
export default async function ResetPasswordPage({
|
|
16
|
+
searchParams,
|
|
17
|
+
}: {
|
|
18
|
+
searchParams: Promise<{ token?: string }>;
|
|
19
|
+
}) {
|
|
20
|
+
const { token } = await searchParams;
|
|
21
|
+
if (token) {
|
|
22
|
+
redirect(`/api/auth/reset-callback?token=${encodeURIComponent(token)}`);
|
|
23
|
+
}
|
|
24
|
+
return <ResetPasswordForm />;
|
|
25
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useRouter, Link } from '@/core/lib/navigation';
|
|
5
|
+
import { proxyResetPassword } from '@/core/lib/auth';
|
|
6
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
7
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
8
|
+
import { getPasswordError } from '@/core/lib/validation';
|
|
9
|
+
|
|
10
|
+
export function ResetPasswordForm() {
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
const t = useTranslations('auth');
|
|
13
|
+
|
|
14
|
+
const [newPassword, setNewPassword] = useState('');
|
|
15
|
+
const [confirmPassword, setConfirmPassword] = useState('');
|
|
16
|
+
const [loading, setLoading] = useState(false);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const [success, setSuccess] = useState(false);
|
|
19
|
+
|
|
20
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
if (loading) return;
|
|
23
|
+
|
|
24
|
+
const pwCode = getPasswordError(newPassword);
|
|
25
|
+
if (pwCode) {
|
|
26
|
+
setError(t(pwCode));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (newPassword !== confirmPassword) {
|
|
31
|
+
setError(t('passwordsMustMatch'));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
setLoading(true);
|
|
37
|
+
setError(null);
|
|
38
|
+
await proxyResetPassword(newPassword);
|
|
39
|
+
setSuccess(true);
|
|
40
|
+
setTimeout(() => router.push('/login'), 2000);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
const message =
|
|
43
|
+
err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
|
44
|
+
setError(message);
|
|
45
|
+
} finally {
|
|
46
|
+
setLoading(false);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
52
|
+
<div className="w-full max-w-md space-y-6">
|
|
53
|
+
<div className="text-center">
|
|
54
|
+
<h1 className="text-foreground text-2xl font-bold">{t('resetPasswordTitle')}</h1>
|
|
55
|
+
<p className="text-muted-foreground mt-1 text-sm">{t('resetPasswordSubtitle')}</p>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{error && (
|
|
59
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive space-y-2 rounded-lg border px-4 py-3 text-sm">
|
|
60
|
+
<p>{error}</p>
|
|
61
|
+
<Link
|
|
62
|
+
href="/forgot-password"
|
|
63
|
+
className="text-primary block font-medium hover:underline"
|
|
64
|
+
>
|
|
65
|
+
{t('sendResetLink')}
|
|
66
|
+
</Link>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
{success ? (
|
|
71
|
+
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300">
|
|
72
|
+
{t('passwordResetSuccess')}
|
|
73
|
+
</div>
|
|
74
|
+
) : (
|
|
75
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
76
|
+
<div>
|
|
77
|
+
<label
|
|
78
|
+
htmlFor="new-password"
|
|
79
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
80
|
+
>
|
|
81
|
+
{t('newPassword')}
|
|
82
|
+
</label>
|
|
83
|
+
<input
|
|
84
|
+
id="new-password"
|
|
85
|
+
type="password"
|
|
86
|
+
required
|
|
87
|
+
minLength={8}
|
|
88
|
+
value={newPassword}
|
|
89
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
90
|
+
placeholder={t('newPasswordPlaceholder')}
|
|
91
|
+
autoComplete="new-password"
|
|
92
|
+
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2"
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div>
|
|
97
|
+
<label
|
|
98
|
+
htmlFor="confirm-password"
|
|
99
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
100
|
+
>
|
|
101
|
+
{t('confirmPassword')}
|
|
102
|
+
</label>
|
|
103
|
+
<input
|
|
104
|
+
id="confirm-password"
|
|
105
|
+
type="password"
|
|
106
|
+
required
|
|
107
|
+
minLength={8}
|
|
108
|
+
value={confirmPassword}
|
|
109
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
110
|
+
placeholder={t('confirmPasswordPlaceholder')}
|
|
111
|
+
autoComplete="new-password"
|
|
112
|
+
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<button
|
|
117
|
+
type="submit"
|
|
118
|
+
disabled={loading}
|
|
119
|
+
className="bg-primary text-primary-foreground flex h-10 w-full items-center justify-center gap-2 rounded text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
120
|
+
>
|
|
121
|
+
{loading ? (
|
|
122
|
+
<>
|
|
123
|
+
<LoadingSpinner
|
|
124
|
+
size="sm"
|
|
125
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
126
|
+
/>
|
|
127
|
+
{t('resettingPassword')}
|
|
128
|
+
</>
|
|
129
|
+
) : (
|
|
130
|
+
t('resetPassword')
|
|
131
|
+
)}
|
|
132
|
+
</button>
|
|
133
|
+
</form>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -29,7 +29,7 @@ const FINANCIAL_STATUS_COLOR: Record<string, string> = {
|
|
|
29
29
|
|
|
30
30
|
export function OrderPaymentBlock({ order, className }: OrderPaymentBlockProps) {
|
|
31
31
|
const t = useTranslations('account');
|
|
32
|
-
if (!order.paymentMethod && !order.financialStatus) return null;
|
|
32
|
+
if (!order.paymentMethod && !order.financialStatus && !order.invoiceUrl) return null;
|
|
33
33
|
|
|
34
34
|
const statusKey = order.financialStatus ? FINANCIAL_STATUS_KEY[order.financialStatus] : null;
|
|
35
35
|
const statusLabel = statusKey ? t(statusKey) : order.financialStatus || '';
|
|
@@ -38,7 +38,7 @@ export function OrderPaymentBlock({ order, className }: OrderPaymentBlockProps)
|
|
|
38
38
|
: '';
|
|
39
39
|
|
|
40
40
|
return (
|
|
41
|
-
<div className={cn('border-border flex items-center gap-3 border-t pt-2 text-xs', className)}>
|
|
41
|
+
<div className={cn('border-border flex flex-wrap items-center gap-3 border-t pt-2 text-xs', className)}>
|
|
42
42
|
<p className="text-foreground text-sm font-medium">{t('paymentMethod')}</p>
|
|
43
43
|
{order.paymentMethod && (
|
|
44
44
|
<span className="text-foreground capitalize">{order.paymentMethod.replace(/_/g, ' ')}</span>
|
|
@@ -53,6 +53,21 @@ export function OrderPaymentBlock({ order, className }: OrderPaymentBlockProps)
|
|
|
53
53
|
{statusLabel}
|
|
54
54
|
</span>
|
|
55
55
|
)}
|
|
56
|
+
{/* `invoiceUrl` is null until the connected payment provider (e.g.
|
|
57
|
+
Cardcom) generates the document, and stays null when the store has
|
|
58
|
+
no invoicing-capable provider connected — render nothing then,
|
|
59
|
+
never a broken link or a retry loop. */}
|
|
60
|
+
{order.invoiceUrl && (
|
|
61
|
+
<a
|
|
62
|
+
href={order.invoiceUrl}
|
|
63
|
+
target="_blank"
|
|
64
|
+
rel="noopener noreferrer"
|
|
65
|
+
className="text-primary text-sm font-medium hover:underline"
|
|
66
|
+
>
|
|
67
|
+
{t('downloadInvoice')}
|
|
68
|
+
{order.invoiceNumber ? ` (#${order.invoiceNumber})` : ''}
|
|
69
|
+
</a>
|
|
70
|
+
)}
|
|
56
71
|
</div>
|
|
57
72
|
);
|
|
58
73
|
}
|