Ajouter SSR prerending, espace employe, formulaire inscription employee depuis admin

This commit is contained in:
Alexis SANOGO
2026-09-04 16:08:22 +02:00
parent dfa5fb81a3
commit 28d743deb5
12 changed files with 279 additions and 177 deletions

View File

@@ -1,6 +1,5 @@
import { useState, useEffect } from 'react'
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin'
import { searchTicketApi, deliverClaimApi } from '../services/employee'
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi, createEmployeeApi } from '../services/admin'
import { useHandleAuthError } from '../hooks/useHandleAuthError'
function StatCard({ label, value, sub }) {
@@ -63,20 +62,17 @@ export default function Admin() {
const [settingsError, setSettingsError] = useState('')
const [settingsOk, setSettingsOk] = useState('')
// Remise de lot
const [ticketCode, setTicketCode] = useState('')
const [ticketResult, setTicketResult] = useState(null)
const [ticketSearching, setTicketSearching] = useState(false)
const [ticketDelivering, setTicketDelivering] = useState(false)
const [ticketSearchErr, setTicketSearchErr] = useState('')
const [ticketDeliverErr, setTicketDeliverErr] = useState('')
const [ticketSuccess, setTicketSuccess] = useState(false)
// Contacts emailing
const [emailingContacts, setEmailingContacts] = useState(null)
const [emailingLoading, setEmailingLoading] = useState(false)
const [emailingError, setEmailingError] = useState('')
// Création d'un compte employé
const [employeeForm, setEmployeeForm] = useState({ firstname: '', lastname: '', email: '', password: '' })
const [employeeSaving, setEmployeeSaving] = useState(false)
const [employeeError, setEmployeeError] = useState('')
const [employeeOk, setEmployeeOk] = useState('')
useEffect(() => {
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
.then(([s, u, w]) => {
@@ -154,43 +150,19 @@ export default function Admin() {
URL.revokeObjectURL(url)
}
const handleTicketSearch = async (e) => {
const handleCreateEmployee = async (e) => {
e.preventDefault()
setTicketResult(null)
setTicketSearchErr('')
setTicketDeliverErr('')
setTicketSuccess(false)
setTicketSearching(true)
setEmployeeError('')
setEmployeeOk('')
setEmployeeSaving(true)
try {
const data = await searchTicketApi(ticketCode.trim())
setTicketResult(data)
const data = await createEmployeeApi(employeeForm)
setEmployeeOk(`Compte employé créé pour ${data.employee.firstname} ${data.employee.lastname} (${data.employee.email}).`)
setEmployeeForm({ firstname: '', lastname: '', email: '', password: '' })
} catch (err) {
if (!handleAuthError(err)) setTicketSearchErr(err.message)
if (!handleAuthError(err)) setEmployeeError(err.message)
} finally {
setTicketSearching(false)
}
}
const handleTicketDeliver = async () => {
if (!ticketResult?.claim?.id) return
setTicketDeliverErr('')
setTicketDelivering(true)
try {
await deliverClaimApi(ticketResult.claim.id)
setTicketResult((prev) => ({
...prev,
claim: { ...prev.claim, status: 'DELIVERED', claimedAt: new Date().toISOString() },
}))
setTicketSuccess(true)
setStats((prev) => prev ? {
...prev,
claimsPending: Math.max(0, (prev.claimsPending ?? 1) - 1),
claimsDelivered: (prev.claimsDelivered ?? 0) + 1,
} : prev)
} catch (err) {
if (!handleAuthError(err)) setTicketDeliverErr(err.message)
} finally {
setTicketDelivering(false)
setEmployeeSaving(false)
}
}
@@ -415,93 +387,73 @@ export default function Admin() {
)}
</div>
{/* ── Remise de lot ── */}
{/* ── Création d'un compte employé ── */}
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Remise de lot</h2>
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Créer un compte employé</h2>
<p className="text-xs sm:text-sm text-gray-500 mb-4">
Donne accès à l'espace employé (remise des lots en boutique). L'admin ne gère pas les remises lui-même.
</p>
<form onSubmit={handleTicketSearch} className="flex gap-2 mb-4">
<label htmlFor="admin-ticket-code" className="sr-only">Code ticket</label>
<input
type="text"
id="admin-ticket-code"
value={ticketCode}
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
placeholder="Code ticket (10 caractères)"
maxLength={10}
className="flex-1 min-w-0 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<button
type="submit"
disabled={ticketSearching || ticketCode.trim().length === 0}
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2.5 rounded-lg transition-colors text-sm whitespace-nowrap"
>
{ticketSearching ? 'Recherche…' : 'Rechercher'}
</button>
</form>
{ticketSearchErr && (
<p className="text-red-500 text-sm mb-3">{ticketSearchErr}</p>
)}
{ticketResult && (
<div className="border border-gray-100 rounded-xl overflow-hidden">
{ticketSuccess && (
<div className="bg-green-500 px-4 py-2.5 flex items-center gap-2">
<svg className="w-4 h-4 text-white flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<p className="text-white text-sm font-medium">Lot remis avec succès.</p>
</div>
)}
<div className="px-4 py-3 flex items-start justify-between gap-3 border-b border-gray-100">
<div>
<p className="font-semibold text-gray-900 text-sm">{ticketResult.lot.name}</p>
<p className="text-gray-500 text-xs mt-0.5">{ticketResult.lot.description}</p>
{ticketResult.lot.value && <p className="text-[#006B6B] text-xs font-semibold mt-1">{ticketResult.lot.value} €</p>}
</div>
<span className={`flex-shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full ${
ticketResult.claim?.status === 'DELIVERED' ? 'bg-green-100 text-green-700'
: ticketResult.claim?.status === 'CANCELLED' ? 'bg-gray-100 text-gray-500'
: 'bg-yellow-100 text-yellow-700'
}`}>
{ticketResult.claim?.status === 'DELIVERED' ? 'Remis'
: ticketResult.claim?.status === 'CANCELLED' ? 'Expiré'
: 'À remettre'}
</span>
</div>
<div className="px-4 py-3 flex items-center gap-3 border-b border-gray-100">
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{(ticketResult.client.firstname?.[0] ?? '?').toUpperCase()}{(ticketResult.client.lastname?.[0] ?? '?').toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-gray-800">{ticketResult.client.firstname} {ticketResult.client.lastname}</p>
<p className="text-xs text-gray-500">{ticketResult.client.email}</p>
</div>
</div>
<div className="px-4 py-3 space-y-2">
{ticketDeliverErr && <p className="text-red-500 text-xs">{ticketDeliverErr}</p>}
{ticketResult.claim?.status === 'PENDING' && !ticketSuccess && (
<button
onClick={handleTicketDeliver}
disabled={ticketDelivering}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold py-2.5 rounded-lg transition-colors text-sm"
>
{ticketDelivering ? 'Confirmation…' : 'Confirmer la remise du lot'}
</button>
)}
<button
onClick={() => { setTicketCode(''); setTicketResult(null); setTicketSuccess(false); setTicketSearchErr(''); setTicketDeliverErr('') }}
className="w-full border border-gray-200 text-gray-500 hover:bg-gray-50 py-2 rounded-lg transition-colors text-sm"
>
Rechercher un autre code
</button>
</div>
<form onSubmit={handleCreateEmployee} className="grid sm:grid-cols-2 gap-3">
<div>
<label htmlFor="employee-firstname" className="block text-xs font-medium text-gray-700 mb-1">Prénom</label>
<input
type="text"
id="employee-firstname"
value={employeeForm.firstname}
onChange={(e) => setEmployeeForm({ ...employeeForm, firstname: e.target.value })}
required
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
)}
<div>
<label htmlFor="employee-lastname" className="block text-xs font-medium text-gray-700 mb-1">Nom</label>
<input
type="text"
id="employee-lastname"
value={employeeForm.lastname}
onChange={(e) => setEmployeeForm({ ...employeeForm, lastname: e.target.value })}
required
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
<div>
<label htmlFor="employee-email" className="block text-xs font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
id="employee-email"
value={employeeForm.email}
onChange={(e) => setEmployeeForm({ ...employeeForm, email: e.target.value })}
required
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
<div>
<label htmlFor="employee-password" className="block text-xs font-medium text-gray-700 mb-1">Mot de passe</label>
<input
type="password"
id="employee-password"
value={employeeForm.password}
onChange={(e) => setEmployeeForm({ ...employeeForm, password: e.target.value })}
required
minLength={8}
placeholder="8 caractères minimum"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
<div className="sm:col-span-2 flex items-center gap-3 pt-1">
<button
type="submit"
disabled={employeeSaving}
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
>
{employeeSaving ? 'Création…' : 'Créer le compte'}
</button>
{employeeError && <p className="text-red-500 text-xs sm:text-sm">{employeeError}</p>}
{employeeOk && <p className="text-green-600 text-xs sm:text-sm">{employeeOk}</p>}
</div>
</form>
</div>
{/* ── Tirage au sort ── */}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef } from 'react'
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
@@ -8,39 +8,40 @@ export default function AuthCallback() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { login } = useAuth()
const [error, setError] = useState('')
// StrictMode monte deux fois en dev : on ne traite le token qu'une seule fois.
const handled = useRef(false)
// L'erreur (s'il y en a une) est entièrement dérivée de l'URL : pas besoin
// d'un state ni d'un effet pour la calculer, juste pour l'action à usage
// unique (login + redirection) une fois l'utilisateur décodé avec succès.
const errorParam = searchParams.get('error')
const token = searchParams.get('token')
const encodedUser = searchParams.get('user')
let error = ''
let user = null
if (errorParam) {
error = errorParam
} else if (!token || !encodedUser) {
error = 'Réponse de connexion incomplète.'
} else {
try {
user = JSON.parse(atob(encodedUser))
} catch {
error = 'Réponse de connexion illisible.'
}
}
useEffect(() => {
if (handled.current) return
if (handled.current || !user) return
handled.current = true
const errorParam = searchParams.get('error')
if (errorParam) {
setError(errorParam)
return
}
login(user, token)
const token = searchParams.get('token')
const encodedUser = searchParams.get('user')
if (!token || !encodedUser) {
setError('Réponse de connexion incomplète.')
return
}
try {
const user = JSON.parse(atob(encodedUser))
login(user, token)
if (user.role === 'ADMIN') navigate('/admin', { replace: true })
else if (user.role === 'EMPLOYEE') navigate('/employe', { replace: true })
else navigate('/mes-gains', { replace: true })
} catch {
setError('Réponse de connexion illisible.')
}
}, [searchParams, login, navigate])
if (user.role === 'ADMIN') navigate('/admin', { replace: true })
else if (user.role === 'EMPLOYEE') navigate('/employe', { replace: true })
else navigate('/mes-gains', { replace: true })
}, [user, token, login, navigate])
if (error) {
return (

View File

@@ -41,7 +41,7 @@ export default function Profile() {
.catch((e) => {
if (!handleAuthError(e)) setHasPassword(true)
})
}, [])
}, [handleAuthError])
const handleSendSetupLink = async () => {
setLinkError('')

View File

@@ -60,3 +60,14 @@ export async function getWinnerApi() {
if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status)
return data
}
export async function createEmployeeApi({ firstname, lastname, email, password }) {
const res = await fetch(`${BASE}/employees`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ firstname, lastname, email, password }),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || "Erreur lors de la création de l'employé.", res.status)
return data
}