361 lines
18 KiB
JavaScript
361 lines
18 KiB
JavaScript
import { useState, useRef } from 'react'
|
|
import { Link, useNavigate } from 'react-router-dom'
|
|
import { registerApi, verifyCodeApi } from '../services/auth'
|
|
import { useAuth } from '../context/AuthContext'
|
|
import usePageMeta from '../hooks/usePageMeta'
|
|
import Captcha from '../components/Captcha'
|
|
import { CAPTCHA_ENABLED } from '../config/captcha'
|
|
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
|
|
import PasswordRules from '../components/PasswordRules'
|
|
|
|
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
|
|
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
|
|
|
|
// Le jeu est réservé aux personnes majeures.
|
|
function hasAtLeast18Years(birthdate) {
|
|
const birth = new Date(birthdate)
|
|
const today = new Date()
|
|
let age = today.getFullYear() - birth.getFullYear()
|
|
const monthDiff = today.getMonth() - birth.getMonth()
|
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
|
age--
|
|
}
|
|
return age >= 18
|
|
}
|
|
|
|
// Valeurs alignées sur le tableau de bord admin, qui compte exactement
|
|
// "Homme" et "Femme" ; toute autre valeur retombe dans la catégorie "autre".
|
|
const GENRES = ['Femme', 'Homme', 'Autre']
|
|
|
|
export default function Inscription() {
|
|
usePageMeta({
|
|
title: 'Créer un compte | Thé Tip Top',
|
|
description: "Inscrivez-vous en quelques instants pour participer au jeu-concours 100 % Gagnant Thé Tip Top et saisir le code de votre ticket de caisse.",
|
|
})
|
|
|
|
const navigate = useNavigate()
|
|
const { login } = useAuth()
|
|
|
|
// Étape : 'form' | 'verify'
|
|
const [step, setStep] = useState('form')
|
|
const [email, setEmail] = useState('')
|
|
const [form, setForm] = useState({
|
|
email: '',
|
|
lastName: '',
|
|
firstName: '',
|
|
password: '',
|
|
confirmPassword: '',
|
|
birthdate: '',
|
|
gender: '',
|
|
})
|
|
const [acceptedTerms, setAcceptedTerms] = useState(false)
|
|
// Consentement marketing : distinct des CGU et décoché par défaut, il doit
|
|
// rester un choix libre et non une condition d'inscription.
|
|
const [marketingOptIn, setMarketingOptIn] = useState(false)
|
|
const [showPassword, setShowPassword] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [captchaToken, setCaptchaToken] = useState('')
|
|
const captchaRef = useRef()
|
|
|
|
// Code à 4 chiffres
|
|
const [digits, setDigits] = useState(['', '', '', ''])
|
|
const [codeError, setCodeError] = useState('')
|
|
const [codeLoading, setCodeLoading] = useState(false)
|
|
const inputRefs = [useRef(), useRef(), useRef(), useRef()]
|
|
|
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
|
|
|
// Signalé dès que la confirmation est saisie, sans attendre l'envoi.
|
|
const passwordMismatch = form.confirmPassword !== '' && form.password !== form.confirmPassword
|
|
|
|
// Même route que la connexion : le provider crée le compte à la première venue.
|
|
const handleOAuthSignup = (provider) => {
|
|
const apiUrl = import.meta.env.VITE_API_URL || ''
|
|
window.location.href = `${apiUrl}/api/auth/${provider}`
|
|
}
|
|
|
|
// Étape 1 — Inscription
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault()
|
|
setError('')
|
|
|
|
if (!hasAtLeast18Years(form.birthdate)) {
|
|
setError('Vous devez avoir au moins 18 ans pour vous inscrire.')
|
|
return
|
|
}
|
|
|
|
if (!isPasswordValid(form.password)) {
|
|
setError(PASSWORD_RULES_ERROR)
|
|
return
|
|
}
|
|
|
|
if (form.password !== form.confirmPassword) {
|
|
setError('Les deux mots de passe ne correspondent pas.')
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
try {
|
|
await registerApi({
|
|
email: form.email,
|
|
firstname: form.firstName,
|
|
lastname: form.lastName,
|
|
password: form.password,
|
|
confirmPassword: form.confirmPassword,
|
|
birthdate: form.birthdate,
|
|
gender: form.gender,
|
|
marketingOptIn,
|
|
captchaToken,
|
|
})
|
|
setEmail(form.email)
|
|
setStep('verify')
|
|
} catch (err) {
|
|
setError(err.message)
|
|
// Le jeton a été consommé par le backend : on en redemande un.
|
|
captchaRef.current?.reset()
|
|
setCaptchaToken('')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// Étape 2 — Saisie du code
|
|
const handleDigit = (index, value) => {
|
|
if (!/^\d?$/.test(value)) return
|
|
const next = [...digits]
|
|
next[index] = value
|
|
setDigits(next)
|
|
if (value && index < 3) inputRefs[index + 1].current?.focus()
|
|
}
|
|
|
|
const handleDigitKeyDown = (index, e) => {
|
|
if (e.key === 'Backspace' && !digits[index] && index > 0) {
|
|
inputRefs[index - 1].current?.focus()
|
|
}
|
|
}
|
|
|
|
const handleVerify = async (e) => {
|
|
e.preventDefault()
|
|
const code = digits.join('')
|
|
if (code.length !== 4) return
|
|
setCodeError('')
|
|
setCodeLoading(true)
|
|
try {
|
|
const data = await verifyCodeApi({ email, code })
|
|
login(data.user, data.token)
|
|
navigate('/saisir-code-unique')
|
|
} catch (err) {
|
|
setCodeError(err.message)
|
|
setDigits(['', '', '', ''])
|
|
inputRefs[0].current?.focus()
|
|
} finally {
|
|
setCodeLoading(false)
|
|
}
|
|
}
|
|
|
|
// ── Étape 2 : Vérification du code ──
|
|
if (step === 'verify') {
|
|
return (
|
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
|
<div className="mb-8 text-center">
|
|
<div className="w-14 h-14 rounded-full bg-[#006B6B]/10 flex items-center justify-center mx-auto mb-4">
|
|
<svg className="w-7 h-7 text-[#006B6B]" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
|
</svg>
|
|
</div>
|
|
<h1 className="text-2xl font-bold italic font-serif text-[#006B6B]">Vérifiez votre email</h1>
|
|
<p className="text-gray-500 text-sm mt-2">
|
|
Un code à 4 chiffres a été envoyé à<br />
|
|
<span className="font-medium text-gray-700">{email}</span>
|
|
</p>
|
|
</div>
|
|
|
|
{codeError && (
|
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm text-center">
|
|
{codeError}
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleVerify} className="space-y-6">
|
|
<div className="flex justify-center gap-3">
|
|
{digits.map((d, i) => (
|
|
<input
|
|
key={i}
|
|
ref={inputRefs[i]}
|
|
type="text"
|
|
inputMode="numeric"
|
|
maxLength={1}
|
|
value={d}
|
|
onChange={(e) => handleDigit(i, e.target.value)}
|
|
onKeyDown={(e) => handleDigitKeyDown(i, e)}
|
|
aria-label={`Chiffre ${i + 1} du code de vérification`}
|
|
className="w-14 h-14 text-center text-2xl font-bold border-2 border-gray-200 rounded-xl focus:outline-none focus:border-[#006B6B] focus:ring-1 focus:ring-[#006B6B] transition-colors"
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={codeLoading || digits.join('').length !== 4}
|
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
|
>
|
|
{codeLoading ? 'Vérification…' : 'Valider le code'}
|
|
</button>
|
|
</form>
|
|
|
|
<p className="text-center text-xs text-gray-500 mt-6">
|
|
Le code expire dans 15 minutes.{' '}
|
|
<button
|
|
type="button"
|
|
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError(''); setCaptchaToken('') }}
|
|
className="text-[#006B6B] hover:underline"
|
|
>
|
|
Recommencer
|
|
</button>
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Étape 1 : Formulaire d'inscription ──
|
|
return (
|
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
|
<div className="mb-8">
|
|
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">S'inscrire</h1>
|
|
<p className="text-gray-500 mt-1 text-sm">Créer votre compte</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-5">
|
|
<div>
|
|
<label htmlFor="reg-email" className="block text-sm font-medium text-gray-800 mb-1">Adresse email <span className="text-gray-800">*</span></label>
|
|
<input type="email" id="reg-email" name="email" value={form.email} onChange={handleChange} required placeholder="Votre email"
|
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="reg-lastName" className="block text-sm font-medium text-gray-800 mb-1">Nom <span className="text-gray-800">*</span></label>
|
|
<input type="text" id="reg-lastName" name="lastName" value={form.lastName} onChange={handleChange} required placeholder="Votre nom"
|
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="reg-firstName" className="block text-sm font-medium text-gray-800 mb-1">Prénom <span className="text-gray-800">*</span></label>
|
|
<input type="text" id="reg-firstName" name="firstName" value={form.firstName} onChange={handleChange} required placeholder="Votre prénom"
|
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="reg-birthdate" className="block text-sm font-medium text-gray-800 mb-1">Date de naissance <span className="text-gray-800">*</span></label>
|
|
<input type="date" id="reg-birthdate" name="birthdate" value={form.birthdate} onChange={handleChange} required max={AUJOURD_HUI}
|
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
|
<p className="text-xs text-gray-500 mt-1">Le jeu-concours est réservé aux personnes majeures.</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="reg-gender" className="block text-sm font-medium text-gray-800 mb-1">Genre</label>
|
|
<select id="reg-gender" name="gender" value={form.gender} onChange={handleChange}
|
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]">
|
|
<option value="">Non précisé</option>
|
|
{GENRES.map((genre) => (
|
|
<option key={genre} value={genre}>{genre}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
|
<div className="relative">
|
|
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
|
|
autoComplete="new-password" aria-describedby="reg-password-rules"
|
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
|
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600" aria-label="Afficher/masquer">
|
|
<div className="w-6 h-6 rounded-full border-2 border-gray-400 flex items-center justify-center">
|
|
{showPassword && <div className="w-1.5 h-1.5 rounded-full bg-gray-400" />}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
<PasswordRules id="reg-password-rules" password={form.password} />
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="reg-confirm-password" className="block text-sm font-medium text-gray-800 mb-1">Confirmer le mot de passe <span className="text-gray-800">*</span></label>
|
|
<input type={showPassword ? 'text' : 'password'} id="reg-confirm-password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required placeholder="Saisissez-le à nouveau"
|
|
autoComplete="new-password" aria-invalid={passwordMismatch} aria-describedby={passwordMismatch ? 'reg-confirm-password-error' : undefined}
|
|
className={`w-full border rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 ${passwordMismatch ? 'border-red-300 focus:ring-red-400 focus:border-red-400' : 'border-gray-200 focus:ring-[#006B6B] focus:border-[#006B6B]'}`} />
|
|
{passwordMismatch && (
|
|
<p id="reg-confirm-password-error" className="text-xs text-red-600 mt-1">Les deux mots de passe ne correspondent pas.</p>
|
|
)}
|
|
</div>
|
|
|
|
<label className="flex items-start gap-3 cursor-pointer">
|
|
<input type="checkbox" checked={acceptedTerms} onChange={(e) => setAcceptedTerms(e.target.checked)} required
|
|
className="mt-0.5 w-5 h-5 accent-[#006B6B] flex-shrink-0" />
|
|
<span className="text-xs text-gray-500 leading-relaxed">
|
|
J'accepte les{' '}
|
|
<Link to="/cgu" className="text-[#006B6B] hover:underline font-medium">conditions d'utilisation</Link>{' '}
|
|
et la{' '}
|
|
<Link to="/confidentialite" className="text-[#006B6B] hover:underline font-medium">politique de confidentialité</Link>
|
|
</span>
|
|
</label>
|
|
|
|
<label className="flex items-start gap-3 cursor-pointer">
|
|
<input type="checkbox" name="marketingOptIn" checked={marketingOptIn}
|
|
onChange={(e) => setMarketingOptIn(e.target.checked)}
|
|
className="mt-0.5 w-5 h-5 accent-[#006B6B] flex-shrink-0" />
|
|
<span className="text-xs text-gray-500 leading-relaxed">
|
|
J'accepte de recevoir les offres et actualités de Thé Tip Top par email.
|
|
<span className="block text-gray-500 mt-0.5">
|
|
Facultatif — vous pouvez vous désinscrire à tout moment.
|
|
</span>
|
|
</span>
|
|
</label>
|
|
|
|
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
|
|
|
|
<button type="submit" disabled={loading || !acceptedTerms || (CAPTCHA_ENABLED && !captchaToken)}
|
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
|
|
{loading ? 'Inscription…' : "S'inscrire"}
|
|
</button>
|
|
</form>
|
|
|
|
<div className="flex items-center gap-3 my-6">
|
|
<div className="flex-1 h-px bg-gray-200" />
|
|
<span className="text-sm text-gray-500">Ou</span>
|
|
<div className="flex-1 h-px bg-gray-200" />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<button type="button" onClick={() => handleOAuthSignup('google')} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
|
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
|
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
|
|
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path>
|
|
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
|
</svg>
|
|
Google
|
|
</button>
|
|
<button type="button" onClick={() => handleOAuthSignup('facebook')} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
|
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
|
|
</svg>
|
|
Facebook
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-center text-sm text-gray-500 mt-8">
|
|
Vous avez déjà un compte ?{' '}
|
|
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">Se connecter</Link>
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|