452 lines
21 KiB
TypeScript
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]
|
|
)
|
|
}
|