8ca3975cac
El navegador mostraba su tooltip nativo "Rellene este campo" en los inputs required en vez del mensaje de error del sitio. Con noValidate en el form, la validación en cliente (clientError) muestra el red-form-response también para campos vacíos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
6.5 KiB
TypeScript
203 lines
6.5 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { Link } from '@/i18n/navigation'
|
|
import { Turnstile } from '@/components/Turnstile'
|
|
|
|
const ERROR_KEYS = [
|
|
'missingFields',
|
|
'passwordMismatch',
|
|
'passwordTooLong',
|
|
'invalidEmail',
|
|
'emailExists',
|
|
'recruiterNotFound',
|
|
'captchaFailed',
|
|
] as const
|
|
|
|
/** Campo de contraseña con ojo para mostrar/ocultar. */
|
|
function PasswordInput({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
ariaLabel,
|
|
blockPaste,
|
|
}: {
|
|
value: string
|
|
onChange: (v: string) => void
|
|
placeholder: string
|
|
ariaLabel: string
|
|
blockPaste?: boolean
|
|
}) {
|
|
const [show, setShow] = useState(false)
|
|
const noPaste = blockPaste ? (e: React.ClipboardEvent) => e.preventDefault() : undefined
|
|
return (
|
|
<>
|
|
<input
|
|
type={show ? 'text' : 'password'}
|
|
maxLength={16}
|
|
placeholder={placeholder}
|
|
required
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onPaste={noPaste}
|
|
onCopy={noPaste}
|
|
onCut={noPaste}
|
|
/>{' '}
|
|
<span
|
|
className={`far ${show ? 'fa-eye-slash' : 'fa-eye'} toggle-password`}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={ariaLabel}
|
|
onClick={() => setShow((s) => !s)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function RegisterForm() {
|
|
const t = useTranslations('Register')
|
|
const [form, setForm] = useState({ password: '', confPassword: '', email: '', confEmail: '', recruiter: '' })
|
|
const [accepted, setAccepted] = useState(false)
|
|
const [notUs, setNotUs] = useState(false)
|
|
const [busy, setBusy] = useState(false)
|
|
const [captcha, setCaptcha] = useState('')
|
|
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
|
|
|
|
const canSubmit = accepted && notUs && !busy
|
|
|
|
function upd(k: string, v: string) {
|
|
setForm((f) => ({ ...f, [k]: v }))
|
|
}
|
|
|
|
/** Validación en cliente: muestra el error en rojo al instante, sin ir al servidor. */
|
|
function clientError(): string | null {
|
|
if (!form.password || !form.confPassword || !form.email || !form.confEmail) return 'missingFields'
|
|
if (form.password !== form.confPassword) return 'passwordMismatch'
|
|
if (form.password.length > 16) return 'passwordTooLong'
|
|
if (form.email.trim() !== form.confEmail.trim()) return 'emailMismatch'
|
|
return null
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!canSubmit) return
|
|
const err = clientError()
|
|
if (err) {
|
|
setMessage({ ok: false, text: t(err) })
|
|
return
|
|
}
|
|
setBusy(true)
|
|
setMessage(null)
|
|
try {
|
|
const res = await fetch('/api/auth/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ ...form, turnstileToken: captcha }),
|
|
})
|
|
const data: { success?: boolean; error?: string } = await res.json()
|
|
if (data.success) {
|
|
setMessage({ ok: true, text: t('success') })
|
|
} else {
|
|
const key = (ERROR_KEYS as readonly string[]).includes(data.error ?? '') ? data.error! : 'genericError'
|
|
setMessage({ ok: false, text: t(key) })
|
|
}
|
|
} catch {
|
|
setMessage({ ok: false, text: t('genericError') })
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const noPaste = (e: React.ClipboardEvent) => e.preventDefault()
|
|
|
|
return (
|
|
<>
|
|
<form onSubmit={handleSubmit} acceptCharset="utf-8" noValidate>
|
|
<table className="middle-center-table">
|
|
<tbody>
|
|
<tr>
|
|
<td>
|
|
<PasswordInput value={form.password} onChange={(v) => upd('password', v)} placeholder={t('password')} ariaLabel={t('showPassword')} />
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<PasswordInput value={form.confPassword} onChange={(v) => upd('confPassword', v)} placeholder={t('confPassword')} ariaLabel={t('showPassword')} blockPaste />
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<input type="email" maxLength={320} placeholder={t('email')} required value={form.email} onChange={(e) => upd('email', e.target.value)} />
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<input
|
|
type="email"
|
|
maxLength={320}
|
|
placeholder={t('confEmail')}
|
|
required
|
|
value={form.confEmail}
|
|
onChange={(e) => upd('confEmail', e.target.value)}
|
|
onPaste={noPaste}
|
|
onCopy={noPaste}
|
|
onCut={noPaste}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<hr />
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td className="second-brown">{t('recruiterOptional')}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<input type="text" maxLength={12} placeholder={t('recruiter')} value={form.recruiter} onChange={(e) => upd('recruiter', e.target.value)} />
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<Turnstile onVerify={setCaptcha} />
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td className="lefted separate">
|
|
<label>
|
|
<input type="checkbox" className="terms-check" checked={accepted} onChange={(e) => setAccepted(e.target.checked)} />{' '}
|
|
{t.rich('termsRich', {
|
|
terms: (c) => <Link href="/terms-and-conditions" target="_blank">{c}</Link>,
|
|
privacy: (c) => <Link href="/privacy-policy" target="_blank">{c}</Link>,
|
|
})}
|
|
</label>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td className="lefted separate">
|
|
<label>
|
|
<input type="checkbox" className="terms-check" checked={notUs} onChange={(e) => setNotUs(e.target.checked)} /> {t('notUs')}
|
|
</label>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<button type="submit" className="create-button" disabled={!canSubmit}>
|
|
{busy ? t('creating') : t('submit')}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</form>
|
|
<hr />
|
|
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
|
{message && <span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|