create-brainerce-store 1.4.1 → 1.5.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 +9 -1
- package/messages/he.json +9 -1
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/account/page.tsx +8 -4
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +90 -90
- package/templates/nextjs/base/src/app/cart/page.tsx +110 -110
- package/templates/nextjs/base/src/app/checkout/page.tsx +614 -614
- package/templates/nextjs/base/src/app/login/page.tsx +58 -58
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +193 -193
- package/templates/nextjs/base/src/app/page.tsx +98 -98
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +435 -435
- package/templates/nextjs/base/src/app/products/page.tsx +246 -246
- package/templates/nextjs/base/src/app/register/page.tsx +68 -68
- package/templates/nextjs/base/src/app/verify-email/page.tsx +293 -293
- package/templates/nextjs/base/src/components/account/order-history.tsx +198 -198
- package/templates/nextjs/base/src/components/account/profile-section.tsx +189 -40
- package/templates/nextjs/base/src/components/auth/login-form.tsx +94 -94
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +137 -137
- package/templates/nextjs/base/src/components/auth/register-form.tsx +184 -184
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
- package/templates/nextjs/base/src/components/cart/cart-summary.tsx +70 -70
- package/templates/nextjs/base/src/components/cart/coupon-input.tsx +134 -134
- package/templates/nextjs/base/src/components/cart/reservation-countdown.tsx +103 -103
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +305 -305
- package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +64 -64
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +350 -344
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +199 -199
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +110 -110
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +65 -65
- package/templates/nextjs/base/src/components/layout/footer.tsx +38 -38
- package/templates/nextjs/base/src/components/layout/header.tsx +332 -332
- package/templates/nextjs/base/src/components/products/product-card.tsx +96 -96
- package/templates/nextjs/base/src/components/products/product-grid.tsx +35 -35
- package/templates/nextjs/base/src/components/shared/loading-spinner.tsx +32 -32
- package/templates/nextjs/base/src/lib/translations.ts +11 -11
- package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +5 -1
|
@@ -1,293 +1,293 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Suspense, useState, useRef, useEffect, useCallback } from 'react';
|
|
4
|
-
import { useRouter, useSearchParams } from 'next/navigation';
|
|
5
|
-
import Link from 'next/link';
|
|
6
|
-
import { getClient } from '@/lib/brainerce';
|
|
7
|
-
import { useAuth } from '@/providers/store-provider';
|
|
8
|
-
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
9
|
-
import { useTranslations } from '@/lib/translations';
|
|
10
|
-
|
|
11
|
-
const CODE_LENGTH = 6;
|
|
12
|
-
const RESEND_COOLDOWN_SECONDS = 60;
|
|
13
|
-
|
|
14
|
-
function VerifyEmailContent() {
|
|
15
|
-
const router = useRouter();
|
|
16
|
-
const searchParams = useSearchParams();
|
|
17
|
-
const auth = useAuth();
|
|
18
|
-
|
|
19
|
-
const token = searchParams.get('token');
|
|
20
|
-
const t = useTranslations('auth');
|
|
21
|
-
|
|
22
|
-
const [digits, setDigits] = useState<string[]>(Array(CODE_LENGTH).fill(''));
|
|
23
|
-
const [loading, setLoading] = useState(false);
|
|
24
|
-
const [resending, setResending] = useState(false);
|
|
25
|
-
const [error, setError] = useState<string | null>(null);
|
|
26
|
-
const [success, setSuccess] = useState<string | null>(null);
|
|
27
|
-
const [cooldown, setCooldown] = useState(0);
|
|
28
|
-
|
|
29
|
-
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
|
30
|
-
|
|
31
|
-
// Auto-focus first input on mount
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
inputRefs.current[0]?.focus();
|
|
34
|
-
}, []);
|
|
35
|
-
|
|
36
|
-
// Cooldown timer
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
if (cooldown <= 0) return;
|
|
39
|
-
const timer = setInterval(() => {
|
|
40
|
-
setCooldown((prev) => prev - 1);
|
|
41
|
-
}, 1000);
|
|
42
|
-
return () => clearInterval(timer);
|
|
43
|
-
}, [cooldown]);
|
|
44
|
-
|
|
45
|
-
const handleSubmit = useCallback(
|
|
46
|
-
async (code: string) => {
|
|
47
|
-
if (!token || code.length !== CODE_LENGTH || loading) return;
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
setLoading(true);
|
|
51
|
-
setError(null);
|
|
52
|
-
const client = getClient();
|
|
53
|
-
const result = await client.verifyEmail(code, token);
|
|
54
|
-
|
|
55
|
-
if (result.verified) {
|
|
56
|
-
// token field exists on newer SDK versions
|
|
57
|
-
const authToken = (result as unknown as { token?: string }).token || token;
|
|
58
|
-
auth.login(authToken);
|
|
59
|
-
setSuccess('Email verified successfully! Redirecting...');
|
|
60
|
-
setTimeout(() => router.push('/'), 1500);
|
|
61
|
-
} else {
|
|
62
|
-
setError(result.message || 'Verification failed. Please try again.');
|
|
63
|
-
}
|
|
64
|
-
} catch (err) {
|
|
65
|
-
const message =
|
|
66
|
-
err instanceof Error ? err.message : 'Verification failed. Please try again.';
|
|
67
|
-
setError(message);
|
|
68
|
-
} finally {
|
|
69
|
-
setLoading(false);
|
|
70
|
-
}
|
|
71
|
-
},
|
|
72
|
-
[token, loading, auth, router]
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
function handleDigitChange(index: number, value: string) {
|
|
76
|
-
// Allow only single digits
|
|
77
|
-
const digit = value.replace(/\D/g, '').slice(-1);
|
|
78
|
-
|
|
79
|
-
const newDigits = [...digits];
|
|
80
|
-
newDigits[index] = digit;
|
|
81
|
-
setDigits(newDigits);
|
|
82
|
-
|
|
83
|
-
// Auto-focus next input
|
|
84
|
-
if (digit && index < CODE_LENGTH - 1) {
|
|
85
|
-
inputRefs.current[index + 1]?.focus();
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Auto-submit when all digits are filled
|
|
89
|
-
const fullCode = newDigits.join('');
|
|
90
|
-
if (fullCode.length === CODE_LENGTH) {
|
|
91
|
-
handleSubmit(fullCode);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function handleKeyDown(index: number, e: React.KeyboardEvent<HTMLInputElement>) {
|
|
96
|
-
if (e.key === 'Backspace' && !digits[index] && index > 0) {
|
|
97
|
-
// Move focus to previous input on backspace when current is empty
|
|
98
|
-
inputRefs.current[index - 1]?.focus();
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function handlePaste(e: React.ClipboardEvent) {
|
|
103
|
-
e.preventDefault();
|
|
104
|
-
const pastedText = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, CODE_LENGTH);
|
|
105
|
-
|
|
106
|
-
if (pastedText.length === 0) return;
|
|
107
|
-
|
|
108
|
-
const newDigits = [...digits];
|
|
109
|
-
for (let i = 0; i < pastedText.length; i++) {
|
|
110
|
-
newDigits[i] = pastedText[i];
|
|
111
|
-
}
|
|
112
|
-
setDigits(newDigits);
|
|
113
|
-
|
|
114
|
-
// Focus the next empty input, or the last filled one
|
|
115
|
-
const nextEmptyIndex = newDigits.findIndex((d) => !d);
|
|
116
|
-
const focusIndex = nextEmptyIndex === -1 ? CODE_LENGTH - 1 : nextEmptyIndex;
|
|
117
|
-
inputRefs.current[focusIndex]?.focus();
|
|
118
|
-
|
|
119
|
-
// Auto-submit if all digits pasted
|
|
120
|
-
if (pastedText.length === CODE_LENGTH) {
|
|
121
|
-
handleSubmit(pastedText);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async function handleResend() {
|
|
126
|
-
if (!token || resending || cooldown > 0) return;
|
|
127
|
-
|
|
128
|
-
try {
|
|
129
|
-
setResending(true);
|
|
130
|
-
setError(null);
|
|
131
|
-
const client = getClient();
|
|
132
|
-
await client.resendVerificationEmail(token);
|
|
133
|
-
setSuccess('Verification code sent! Check your email.');
|
|
134
|
-
setCooldown(RESEND_COOLDOWN_SECONDS);
|
|
135
|
-
// Clear digits for fresh entry
|
|
136
|
-
setDigits(Array(CODE_LENGTH).fill(''));
|
|
137
|
-
inputRefs.current[0]?.focus();
|
|
138
|
-
} catch (err) {
|
|
139
|
-
const message = err instanceof Error ? err.message : 'Failed to resend code.';
|
|
140
|
-
setError(message);
|
|
141
|
-
} finally {
|
|
142
|
-
setResending(false);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function handleFormSubmit(e: React.FormEvent) {
|
|
147
|
-
e.preventDefault();
|
|
148
|
-
const code = digits.join('');
|
|
149
|
-
handleSubmit(code);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// No token provided
|
|
153
|
-
if (!token) {
|
|
154
|
-
return (
|
|
155
|
-
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
156
|
-
<div className="w-full max-w-md space-y-4 text-center">
|
|
157
|
-
<svg
|
|
158
|
-
className="text-muted-foreground mx-auto h-12 w-12"
|
|
159
|
-
fill="none"
|
|
160
|
-
viewBox="0 0 24 24"
|
|
161
|
-
stroke="currentColor"
|
|
162
|
-
>
|
|
163
|
-
<path
|
|
164
|
-
strokeLinecap="round"
|
|
165
|
-
strokeLinejoin="round"
|
|
166
|
-
strokeWidth={1.5}
|
|
167
|
-
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
168
|
-
/>
|
|
169
|
-
</svg>
|
|
170
|
-
<h1 className="text-foreground text-2xl font-bold">{t('verificationInvalid')}</h1>
|
|
171
|
-
<p className="text-muted-foreground text-sm">{t('verificationInvalidDesc')}</p>
|
|
172
|
-
<Link
|
|
173
|
-
href="/register"
|
|
174
|
-
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
175
|
-
>
|
|
176
|
-
{t('goToRegister')}
|
|
177
|
-
</Link>
|
|
178
|
-
</div>
|
|
179
|
-
</div>
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return (
|
|
184
|
-
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
185
|
-
<div className="w-full max-w-md space-y-6">
|
|
186
|
-
<div className="text-center">
|
|
187
|
-
<svg
|
|
188
|
-
className="text-primary mx-auto mb-3 h-12 w-12"
|
|
189
|
-
fill="none"
|
|
190
|
-
viewBox="0 0 24 24"
|
|
191
|
-
stroke="currentColor"
|
|
192
|
-
>
|
|
193
|
-
<path
|
|
194
|
-
strokeLinecap="round"
|
|
195
|
-
strokeLinejoin="round"
|
|
196
|
-
strokeWidth={1.5}
|
|
197
|
-
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
198
|
-
/>
|
|
199
|
-
</svg>
|
|
200
|
-
<h1 className="text-foreground text-2xl font-bold">{t('verifyTitle')}</h1>
|
|
201
|
-
<p className="text-muted-foreground mt-1 text-sm">{t('verifySubtitle')}</p>
|
|
202
|
-
</div>
|
|
203
|
-
|
|
204
|
-
{error && (
|
|
205
|
-
<div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm">
|
|
206
|
-
{error}
|
|
207
|
-
</div>
|
|
208
|
-
)}
|
|
209
|
-
|
|
210
|
-
{success && (
|
|
211
|
-
<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">
|
|
212
|
-
{success}
|
|
213
|
-
</div>
|
|
214
|
-
)}
|
|
215
|
-
|
|
216
|
-
<form onSubmit={handleFormSubmit} className="space-y-6">
|
|
217
|
-
{/* Digit inputs */}
|
|
218
|
-
<div className="flex justify-center gap-2 sm:gap-3" onPaste={handlePaste}>
|
|
219
|
-
{digits.map((digit, index) => (
|
|
220
|
-
<input
|
|
221
|
-
key={index}
|
|
222
|
-
ref={(el) => {
|
|
223
|
-
inputRefs.current[index] = el;
|
|
224
|
-
}}
|
|
225
|
-
type="text"
|
|
226
|
-
inputMode="numeric"
|
|
227
|
-
autoComplete="one-time-code"
|
|
228
|
-
maxLength={1}
|
|
229
|
-
value={digit}
|
|
230
|
-
onChange={(e) => handleDigitChange(index, e.target.value)}
|
|
231
|
-
onKeyDown={(e) => handleKeyDown(index, e)}
|
|
232
|
-
disabled={loading}
|
|
233
|
-
className="border-border bg-background text-foreground focus:ring-primary/20 focus:border-primary h-12 w-11 rounded border text-center text-xl font-semibold focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-14 sm:w-12"
|
|
234
|
-
aria-label={`${t('digitAriaLabel')} ${index + 1}`}
|
|
235
|
-
/>
|
|
236
|
-
))}
|
|
237
|
-
</div>
|
|
238
|
-
|
|
239
|
-
<button
|
|
240
|
-
type="submit"
|
|
241
|
-
disabled={loading || digits.join('').length !== CODE_LENGTH}
|
|
242
|
-
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"
|
|
243
|
-
>
|
|
244
|
-
{loading ? (
|
|
245
|
-
<>
|
|
246
|
-
<LoadingSpinner
|
|
247
|
-
size="sm"
|
|
248
|
-
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
249
|
-
/>
|
|
250
|
-
{t('verifying')}
|
|
251
|
-
</>
|
|
252
|
-
) : (
|
|
253
|
-
t('verifyButton')
|
|
254
|
-
)}
|
|
255
|
-
</button>
|
|
256
|
-
</form>
|
|
257
|
-
|
|
258
|
-
{/* Resend code */}
|
|
259
|
-
<div className="text-center">
|
|
260
|
-
<p className="text-muted-foreground text-sm">
|
|
261
|
-
{t('didntReceive')}{' '}
|
|
262
|
-
<button
|
|
263
|
-
type="button"
|
|
264
|
-
onClick={handleResend}
|
|
265
|
-
disabled={resending || cooldown > 0}
|
|
266
|
-
className="text-primary font-medium hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50"
|
|
267
|
-
>
|
|
268
|
-
{resending
|
|
269
|
-
? t('sending')
|
|
270
|
-
: cooldown > 0
|
|
271
|
-
? `${t('resendIn')} ${cooldown}${t('secondsSuffix')}`
|
|
272
|
-
: t('resendCode')}
|
|
273
|
-
</button>
|
|
274
|
-
</p>
|
|
275
|
-
</div>
|
|
276
|
-
</div>
|
|
277
|
-
</div>
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
export default function VerifyEmailPage() {
|
|
282
|
-
return (
|
|
283
|
-
<Suspense
|
|
284
|
-
fallback={
|
|
285
|
-
<div className="flex min-h-[60vh] items-center justify-center">
|
|
286
|
-
<LoadingSpinner size="lg" />
|
|
287
|
-
</div>
|
|
288
|
-
}
|
|
289
|
-
>
|
|
290
|
-
<VerifyEmailContent />
|
|
291
|
-
</Suspense>
|
|
292
|
-
);
|
|
293
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Suspense, useState, useRef, useEffect, useCallback } from 'react';
|
|
4
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { getClient } from '@/lib/brainerce';
|
|
7
|
+
import { useAuth } from '@/providers/store-provider';
|
|
8
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
9
|
+
import { useTranslations } from '@/lib/translations';
|
|
10
|
+
|
|
11
|
+
const CODE_LENGTH = 6;
|
|
12
|
+
const RESEND_COOLDOWN_SECONDS = 60;
|
|
13
|
+
|
|
14
|
+
function VerifyEmailContent() {
|
|
15
|
+
const router = useRouter();
|
|
16
|
+
const searchParams = useSearchParams();
|
|
17
|
+
const auth = useAuth();
|
|
18
|
+
|
|
19
|
+
const token = searchParams.get('token');
|
|
20
|
+
const t = useTranslations('auth');
|
|
21
|
+
|
|
22
|
+
const [digits, setDigits] = useState<string[]>(Array(CODE_LENGTH).fill(''));
|
|
23
|
+
const [loading, setLoading] = useState(false);
|
|
24
|
+
const [resending, setResending] = useState(false);
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
const [success, setSuccess] = useState<string | null>(null);
|
|
27
|
+
const [cooldown, setCooldown] = useState(0);
|
|
28
|
+
|
|
29
|
+
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
|
30
|
+
|
|
31
|
+
// Auto-focus first input on mount
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
inputRefs.current[0]?.focus();
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
// Cooldown timer
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (cooldown <= 0) return;
|
|
39
|
+
const timer = setInterval(() => {
|
|
40
|
+
setCooldown((prev) => prev - 1);
|
|
41
|
+
}, 1000);
|
|
42
|
+
return () => clearInterval(timer);
|
|
43
|
+
}, [cooldown]);
|
|
44
|
+
|
|
45
|
+
const handleSubmit = useCallback(
|
|
46
|
+
async (code: string) => {
|
|
47
|
+
if (!token || code.length !== CODE_LENGTH || loading) return;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
setLoading(true);
|
|
51
|
+
setError(null);
|
|
52
|
+
const client = getClient();
|
|
53
|
+
const result = await client.verifyEmail(code, token);
|
|
54
|
+
|
|
55
|
+
if (result.verified) {
|
|
56
|
+
// token field exists on newer SDK versions
|
|
57
|
+
const authToken = (result as unknown as { token?: string }).token || token;
|
|
58
|
+
auth.login(authToken);
|
|
59
|
+
setSuccess('Email verified successfully! Redirecting...');
|
|
60
|
+
setTimeout(() => router.push('/'), 1500);
|
|
61
|
+
} else {
|
|
62
|
+
setError(result.message || 'Verification failed. Please try again.');
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const message =
|
|
66
|
+
err instanceof Error ? err.message : 'Verification failed. Please try again.';
|
|
67
|
+
setError(message);
|
|
68
|
+
} finally {
|
|
69
|
+
setLoading(false);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
[token, loading, auth, router]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
function handleDigitChange(index: number, value: string) {
|
|
76
|
+
// Allow only single digits
|
|
77
|
+
const digit = value.replace(/\D/g, '').slice(-1);
|
|
78
|
+
|
|
79
|
+
const newDigits = [...digits];
|
|
80
|
+
newDigits[index] = digit;
|
|
81
|
+
setDigits(newDigits);
|
|
82
|
+
|
|
83
|
+
// Auto-focus next input
|
|
84
|
+
if (digit && index < CODE_LENGTH - 1) {
|
|
85
|
+
inputRefs.current[index + 1]?.focus();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Auto-submit when all digits are filled
|
|
89
|
+
const fullCode = newDigits.join('');
|
|
90
|
+
if (fullCode.length === CODE_LENGTH) {
|
|
91
|
+
handleSubmit(fullCode);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function handleKeyDown(index: number, e: React.KeyboardEvent<HTMLInputElement>) {
|
|
96
|
+
if (e.key === 'Backspace' && !digits[index] && index > 0) {
|
|
97
|
+
// Move focus to previous input on backspace when current is empty
|
|
98
|
+
inputRefs.current[index - 1]?.focus();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function handlePaste(e: React.ClipboardEvent) {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
const pastedText = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, CODE_LENGTH);
|
|
105
|
+
|
|
106
|
+
if (pastedText.length === 0) return;
|
|
107
|
+
|
|
108
|
+
const newDigits = [...digits];
|
|
109
|
+
for (let i = 0; i < pastedText.length; i++) {
|
|
110
|
+
newDigits[i] = pastedText[i];
|
|
111
|
+
}
|
|
112
|
+
setDigits(newDigits);
|
|
113
|
+
|
|
114
|
+
// Focus the next empty input, or the last filled one
|
|
115
|
+
const nextEmptyIndex = newDigits.findIndex((d) => !d);
|
|
116
|
+
const focusIndex = nextEmptyIndex === -1 ? CODE_LENGTH - 1 : nextEmptyIndex;
|
|
117
|
+
inputRefs.current[focusIndex]?.focus();
|
|
118
|
+
|
|
119
|
+
// Auto-submit if all digits pasted
|
|
120
|
+
if (pastedText.length === CODE_LENGTH) {
|
|
121
|
+
handleSubmit(pastedText);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function handleResend() {
|
|
126
|
+
if (!token || resending || cooldown > 0) return;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
setResending(true);
|
|
130
|
+
setError(null);
|
|
131
|
+
const client = getClient();
|
|
132
|
+
await client.resendVerificationEmail(token);
|
|
133
|
+
setSuccess('Verification code sent! Check your email.');
|
|
134
|
+
setCooldown(RESEND_COOLDOWN_SECONDS);
|
|
135
|
+
// Clear digits for fresh entry
|
|
136
|
+
setDigits(Array(CODE_LENGTH).fill(''));
|
|
137
|
+
inputRefs.current[0]?.focus();
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const message = err instanceof Error ? err.message : 'Failed to resend code.';
|
|
140
|
+
setError(message);
|
|
141
|
+
} finally {
|
|
142
|
+
setResending(false);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function handleFormSubmit(e: React.FormEvent) {
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
const code = digits.join('');
|
|
149
|
+
handleSubmit(code);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// No token provided
|
|
153
|
+
if (!token) {
|
|
154
|
+
return (
|
|
155
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
156
|
+
<div className="w-full max-w-md space-y-4 text-center">
|
|
157
|
+
<svg
|
|
158
|
+
className="text-muted-foreground mx-auto h-12 w-12"
|
|
159
|
+
fill="none"
|
|
160
|
+
viewBox="0 0 24 24"
|
|
161
|
+
stroke="currentColor"
|
|
162
|
+
>
|
|
163
|
+
<path
|
|
164
|
+
strokeLinecap="round"
|
|
165
|
+
strokeLinejoin="round"
|
|
166
|
+
strokeWidth={1.5}
|
|
167
|
+
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
168
|
+
/>
|
|
169
|
+
</svg>
|
|
170
|
+
<h1 className="text-foreground text-2xl font-bold">{t('verificationInvalid')}</h1>
|
|
171
|
+
<p className="text-muted-foreground text-sm">{t('verificationInvalidDesc')}</p>
|
|
172
|
+
<Link
|
|
173
|
+
href="/register"
|
|
174
|
+
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
175
|
+
>
|
|
176
|
+
{t('goToRegister')}
|
|
177
|
+
</Link>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
185
|
+
<div className="w-full max-w-md space-y-6">
|
|
186
|
+
<div className="text-center">
|
|
187
|
+
<svg
|
|
188
|
+
className="text-primary mx-auto mb-3 h-12 w-12"
|
|
189
|
+
fill="none"
|
|
190
|
+
viewBox="0 0 24 24"
|
|
191
|
+
stroke="currentColor"
|
|
192
|
+
>
|
|
193
|
+
<path
|
|
194
|
+
strokeLinecap="round"
|
|
195
|
+
strokeLinejoin="round"
|
|
196
|
+
strokeWidth={1.5}
|
|
197
|
+
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
198
|
+
/>
|
|
199
|
+
</svg>
|
|
200
|
+
<h1 className="text-foreground text-2xl font-bold">{t('verifyTitle')}</h1>
|
|
201
|
+
<p className="text-muted-foreground mt-1 text-sm">{t('verifySubtitle')}</p>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{error && (
|
|
205
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm">
|
|
206
|
+
{error}
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{success && (
|
|
211
|
+
<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">
|
|
212
|
+
{success}
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
<form onSubmit={handleFormSubmit} className="space-y-6">
|
|
217
|
+
{/* Digit inputs */}
|
|
218
|
+
<div className="flex justify-center gap-2 sm:gap-3" onPaste={handlePaste}>
|
|
219
|
+
{digits.map((digit, index) => (
|
|
220
|
+
<input
|
|
221
|
+
key={index}
|
|
222
|
+
ref={(el) => {
|
|
223
|
+
inputRefs.current[index] = el;
|
|
224
|
+
}}
|
|
225
|
+
type="text"
|
|
226
|
+
inputMode="numeric"
|
|
227
|
+
autoComplete="one-time-code"
|
|
228
|
+
maxLength={1}
|
|
229
|
+
value={digit}
|
|
230
|
+
onChange={(e) => handleDigitChange(index, e.target.value)}
|
|
231
|
+
onKeyDown={(e) => handleKeyDown(index, e)}
|
|
232
|
+
disabled={loading}
|
|
233
|
+
className="border-border bg-background text-foreground focus:ring-primary/20 focus:border-primary h-12 w-11 rounded border text-center text-xl font-semibold focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-14 sm:w-12"
|
|
234
|
+
aria-label={`${t('digitAriaLabel')} ${index + 1}`}
|
|
235
|
+
/>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<button
|
|
240
|
+
type="submit"
|
|
241
|
+
disabled={loading || digits.join('').length !== CODE_LENGTH}
|
|
242
|
+
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"
|
|
243
|
+
>
|
|
244
|
+
{loading ? (
|
|
245
|
+
<>
|
|
246
|
+
<LoadingSpinner
|
|
247
|
+
size="sm"
|
|
248
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
249
|
+
/>
|
|
250
|
+
{t('verifying')}
|
|
251
|
+
</>
|
|
252
|
+
) : (
|
|
253
|
+
t('verifyButton')
|
|
254
|
+
)}
|
|
255
|
+
</button>
|
|
256
|
+
</form>
|
|
257
|
+
|
|
258
|
+
{/* Resend code */}
|
|
259
|
+
<div className="text-center">
|
|
260
|
+
<p className="text-muted-foreground text-sm">
|
|
261
|
+
{t('didntReceive')}{' '}
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
onClick={handleResend}
|
|
265
|
+
disabled={resending || cooldown > 0}
|
|
266
|
+
className="text-primary font-medium hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50"
|
|
267
|
+
>
|
|
268
|
+
{resending
|
|
269
|
+
? t('sending')
|
|
270
|
+
: cooldown > 0
|
|
271
|
+
? `${t('resendIn')} ${cooldown}${t('secondsSuffix')}`
|
|
272
|
+
: t('resendCode')}
|
|
273
|
+
</button>
|
|
274
|
+
</p>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export default function VerifyEmailPage() {
|
|
282
|
+
return (
|
|
283
|
+
<Suspense
|
|
284
|
+
fallback={
|
|
285
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
286
|
+
<LoadingSpinner size="lg" />
|
|
287
|
+
</div>
|
|
288
|
+
}
|
|
289
|
+
>
|
|
290
|
+
<VerifyEmailContent />
|
|
291
|
+
</Suspense>
|
|
292
|
+
);
|
|
293
|
+
}
|