create-brainerce-store 1.6.1 → 1.7.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.
@@ -1,293 +1,258 @@
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 } from 'next/navigation';
5
+ import Link from 'next/link';
6
+ import { useAuth } from '@/providers/store-provider';
7
+ import { proxyVerifyEmail, proxyResendVerification } from '@/lib/auth';
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 auth = useAuth();
17
+
18
+ const t = useTranslations('auth');
19
+
20
+ const [digits, setDigits] = useState<string[]>(Array(CODE_LENGTH).fill(''));
21
+ const [loading, setLoading] = useState(false);
22
+ const [resending, setResending] = useState(false);
23
+ const [error, setError] = useState<string | null>(null);
24
+ const [success, setSuccess] = useState<string | null>(null);
25
+ const [cooldown, setCooldown] = useState(0);
26
+
27
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
28
+
29
+ // Auto-focus first input on mount
30
+ useEffect(() => {
31
+ inputRefs.current[0]?.focus();
32
+ }, []);
33
+
34
+ // Cooldown timer
35
+ useEffect(() => {
36
+ if (cooldown <= 0) return;
37
+ const timer = setInterval(() => {
38
+ setCooldown((prev) => prev - 1);
39
+ }, 1000);
40
+ return () => clearInterval(timer);
41
+ }, [cooldown]);
42
+
43
+ const handleSubmit = useCallback(
44
+ async (code: string) => {
45
+ if (code.length !== CODE_LENGTH || loading) return;
46
+
47
+ try {
48
+ setLoading(true);
49
+ setError(null);
50
+ // Auth token is in httpOnly cookie — proxy adds Authorization header
51
+ const result = await proxyVerifyEmail(code);
52
+
53
+ if (result.verified) {
54
+ // Refresh auth state (cookie already set)
55
+ await auth.login();
56
+ setSuccess('Email verified successfully! Redirecting...');
57
+ setTimeout(() => router.push('/'), 1500);
58
+ } else {
59
+ setError(result.message || 'Verification failed. Please try again.');
60
+ }
61
+ } catch (err) {
62
+ const message =
63
+ err instanceof Error ? err.message : 'Verification failed. Please try again.';
64
+ setError(message);
65
+ } finally {
66
+ setLoading(false);
67
+ }
68
+ },
69
+ [loading, auth, router]
70
+ );
71
+
72
+ function handleDigitChange(index: number, value: string) {
73
+ // Allow only single digits
74
+ const digit = value.replace(/\D/g, '').slice(-1);
75
+
76
+ const newDigits = [...digits];
77
+ newDigits[index] = digit;
78
+ setDigits(newDigits);
79
+
80
+ // Auto-focus next input
81
+ if (digit && index < CODE_LENGTH - 1) {
82
+ inputRefs.current[index + 1]?.focus();
83
+ }
84
+
85
+ // Auto-submit when all digits are filled
86
+ const fullCode = newDigits.join('');
87
+ if (fullCode.length === CODE_LENGTH) {
88
+ handleSubmit(fullCode);
89
+ }
90
+ }
91
+
92
+ function handleKeyDown(index: number, e: React.KeyboardEvent<HTMLInputElement>) {
93
+ if (e.key === 'Backspace' && !digits[index] && index > 0) {
94
+ // Move focus to previous input on backspace when current is empty
95
+ inputRefs.current[index - 1]?.focus();
96
+ }
97
+ }
98
+
99
+ function handlePaste(e: React.ClipboardEvent) {
100
+ e.preventDefault();
101
+ const pastedText = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, CODE_LENGTH);
102
+
103
+ if (pastedText.length === 0) return;
104
+
105
+ const newDigits = [...digits];
106
+ for (let i = 0; i < pastedText.length; i++) {
107
+ newDigits[i] = pastedText[i];
108
+ }
109
+ setDigits(newDigits);
110
+
111
+ // Focus the next empty input, or the last filled one
112
+ const nextEmptyIndex = newDigits.findIndex((d) => !d);
113
+ const focusIndex = nextEmptyIndex === -1 ? CODE_LENGTH - 1 : nextEmptyIndex;
114
+ inputRefs.current[focusIndex]?.focus();
115
+
116
+ // Auto-submit if all digits pasted
117
+ if (pastedText.length === CODE_LENGTH) {
118
+ handleSubmit(pastedText);
119
+ }
120
+ }
121
+
122
+ async function handleResend() {
123
+ if (resending || cooldown > 0) return;
124
+
125
+ try {
126
+ setResending(true);
127
+ setError(null);
128
+ await proxyResendVerification();
129
+ setSuccess('Verification code sent! Check your email.');
130
+ setCooldown(RESEND_COOLDOWN_SECONDS);
131
+ // Clear digits for fresh entry
132
+ setDigits(Array(CODE_LENGTH).fill(''));
133
+ inputRefs.current[0]?.focus();
134
+ } catch (err) {
135
+ const message = err instanceof Error ? err.message : 'Failed to resend code.';
136
+ setError(message);
137
+ } finally {
138
+ setResending(false);
139
+ }
140
+ }
141
+
142
+ function handleFormSubmit(e: React.FormEvent) {
143
+ e.preventDefault();
144
+ const code = digits.join('');
145
+ handleSubmit(code);
146
+ }
147
+
148
+ return (
149
+ <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
150
+ <div className="w-full max-w-md space-y-6">
151
+ <div className="text-center">
152
+ <svg
153
+ className="text-primary mx-auto mb-3 h-12 w-12"
154
+ fill="none"
155
+ viewBox="0 0 24 24"
156
+ stroke="currentColor"
157
+ >
158
+ <path
159
+ strokeLinecap="round"
160
+ strokeLinejoin="round"
161
+ strokeWidth={1.5}
162
+ 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"
163
+ />
164
+ </svg>
165
+ <h1 className="text-foreground text-2xl font-bold">{t('verifyTitle')}</h1>
166
+ <p className="text-muted-foreground mt-1 text-sm">{t('verifySubtitle')}</p>
167
+ </div>
168
+
169
+ {error && (
170
+ <div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm">
171
+ {error}
172
+ </div>
173
+ )}
174
+
175
+ {success && (
176
+ <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">
177
+ {success}
178
+ </div>
179
+ )}
180
+
181
+ <form onSubmit={handleFormSubmit} className="space-y-6">
182
+ {/* Digit inputs */}
183
+ <div className="flex justify-center gap-2 sm:gap-3" onPaste={handlePaste}>
184
+ {digits.map((digit, index) => (
185
+ <input
186
+ key={index}
187
+ ref={(el) => {
188
+ inputRefs.current[index] = el;
189
+ }}
190
+ type="text"
191
+ inputMode="numeric"
192
+ autoComplete="one-time-code"
193
+ maxLength={1}
194
+ value={digit}
195
+ onChange={(e) => handleDigitChange(index, e.target.value)}
196
+ onKeyDown={(e) => handleKeyDown(index, e)}
197
+ disabled={loading}
198
+ 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"
199
+ aria-label={`${t('digitAriaLabel')} ${index + 1}`}
200
+ />
201
+ ))}
202
+ </div>
203
+
204
+ <button
205
+ type="submit"
206
+ disabled={loading || digits.join('').length !== CODE_LENGTH}
207
+ 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"
208
+ >
209
+ {loading ? (
210
+ <>
211
+ <LoadingSpinner
212
+ size="sm"
213
+ className="border-primary-foreground/30 border-t-primary-foreground"
214
+ />
215
+ {t('verifying')}
216
+ </>
217
+ ) : (
218
+ t('verifyButton')
219
+ )}
220
+ </button>
221
+ </form>
222
+
223
+ {/* Resend code */}
224
+ <div className="text-center">
225
+ <p className="text-muted-foreground text-sm">
226
+ {t('didntReceive')}{' '}
227
+ <button
228
+ type="button"
229
+ onClick={handleResend}
230
+ disabled={resending || cooldown > 0}
231
+ className="text-primary font-medium hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50"
232
+ >
233
+ {resending
234
+ ? t('sending')
235
+ : cooldown > 0
236
+ ? `${t('resendIn')} ${cooldown}${t('secondsSuffix')}`
237
+ : t('resendCode')}
238
+ </button>
239
+ </p>
240
+ </div>
241
+ </div>
242
+ </div>
243
+ );
244
+ }
245
+
246
+ export default function VerifyEmailPage() {
247
+ return (
248
+ <Suspense
249
+ fallback={
250
+ <div className="flex min-h-[60vh] items-center justify-center">
251
+ <LoadingSpinner size="lg" />
252
+ </div>
253
+ }
254
+ >
255
+ <VerifyEmailContent />
256
+ </Suspense>
257
+ );
258
+ }