2026-09-04 16:48:42 +02:00

452 lines
21 KiB
TypeScript

'use client'
import { useEffect, useRef, useState } from 'react'
import { useBack } from '@/hooks/useBack'
import Image from 'next/image'
import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n'
import { trackEvent } from '@/lib/stats'
import type { SectionDTO, TranslationAndResourceDTO, ResourceDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar'
import ResourceViewer from '@/components/ui/ResourceViewer'
interface Props {
section: SectionDTO
slug: string
configId: string
languages: string[]
}
type Phase = 'quiz' | 'result' | 'review'
const CONTENT_MAX_WIDTH = 560
function isAudio(r: ResourceDTO) {
const t = r.type as string | number | undefined
return t === 'Audio' || t === 4
}
function isVideo(r: ResourceDTO) {
const t = r.type as string | number | undefined
return t === 'Video' || t === 'VideoUrl' || t === 1 || t === 3
}
export default function QuizSection({ section, configId, languages }: Props) {
const { language, setAvailableLanguages, instanceId } = useVisitor()
const back = useBack()
useEffect(() => { setAvailableLanguages([]) }, [languages])
const quiz = section.quiz
const questions = [...(quiz?.questions ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const [phase, setPhase] = useState<Phase>('quiz')
const [currentIndex, setCurrentIndex] = useState(0)
const [answers, setAnswers] = useState<Record<number, number>>({})
const [modalResource, setModalResource] = useState<ResourceDTO | null>(null)
const trackedRef = useRef(false)
if (questions.length === 0) {
return (
<div className="flex flex-col" style={{ background: 'var(--color-paper)', height: '100dvh', minHeight: '100vh' }}>
<AppBar title={tPlain(section.title, language)} onBack={back} />
<div className="flex-1 flex items-center justify-center text-sm p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>
Aucune question disponible.
</div>
</div>
)
}
const totalQuestions = questions.length
const correctCount = questions.filter((q, i) => {
const chosen = answers[i]
if (chosen === undefined) return false
const sorted = [...(q.responses ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
return sorted[chosen]?.isCorrect === true
}).length
const scorePercent = totalQuestions > 0 ? (correctCount / totalQuestions) * 100 : 0
function getLevel() {
if (scorePercent < 25) return quiz?.badLevel
if (scorePercent < 50) return quiz?.mediumLevel
if (scorePercent < 75) return quiz?.goodLevel
return quiz?.greatLevel
}
function selectAnswer(questionIndex: number, answerIndex: number) {
const newAnswers = { ...answers, [questionIndex]: answerIndex }
setAnswers(newAnswers)
if (questionIndex === totalQuestions - 1) {
setTimeout(() => setPhase('result'), 400)
} else {
setTimeout(() => setCurrentIndex(questionIndex + 1), 500)
}
}
function restart() {
setAnswers({})
setCurrentIndex(0)
setPhase('quiz')
trackedRef.current = false
}
// ── RESULT SCREEN ────────────────────────────────────────────────────────
if (phase === 'result') {
if (!trackedRef.current && instanceId) {
trackedRef.current = true
trackEvent({
instanceId, configurationId: configId, sectionId: section.id,
eventType: 'QuizComplete', language,
metadata: JSON.stringify({ score: correctCount, totalQuestions }),
})
}
const levelText = t(getLevel(), language)
return (
<div className="flex flex-col" style={{ background: 'var(--color-paper)', height: '100dvh', minHeight: '100vh' }}>
<AppBar title={tPlain(section.title, language)} onBack={back} />
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col items-center justify-center px-4 py-6">
<div className="w-full flex flex-col items-center gap-6" style={{ maxWidth: CONTENT_MAX_WIDTH }}>
<div
className="flex flex-col items-center justify-center rounded-full shrink-0"
style={{
width: 'clamp(120px, 26vw, 168px)',
height: 'clamp(120px, 26vw, 168px)',
background: 'var(--color-primary)',
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
}}
>
<span className="font-bold" style={{ color: 'var(--color-on-primary)', fontSize: 'clamp(28px, 7vw, 40px)' }}>
{correctCount}/{totalQuestions}
</span>
<span className="text-sm mt-1" style={{ color: 'var(--color-on-primary)', opacity: 0.85 }}>
{Math.round(scorePercent)}%
</span>
</div>
{levelText && (
<div
className="w-full px-5 py-4 text-sm text-center [&_p]:m-0"
style={{
background: '#fff',
color: 'var(--color-ink)',
border: '1px solid var(--color-paper-soft)',
borderRadius: 16,
boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)',
}}
dangerouslySetInnerHTML={{ __html: levelText }}
/>
)}
<div className="flex flex-col gap-3 w-full">
<button
onClick={restart}
className="w-full font-bold"
style={{ padding: 16, borderRadius: 18, border: 'none', background: 'var(--color-primary)', color: 'var(--color-on-primary)', fontSize: 16, cursor: 'pointer' }}
>
Recommencer
</button>
<button
onClick={() => { setCurrentIndex(0); setPhase('review') }}
className="w-full font-bold"
style={{ padding: 16, borderRadius: 18, border: '1.5px solid var(--color-paper-border)', background: '#fff', color: 'var(--color-ink-soft)', fontSize: 16, cursor: 'pointer' }}
>
Voir les réponses
</button>
</div>
</div>
</div>
</div>
)
}
// ── QUIZ + REVIEW SCREEN ─────────────────────────────────────────────────
const isReview = phase === 'review'
const question = questions[currentIndex]
const sortedResponses = [...(question.responses ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const chosen = answers[currentIndex]
const canGoNext = isReview || chosen !== undefined
const isLast = currentIndex === totalQuestions - 1
const questionEntry = getLabelEntry(question.label, language)
const questionText = questionEntry?.value ?? ''
const questionResource = questionEntry?.resource
// Les réponses illustrées gardent leur hauteur naturelle ; sinon elles se
// répartissent l'espace vertical restant pour remplir l'écran.
const hasAnswerMedia = sortedResponses.some((r) => getLabelEntry(r.label, language)?.resource?.url)
const mediaHeight = 'clamp(120px, 20vh, 240px)'
return (
<>
<div className="flex flex-col" style={{ background: 'var(--color-paper)', height: '100dvh', minHeight: '100vh' }}>
<AppBar
title={isReview ? tPlain(section.title, language) : undefined}
onBack={isReview ? () => { setCurrentIndex(0); setPhase('result') } : back}
/>
<div className="flex-1 min-h-0 flex flex-col relative overflow-hidden">
{/* Background image */}
{question.imageBackgroundResourceUrl && (
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${question.imageBackgroundResourceUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
opacity: 0.35,
}}
/>
)}
<div
className="relative z-10 flex flex-col flex-1 min-h-0 w-full mx-auto p-4 gap-3"
style={{ maxWidth: CONTENT_MAX_WIDTH }}
>
{/* Question media */}
{questionResource?.url && (
isAudio(questionResource) ? (
<div className="shrink-0" style={{ background: 'white', borderRadius: 12, padding: '12px 16px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
<audio controls src={questionResource.url} style={{ width: '100%' }} />
</div>
) : isVideo(questionResource) ? (
<button
onClick={() => setModalResource(questionResource)}
style={{ height: mediaHeight, borderRadius: 12, overflow: 'hidden', border: 'none', cursor: 'pointer', flexShrink: 0, padding: 0, display: 'block', width: '100%' }}
>
<VideoPreview resource={questionResource} playSize={52} />
</button>
) : (
<button
onClick={() => setModalResource(questionResource)}
style={{ position: 'relative', height: mediaHeight, borderRadius: 12, overflow: 'hidden', border: 'none', cursor: 'pointer', flexShrink: 0 }}
>
<Image src={questionResource.url} alt="" fill className="object-contain" sizes="100vw" />
<div style={{ position: 'absolute', bottom: 8, right: 8, background: 'rgba(0,0,0,0.45)', borderRadius: 8, padding: '3px 7px', backdropFilter: 'blur(4px)' }}>
<ZoomIcon />
</div>
</button>
)
)}
{/* Question text */}
{questionText && (
<div
className="rounded-2xl px-5 font-medium text-center shrink-0 flex items-center justify-center [&_p]:m-0"
style={{
background: 'white',
color: 'var(--color-text)',
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
fontSize: 'clamp(15px, 1.4vh + 10px, 19px)',
lineHeight: 1.4,
minHeight: 'clamp(76px, 12vh, 132px)',
paddingTop: 16,
paddingBottom: 16,
}}
dangerouslySetInnerHTML={{ __html: questionText }}
/>
)}
{/* Answer buttons */}
<div
className="flex flex-col justify-center gap-2.5"
style={{ flex: '1 1 0', minHeight: 0, overflowY: hasAnswerMedia ? 'auto' : 'visible' }}
>
{sortedResponses.map((response, i) => {
const isSelected = chosen === i
const bg = getAnswerBg(isReview, isSelected, response.isCorrect)
// Sélection = couleur principale de l'instance → texte lisible calculé (--color-on-primary).
// Vert/rouge de correction = couleurs sémantiques fixes → texte blanc.
const textColor = isReview
? (response.isCorrect || isSelected ? '#fff' : 'var(--color-text)')
: (isSelected ? 'var(--color-on-primary)' : 'var(--color-text)')
const responseEntry = getLabelEntry(response.label, language)
const responseText = responseEntry?.value ?? ''
const responseResource = responseEntry?.resource
return (
<button
key={response.id ?? i}
onClick={() => !isReview && chosen === undefined && selectAnswer(currentIndex, i)}
className="w-full text-left [&_p]:m-0 transition-colors"
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '13px 14px', borderRadius: 16,
background: bg,
color: textColor,
border: `1.5px solid ${isReview && response.isCorrect ? '#2E9E6B' : isReview && isSelected ? '#D14343' : isSelected ? 'var(--color-primary)' : 'var(--color-paper-border)'}`,
boxShadow: (!isReview && !isSelected) ? '0 6px 16px -12px rgba(0,0,0,0.3)' : 'none',
cursor: isReview || chosen !== undefined ? 'default' : 'pointer',
fontSize: 'clamp(14px, 1.2vh + 9px, 17px)',
flex: hasAnswerMedia ? '0 0 auto' : '1 1 0',
minHeight: 56,
maxHeight: hasAnswerMedia ? undefined : 110,
}}
>
<span style={{
width: 30, height: 30, borderRadius: 9, flexShrink: 0,
background: (isReview && response.isCorrect) || (isReview && isSelected) || (!isReview && isSelected) ? 'color-mix(in srgb, currentColor 22%, transparent)' : 'var(--color-paper-accent)',
color: (isReview && response.isCorrect) || (isReview && isSelected) || (!isReview && isSelected) ? 'inherit' : 'var(--color-ink-muted)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 800, fontSize: 13,
}}>
{String.fromCharCode(65 + i)}
</span>
{responseResource?.url && (
isAudio(responseResource) ? (
<div onClick={(e) => e.stopPropagation()} style={{ flexShrink: 0 }}>
<audio controls src={responseResource.url} style={{ width: '100%' }} />
</div>
) : isVideo(responseResource) ? (
<div
onClick={(e) => { e.stopPropagation(); setModalResource(responseResource) }}
style={{ width: 120, height: 76, borderRadius: 8, overflow: 'hidden', flexShrink: 0, cursor: 'pointer' }}
>
<VideoPreview resource={responseResource} playSize={32} />
</div>
) : (
<div
onClick={(e) => { e.stopPropagation(); setModalResource(responseResource) }}
style={{ position: 'relative', width: 90, height: 68, borderRadius: 8, overflow: 'hidden', flexShrink: 0, cursor: 'pointer' }}
>
<Image src={responseResource.url} alt="" fill className="object-contain" sizes="120px" />
</div>
)
)}
{responseText && (
<div style={{ flex: 1, minWidth: 0 }} dangerouslySetInnerHTML={{ __html: responseText }} />
)}
</button>
)
})}
</div>
{/* Segmented progress + counter */}
<div className="shrink-0" style={{ display: 'flex', alignItems: 'center', gap: 10, paddingTop: 4 }}>
{isReview && (
<button
onClick={() => currentIndex > 0 && setCurrentIndex(currentIndex - 1)}
disabled={currentIndex === 0}
style={{ width: 36, height: 36, borderRadius: 10, border: 'none', cursor: 'pointer', flexShrink: 0, background: currentIndex === 0 ? 'var(--color-paper-accent)' : 'color-mix(in srgb, var(--color-primary) 12%, white)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2.4"><path d="M15 6l-6 6 6 6"/></svg>
</button>
)}
<div style={{ flex: 1, display: 'flex', gap: 4 }}>
{questions.map((_, i) => (
<div key={i} style={{ flex: 1, height: 6, borderRadius: 3, background: i <= currentIndex ? 'var(--color-primary)' : 'var(--color-paper-border)' }} />
))}
</div>
<span style={{ padding: '6px 12px', borderRadius: 11, fontSize: 13, fontWeight: 700, background: 'color-mix(in srgb, var(--color-primary) 12%, white)', color: 'var(--color-primary)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{currentIndex + 1} / {totalQuestions}
</span>
{isReview && (
<button
onClick={() => canGoNext && !isLast && setCurrentIndex(currentIndex + 1)}
disabled={!canGoNext || isLast}
style={{ width: 36, height: 36, borderRadius: 10, border: 'none', cursor: 'pointer', flexShrink: 0, background: (!canGoNext || isLast) ? 'var(--color-paper-accent)' : 'color-mix(in srgb, var(--color-primary) 12%, white)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2.4"><path d="M9 6l6 6-6 6"/></svg>
</button>
)}
</div>
{/* Restart button (review only) */}
{isReview && (
<button
onClick={restart}
className="w-full font-bold shrink-0"
style={{ padding: 14, borderRadius: 18, border: '1.5px solid var(--color-paper-border)', background: '#fff', color: 'var(--color-ink-soft)', fontSize: 15, cursor: 'pointer' }}
>
Recommencer
</button>
)}
</div>
</div>
</div>
{/* Media modal */}
{modalResource && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 50, background: 'rgba(0,0,0,0.93)', display: 'flex', flexDirection: 'column' }}
onClick={() => setModalResource(null)}
>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: 16, flexShrink: 0 }}>
<button
onClick={() => setModalResource(null)}
style={{ width: 40, height: 40, borderRadius: '50%', border: 'none', background: 'rgba(255,255,255,0.15)', color: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</div>
<div
style={{ flex: 1, minHeight: 0, position: 'relative' }}
onClick={(e) => e.stopPropagation()}
>
<ResourceViewer resource={modalResource} objectFit="contain" />
</div>
</div>
)}
</>
)
}
function youtubeThumbnail(url: string): string | null {
const match = url.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/)
return match?.[1] ? `https://img.youtube.com/vi/${match[1]}/mqdefault.jpg` : null
}
function VideoPreview({ resource, playSize }: { resource: ResourceDTO; playSize: number }) {
const thumbnail = resource.url ? youtubeThumbnail(resource.url) : null
return (
<div style={{ position: 'relative', width: '100%', height: '100%', background: thumbnail ? '#000' : 'linear-gradient(135deg, #1c1c2e 0%, #2d2d44 100%)' }}>
{thumbnail && (
// eslint-disable-next-line @next/next/no-img-element
<img src={thumbnail} alt="" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }} />
)}
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.32)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PlayIcon size={playSize} />
</div>
</div>
)
}
function PlayIcon({ size = 48 }: { size?: number }) {
const r = size / 2
return (
<div style={{ width: size, height: size, borderRadius: '50%', background: 'rgba(255,255,255,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '2px solid rgba(255,255,255,0.4)' }}>
<svg width={r} height={r} viewBox="0 0 24 24" fill="white">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
)
}
function ZoomIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="white">
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
)
}
function getAnswerBg(isReview: boolean, isSelected: boolean, isCorrect?: boolean): string {
if (!isReview) {
return isSelected ? 'var(--color-primary)' : '#fff'
}
if (isCorrect) return '#2E9E6B'
if (isSelected) return '#D14343'
return '#fff'
}
function getLabelEntry(labels: TranslationAndResourceDTO[] | undefined, lang: string) {
if (!labels || labels.length === 0) return null
return (
labels.find((l) => l.language === lang) ??
labels.find((l) => l.language === 'FR') ??
labels[0]
)
}