create-brainerce-store 1.5.7 → 1.6.1
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/dist/index.js +1 -1
- package/messages/en.json +19 -1
- package/messages/he.json +19 -1
- package/package.json +1 -1
- package/templates/nextjs/base/next.config.ts +31 -9
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +111 -0
- package/templates/nextjs/base/src/app/reset-password/page.tsx +161 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +278 -276
- package/templates/nextjs/base/src/components/account/profile-section.tsx +10 -8
- package/templates/nextjs/base/src/components/auth/login-form.tsx +7 -0
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +28 -6
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "create-brainerce-store",
|
|
34
|
-
version: "1.
|
|
34
|
+
version: "1.6.1",
|
|
35
35
|
description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
36
36
|
bin: {
|
|
37
37
|
"create-brainerce-store": "dist/index.js"
|
package/messages/en.json
CHANGED
|
@@ -212,7 +212,25 @@
|
|
|
212
212
|
"authFailed": "Authentication failed",
|
|
213
213
|
"authFailedDesc": "Missing authentication parameters. Please try again.",
|
|
214
214
|
"backToLogin": "Back to Login",
|
|
215
|
-
"completingSignIn": "Completing sign in..."
|
|
215
|
+
"completingSignIn": "Completing sign in...",
|
|
216
|
+
"forgotPassword": "Forgot password?",
|
|
217
|
+
"forgotPasswordTitle": "Reset your password",
|
|
218
|
+
"forgotPasswordSubtitle": "Enter your email and we'll send you a reset link.",
|
|
219
|
+
"sendResetLink": "Send Reset Link",
|
|
220
|
+
"sendingResetLink": "Sending...",
|
|
221
|
+
"resetLinkSent": "If an account exists with this email, you'll receive a password reset link shortly.",
|
|
222
|
+
"resetPasswordTitle": "Set new password",
|
|
223
|
+
"resetPasswordSubtitle": "Enter your new password below.",
|
|
224
|
+
"newPassword": "New Password",
|
|
225
|
+
"newPasswordPlaceholder": "Enter new password",
|
|
226
|
+
"confirmPassword": "Confirm Password",
|
|
227
|
+
"confirmPasswordPlaceholder": "Re-enter new password",
|
|
228
|
+
"resetPassword": "Reset Password",
|
|
229
|
+
"resettingPassword": "Resetting...",
|
|
230
|
+
"passwordResetSuccess": "Password reset successfully! Redirecting to login...",
|
|
231
|
+
"passwordsMustMatch": "Passwords must match",
|
|
232
|
+
"invalidResetLink": "Invalid reset link",
|
|
233
|
+
"invalidResetLinkDesc": "This password reset link is invalid or has expired. Please request a new one."
|
|
216
234
|
},
|
|
217
235
|
"account": {
|
|
218
236
|
"pageTitle": "My Account",
|
package/messages/he.json
CHANGED
|
@@ -212,7 +212,25 @@
|
|
|
212
212
|
"authFailed": "ההתחברות נכשלה",
|
|
213
213
|
"authFailedDesc": "חסרים פרמטרי התחברות. אנא נסו שוב.",
|
|
214
214
|
"backToLogin": "חזרה להתחברות",
|
|
215
|
-
"completingSignIn": "...משלים התחברות"
|
|
215
|
+
"completingSignIn": "...משלים התחברות",
|
|
216
|
+
"forgotPassword": "שכחת סיסמא?",
|
|
217
|
+
"forgotPasswordTitle": "איפוס סיסמא",
|
|
218
|
+
"forgotPasswordSubtitle": "הזינו את האימייל שלכם ונשלח לכם קישור לאיפוס.",
|
|
219
|
+
"sendResetLink": "שליחת קישור איפוס",
|
|
220
|
+
"sendingResetLink": "...שולח",
|
|
221
|
+
"resetLinkSent": "אם קיים חשבון עם האימייל הזה, תקבלו קישור לאיפוס סיסמא בקרוב.",
|
|
222
|
+
"resetPasswordTitle": "הגדרת סיסמא חדשה",
|
|
223
|
+
"resetPasswordSubtitle": "הזינו את הסיסמא החדשה שלכם.",
|
|
224
|
+
"newPassword": "סיסמא חדשה",
|
|
225
|
+
"newPasswordPlaceholder": "הזינו סיסמא חדשה",
|
|
226
|
+
"confirmPassword": "אישור סיסמא",
|
|
227
|
+
"confirmPasswordPlaceholder": "הזינו שוב את הסיסמא",
|
|
228
|
+
"resetPassword": "איפוס סיסמא",
|
|
229
|
+
"resettingPassword": "...מאפס",
|
|
230
|
+
"passwordResetSuccess": "...הסיסמא אופסה בהצלחה! מעביר להתחברות",
|
|
231
|
+
"passwordsMustMatch": "הסיסמאות חייבות להיות זהות",
|
|
232
|
+
"invalidResetLink": "קישור איפוס לא תקין",
|
|
233
|
+
"invalidResetLinkDesc": "קישור האיפוס הזה אינו תקין או שפג תוקפו. בקשו קישור חדש."
|
|
216
234
|
},
|
|
217
235
|
"account": {
|
|
218
236
|
"pageTitle": "החשבון שלי",
|
package/package.json
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
|
-
import type { NextConfig } from 'next';
|
|
2
|
-
|
|
3
|
-
const nextConfig: NextConfig = {
|
|
4
|
-
images: {
|
|
5
|
-
remotePatterns: [{ protocol: 'https', hostname: '**' }],
|
|
6
|
-
},
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import type { NextConfig } from 'next';
|
|
2
|
+
|
|
3
|
+
const nextConfig: NextConfig = {
|
|
4
|
+
images: {
|
|
5
|
+
remotePatterns: [{ protocol: 'https', hostname: '**' }],
|
|
6
|
+
},
|
|
7
|
+
async headers() {
|
|
8
|
+
return [
|
|
9
|
+
{
|
|
10
|
+
source: '/(.*)',
|
|
11
|
+
headers: [
|
|
12
|
+
{
|
|
13
|
+
key: 'Content-Security-Policy',
|
|
14
|
+
value: [
|
|
15
|
+
"default-src 'self'",
|
|
16
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.meshulam.co.il https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://js.stripe.com https://pay.google.com",
|
|
17
|
+
"style-src 'self' 'unsafe-inline'",
|
|
18
|
+
"img-src 'self' data: blob: https:",
|
|
19
|
+
"font-src 'self' data:",
|
|
20
|
+
"frame-src 'self' https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com",
|
|
21
|
+
"connect-src 'self' https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://google.com https://pay.google.com https://*.stripe.com https://*.creditguard.co.il",
|
|
22
|
+
"worker-src 'self' blob:",
|
|
23
|
+
].join('; '),
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default nextConfig;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { getClient } from '@/lib/brainerce';
|
|
6
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
7
|
+
import { useTranslations } from '@/lib/translations';
|
|
8
|
+
|
|
9
|
+
export default function ForgotPasswordPage() {
|
|
10
|
+
const t = useTranslations('auth');
|
|
11
|
+
const [email, setEmail] = useState('');
|
|
12
|
+
const [loading, setLoading] = useState(false);
|
|
13
|
+
const [sent, setSent] = useState(false);
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
17
|
+
e.preventDefault();
|
|
18
|
+
if (loading) return;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
setLoading(true);
|
|
22
|
+
setError(null);
|
|
23
|
+
const client = getClient();
|
|
24
|
+
await client.forgotPassword(email);
|
|
25
|
+
setSent(true);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
const message =
|
|
28
|
+
err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
|
29
|
+
setError(message);
|
|
30
|
+
} finally {
|
|
31
|
+
setLoading(false);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
37
|
+
<div className="w-full max-w-md space-y-6">
|
|
38
|
+
<div className="text-center">
|
|
39
|
+
<h1 className="text-foreground text-2xl font-bold">{t('forgotPasswordTitle')}</h1>
|
|
40
|
+
<p className="text-muted-foreground mt-1 text-sm">{t('forgotPasswordSubtitle')}</p>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
{error && (
|
|
44
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm">
|
|
45
|
+
{error}
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
|
|
49
|
+
{sent ? (
|
|
50
|
+
<div className="space-y-4">
|
|
51
|
+
<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">
|
|
52
|
+
{t('resetLinkSent')}
|
|
53
|
+
</div>
|
|
54
|
+
<Link
|
|
55
|
+
href="/login"
|
|
56
|
+
className="text-primary block text-center text-sm font-medium hover:underline"
|
|
57
|
+
>
|
|
58
|
+
{t('backToLogin')}
|
|
59
|
+
</Link>
|
|
60
|
+
</div>
|
|
61
|
+
) : (
|
|
62
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
63
|
+
<div>
|
|
64
|
+
<label
|
|
65
|
+
htmlFor="forgot-email"
|
|
66
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
67
|
+
>
|
|
68
|
+
{t('email')}
|
|
69
|
+
</label>
|
|
70
|
+
<input
|
|
71
|
+
id="forgot-email"
|
|
72
|
+
type="email"
|
|
73
|
+
required
|
|
74
|
+
value={email}
|
|
75
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
76
|
+
placeholder={t('emailPlaceholder')}
|
|
77
|
+
autoComplete="email"
|
|
78
|
+
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"
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<button
|
|
83
|
+
type="submit"
|
|
84
|
+
disabled={loading}
|
|
85
|
+
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"
|
|
86
|
+
>
|
|
87
|
+
{loading ? (
|
|
88
|
+
<>
|
|
89
|
+
<LoadingSpinner
|
|
90
|
+
size="sm"
|
|
91
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
92
|
+
/>
|
|
93
|
+
{t('sendingResetLink')}
|
|
94
|
+
</>
|
|
95
|
+
) : (
|
|
96
|
+
t('sendResetLink')
|
|
97
|
+
)}
|
|
98
|
+
</button>
|
|
99
|
+
|
|
100
|
+
<Link
|
|
101
|
+
href="/login"
|
|
102
|
+
className="text-muted-foreground block text-center text-sm hover:underline"
|
|
103
|
+
>
|
|
104
|
+
{t('backToLogin')}
|
|
105
|
+
</Link>
|
|
106
|
+
</form>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Suspense, useState } from 'react';
|
|
4
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { getClient } from '@/lib/brainerce';
|
|
7
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
8
|
+
import { useTranslations } from '@/lib/translations';
|
|
9
|
+
|
|
10
|
+
function ResetPasswordContent() {
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
const searchParams = useSearchParams();
|
|
13
|
+
const t = useTranslations('auth');
|
|
14
|
+
|
|
15
|
+
const token = searchParams.get('token');
|
|
16
|
+
|
|
17
|
+
const [newPassword, setNewPassword] = useState('');
|
|
18
|
+
const [confirmPassword, setConfirmPassword] = useState('');
|
|
19
|
+
const [loading, setLoading] = useState(false);
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
const [success, setSuccess] = useState(false);
|
|
22
|
+
|
|
23
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
if (loading || !token) return;
|
|
26
|
+
|
|
27
|
+
if (newPassword !== confirmPassword) {
|
|
28
|
+
setError(t('passwordsMustMatch'));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
setLoading(true);
|
|
34
|
+
setError(null);
|
|
35
|
+
const client = getClient();
|
|
36
|
+
await client.resetPassword(token, newPassword);
|
|
37
|
+
setSuccess(true);
|
|
38
|
+
setTimeout(() => router.push('/login'), 2000);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
const message =
|
|
41
|
+
err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
|
42
|
+
setError(message);
|
|
43
|
+
} finally {
|
|
44
|
+
setLoading(false);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!token) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
51
|
+
<div className="w-full max-w-md space-y-4 text-center">
|
|
52
|
+
<h1 className="text-foreground text-2xl font-bold">{t('invalidResetLink')}</h1>
|
|
53
|
+
<p className="text-muted-foreground text-sm">{t('invalidResetLinkDesc')}</p>
|
|
54
|
+
<Link
|
|
55
|
+
href="/forgot-password"
|
|
56
|
+
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
57
|
+
>
|
|
58
|
+
{t('sendResetLink')}
|
|
59
|
+
</Link>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
67
|
+
<div className="w-full max-w-md space-y-6">
|
|
68
|
+
<div className="text-center">
|
|
69
|
+
<h1 className="text-foreground text-2xl font-bold">{t('resetPasswordTitle')}</h1>
|
|
70
|
+
<p className="text-muted-foreground mt-1 text-sm">{t('resetPasswordSubtitle')}</p>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{error && (
|
|
74
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm">
|
|
75
|
+
{error}
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{success ? (
|
|
80
|
+
<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">
|
|
81
|
+
{t('passwordResetSuccess')}
|
|
82
|
+
</div>
|
|
83
|
+
) : (
|
|
84
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
85
|
+
<div>
|
|
86
|
+
<label
|
|
87
|
+
htmlFor="new-password"
|
|
88
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
89
|
+
>
|
|
90
|
+
{t('newPassword')}
|
|
91
|
+
</label>
|
|
92
|
+
<input
|
|
93
|
+
id="new-password"
|
|
94
|
+
type="password"
|
|
95
|
+
required
|
|
96
|
+
minLength={8}
|
|
97
|
+
value={newPassword}
|
|
98
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
99
|
+
placeholder={t('newPasswordPlaceholder')}
|
|
100
|
+
autoComplete="new-password"
|
|
101
|
+
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"
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div>
|
|
106
|
+
<label
|
|
107
|
+
htmlFor="confirm-password"
|
|
108
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
109
|
+
>
|
|
110
|
+
{t('confirmPassword')}
|
|
111
|
+
</label>
|
|
112
|
+
<input
|
|
113
|
+
id="confirm-password"
|
|
114
|
+
type="password"
|
|
115
|
+
required
|
|
116
|
+
minLength={8}
|
|
117
|
+
value={confirmPassword}
|
|
118
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
119
|
+
placeholder={t('confirmPasswordPlaceholder')}
|
|
120
|
+
autoComplete="new-password"
|
|
121
|
+
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"
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<button
|
|
126
|
+
type="submit"
|
|
127
|
+
disabled={loading}
|
|
128
|
+
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"
|
|
129
|
+
>
|
|
130
|
+
{loading ? (
|
|
131
|
+
<>
|
|
132
|
+
<LoadingSpinner
|
|
133
|
+
size="sm"
|
|
134
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
135
|
+
/>
|
|
136
|
+
{t('resettingPassword')}
|
|
137
|
+
</>
|
|
138
|
+
) : (
|
|
139
|
+
t('resetPassword')
|
|
140
|
+
)}
|
|
141
|
+
</button>
|
|
142
|
+
</form>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default function ResetPasswordPage() {
|
|
150
|
+
return (
|
|
151
|
+
<Suspense
|
|
152
|
+
fallback={
|
|
153
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
154
|
+
<LoadingSpinner size="lg" />
|
|
155
|
+
</div>
|
|
156
|
+
}
|
|
157
|
+
>
|
|
158
|
+
<ResetPasswordContent />
|
|
159
|
+
</Suspense>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -1,276 +1,278 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import Image from 'next/image';
|
|
5
|
-
import type { Order, OrderStatus } from 'brainerce';
|
|
6
|
-
import { formatPrice } from 'brainerce';
|
|
7
|
-
import { useTranslations } from '@/lib/translations';
|
|
8
|
-
import { cn } from '@/lib/utils';
|
|
9
|
-
|
|
10
|
-
const STATUS_CONFIG: Record<OrderStatus, { labelKey: string; className: string }> = {
|
|
11
|
-
pending: {
|
|
12
|
-
labelKey: 'statusPending',
|
|
13
|
-
className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950/30 dark:text-yellow-400',
|
|
14
|
-
},
|
|
15
|
-
processing: {
|
|
16
|
-
labelKey: 'statusProcessing',
|
|
17
|
-
className: 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-400',
|
|
18
|
-
},
|
|
19
|
-
shipped: {
|
|
20
|
-
labelKey: 'statusShipped',
|
|
21
|
-
className: 'bg-purple-100 text-purple-800 dark:bg-purple-950/30 dark:text-purple-400',
|
|
22
|
-
},
|
|
23
|
-
delivered: {
|
|
24
|
-
labelKey: 'statusDelivered',
|
|
25
|
-
className: 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400',
|
|
26
|
-
},
|
|
27
|
-
cancelled: {
|
|
28
|
-
labelKey: 'statusCancelled',
|
|
29
|
-
className: 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-400',
|
|
30
|
-
},
|
|
31
|
-
refunded: {
|
|
32
|
-
labelKey: 'statusRefunded',
|
|
33
|
-
className: 'bg-orange-100 text-orange-800 dark:bg-orange-950/30 dark:text-orange-400',
|
|
34
|
-
},
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
interface OrderHistoryProps {
|
|
38
|
-
orders: Order[];
|
|
39
|
-
className?: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function OrderHistory({ orders, className }: OrderHistoryProps) {
|
|
43
|
-
const t = useTranslations('account');
|
|
44
|
-
if (orders.length === 0) {
|
|
45
|
-
return (
|
|
46
|
-
<div className={cn('py-12 text-center', className)}>
|
|
47
|
-
<svg
|
|
48
|
-
className="text-muted-foreground mx-auto mb-3 h-12 w-12"
|
|
49
|
-
fill="none"
|
|
50
|
-
viewBox="0 0 24 24"
|
|
51
|
-
stroke="currentColor"
|
|
52
|
-
>
|
|
53
|
-
<path
|
|
54
|
-
strokeLinecap="round"
|
|
55
|
-
strokeLinejoin="round"
|
|
56
|
-
strokeWidth={1.5}
|
|
57
|
-
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
|
58
|
-
/>
|
|
59
|
-
</svg>
|
|
60
|
-
<h3 className="text-foreground text-lg font-semibold">{t('noOrders')}</h3>
|
|
61
|
-
<p className="text-muted-foreground mt-1 text-sm">{t('noOrdersDesc')}</p>
|
|
62
|
-
</div>
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return (
|
|
67
|
-
<div className={cn('space-y-4', className)}>
|
|
68
|
-
{orders.map((order) => (
|
|
69
|
-
<OrderCard key={order.id} order={order} />
|
|
70
|
-
))}
|
|
71
|
-
</div>
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function OrderCard({ order }: { order: Order }) {
|
|
76
|
-
const t = useTranslations('account');
|
|
77
|
-
const tc = useTranslations('common');
|
|
78
|
-
const [expanded, setExpanded] = useState(false);
|
|
79
|
-
const statusConfig = STATUS_CONFIG[order.status] || STATUS_CONFIG.pending;
|
|
80
|
-
const currency = order.currency || 'USD';
|
|
81
|
-
const totalAmount = order.totalAmount || order.total || '0';
|
|
82
|
-
|
|
83
|
-
return (
|
|
84
|
-
<div className="border-border overflow-hidden rounded-lg border">
|
|
85
|
-
{/* Order header */}
|
|
86
|
-
<button
|
|
87
|
-
type="button"
|
|
88
|
-
onClick={() => setExpanded(!expanded)}
|
|
89
|
-
className="hover:bg-muted/50 flex w-full items-center justify-between p-4 text-start transition-colors"
|
|
90
|
-
>
|
|
91
|
-
<div className="min-w-0 flex-1">
|
|
92
|
-
<div className="flex flex-wrap items-center gap-3">
|
|
93
|
-
<span className="text-foreground text-sm font-semibold">
|
|
94
|
-
{order.orderNumber || `${t('orderPrefix')} ${order.id.slice(0, 8)}`}
|
|
95
|
-
</span>
|
|
96
|
-
<span
|
|
97
|
-
className={cn(
|
|
98
|
-
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
|
99
|
-
statusConfig.className
|
|
100
|
-
)}
|
|
101
|
-
>
|
|
102
|
-
{t(
|
|
103
|
-
statusConfig.labelKey as
|
|
104
|
-
| 'statusPending'
|
|
105
|
-
| 'statusProcessing'
|
|
106
|
-
| 'statusShipped'
|
|
107
|
-
| 'statusDelivered'
|
|
108
|
-
| 'statusCancelled'
|
|
109
|
-
| 'statusRefunded'
|
|
110
|
-
)}
|
|
111
|
-
</span>
|
|
112
|
-
</div>
|
|
113
|
-
<div className="text-muted-foreground mt-1 flex items-center gap-4 text-xs">
|
|
114
|
-
<span>
|
|
115
|
-
{new Date(order.createdAt).
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
const
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import Image from 'next/image';
|
|
5
|
+
import type { Order, OrderStatus } from 'brainerce';
|
|
6
|
+
import { formatPrice } from 'brainerce';
|
|
7
|
+
import { useTranslations } from '@/lib/translations';
|
|
8
|
+
import { cn } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
const STATUS_CONFIG: Record<OrderStatus, { labelKey: string; className: string }> = {
|
|
11
|
+
pending: {
|
|
12
|
+
labelKey: 'statusPending',
|
|
13
|
+
className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950/30 dark:text-yellow-400',
|
|
14
|
+
},
|
|
15
|
+
processing: {
|
|
16
|
+
labelKey: 'statusProcessing',
|
|
17
|
+
className: 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-400',
|
|
18
|
+
},
|
|
19
|
+
shipped: {
|
|
20
|
+
labelKey: 'statusShipped',
|
|
21
|
+
className: 'bg-purple-100 text-purple-800 dark:bg-purple-950/30 dark:text-purple-400',
|
|
22
|
+
},
|
|
23
|
+
delivered: {
|
|
24
|
+
labelKey: 'statusDelivered',
|
|
25
|
+
className: 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400',
|
|
26
|
+
},
|
|
27
|
+
cancelled: {
|
|
28
|
+
labelKey: 'statusCancelled',
|
|
29
|
+
className: 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-400',
|
|
30
|
+
},
|
|
31
|
+
refunded: {
|
|
32
|
+
labelKey: 'statusRefunded',
|
|
33
|
+
className: 'bg-orange-100 text-orange-800 dark:bg-orange-950/30 dark:text-orange-400',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
interface OrderHistoryProps {
|
|
38
|
+
orders: Order[];
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function OrderHistory({ orders, className }: OrderHistoryProps) {
|
|
43
|
+
const t = useTranslations('account');
|
|
44
|
+
if (orders.length === 0) {
|
|
45
|
+
return (
|
|
46
|
+
<div className={cn('py-12 text-center', className)}>
|
|
47
|
+
<svg
|
|
48
|
+
className="text-muted-foreground mx-auto mb-3 h-12 w-12"
|
|
49
|
+
fill="none"
|
|
50
|
+
viewBox="0 0 24 24"
|
|
51
|
+
stroke="currentColor"
|
|
52
|
+
>
|
|
53
|
+
<path
|
|
54
|
+
strokeLinecap="round"
|
|
55
|
+
strokeLinejoin="round"
|
|
56
|
+
strokeWidth={1.5}
|
|
57
|
+
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
|
58
|
+
/>
|
|
59
|
+
</svg>
|
|
60
|
+
<h3 className="text-foreground text-lg font-semibold">{t('noOrders')}</h3>
|
|
61
|
+
<p className="text-muted-foreground mt-1 text-sm">{t('noOrdersDesc')}</p>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className={cn('space-y-4', className)}>
|
|
68
|
+
{orders.map((order) => (
|
|
69
|
+
<OrderCard key={order.id} order={order} />
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function OrderCard({ order }: { order: Order }) {
|
|
76
|
+
const t = useTranslations('account');
|
|
77
|
+
const tc = useTranslations('common');
|
|
78
|
+
const [expanded, setExpanded] = useState(false);
|
|
79
|
+
const statusConfig = STATUS_CONFIG[order.status] || STATUS_CONFIG.pending;
|
|
80
|
+
const currency = order.currency || 'USD';
|
|
81
|
+
const totalAmount = order.totalAmount || order.total || '0';
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="border-border overflow-hidden rounded-lg border">
|
|
85
|
+
{/* Order header */}
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={() => setExpanded(!expanded)}
|
|
89
|
+
className="hover:bg-muted/50 flex w-full items-center justify-between p-4 text-start transition-colors"
|
|
90
|
+
>
|
|
91
|
+
<div className="min-w-0 flex-1">
|
|
92
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
93
|
+
<span className="text-foreground text-sm font-semibold">
|
|
94
|
+
{order.orderNumber || `${t('orderPrefix')} ${order.id.slice(0, 8)}`}
|
|
95
|
+
</span>
|
|
96
|
+
<span
|
|
97
|
+
className={cn(
|
|
98
|
+
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
|
99
|
+
statusConfig.className
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
102
|
+
{t(
|
|
103
|
+
statusConfig.labelKey as
|
|
104
|
+
| 'statusPending'
|
|
105
|
+
| 'statusProcessing'
|
|
106
|
+
| 'statusShipped'
|
|
107
|
+
| 'statusDelivered'
|
|
108
|
+
| 'statusCancelled'
|
|
109
|
+
| 'statusRefunded'
|
|
110
|
+
)}
|
|
111
|
+
</span>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="text-muted-foreground mt-1 flex items-center gap-4 text-xs">
|
|
114
|
+
<span>
|
|
115
|
+
{order.createdAt && !isNaN(new Date(order.createdAt).getTime())
|
|
116
|
+
? new Date(order.createdAt).toLocaleDateString(undefined, {
|
|
117
|
+
year: 'numeric',
|
|
118
|
+
month: 'short',
|
|
119
|
+
day: 'numeric',
|
|
120
|
+
})
|
|
121
|
+
: '—'}
|
|
122
|
+
</span>
|
|
123
|
+
<span>
|
|
124
|
+
{order.items.length} {order.items.length === 1 ? tc('item') : tc('items')}
|
|
125
|
+
</span>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div className="flex flex-shrink-0 items-center gap-3">
|
|
130
|
+
<span className="text-foreground text-sm font-semibold">
|
|
131
|
+
{formatPrice(parseFloat(totalAmount), { currency }) as string}
|
|
132
|
+
</span>
|
|
133
|
+
<svg
|
|
134
|
+
className={cn(
|
|
135
|
+
'text-muted-foreground h-4 w-4 transition-transform',
|
|
136
|
+
expanded && 'rotate-180'
|
|
137
|
+
)}
|
|
138
|
+
fill="none"
|
|
139
|
+
viewBox="0 0 24 24"
|
|
140
|
+
stroke="currentColor"
|
|
141
|
+
>
|
|
142
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
143
|
+
</svg>
|
|
144
|
+
</div>
|
|
145
|
+
</button>
|
|
146
|
+
|
|
147
|
+
{/* Expanded order items */}
|
|
148
|
+
{expanded && (
|
|
149
|
+
<div className="border-border bg-muted/30 space-y-3 border-t px-4 py-3">
|
|
150
|
+
{order.items.map((item, index) => (
|
|
151
|
+
<div key={`${item.productId}-${index}`} className="flex items-center gap-3">
|
|
152
|
+
<div className="bg-muted relative h-10 w-10 flex-shrink-0 overflow-hidden rounded">
|
|
153
|
+
{item.image ? (
|
|
154
|
+
<Image
|
|
155
|
+
src={item.image}
|
|
156
|
+
alt={item.name || t('productFallback')}
|
|
157
|
+
fill
|
|
158
|
+
sizes="40px"
|
|
159
|
+
className="object-cover"
|
|
160
|
+
/>
|
|
161
|
+
) : (
|
|
162
|
+
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
163
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
164
|
+
<path
|
|
165
|
+
strokeLinecap="round"
|
|
166
|
+
strokeLinejoin="round"
|
|
167
|
+
strokeWidth={1.5}
|
|
168
|
+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
169
|
+
/>
|
|
170
|
+
</svg>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<div className="min-w-0 flex-1">
|
|
176
|
+
<p className="text-foreground truncate text-sm">
|
|
177
|
+
{item.name || t('productFallback')}
|
|
178
|
+
</p>
|
|
179
|
+
<p className="text-muted-foreground text-xs">
|
|
180
|
+
{tc('qty')} {item.quantity}
|
|
181
|
+
</p>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<span className="text-foreground flex-shrink-0 text-sm">
|
|
185
|
+
{formatPrice(parseFloat(item.price), { currency }) as string}
|
|
186
|
+
</span>
|
|
187
|
+
</div>
|
|
188
|
+
))}
|
|
189
|
+
|
|
190
|
+
<OrderFinancialSummary order={order} currency={currency} />
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function OrderFinancialSummary({ order, currency }: { order: Order; currency: string }) {
|
|
198
|
+
const tc = useTranslations('common');
|
|
199
|
+
const totalAmount = order.totalAmount || order.total || '0';
|
|
200
|
+
const subtotal = order.subtotal ? parseFloat(order.subtotal) : null;
|
|
201
|
+
const ruleAmt = order.ruleDiscountAmount ? parseFloat(order.ruleDiscountAmount) : 0;
|
|
202
|
+
const couponAmt = order.couponDiscount ? parseFloat(order.couponDiscount) : 0;
|
|
203
|
+
const shipping = order.shippingAmount ? parseFloat(order.shippingAmount) : 0;
|
|
204
|
+
const tax = order.taxAmount ? parseFloat(order.taxAmount) : 0;
|
|
205
|
+
const rules = order.appliedDiscounts;
|
|
206
|
+
|
|
207
|
+
const hasBreakdown = subtotal !== null && subtotal > 0;
|
|
208
|
+
|
|
209
|
+
if (!hasBreakdown) {
|
|
210
|
+
return (
|
|
211
|
+
<div className="border-border flex items-center justify-between border-t pt-2">
|
|
212
|
+
<span className="text-muted-foreground text-sm font-medium">{tc('total')}</span>
|
|
213
|
+
<span className="text-foreground text-sm font-semibold">
|
|
214
|
+
{formatPrice(parseFloat(totalAmount), { currency }) as string}
|
|
215
|
+
</span>
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div className="border-border space-y-1 border-t pt-2 text-sm">
|
|
222
|
+
<div className="flex items-center justify-between">
|
|
223
|
+
<span className="text-muted-foreground">{tc('subtotal')}</span>
|
|
224
|
+
<span className="text-foreground">{formatPrice(subtotal, { currency }) as string}</span>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{rules && rules.length > 0
|
|
228
|
+
? rules.map((rule) => (
|
|
229
|
+
<div key={rule.ruleId} className="flex items-center justify-between">
|
|
230
|
+
<span className="text-muted-foreground">{rule.ruleName}</span>
|
|
231
|
+
<span className="text-destructive">
|
|
232
|
+
-{formatPrice(parseFloat(rule.discountAmount || '0'), { currency }) as string}
|
|
233
|
+
</span>
|
|
234
|
+
</div>
|
|
235
|
+
))
|
|
236
|
+
: ruleAmt > 0 && (
|
|
237
|
+
<div className="flex items-center justify-between">
|
|
238
|
+
<span className="text-muted-foreground">{tc('generalDiscount')}</span>
|
|
239
|
+
<span className="text-destructive">
|
|
240
|
+
-{formatPrice(ruleAmt, { currency }) as string}
|
|
241
|
+
</span>
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
|
|
245
|
+
{order.couponCode && couponAmt > 0 && (
|
|
246
|
+
<div className="flex items-center justify-between">
|
|
247
|
+
<span className="text-muted-foreground">
|
|
248
|
+
{tc('couponDiscount')} ({order.couponCode})
|
|
249
|
+
</span>
|
|
250
|
+
<span className="text-destructive">
|
|
251
|
+
-{formatPrice(couponAmt, { currency }) as string}
|
|
252
|
+
</span>
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
{shipping > 0 && (
|
|
257
|
+
<div className="flex items-center justify-between">
|
|
258
|
+
<span className="text-muted-foreground">{tc('shipping')}</span>
|
|
259
|
+
<span className="text-foreground">{formatPrice(shipping, { currency }) as string}</span>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{tax > 0 && (
|
|
264
|
+
<div className="flex items-center justify-between">
|
|
265
|
+
<span className="text-muted-foreground">{tc('tax')}</span>
|
|
266
|
+
<span className="text-foreground">{formatPrice(tax, { currency }) as string}</span>
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
|
|
270
|
+
<div className="border-border flex items-center justify-between border-t pt-1">
|
|
271
|
+
<span className="text-foreground font-medium">{tc('total')}</span>
|
|
272
|
+
<span className="text-foreground font-semibold">
|
|
273
|
+
{formatPrice(parseFloat(totalAmount), { currency }) as string}
|
|
274
|
+
</span>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
@@ -193,14 +193,16 @@ export function ProfileSection({ profile, onProfileUpdate, className }: ProfileS
|
|
|
193
193
|
<p className="text-muted-foreground mt-2 text-sm">{profile.phone}</p>
|
|
194
194
|
)}
|
|
195
195
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
196
|
+
{profile.createdAt && !isNaN(new Date(profile.createdAt).getTime()) && (
|
|
197
|
+
<p className="text-muted-foreground mt-3 text-xs">
|
|
198
|
+
{t('memberSince')}{' '}
|
|
199
|
+
{new Date(profile.createdAt).toLocaleDateString(undefined, {
|
|
200
|
+
year: 'numeric',
|
|
201
|
+
month: 'long',
|
|
202
|
+
day: 'numeric',
|
|
203
|
+
})}
|
|
204
|
+
</p>
|
|
205
|
+
)}
|
|
204
206
|
</>
|
|
205
207
|
)}
|
|
206
208
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
4
5
|
import { useTranslations } from '@/lib/translations';
|
|
5
6
|
import { cn } from '@/lib/utils';
|
|
6
7
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
@@ -72,6 +73,12 @@ export function LoginForm({ onSubmit, error, className }: LoginFormProps) {
|
|
|
72
73
|
/>
|
|
73
74
|
</div>
|
|
74
75
|
|
|
76
|
+
<div className="flex justify-end">
|
|
77
|
+
<Link href="/forgot-password" className="text-primary text-sm hover:underline">
|
|
78
|
+
{t('forgotPassword')}
|
|
79
|
+
</Link>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
75
82
|
<button
|
|
76
83
|
type="submit"
|
|
77
84
|
disabled={loading}
|
|
@@ -120,9 +120,21 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
120
120
|
}, []);
|
|
121
121
|
|
|
122
122
|
const handleSdkPaymentError = useCallback((response: unknown) => {
|
|
123
|
+
const TRANSIENT_SDK_ERRORS = [
|
|
124
|
+
'Wallet not initialized',
|
|
125
|
+
"SDK was not loaded as needed and therefore can't run",
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const msg = (response as { message?: string })?.message || '';
|
|
129
|
+
// Grow SDK fires transient errors during startup before its internal
|
|
130
|
+
// state is ready. The polling mechanism in Step 3 retries
|
|
131
|
+
// renderPaymentOptions() until the SDK is fully initialized.
|
|
132
|
+
if (TRANSIENT_SDK_ERRORS.some((e) => msg.includes(e))) {
|
|
133
|
+
console.info('Payment SDK: transient startup error, waiting for retry...', msg);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
123
136
|
console.error('Payment SDK error:', response);
|
|
124
|
-
|
|
125
|
-
setError(msg);
|
|
137
|
+
setError(msg || t('paymentError'));
|
|
126
138
|
}, []);
|
|
127
139
|
|
|
128
140
|
// Step 1: Load SDK scripts — just load, don't init yet
|
|
@@ -214,6 +226,7 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
214
226
|
: paymentIntent.clientSecret;
|
|
215
227
|
|
|
216
228
|
let rendered = false;
|
|
229
|
+
let walletReady = false;
|
|
217
230
|
let pollId: ReturnType<typeof setInterval>;
|
|
218
231
|
|
|
219
232
|
function tryRenderPaymentOptions() {
|
|
@@ -239,20 +252,29 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
239
252
|
onError: handleSdkPaymentError,
|
|
240
253
|
onWalletChange: (state: string) => {
|
|
241
254
|
console.info('Payment SDK wallet state:', state);
|
|
255
|
+
walletReady = true;
|
|
242
256
|
tryRenderPaymentOptions();
|
|
243
257
|
},
|
|
244
258
|
},
|
|
245
259
|
});
|
|
246
260
|
|
|
247
|
-
// Poll as fallback
|
|
261
|
+
// Poll as fallback — but respect the SDK's readiness signal:
|
|
262
|
+
// - First 4s: only render if onWalletChange has fired (SDK says it's ready)
|
|
263
|
+
// - After 4s: force-attempt even without wallet signal (onWalletChange may not fire)
|
|
264
|
+
const WALLET_GRACE_PERIOD = 4000;
|
|
265
|
+
const initTime = Date.now();
|
|
266
|
+
|
|
248
267
|
const timeoutId = setTimeout(() => {
|
|
249
|
-
tryRenderPaymentOptions();
|
|
268
|
+
if (walletReady) tryRenderPaymentOptions();
|
|
250
269
|
if (!rendered) {
|
|
251
270
|
let attempts = 0;
|
|
252
|
-
const maxAttempts = 16; // 16 * 500ms =
|
|
271
|
+
const maxAttempts = 16; // 16 * 500ms = 8s after initial 1s delay
|
|
253
272
|
pollId = setInterval(() => {
|
|
254
273
|
attempts++;
|
|
255
|
-
|
|
274
|
+
const elapsed = Date.now() - initTime;
|
|
275
|
+
if (walletReady || elapsed > WALLET_GRACE_PERIOD) {
|
|
276
|
+
tryRenderPaymentOptions();
|
|
277
|
+
}
|
|
256
278
|
if (!rendered && attempts >= maxAttempts) {
|
|
257
279
|
clearInterval(pollId);
|
|
258
280
|
console.error('Payment SDK: renderPaymentOptions failed after max attempts');
|