veza/apps/web/src/features/auth/components/register-page/RegisterPageForm.tsx
senke 9a4c0d2af4 feat(web): update all features, stories, e2e tests, and auth interceptor
Update auth, playlists, tracks, search, profile, dashboard, player,
settings, and social features. Add e2e audit specs for all major pages.
Update ESLint config, vitest config, and route configuration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:16:36 +02:00

201 lines
7 KiB
TypeScript

import { Link } from 'react-router-dom';
import { Checkbox } from '@/components/ui/checkbox';
import { AlertCircle, Loader2, Check, X } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
import { AuthInput } from '../AuthInput';
import { AuthButton } from '../AuthButton';
import { PasswordStrengthIndicator } from '../PasswordStrengthIndicator';
import type { RegisterFormData } from '../../types';
import type { FormErrors } from './useRegisterPage';
interface RegisterPageFormProps {
formData: RegisterFormData;
errors: FormErrors;
acceptedTerms: boolean;
onAcceptedTermsChange: (checked: boolean) => void;
onErrorsChange: (updater: (prev: FormErrors) => FormErrors) => void;
loading: boolean;
error: Error | null;
usernameAvailable: boolean | null;
checkingUsername: boolean;
onFieldChange: (field: keyof RegisterFormData, value: string) => void;
onFieldBlur: (field: keyof RegisterFormData) => void;
onSubmit: (e: React.FormEvent) => void;
}
export function RegisterPageForm({
formData,
errors,
acceptedTerms,
onAcceptedTermsChange,
onErrorsChange,
loading,
error,
usernameAvailable,
checkingUsername,
onFieldChange,
onFieldBlur,
onSubmit,
}: RegisterPageFormProps) {
const { t } = useTranslation();
return (
<form
onSubmit={onSubmit}
className="space-y-4"
aria-label={t('auth.register.formAriaLabel')}
data-testid="register-form"
>
{error && (
<div
className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded-lg text-sm flex items-center gap-2 animate-in fade-in slide-in-from-top-1"
role="alert"
aria-live="assertive"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<p>{error.message}</p>
</div>
)}
<div className="space-y-4">
{/* Username */}
<div>
<AuthInput
id="register-username"
type="text"
label={t('auth.register.username')}
value={formData.username}
onChange={(e) => onFieldChange('username', e.target.value)}
onBlur={() => onFieldBlur('username')}
required
autoComplete="username"
error={errors.username}
/>
{formData.username.length >= 3 && (
<div className="mt-1.5" aria-live="polite" aria-atomic="true">
{checkingUsername ? (
<p className="text-xs text-muted-foreground flex items-center gap-1.5" role="status">
<span className="h-3 w-3 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" />
<span>{t('auth.register.usernameCheck.checking')}</span>
</p>
) : usernameAvailable === true ? (
<p className="text-xs text-success flex items-center gap-1.5" role="status">
<Check className="h-3 w-3" />
<span>{t('auth.register.usernameCheck.available')}</span>
</p>
) : usernameAvailable === false ? (
<p className="text-xs text-destructive flex items-center gap-1.5" role="alert">
<X className="h-3 w-3" />
<span>{t('auth.register.usernameCheck.unavailable')}</span>
</p>
) : null}
</div>
)}
</div>
{/* Email */}
<AuthInput
id="register-email"
type="email"
label={t('auth.register.email')}
value={formData.email}
onChange={(e) => onFieldChange('email', e.target.value)}
onBlur={() => onFieldBlur('email')}
required
autoComplete="email"
error={errors.email}
/>
{/* Password */}
<div>
<AuthInput
id="register-password"
type="password"
label={t('auth.register.password')}
value={formData.password}
onChange={(e) => onFieldChange('password', e.target.value)}
onBlur={() => onFieldBlur('password')}
required
autoComplete="new-password"
showPasswordToggle
error={errors.password}
/>
<PasswordStrengthIndicator password={formData.password} />
</div>
{/* Confirm password */}
<AuthInput
id="register-password_confirm"
type="password"
label={t('auth.register.confirmPassword')}
value={formData.password_confirm}
onChange={(e) => onFieldChange('password_confirm', e.target.value)}
onBlur={() => onFieldBlur('password_confirm')}
required
autoComplete="new-password"
showPasswordToggle
error={errors.password_confirm}
/>
</div>
{/* Terms */}
<div className="flex items-start gap-3 pt-1">
<div className="pt-0.5">
<Checkbox
id="register-terms"
checked={acceptedTerms}
onCheckedChange={(checked) => {
onAcceptedTermsChange(checked as boolean);
if (errors.terms) onErrorsChange((prev) => ({ ...prev, terms: undefined }));
}}
required
aria-invalid={errors.terms ? 'true' : 'false'}
aria-describedby={errors.terms ? 'terms-description terms-error' : 'terms-description'}
/>
</div>
<label htmlFor="register-terms" className="text-sm text-muted-foreground leading-relaxed cursor-pointer inline-flex flex-wrap gap-x-1">
<span>{t('auth.register.terms.accept')}</span>
<Link
to="/terms"
className="text-foreground hover:underline underline-offset-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
aria-label={t('auth.register.terms.termsAriaLabel')}
>
{t('auth.register.terms.termsOfService')}
</Link>
<span>{t('auth.register.terms.and')}</span>
<Link
to="/privacy"
className="text-foreground hover:underline underline-offset-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
aria-label={t('auth.register.terms.privacyAriaLabel')}
>
{t('auth.register.terms.privacyPolicy')}
</Link>
</label>
</div>
<p id="terms-description" className="sr-only">
{t('auth.register.terms.description')}
</p>
{errors.terms && (
<p id="terms-error" className="text-sm text-destructive animate-shake" role="alert">
{errors.terms}
</p>
)}
<AuthButton
type="submit"
loading={loading}
className="w-full bg-primary text-primary-foreground hover:opacity-90 shadow-sm"
data-testid="register-submit"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('auth.register.loadingText')}
</>
) : (
t('auth.register.registerButton')
)}
</AuthButton>
</form>
);
}