Files
NightSpire/web-next/app/[locale]/create-account/RegisterForm.tsx
T
Inna 8ca3975cac create-account: desactivar validación nativa (noValidate) para usar red-response
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>
2026-07-14 20:52:12 +00:00

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>
</>
)
}