返回 AiToEarn
EmailLoginForm.tsx
根目录 / project / aitoearn-web / src / app / [lng] / auth / login / components / LoginContent / EmailLoginForm.tsx
1 /**
2 * EmailLoginForm - 邮箱验证码登录表单(国外环境)
3 * 支持 Google 登录 + 邮箱验证码登录,可按场景隐藏 Google 登录
4 */
5
6 'use client'
7
8 import type { GoogleLoginParams } from '@/api/auth/auth.types'
9 import { zodResolver } from '@hookform/resolvers/zod'
10 import { GoogleLogin } from '@react-oauth/google'
11 import { Loader2 } from 'lucide-react'
12 import { useRouter, useSearchParams } from 'next/navigation'
13 import { useEffect, useMemo, useRef, useState } from 'react'
14 import { useForm } from 'react-hook-form'
15 import { z } from 'zod'
16
17 import { emailCodeLoginApi, googleLoginApi, sendEmailCodeApi } from '@/api/auth/auth.api'
18
19 import { useTransClient } from '@/app/i18n/client'
20 import { Button } from '@/components/ui/button'
21 import { Input } from '@/components/ui/input'
22 import { useGetClientLng } from '@/hooks/useSystem'
23 import { useUserStore } from '@/store/user'
24 import { toast } from '@/utils/ui/toast'
25
26 import { useCountdown } from './useCountdown'
27
28 interface EmailLoginFormProps {
29 /** 弹框模式:登录成功回调,替代 router.push */
30 onLoginSuccess?: () => void
31 /** 覆盖 searchParams 的 redirect */
32 redirectUrl?: string
33 /** 覆盖 searchParams 的 inviteCode */
34 inviteCode?: string
35 /** 是否显示 Google 登录 */
36 showGoogleLogin?: boolean
37 }
38
39 interface EmailLoginFormData {
40 email: string
41 code: string
42 }
43
44 export function EmailLoginForm({
45 onLoginSuccess,
46 redirectUrl,
47 inviteCode: inviteCodeProp,
48 showGoogleLogin = true,
49 }: EmailLoginFormProps = {}) {
50 const router = useRouter()
51 const searchParams = useSearchParams()
52 const redirect = redirectUrl ?? searchParams.get('redirect')
53 const { setToken, setUserInfo } = useUserStore()
54 const { t } = useTransClient('login')
55 const lng = useGetClientLng()
56 const { countdown, isCounting, start: startCountdown } = useCountdown()
57 const [sendingCode, setSendingCode] = useState(false)
58 const googleContainerRef = useRef<HTMLDivElement>(null)
59 const [googleBtnWidth, setGoogleBtnWidth] = useState(0)
60
61 useEffect(() => {
62 if (!showGoogleLogin)
63 return
64
65 const el = googleContainerRef.current
66 if (!el)
67 return
68
69 const observer = new ResizeObserver(() => {
70 const w = el.offsetWidth
71 if (w > 0)
72 setGoogleBtnWidth(Math.min(w, 400))
73 })
74 observer.observe(el)
75
76 return () => observer.disconnect()
77 }, [showGoogleLogin])
78
79 const schema = useMemo(
80 () =>
81 z.object({
82 email: z.string().min(1, t('emailRequired')).email(t('emailInvalid')),
83 code: z.string().min(1, t('emailCodeRequired')).length(6, t('emailCodeLength')),
84 }),
85 [t],
86 )
87
88 const form = useForm<EmailLoginFormData>({
89 resolver: zodResolver(schema),
90 defaultValues: { email: '', code: '' },
91 })
92
93 /** 发送邮箱验证码 */
94 const handleSendCode = async () => {
95 const email = form.getValues('email')
96 const result = await form.trigger('email')
97 if (!result)
98 return
99
100 setSendingCode(true)
101 try {
102 const res = await sendEmailCodeApi({ mail: email })
103 if (res?.code === 0) {
104 toast.success(t('codeSentSuccess'))
105 startCountdown()
106 }
107 else {
108 toast.error(res?.message || t('codeSendFailed'))
109 }
110 }
111 catch {
112 toast.error(t('codeSendFailed'))
113 }
114 finally {
115 setSendingCode(false)
116 }
117 }
118
119 /** 邮箱验证码登录 */
120 const handleSubmit = async (data: EmailLoginFormData) => {
121 try {
122 const inviteCode = inviteCodeProp ?? searchParams.get('inviteCode') ?? undefined
123 const res = await emailCodeLoginApi({ mail: data.email, code: data.code, inviteCode })
124 if (!res)
125 return
126
127 if (res.code === 0 && res.data.token) {
128 setToken(res.data.token)
129 if (res.data.userInfo) {
130 setUserInfo(res.data.userInfo)
131 }
132 toast.success(t('loginSuccess'))
133 if (onLoginSuccess) {
134 onLoginSuccess()
135 }
136 else {
137 router.push(redirect || '/')
138 }
139 }
140 else {
141 toast.error(res.message || t('loginFailed'))
142 }
143 }
144 catch {
145 toast.error(t('loginError'))
146 }
147 }
148
149 /** Google 登录成功 */
150 const handleGoogleSuccess = async (credentialResponse: any) => {
151 try {
152 const params: GoogleLoginParams = {
153 clientId: credentialResponse.clientId,
154 credential: credentialResponse.credential,
155 }
156 const res = await googleLoginApi(params)
157 if (!res) {
158 toast.error(t('googleLoginFailed'))
159 return
160 }
161 if (res.code === 0 && res.data.token) {
162 setToken(res.data.token)
163 if (res.data.userInfo) {
164 setUserInfo(res.data.userInfo)
165 }
166 toast.success(t('loginSuccess'))
167 if (onLoginSuccess) {
168 onLoginSuccess()
169 }
170 else {
171 router.push(redirect || '/')
172 }
173 }
174 else {
175 toast.error(res.message || t('googleLoginFailed'))
176 }
177 }
178 catch {
179 toast.error(t('googleLoginFailed'))
180 }
181 }
182
183 return (
184 <>
185 {showGoogleLogin && (
186 <>
187 {/* Google 登录 */}
188 <div ref={googleContainerRef} className="space-y-3">
189 {googleBtnWidth > 0 && (
190 <GoogleLogin
191 key={`${lng}-${googleBtnWidth}`}
192 onSuccess={handleGoogleSuccess}
193 onError={() => toast.error(t('googleLoginFailed'))}
194 useOneTap={false}
195 theme="outline"
196 shape="rectangular"
197 text="continue_with"
198 locale={lng.replace('-', '_')}
199 size="large"
200 width={String(googleBtnWidth)}
201 />
202 )}
203 </div>
204
205 {/* 分隔线 */}
206 <div className="my-6 flex items-center gap-4">
207 <div className="h-px flex-1 bg-border" />
208 <span className="text-sm text-muted-foreground/70">{t('or')}</span>
209 <div className="h-px flex-1 bg-border" />
210 </div>
211 </>
212 )}
213
214 {/* 邮箱验证码表单 */}
215 <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
216 <div>
217 <Input
218 type="email"
219 placeholder={t('emailPlaceholder')}
220 {...form.register('email')}
221 className="h-12 rounded-xl border-input bg-background px-4 text-base placeholder:text-muted-foreground/70 focus:border-ring focus:ring-0"
222 />
223 {form.formState.errors.email && (
224 <p className="mt-1 text-xs text-destructive">
225 {form.formState.errors.email.message}
226 </p>
227 )}
228 </div>
229
230 <div className="flex gap-2">
231 <div className="flex-1">
232 <Input
233 type="text"
234 inputMode="numeric"
235 maxLength={6}
236 autoComplete="off"
237 placeholder={t('enterCode')}
238 {...form.register('code')}
239 className="h-12 rounded-xl border-input bg-background px-4 text-base placeholder:text-muted-foreground/70 focus:border-ring focus:ring-0"
240 />
241 {form.formState.errors.code && (
242 <p className="mt-1 text-xs text-destructive">
243 {form.formState.errors.code.message}
244 </p>
245 )}
246 </div>
247 <Button
248 type="button"
249 variant="outline"
250 disabled={isCounting || sendingCode}
251 onClick={handleSendCode}
252 className="h-12 shrink-0 cursor-pointer rounded-xl px-4"
253 >
254 {sendingCode ? (
255 <Loader2 className="h-4 w-4 animate-spin" />
256 ) : isCounting ? (
257 `${countdown}s`
258 ) : (
259 t('sendCode')
260 )}
261 </Button>
262 </div>
263
264 <Button
265 type="submit"
266 disabled={form.formState.isSubmitting}
267 className="h-12 w-full cursor-pointer rounded-xl text-base font-medium"
268 >
269 {form.formState.isSubmitting ? (
270 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
271 ) : (
272 t('login')
273 )}
274 </Button>
275 </form>
276 </>
277 )
278 }
279
279 lines Plain Text