Ajouter SSR prerending, espace employe, formulaire inscription employee depuis admin
This commit is contained in:
@@ -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 ── */}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function Profile() {
|
||||
.catch((e) => {
|
||||
if (!handleAuthError(e)) setHasPassword(true)
|
||||
})
|
||||
}, [])
|
||||
}, [handleAuthError])
|
||||
|
||||
const handleSendSetupLink = async () => {
|
||||
setLinkError('')
|
||||
|
||||
Reference in New Issue
Block a user