feat: show real-time register validation hints (#625)
This commit is contained in:
parent
4fd281f2b6
commit
e3a5d4f75c
@ -3,6 +3,7 @@
|
||||
import Link from 'next/link'
|
||||
import { Github } from 'lucide-react'
|
||||
import {
|
||||
ChangeEvent,
|
||||
ClipboardEvent,
|
||||
FormEvent,
|
||||
KeyboardEvent,
|
||||
@ -124,6 +125,13 @@ export default function RegisterContent() {
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
const [resendCooldown, setResendCooldown] = useState(0)
|
||||
const [isVerified, setIsVerified] = useState(false)
|
||||
const [formValues, setFormValues] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
agreement: false,
|
||||
})
|
||||
const [isFormReady, setIsFormReady] = useState(false)
|
||||
const formRef = useRef<HTMLFormElement | null>(null)
|
||||
const codeInputRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
|
||||
@ -131,6 +139,10 @@ export default function RegisterContent() {
|
||||
setAlert(initialAlert)
|
||||
}, [initialAlert])
|
||||
|
||||
useEffect(() => {
|
||||
setIsFormReady(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (resendCooldown <= 0) {
|
||||
return
|
||||
@ -155,6 +167,19 @@ export default function RegisterContent() {
|
||||
setCodeDigits(Array(VERIFICATION_CODE_LENGTH).fill(''))
|
||||
}, [])
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(field: 'email' | 'password' | 'confirmPassword') =>
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target
|
||||
setFormValues((previous) => ({ ...previous, [field]: value }))
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleAgreementChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setFormValues((previous) => ({ ...previous, agreement: event.target.checked }))
|
||||
}, [])
|
||||
|
||||
const handleCodeChange = useCallback(
|
||||
(index: number, value: string) => {
|
||||
const sanitized = value.replace(/\D/g, '')
|
||||
@ -242,6 +267,14 @@ export default function RegisterContent() {
|
||||
const agreementAccepted = formData.get('agreement') === 'on'
|
||||
const verificationCode = codeDigits.join('')
|
||||
|
||||
setFormValues((previous) => ({
|
||||
...previous,
|
||||
email: emailInput,
|
||||
password,
|
||||
confirmPassword,
|
||||
agreement: agreementAccepted,
|
||||
}))
|
||||
|
||||
const showError = (message: string) => {
|
||||
setAlert({ type: 'error', message })
|
||||
}
|
||||
@ -618,60 +651,124 @@ export default function RegisterContent() {
|
||||
? `${t.form.verificationCodeResend} (${resendCooldown}s)`
|
||||
: t.form.verificationCodeResend
|
||||
const verificationDescriptionId = useId()
|
||||
const isSubmitDisabled = useMemo(() => {
|
||||
const validationHints = t.form.validation
|
||||
const validationState = useMemo(() => {
|
||||
const messages: string[] = []
|
||||
|
||||
if (!isFormReady && validationHints?.initializing) {
|
||||
return { disabled: true, messages: [validationHints.initializing] }
|
||||
}
|
||||
|
||||
if (isSubmitting) {
|
||||
return true
|
||||
}
|
||||
|
||||
const formElement = formRef.current
|
||||
if (!formElement) {
|
||||
if (!hasRequestedCode) {
|
||||
return true
|
||||
if (isVerified) {
|
||||
messages.push(
|
||||
validationHints?.completing ??
|
||||
t.form.completing ??
|
||||
t.form.completeSubmit ??
|
||||
t.form.submit,
|
||||
)
|
||||
} else if (isVerificationStep) {
|
||||
messages.push(
|
||||
validationHints?.verifying ??
|
||||
t.form.verifying ??
|
||||
t.form.verifySubmit ??
|
||||
t.form.submit,
|
||||
)
|
||||
} else {
|
||||
messages.push(validationHints?.submitting ?? t.form.submitting ?? t.form.submit)
|
||||
}
|
||||
|
||||
if (!isVerified) {
|
||||
return codeDigits.some((digit) => !digit)
|
||||
}
|
||||
|
||||
return codeDigits.some((digit) => !digit) || !pendingPassword
|
||||
return { disabled: true, messages }
|
||||
}
|
||||
|
||||
const formData = new FormData(formElement)
|
||||
const emailValue = String(formData.get('email') ?? '').trim()
|
||||
const passwordValue = String(formData.get('password') ?? '')
|
||||
const confirmValue = String(formData.get('confirmPassword') ?? '')
|
||||
const agreementAccepted = formData.get('agreement') === 'on'
|
||||
|
||||
if (!hasRequestedCode) {
|
||||
if (!emailValue || !EMAIL_PATTERN.test(emailValue)) {
|
||||
return true
|
||||
const emailValue = formValues.email.trim()
|
||||
|
||||
if (!emailValue) {
|
||||
messages.push(validationHints?.emailMissing ?? alerts.invalidEmail)
|
||||
} else if (!EMAIL_PATTERN.test(emailValue)) {
|
||||
messages.push(validationHints?.emailInvalid ?? alerts.invalidEmail)
|
||||
}
|
||||
|
||||
if (!passwordValue || !confirmValue) {
|
||||
return true
|
||||
if (!formValues.password) {
|
||||
messages.push(validationHints?.passwordMissing ?? alerts.missingFields)
|
||||
}
|
||||
|
||||
if (!PASSWORD_STRENGTH_PATTERN.test(passwordValue)) {
|
||||
return true
|
||||
if (!formValues.confirmPassword) {
|
||||
messages.push(validationHints?.confirmPasswordMissing ?? alerts.missingFields)
|
||||
}
|
||||
|
||||
if (passwordValue !== confirmValue) {
|
||||
return true
|
||||
if (formValues.password && !PASSWORD_STRENGTH_PATTERN.test(formValues.password)) {
|
||||
messages.push(validationHints?.passwordWeak ?? alerts.weakPassword ?? alerts.genericError)
|
||||
}
|
||||
|
||||
if (!agreementAccepted) {
|
||||
return true
|
||||
if (
|
||||
formValues.password &&
|
||||
formValues.confirmPassword &&
|
||||
formValues.password !== formValues.confirmPassword
|
||||
) {
|
||||
messages.push(validationHints?.passwordMismatch ?? alerts.passwordMismatch)
|
||||
}
|
||||
|
||||
return false
|
||||
if (!formValues.agreement) {
|
||||
messages.push(
|
||||
validationHints?.agreementRequired ?? alerts.agreementRequired ?? alerts.missingFields,
|
||||
)
|
||||
}
|
||||
|
||||
const uniqueMessages = Array.from(new Set(messages.filter(Boolean)))
|
||||
return { disabled: uniqueMessages.length > 0, messages: uniqueMessages }
|
||||
}
|
||||
|
||||
if (!isVerified) {
|
||||
return codeDigits.some((digit) => !digit)
|
||||
if (codeDigits.some((digit) => !digit)) {
|
||||
messages.push(
|
||||
validationHints?.codeIncomplete ??
|
||||
alerts.codeRequired ??
|
||||
alerts.invalidCode ??
|
||||
alerts.missingFields,
|
||||
)
|
||||
}
|
||||
|
||||
const uniqueMessages = Array.from(new Set(messages.filter(Boolean)))
|
||||
return { disabled: uniqueMessages.length > 0, messages: uniqueMessages }
|
||||
}
|
||||
|
||||
return codeDigits.some((digit) => !digit) || !pendingPassword
|
||||
}, [codeDigits, hasRequestedCode, isSubmitting, isVerified, pendingPassword])
|
||||
if (codeDigits.some((digit) => !digit)) {
|
||||
messages.push(
|
||||
validationHints?.codeIncomplete ??
|
||||
alerts.codeRequired ??
|
||||
alerts.invalidCode ??
|
||||
alerts.missingFields,
|
||||
)
|
||||
}
|
||||
|
||||
if (!pendingPassword) {
|
||||
messages.push(validationHints?.passwordUnavailable ?? alerts.genericError)
|
||||
}
|
||||
|
||||
const uniqueMessages = Array.from(new Set(messages.filter(Boolean)))
|
||||
return { disabled: uniqueMessages.length > 0, messages: uniqueMessages }
|
||||
}, [
|
||||
alerts,
|
||||
codeDigits,
|
||||
formValues,
|
||||
hasRequestedCode,
|
||||
isFormReady,
|
||||
isSubmitting,
|
||||
isVerificationStep,
|
||||
isVerified,
|
||||
pendingPassword,
|
||||
t.form.completeSubmit,
|
||||
t.form.completing,
|
||||
t.form.submit,
|
||||
t.form.submitting,
|
||||
t.form.verifySubmit,
|
||||
t.form.verifying,
|
||||
validationHints,
|
||||
])
|
||||
const isSubmitDisabled = validationState.disabled
|
||||
const validationMessages = validationState.messages
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
@ -706,6 +803,8 @@ export default function RegisterContent() {
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
|
||||
required
|
||||
disabled={isVerificationStep}
|
||||
value={formValues.email}
|
||||
onChange={handleInputChange('email')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
@ -722,6 +821,8 @@ export default function RegisterContent() {
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
|
||||
required={!isVerificationStep}
|
||||
disabled={isVerificationStep}
|
||||
value={formValues.password}
|
||||
onChange={handleInputChange('password')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -737,6 +838,8 @@ export default function RegisterContent() {
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
|
||||
required={!isVerificationStep}
|
||||
disabled={isVerificationStep}
|
||||
value={formValues.confirmPassword}
|
||||
onChange={handleInputChange('confirmPassword')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -796,6 +899,8 @@ export default function RegisterContent() {
|
||||
required={!isVerificationStep}
|
||||
disabled={isVerificationStep}
|
||||
className="mt-1 h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
checked={formValues.agreement}
|
||||
onChange={handleAgreementChange}
|
||||
/>
|
||||
<span>
|
||||
{t.form.agreement}{' '}
|
||||
@ -804,6 +909,19 @@ export default function RegisterContent() {
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
{validationMessages.length > 0 ? (
|
||||
<div
|
||||
className="rounded-2xl border border-slate-200 bg-white/80 px-4 py-3 text-sm text-slate-600"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
{validationMessages.map((message) => (
|
||||
<li key={message}>{message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitDisabled}
|
||||
|
||||
@ -230,6 +230,21 @@ type AuthRegisterTranslation = {
|
||||
verificationCodeDescription?: string
|
||||
verificationCodeResend: string
|
||||
verificationCodeResending?: string
|
||||
validation?: {
|
||||
initializing?: string
|
||||
submitting?: string
|
||||
verifying?: string
|
||||
completing?: string
|
||||
emailMissing?: string
|
||||
emailInvalid?: string
|
||||
passwordMissing?: string
|
||||
confirmPasswordMissing?: string
|
||||
passwordWeak?: string
|
||||
passwordMismatch?: string
|
||||
agreementRequired?: string
|
||||
codeIncomplete?: string
|
||||
passwordUnavailable?: string
|
||||
}
|
||||
}
|
||||
social: {
|
||||
title: string
|
||||
@ -690,6 +705,21 @@ export const translations: Record<'en' | 'zh', Translation> = {
|
||||
verificationCodeDescription: 'Enter the 6-digit code sent to your email. It expires in 10 minutes.',
|
||||
verificationCodeResend: 'Resend',
|
||||
verificationCodeResending: 'Resending…',
|
||||
validation: {
|
||||
initializing: 'Loading the registration form…',
|
||||
submitting: 'Submitting your registration…',
|
||||
verifying: 'Verifying the code you entered…',
|
||||
completing: 'Finalizing your registration…',
|
||||
emailMissing: 'Enter your work email to continue.',
|
||||
emailInvalid: 'The email format looks incorrect.',
|
||||
passwordMissing: 'Enter and confirm your password to continue.',
|
||||
confirmPasswordMissing: 'Re-enter your password in the confirmation field.',
|
||||
passwordWeak: 'Use at least 8 characters that include both letters and numbers.',
|
||||
passwordMismatch: 'The two password entries must match exactly.',
|
||||
agreementRequired: 'You must accept the terms to continue.',
|
||||
codeIncomplete: 'Enter the complete 6-digit verification code sent to your email.',
|
||||
passwordUnavailable: 'Your password is missing. Restart the registration flow.',
|
||||
},
|
||||
},
|
||||
social: {
|
||||
title: 'Or continue with',
|
||||
@ -1307,6 +1337,21 @@ export const translations: Record<'en' | 'zh', Translation> = {
|
||||
verificationCodeDescription: '请输入发送到注册邮箱的 6 位数字验证码,10 分钟内有效。',
|
||||
verificationCodeResend: '重发',
|
||||
verificationCodeResending: '重发中…',
|
||||
validation: {
|
||||
initializing: '正在载入注册表单…',
|
||||
submitting: '正在提交注册请求…',
|
||||
verifying: '正在校验验证码…',
|
||||
completing: '正在完成注册…',
|
||||
emailMissing: '请输入邮箱地址以继续。',
|
||||
emailInvalid: '邮箱格式看起来不正确。',
|
||||
passwordMissing: '请输入密码并再次确认后继续。',
|
||||
confirmPasswordMissing: '请在确认密码栏中再次输入密码。',
|
||||
passwordWeak: '密码至少 8 位,并同时包含字母和数字。',
|
||||
passwordMismatch: '两次输入的密码必须完全一致。',
|
||||
agreementRequired: '请先勾选同意条款后再继续。',
|
||||
codeIncomplete: '请输入邮箱收到的完整 6 位验证码。',
|
||||
passwordUnavailable: '密码信息缺失,请重新开始注册流程。',
|
||||
},
|
||||
},
|
||||
social: {
|
||||
title: '或选择以下方式',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user