diff --git a/apps/web/src/features/auth/components/register-page/RegisterPage.stories.tsx b/apps/web/src/features/auth/components/register-page/RegisterPage.stories.tsx new file mode 100644 index 000000000..c490cea12 --- /dev/null +++ b/apps/web/src/features/auth/components/register-page/RegisterPage.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { RegisterPage } from './RegisterPage'; +import { RegisterPageSkeleton } from './RegisterPageSkeleton'; + +const meta: Meta = { + title: 'App/Pages/Auth/RegisterPage', + component: RegisterPage, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: "Page d'inscription avec validation complète et vérification email.", + }, + }, + }, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +/** Formulaire vide prêt à être rempli. */ +export const Default: Story = { name: 'Par défaut' }; + +/** Skeleton pendant chargement (layout aligné). */ +export const Loading: Story = { + name: 'Loading', + render: () => , +}; + +/** Erreur soumission (simulée via MSW si besoin). */ +export const WithError: Story = { + name: 'Avec erreur', + parameters: { + docs: { + description: { + story: "Démontre l'affichage d'erreurs de validation en temps réel.", + }, + }, + }, +}; diff --git a/apps/web/src/features/auth/components/register-page/RegisterPage.tsx b/apps/web/src/features/auth/components/register-page/RegisterPage.tsx new file mode 100644 index 000000000..797576315 --- /dev/null +++ b/apps/web/src/features/auth/components/register-page/RegisterPage.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuthStore } from '../../store/authStore'; +import { AuthLayout } from '../AuthLayout'; +import { useRegisterPage } from './useRegisterPage'; +import { RegisterPageForm } from './RegisterPageForm'; +import { RegisterPageVerificationNotice } from './RegisterPageVerificationNotice'; + +export function RegisterPage() { + const { isAuthenticated } = useAuthStore(); + const { + formData, + errors, + acceptedTerms, + setAcceptedTerms, + setErrors, + showVerificationNotice, + usernameAvailable, + checkingUsername, + loading, + error, + resendLoading, + resendSuccess, + handleChange, + handleBlur, + onSubmit, + handleResendVerificationEmail, + } = useRegisterPage(); + + if (isAuthenticated) { + return ; + } + + return ( + + {showVerificationNotice ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/web/src/features/auth/components/register-page/RegisterPageForm.tsx b/apps/web/src/features/auth/components/register-page/RegisterPageForm.tsx new file mode 100644 index 000000000..f8165c2d3 --- /dev/null +++ b/apps/web/src/features/auth/components/register-page/RegisterPageForm.tsx @@ -0,0 +1,199 @@ +import { Link } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Loader2 } from 'lucide-react'; +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) { + return ( +
+ {error && ( +
+ {error.message} +
+ )} +
+ + onFieldChange('username', e.target.value)} + onBlur={() => onFieldBlur('username')} + required + autoComplete="username" + aria-invalid={errors.username ? 'true' : 'false'} + /> + {errors.username && ( +

+ {errors.username} +

+ )} + {formData.username.length >= 3 && ( +
+ {checkingUsername ? ( +

+ Vérification en cours + Vérification en cours... +

+ ) : usernameAvailable === true ? ( +

+ Disponible: + Ce nom d'utilisateur est disponible +

+ ) : usernameAvailable === false ? ( +

+ Indisponible: + Ce nom d'utilisateur est déjà pris +

+ ) : null} +
+ )} +
+
+ + onFieldChange('email', e.target.value)} + onBlur={() => onFieldBlur('email')} + required + autoComplete="email" + aria-invalid={errors.email ? 'true' : 'false'} + /> + {errors.email && ( +

+ {errors.email} +

+ )} +
+
+ + onFieldChange('password', e.target.value)} + onBlur={() => onFieldBlur('password')} + required + autoComplete="new-password" + aria-invalid={errors.password ? 'true' : 'false'} + /> + {errors.password && ( +

+ {errors.password} +

+ )} + +
+
+ + onFieldChange('password_confirm', e.target.value)} + onBlur={() => onFieldBlur('password_confirm')} + required + autoComplete="new-password" + aria-invalid={errors.password_confirm ? 'true' : 'false'} + /> + {errors.password_confirm && ( +

+ {errors.password_confirm} +

+ )} +
+
+ { + onAcceptedTermsChange(checked as boolean); + if (errors.terms) onErrorsChange((prev) => ({ ...prev, terms: undefined })); + }} + required + aria-invalid={errors.terms ? 'true' : 'false'} + aria-describedby={errors.terms ? 'terms-error' : 'terms-description'} + /> + +
+

+ Vous devez accepter les conditions d'utilisation et la politique de confidentialité pour créer un compte +

+ {errors.terms && ( + + )} + +
+ ); +} diff --git a/apps/web/src/features/auth/components/register-page/RegisterPageSkeleton.tsx b/apps/web/src/features/auth/components/register-page/RegisterPageSkeleton.tsx new file mode 100644 index 000000000..6bfbc92f1 --- /dev/null +++ b/apps/web/src/features/auth/components/register-page/RegisterPageSkeleton.tsx @@ -0,0 +1,30 @@ +/** + * Skeleton aligned with RegisterPage form layout (AuthLayout + form fields). + */ +export function RegisterPageSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/features/auth/components/register-page/RegisterPageVerificationNotice.tsx b/apps/web/src/features/auth/components/register-page/RegisterPageVerificationNotice.tsx new file mode 100644 index 000000000..aaa52c292 --- /dev/null +++ b/apps/web/src/features/auth/components/register-page/RegisterPageVerificationNotice.tsx @@ -0,0 +1,56 @@ +import { Button } from '@/components/ui/button'; +import { Loader2 } from 'lucide-react'; + +interface RegisterPageVerificationNoticeProps { + email: string; + resendLoading: boolean; + resendSuccess: boolean; + onResend: () => void; +} + +export function RegisterPageVerificationNotice({ + email, + resendLoading, + resendSuccess, + onResend, +}: RegisterPageVerificationNoticeProps) { + return ( +
+
+

Inscription réussie !

+

+ Un email de vérification a été envoyé à {email} +

+
+

+ Veuillez vérifier votre boîte mail et cliquer sur le lien de vérification. +

+ {resendSuccess && ( +

+ Email de vérification renvoyé avec succès ! +

+ )} + +
+ ); +} diff --git a/apps/web/src/features/auth/components/register-page/index.ts b/apps/web/src/features/auth/components/register-page/index.ts new file mode 100644 index 000000000..2f537244f --- /dev/null +++ b/apps/web/src/features/auth/components/register-page/index.ts @@ -0,0 +1,2 @@ +export { RegisterPage } from './RegisterPage'; +export { RegisterPageSkeleton } from './RegisterPageSkeleton'; diff --git a/apps/web/src/features/auth/components/register-page/useRegisterPage.ts b/apps/web/src/features/auth/components/register-page/useRegisterPage.ts new file mode 100644 index 000000000..1c370b278 --- /dev/null +++ b/apps/web/src/features/auth/components/register-page/useRegisterPage.ts @@ -0,0 +1,132 @@ +import { useState, useEffect } from 'react'; +import { useRegister } from '../../hooks/useRegister'; +import { useUsernameAvailability } from '../../hooks/useUsernameAvailability'; +import { authApi } from '@/services/api/auth'; +import { logger } from '@/utils/logger'; +import type { RegisterFormData } from '../../types'; + +export type FormErrors = Partial>; + +const initialFormData: RegisterFormData = { + email: '', + password: '', + password_confirm: '', + username: '', +}; + +export function useRegisterPage() { + const { + mutate: handleRegister, + isPending: loading, + error, + isSuccess: success, + } = useRegister(); + + const [formData, setFormData] = useState(initialFormData); + const { available: usernameAvailable, checking: checkingUsername } = + useUsernameAvailability(formData.username); + const [errors, setErrors] = useState({}); + const [acceptedTerms, setAcceptedTerms] = useState(false); + const [showVerificationNotice, setShowVerificationNotice] = useState(false); + const [resendLoading, setResendLoading] = useState(false); + const [resendSuccess, setResendSuccess] = useState(false); + + useEffect(() => { + if (success) setShowVerificationNotice(true); + }, [success]); + + const validate = (): boolean => { + const newErrors: FormErrors = {}; + if (!formData.email) { + newErrors.email = 'Email requis'; + } else if (!/\S+@\S+\.\S+/.test(formData.email)) { + newErrors.email = 'Email invalide'; + } + if (!formData.username) { + newErrors.username = "Nom d'utilisateur requis"; + } else if (formData.username.length < 3) { + newErrors.username = "Le nom d'utilisateur doit contenir au moins 3 caractères"; + } else if (usernameAvailable === false) { + newErrors.username = "Ce nom d'utilisateur est déjà pris"; + } + if (!formData.password) { + newErrors.password = 'Mot de passe requis'; + } else if (formData.password.length < 12) { + newErrors.password = 'Le mot de passe doit contenir au moins 12 caractères'; + } + if (formData.password !== formData.password_confirm) { + newErrors.password_confirm = 'Les mots de passe ne correspondent pas'; + } + if (!acceptedTerms) { + newErrors.terms = + "Vous devez accepter les conditions d'utilisation et la politique de confidentialité"; + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleChange = (field: keyof RegisterFormData, value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (errors[field]) setErrors((prev) => ({ ...prev, [field]: undefined })); + }; + + const handleBlur = (field: keyof RegisterFormData) => { + const value = formData[field]; + let error: string | undefined; + switch (field) { + case 'email': + if (!value) error = 'Email requis'; + else if (!/\S+@\S+\.\S+/.test(value)) error = 'Email invalide'; + break; + case 'username': + if (!value) error = "Nom d'utilisateur requis"; + else if (value.length < 3) error = "Le nom d'utilisateur doit contenir au moins 3 caractères"; + break; + case 'password': + if (!value) error = 'Mot de passe requis'; + else if (value.length < 12) error = 'Le mot de passe doit contenir au moins 12 caractères'; + break; + case 'password_confirm': + if (formData.password !== value) error = 'Les mots de passe ne correspondent pas'; + break; + } + setErrors((prev) => ({ ...prev, [field]: error })); + }; + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (validate()) handleRegister(formData); + }; + + const handleResendVerificationEmail = async () => { + try { + setResendLoading(true); + setResendSuccess(false); + await authApi.resendVerification({ email: formData.email }); + setResendSuccess(true); + } catch (err) { + logger.error("Erreur lors du renvoi de l'email:", { error: err }); + } finally { + setResendLoading(false); + } + }; + + return { + formData, + errors, + acceptedTerms, + setAcceptedTerms, + setErrors, + showVerificationNotice, + usernameAvailable, + checkingUsername, + loading, + error, + resendLoading, + resendSuccess, + handleChange, + handleBlur, + onSubmit, + handleResendVerificationEmail, + }; +} diff --git a/apps/web/src/features/auth/pages/RegisterPage.stories.tsx b/apps/web/src/features/auth/pages/RegisterPage.stories.tsx deleted file mode 100644 index 97f9f6818..000000000 --- a/apps/web/src/features/auth/pages/RegisterPage.stories.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; -import { within, userEvent } from '@storybook/test'; -import { RegisterPage } from './RegisterPage'; -import { withRouter, withQueryClient, withToast } from '../../../stories/decorators'; - -const meta: Meta = { - title: 'App/Pages/Auth/RegisterPage', - component: RegisterPage, - parameters: { - layout: 'fullscreen', - docs: { - description: { - component: 'Page d\'inscription avec validation complète et vérification email.', - }, - }, - }, - tags: ['autodocs'], - decorators: [ - withRouter, - withQueryClient, - withToast, - ], -}; - -export default meta; -type Story = StoryObj; - -/** - * État par défaut de la page d'inscription. - * Formulaire vide prêt à être rempli. - */ -export const Default: Story = { - name: 'Par défaut', -}; - -/** - * Simulation d'une erreur d'inscription. - * Par exemple, nom d'utilisateur déjà pris. - */ -export const WithError: Story = { - name: 'Avec erreur', - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Remplir les champs - const usernameInput = canvas.getByLabelText(/nom d'utilisateur/i); - const emailInput = canvas.getByLabelText(/email/i); - const passwordInput = canvas.getByLabelText(/^mot de passe \*/i); - const confirmPasswordInput = canvas.getByLabelText(/confirmer le mot de passe/i); - - await userEvent.type(usernameInput, 'existinguser'); - await userEvent.type(emailInput, 'new@veza.music'); - await userEvent.type(passwordInput, 'SecurePass123!@#'); - await userEvent.type(confirmPasswordInput, 'SecurePass123!@#'); - - // L'erreur de disponibilité sera affichée via le hook useUsernameAvailability - }, - parameters: { - docs: { - description: { - story: 'Démontre l\'affichage d\'erreurs de validation en temps réel.', - }, - }, - }, -}; - -/** - * État de succès après inscription. - * Affiche le message de vérification email. - */ -export const Success: Story = { - name: 'Succès - Vérification email', - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Remplir tous les champs avec des données valides - const usernameInput = canvas.getByLabelText(/nom d'utilisateur/i); - const emailInput = canvas.getByLabelText(/email/i); - const passwordInput = canvas.getByLabelText(/^mot de passe \*/i); - const confirmPasswordInput = canvas.getByLabelText(/confirmer le mot de passe/i); - const termsCheckbox = canvas.getByRole('checkbox'); - - await userEvent.type(usernameInput, 'newartist'); - await userEvent.type(emailInput, 'artist@veza.music'); - await userEvent.type(passwordInput, 'SuperSecure123!@#'); - await userEvent.type(confirmPasswordInput, 'SuperSecure123!@#'); - await userEvent.click(termsCheckbox); - - // Le succès sera affiché après la mutation réussie - }, - parameters: { - docs: { - description: { - story: 'Montre le message de succès avec instructions de vérification email.', - }, - }, - }, -}; diff --git a/apps/web/src/features/auth/pages/RegisterPage.tsx b/apps/web/src/features/auth/pages/RegisterPage.tsx index 60183c431..cadddd34a 100644 --- a/apps/web/src/features/auth/pages/RegisterPage.tsx +++ b/apps/web/src/features/auth/pages/RegisterPage.tsx @@ -1,380 +1,4 @@ -import React, { useState, useEffect } from 'react'; -import { Navigate, Link } from 'react-router-dom'; -import { useAuthStore } from '../store/authStore'; -import { AuthLayout } from '../components/AuthLayout'; -import { PasswordStrengthIndicator } from '../components/PasswordStrengthIndicator'; -import { useRegister } from '../hooks/useRegister'; -import { useUsernameAvailability } from '../hooks/useUsernameAvailability'; -import { authApi } from '@/services/api/auth'; -import { logger } from '@/utils/logger'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Loader2 } from 'lucide-react'; -import type { RegisterFormData } from '../types'; - -export function RegisterPage() { - const { isAuthenticated } = useAuthStore(); - const { - mutate: handleRegister, - isPending: loading, - error, - isSuccess: success, - } = useRegister(); - const [formData, setFormData] = useState({ - email: '', - password: '', - password_confirm: '', - username: '', - }); - const { available: usernameAvailable, checking: checkingUsername } = - useUsernameAvailability(formData.username); - const [errors, setErrors] = useState< - Partial> - >({}); - const [acceptedTerms, setAcceptedTerms] = useState(false); - const [showVerificationNotice, setShowVerificationNotice] = useState(false); - const [resendLoading, setResendLoading] = useState(false); - const [resendSuccess, setResendSuccess] = useState(false); - - // Détecter quand l'inscription réussit - useEffect(() => { - if (success) { - setShowVerificationNotice(true); - } - }, [success]); - - // Rediriger si déjà connecté - if (isAuthenticated) { - return ; - } - - const validate = (): boolean => { - const newErrors: Partial> = - {}; - - if (!formData.email) { - newErrors.email = 'Email requis'; - } else if (!/\S+@\S+\.\S+/.test(formData.email)) { - newErrors.email = 'Email invalide'; - } - - if (!formData.username) { - newErrors.username = "Nom d'utilisateur requis"; - } else if (formData.username.length < 3) { - newErrors.username = - "Le nom d'utilisateur doit contenir au moins 3 caractères"; - } else if (usernameAvailable === false) { - newErrors.username = "Ce nom d'utilisateur est déjà pris"; - } - - if (!formData.password) { - newErrors.password = 'Mot de passe requis'; - } else if (formData.password.length < 12) { - newErrors.password = - 'Le mot de passe doit contenir au moins 12 caractères'; - } - - if (formData.password !== formData.password_confirm) { - newErrors.password_confirm = 'Les mots de passe ne correspondent pas'; - } - - if (!acceptedTerms) { - newErrors.terms = - "Vous devez accepter les conditions d'utilisation et la politique de confidentialité"; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const handleChange = (field: keyof RegisterFormData, value: string) => { - setFormData({ ...formData, [field]: value }); - // Clear error for this field when user starts typing - if (errors[field]) { - setErrors({ ...errors, [field]: undefined }); - } - }; - - const handleBlur = (field: keyof RegisterFormData) => { - const value = formData[field]; - let error: string | undefined; - - switch (field) { - case 'email': - if (!value) { - error = 'Email requis'; - } else if (!/\S+@\S+\.\S+/.test(value)) { - error = 'Email invalide'; - } - break; - case 'username': - if (!value) { - error = "Nom d'utilisateur requis"; - } else if (value.length < 3) { - error = "Le nom d'utilisateur doit contenir au moins 3 caractères"; - } - break; - case 'password': - if (!value) { - error = 'Mot de passe requis'; - } else if (value.length < 12) { - error = 'Le mot de passe doit contenir au moins 12 caractères'; - } - break; - case 'password_confirm': - if (formData.password !== value) { - error = 'Les mots de passe ne correspondent pas'; - } - break; - } - - if (error) { - setErrors({ ...errors, [field]: error }); - } else { - setErrors({ ...errors, [field]: undefined }); - } - }; - - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (validate()) { - await handleRegister(formData); - } - }; - - const handleResendVerificationEmail = async () => { - try { - setResendLoading(true); - setResendSuccess(false); - await authApi.resendVerification({ email: formData.email }); - setResendSuccess(true); - } catch (err) { - // Gérer l'erreur silencieusement ou afficher un message - logger.error("Erreur lors du renvoi de l'email:", { error: err }); - } finally { - setResendLoading(false); - } - }; - - return ( - - {showVerificationNotice ? ( -
-
-

Inscription réussie !

-

- Un email de vérification a été envoyé à {formData.email} -

-
-

- Veuillez vérifier votre boîte mail et cliquer sur le lien de - vérification. -

- {resendSuccess && ( -

- Email de vérification renvoyé avec succès ! -

- )} - -
- ) : ( -
- {error && ( -
- {error.message} -
- )} -
- - handleChange('username', e.target.value)} - onBlur={() => handleBlur('username')} - required - autoComplete="username" - aria-invalid={errors.username ? 'true' : 'false'} - /> - {errors.username && ( -

- {errors.username} -

- )} - {formData.username.length >= 3 && ( -
- {checkingUsername ? ( -

- Vérification en cours - -

- ) : usernameAvailable === true ? ( -

- Disponible: - Ce nom d'utilisateur est - disponible -

- ) : usernameAvailable === false ? ( -

- Indisponible: - Ce nom d'utilisateur est - déjà pris -

- ) : null} -
- )} -
-
- - handleChange('email', e.target.value)} - onBlur={() => handleBlur('email')} - required - autoComplete="email" - aria-invalid={errors.email ? 'true' : 'false'} - /> - {errors.email && ( -

- {errors.email} -

- )} -
-
- - handleChange('password', e.target.value)} - onBlur={() => handleBlur('password')} - required - autoComplete="new-password" - aria-invalid={errors.password ? 'true' : 'false'} - /> - {errors.password && ( -

- {errors.password} -

- )} - -
-
- - handleChange('password_confirm', e.target.value)} - onBlur={() => handleBlur('password_confirm')} - required - autoComplete="new-password" - aria-invalid={errors.password_confirm ? 'true' : 'false'} - /> - {errors.password_confirm && ( -

- {errors.password_confirm} -

- )} -
-
- { - setAcceptedTerms(checked as boolean); - if (errors.terms) { - setErrors({ ...errors, terms: undefined }); - } - }} - required - aria-invalid={errors.terms ? 'true' : 'false'} - aria-describedby={ - errors.terms ? 'terms-error' : 'terms-description' - } - /> - -
-

- Vous devez accepter les conditions d'utilisation et la politique de - confidentialité pour créer un compte -

- {errors.terms && ( - - )} - -
- )} -
- ); -} - -export default RegisterPage; +/** + * Register page — re-export from feature module. + */ +export { RegisterPage } from '../components/register-page';