Files
dynasqy/frontend/src/components/WheelFlow.jsx
T
2026-08-23 09:04:47 +00:00

363 lines
16 KiB
React

import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import SlotMachine from './SlotMachine'
import { THEMES, getRandomQuestion } from '../utils/wheelQuestions'
import { startDrumRoll, stopDrumRoll, playDing } from '../utils/soundUtils'
function formatPhone(num) {
if (!num) return ''
const cleaned = String(num).replace(/\s+/g, '')
return cleaned.replace(/(\d{2})(?=\d)/g, '$1 ').trim()
}
const peopleToItems = (people) => people.map((p) => ({
id: p.id,
label: p.merged_display_name || `${p.prenom} ${p.nom}`,
isGuest: !!p.invite_par_prenom,
person: p,
}))
const themeItems = THEMES.map((t) => ({
id: t.id,
label: `${t.emoji} ${t.name}`,
color: t.color,
theme: t,
}))
function Timer({ duration = 90, onEnd }) {
const [remaining, setRemaining] = useState(duration)
const [running, setRunning] = useState(true)
const intervalRef = useRef(null)
const onEndRef = useRef(onEnd)
onEndRef.current = onEnd
useEffect(() => {
setRemaining(duration)
setRunning(true)
let count = duration
intervalRef.current = setInterval(() => {
count--
setRemaining(count)
if (count <= 0) {
clearInterval(intervalRef.current)
intervalRef.current = null
setRunning(false)
onEndRef.current?.()
}
}, 1000)
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
}, [duration])
useEffect(() => {
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
}, [])
const mins = Math.floor(remaining / 60)
const secs = remaining % 60
const pct = remaining / duration
const color = pct > 0.5 ? 'text-green-400' : pct > 0.2 ? 'text-yellow-400' : 'text-red-400'
return (
<div className="flex flex-col items-center gap-2">
<div className="text-5xl sm:text-6xl font-mono font-bold tracking-wider tabular-nums">
<span className={color}>{String(mins).padStart(2, '0')}:{String(secs).padStart(2, '0')}</span>
</div>
<div className="w-40 sm:w-56 h-1.5 bg-white/20 rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all ${pct > 0.5 ? 'bg-green-400' : pct > 0.2 ? 'bg-yellow-400' : 'bg-red-400'}`}
style={{ width: `${pct * 100}%` }} />
</div>
{running ? (
<button onClick={() => { clearInterval(intervalRef.current); intervalRef.current = null; setRunning(false) }}
className="bg-white/20 hover:bg-white/30 text-yellow-300 px-4 py-1.5 rounded-full text-xs font-medium">
Pause
</button>
) : remaining > 0 ? (
<span className="text-white/40 text-xs">En pause</span>
) : null}
</div>
)
}
function parsePhoto(path) {
if (!path) return null
if (path.startsWith('http')) return path
if (path.startsWith('/uploads')) return path
return `/uploads/${path.replace(/^\/?/, '')}`
}
export default function WheelFlow({ members, guests, onComplete }) {
const allPeople = useMemo(() => [...members.filter((m) => !m.absent), ...guests], [members, guests])
const [phase, setPhase] = useState('theme-idle')
const [themePhase, setThemePhase] = useState('idle')
const [personPhase, setPersonPhase] = useState('idle')
const [selectedTheme, setSelectedTheme] = useState(null)
const [selectedPerson, setSelectedPerson] = useState(null)
const [currentQuestion, setCurrentQuestion] = useState(null)
const [questionCount, setQuestionCount] = useState(0)
const usedQuestionsRef = useRef([])
const [remainingPeople, setRemainingPeople] = useState(() => peopleToItems(allPeople))
const remainingRef = useRef(remainingPeople)
remainingRef.current = remainingPeople
useEffect(() => {
const onKey = (e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'ArrowLeft' || e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
const target = document.querySelector('[class*="cursor-pointer"][class*="w-full"][class*="h-full"]')
if (target) target.click()
}
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [])
const [riggedPersonIdx, setRiggedPersonIdx] = useState(() => {
const items = peopleToItems(allPeople)
const idx = items.findIndex(p => p.person?.prenom?.toLowerCase() === 'estelle')
return idx >= 0 ? idx : undefined
})
useEffect(() => {
const items = peopleToItems(allPeople)
setRemainingPeople(items)
remainingRef.current = items
}, [allPeople])
useEffect(() => {
if (remainingPeople.length === 0 && (phase === 'theme-idle' || phase === 'person-idle')) {
setPhase('done')
}
}, [remainingPeople, phase])
const handleNextRound = useCallback(() => {
setSelectedTheme(null)
setSelectedPerson(null)
setCurrentQuestion(null)
setThemePhase('idle')
setPersonPhase('idle')
if (remainingRef.current.length === 0) {
setPhase('done')
} else {
setPhase('theme-idle')
}
}, [])
const resetRound = useCallback(() => {
setRemainingPeople(peopleToItems(allPeople))
remainingRef.current = peopleToItems(allPeople)
setSelectedTheme(null)
setSelectedPerson(null)
setCurrentQuestion(null)
setThemePhase('idle')
setPersonPhase('idle')
setPhase('theme-idle')
usedQuestionsRef.current = []
}, [allPeople])
const handleThemeSpinEnd = useCallback((item) => {
setSelectedTheme(item.theme)
setPhase('theme-result')
stopDrumRoll()
playDing()
}, [])
const handlePersonSpinEnd = useCallback((item) => {
setSelectedPerson(item.person)
setRiggedPersonIdx(undefined)
const next = remainingRef.current.filter(p => p.id !== item.id)
setRemainingPeople(next)
remainingRef.current = next
stopDrumRoll()
playDing()
let question = getRandomQuestion(selectedTheme?.id, usedQuestionsRef.current)
if (!question) {
const fallbackTheme = THEMES[Math.floor(Math.random() * THEMES.length)]
question = getRandomQuestion(fallbackTheme.id, usedQuestionsRef.current) || {
question: 'Racontez une anecdote sur votre parcours entrepreneurial.',
questionIdx: 0,
themeId: 'fallback',
}
}
if (question) usedQuestionsRef.current.push(`${question.themeId}-${question.questionIdx}`)
setCurrentQuestion(question)
setPhase('person-result')
}, [selectedTheme])
const handleSpinClick = (e) => {
e.stopPropagation()
const startSpin = (setMachinePhase) => {
setMachinePhase('spinning')
startDrumRoll()
}
if (phase === 'theme-idle') startSpin(setThemePhase)
else if (phase === 'person-idle') startSpin(setPersonPhase)
}
const handleResultClick = useCallback((e) => {
e?.stopPropagation()
const nextPhase = phase === 'theme-result' ? 'person-idle' : 'question'
if (phase === 'theme-result') {
setThemePhase('idle')
setPersonPhase('idle')
}
setPhase(nextPhase)
if (nextPhase === 'question') setQuestionCount((c) => c + 1)
}, [phase])
const handleTimerEnd = useCallback(() => {
playDing()
handleNextRound()
}, [handleNextRound])
const themeWheelPhase = themePhase === 'spinning' ? 'spinning' : 'idle'
const personWheelPhase = personPhase === 'spinning' ? 'spinning' : 'idle'
if (phase === 'done') {
return (
<div
className="flex flex-col items-center justify-center w-full h-full text-white px-4 relative cursor-pointer"
onClick={(e) => { e.stopPropagation(); onComplete?.() }}
>
<div className="text-center animate-fadeIn">
<span className="text-7xl block mb-4">{'\uD83C\uDF89'}</span>
<h2 className="text-4xl sm:text-5xl font-bold mb-4">{'Tout le monde est pass\u00e9 !'}</h2>
<p className="text-xl text-blue-200">{'Bravo \u00e0 tous les participants !'}</p>
<p className="text-base text-blue-300 mt-6">{'Cliquez pour continuer la pr\u00e9sentation'}</p>
</div>
<button onClick={(e) => { e.stopPropagation(); resetRound() }} className="absolute bottom-4 right-4 bg-white/10 hover:bg-white/20 text-white/60 hover:text-white text-xs px-3 py-1.5 rounded-full">
{'\u21BA'} Recommencer
</button>
</div>
)
}
if (phase === 'question' && selectedTheme && selectedPerson) {
const q = currentQuestion || { question: 'Pas de question disponible pour ce th\u00e8me.' }
return (
<div
className="flex flex-col sm:flex-row items-center justify-center w-full h-full gap-6 text-white px-4 py-2 relative cursor-pointer"
onClick={(e) => { e.stopPropagation(); handleNextRound() }}
>
<div className="flex flex-col items-center justify-center shrink-0">
<div className="w-28 h-28 sm:w-36 sm:h-36 rounded-full overflow-hidden border-4 border-white/20 shadow-xl bg-white/10 flex items-center justify-center">
{selectedPerson.photo_url ? (
<img src={parsePhoto(selectedPerson.photo_url)} alt="" className="w-full h-full object-cover"
onError={(e) => { e.target.style.display = 'none'; e.target.parentElement.textContent = selectedPerson.prenom[0] + selectedPerson.nom[0] }} />
) : (
<span className="text-3xl font-bold text-white/40">{selectedPerson.prenom[0]}{selectedPerson.nom[0]}</span>
)}
</div>
<h2 className="text-xl sm:text-2xl font-bold mt-3 text-center">
{selectedPerson.merged_display_name || `${selectedPerson.prenom} ${selectedPerson.nom}`}
</h2>
<p className="text-base sm:text-lg text-blue-200 text-center">{selectedPerson.societe || selectedPerson.activite || ''}</p>
{selectedPerson.invite_par_prenom && (
<p className="text-sm text-white/70 mt-1">
Invité(e) par {selectedPerson.invite_par_prenom} {selectedPerson.invite_par_nom}
</p>
)}
<div className="flex flex-col items-center text-sm text-white/60 mt-2 gap-0.5">
{selectedPerson.telephone && <span className="flex items-center gap-1"><svg viewBox="0 0 24 24" fill="none" stroke="#60a5fa" strokeWidth="2" className="w-3.5 h-3.5 shrink-0"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>{formatPhone(selectedPerson.telephone)}</span>}
{selectedPerson.email && <span className="flex items-center gap-1"><svg viewBox="0 0 24 24" fill="none" stroke="#60a5fa" strokeWidth="2" className="w-3.5 h-3.5 shrink-0"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>{selectedPerson.email}</span>}
</div>
<div className="mt-2 text-sm text-white/40">
Plus que {remainingPeople.length} participant{remainingPeople.length > 1 ? 's' : ''}
</div>
</div>
<div className="hidden sm:block w-px bg-white/20 self-stretch" />
<div className="flex flex-col items-center justify-center gap-4 max-w-xl">
<div className="flex items-center gap-2">
<span className="text-2xl">{selectedTheme?.emoji || '\uD83D\uDCCC'}</span>
<span className="text-base sm:text-lg font-bold text-blue-200">{selectedTheme?.name || 'Question'}</span>
</div>
<div className="bg-white/10 rounded-2xl p-5 sm:p-6 w-full">
<p className="text-lg sm:text-xl font-semibold leading-relaxed">{q.question}</p>
</div>
<Timer key={questionCount} duration={90} onEnd={handleTimerEnd} />
</div>
<button onClick={(e) => { e.stopPropagation(); resetRound() }} className="absolute bottom-4 right-4 bg-white/10 hover:bg-white/20 text-white/60 hover:text-white text-xs px-3 py-1.5 rounded-full z-10">
{'\u21BA'} Recommencer
</button>
</div>
)
}
if (phase === 'theme-result' && selectedTheme) {
return (
<div
className="flex flex-col items-center justify-center w-full h-full cursor-pointer text-white px-4 relative"
onClick={handleResultClick}
>
<div className="text-center animate-fadeIn">
<span className="text-6xl sm:text-7xl block mb-4">{selectedTheme.emoji}</span>
<h2 className="text-3xl sm:text-5xl font-bold mb-2">{selectedTheme.name}</h2>
<p className="text-xl text-blue-200 mt-4">Cliquez pour continuer</p>
</div>
<button onClick={(e) => { e.stopPropagation(); resetRound() }} className="absolute bottom-4 right-4 bg-white/10 hover:bg-white/20 text-white/60 hover:text-white text-xs px-3 py-1.5 rounded-full">
{'\u21BA'} Recommencer
</button>
</div>
)
}
if (phase === 'person-result' && selectedPerson) {
return (
<div
className="flex flex-col items-center justify-center w-full h-full cursor-pointer text-white px-4 relative"
onClick={handleResultClick}
>
<div className="text-center animate-fadeIn">
<p className="text-2xl sm:text-3xl text-blue-200 mb-2">La personne d{'\u00e9'}sign{'\u00e9'}e est...</p>
<h2 className="text-4xl sm:text-6xl font-bold text-yellow-300 mb-2">
{selectedPerson.merged_display_name || `${selectedPerson.prenom} ${selectedPerson.nom}`}
</h2>
<p className="text-xl sm:text-2xl text-blue-200">{selectedPerson.societe || selectedPerson.activite || ''}</p>
{selectedPerson.invite_par_prenom && (
<p className="text-lg text-white/70 mt-2">
{'Invité(e) par'} {selectedPerson.invite_par_prenom} {selectedPerson.invite_par_nom}
</p>
)}
<div className="flex flex-wrap items-center justify-center gap-3 text-sm text-white/60 mt-3">
{selectedPerson.telephone && <span>{'\uD83D\uDCDE'} {formatPhone(selectedPerson.telephone)}</span>}
{selectedPerson.email && <span>{'\u2709'} {selectedPerson.email}</span>}
</div>
<p className="text-lg text-blue-200 mt-4">{'Cliquez pour découvrir la question'}</p>
</div>
<button onClick={(e) => { e.stopPropagation(); resetRound() }} className="absolute bottom-4 right-4 bg-white/10 hover:bg-white/20 text-white/60 hover:text-white text-xs px-3 py-1.5 rounded-full">
{'\u21BA'} Recommencer
</button>
</div>
)
}
return (
<div
className="flex flex-col items-center justify-center w-full h-full cursor-pointer text-white px-4 relative"
onClick={handleSpinClick}
>
{phase.startsWith('theme') ? (
<>
<SlotMachine items={themeItems} phase={themeWheelPhase} onSpinEnd={handleThemeSpinEnd} />
<p className="text-lg sm:text-xl text-blue-200 mt-4">
{themePhase === 'idle' && "Cliquez pour lancer la machine \u00e0 sous des th\u00e8mes"}
{themePhase === 'spinning' && 'S\u00e9lection en cours...'}
</p>
</>
) : (
<>
<SlotMachine items={remainingPeople} phase={personWheelPhase} onSpinEnd={handlePersonSpinEnd} riggedIndex={riggedPersonIdx} />
<p className="text-lg sm:text-xl text-blue-200 mt-4">
{personPhase === 'idle' && `Cliquez pour lancer la machine (${remainingPeople.length} restant${remainingPeople.length > 1 ? 's' : ''})`}
{personPhase === 'spinning' && 'S\u00e9lection en cours...'}
</p>
</>
)}
<button onClick={(e) => { e.stopPropagation(); resetRound() }} className="absolute bottom-4 right-4 bg-white/10 hover:bg-white/20 text-white/60 hover:text-white text-xs px-3 py-1.5 rounded-full z-10">
{'\u21BA'} Recommencer
</button>
</div>
)
}