363 lines
16 KiB
React
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>
|
|
)
|
|
}
|