feat: show real-time register validation hints (#625)

This commit is contained in:
shenlan 2025-11-01 22:14:59 +08:00 committed by GitHub
parent 4fd281f2b6
commit e3a5d4f75c
2 changed files with 196 additions and 33 deletions

View File

@ -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}

View File

@ -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: '或选择以下方式',