Import initial de la documentation : statut, roadmap, plans V1/V2, specs verticales (creche, sport), audits securite, plan de test, analyse concurrentielle et maquettes de design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
451 lines
19 KiB
TypeScript
451 lines
19 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'
|
|
|
|
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
|
|
}
|
|
|
|
function isImage(r: ResourceDTO) {
|
|
const t = r.type as string | number | undefined
|
|
return t === 'Image' || t === 'ImageUrl' || t === 0 || t === 2
|
|
}
|
|
|
|
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="min-h-screen flex flex-col" style={{ background: '#F6F3EE' }}>
|
|
<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="min-h-screen flex flex-col" style={{ background: '#F6F3EE' }}>
|
|
<AppBar title={tPlain(section.title, language)} onBack={back} />
|
|
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
|
|
<div
|
|
className="flex flex-col items-center justify-center rounded-full"
|
|
style={{
|
|
width: 140, height: 140,
|
|
background: 'var(--color-primary)',
|
|
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
|
}}
|
|
>
|
|
<span className="text-4xl font-bold" style={{ color: 'var(--color-on-primary)' }}>
|
|
{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 rounded-2xl px-5 py-4 text-sm text-center [&_p]:m-0"
|
|
style={{
|
|
background: '#fff',
|
|
color: '#1E2A33',
|
|
border: '1px solid #EFEAE2',
|
|
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}
|
|
style={{
|
|
width: '100%', border: 'none', cursor: 'pointer',
|
|
background: 'var(--color-primary)', color: 'var(--color-on-primary)',
|
|
padding: 16, borderRadius: 18, fontSize: 16, fontWeight: 700,
|
|
}}
|
|
>
|
|
Recommencer
|
|
</button>
|
|
<button
|
|
onClick={() => { setCurrentIndex(0); setPhase('review') }}
|
|
style={{
|
|
width: '100%', cursor: 'pointer',
|
|
background: '#fff', color: '#46555F',
|
|
border: '1.5px solid #E2DCD2',
|
|
padding: 16, borderRadius: 18, fontSize: 16, fontWeight: 700,
|
|
}}
|
|
>
|
|
Voir les réponses
|
|
</button>
|
|
</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 questionEntry = getLabelEntry(question.label, language)
|
|
const questionText = questionEntry?.value ?? ''
|
|
const questionResource = questionEntry?.resource
|
|
|
|
return (
|
|
<>
|
|
<div className="min-h-screen flex flex-col" style={{ background: '#F6F3EE' }}>
|
|
<AppBar
|
|
title={isReview ? tPlain(section.title, language) : undefined}
|
|
onBack={isReview ? () => { setCurrentIndex(0); setPhase('result') } : back}
|
|
/>
|
|
|
|
<div className="flex-1 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 h-full p-4 gap-3">
|
|
{/* Progress header — segments + counter pill */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex flex-1 gap-1 items-center">
|
|
{questions.map((q, i) => {
|
|
const navigable = isReview || i <= currentIndex
|
|
const reached = i === currentIndex || i < currentIndex || answers[i] !== undefined
|
|
return (
|
|
<button
|
|
key={q.id ?? i}
|
|
onClick={() => navigable && setCurrentIndex(i)}
|
|
disabled={!navigable}
|
|
aria-label={`Question ${i + 1}`}
|
|
style={{
|
|
flex: 1, height: 6, borderRadius: 3, border: 'none', padding: 0,
|
|
cursor: navigable ? 'pointer' : 'default',
|
|
background: reached ? 'var(--color-primary)' : '#E2DCD2',
|
|
}}
|
|
/>
|
|
)
|
|
})}
|
|
</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>
|
|
</div>
|
|
{/* Question media */}
|
|
{questionResource?.url && (
|
|
isAudio(questionResource) ? (
|
|
<div 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: 160, borderRadius: 12, overflow: 'hidden', border: 'none', cursor: 'pointer', flexShrink: 0, padding: 0, display: 'block', width: '100%' }}
|
|
>
|
|
<VideoPreview resource={questionResource} height={160} playSize={52} />
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => setModalResource(questionResource)}
|
|
style={{ position: 'relative', height: 160, 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-4 py-4 text-sm font-medium text-center [&_p]:m-0"
|
|
style={{
|
|
background: 'white',
|
|
color: '#1a1a1a',
|
|
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
|
minHeight: 72,
|
|
}}
|
|
dangerouslySetInnerHTML={{ __html: questionText }}
|
|
/>
|
|
)}
|
|
|
|
{/* Answer buttons */}
|
|
<div className="flex flex-col gap-2 flex-1">
|
|
{sortedResponses.map((response, i) => {
|
|
const isSelected = chosen === i
|
|
const st = getAnswerStyle(isReview, isSelected, response.isCorrect)
|
|
const letter = String.fromCharCode(65 + i)
|
|
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-sm font-medium text-left [&_p]:m-0 transition-colors"
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 12,
|
|
background: st.bg,
|
|
color: st.text,
|
|
border: `1.5px solid ${st.border}`,
|
|
borderRadius: 16,
|
|
padding: '13px 14px',
|
|
boxShadow: st.filled ? 'none' : '0 6px 16px -12px rgba(0,0,0,0.3)',
|
|
cursor: isReview || chosen !== undefined ? 'default' : 'pointer',
|
|
minHeight: 56,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
width: 30, height: 30, borderRadius: 9, flexShrink: 0,
|
|
background: st.badgeBg, color: st.badgeText,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontWeight: 800, fontSize: 13,
|
|
}}
|
|
>
|
|
{letter}
|
|
</span>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
{responseResource?.url && (
|
|
isAudio(responseResource) ? (
|
|
<div
|
|
onClick={(e) => e.stopPropagation()}
|
|
style={{ marginBottom: responseText ? 8 : 0 }}
|
|
>
|
|
<audio controls src={responseResource.url} style={{ width: '100%' }} />
|
|
</div>
|
|
) : isVideo(responseResource) ? (
|
|
<div
|
|
onClick={(e) => { e.stopPropagation(); setModalResource(responseResource) }}
|
|
style={{ height: 90, borderRadius: 8, overflow: 'hidden', marginBottom: responseText ? 8 : 0, cursor: 'pointer' }}
|
|
>
|
|
<VideoPreview resource={responseResource} height={90} playSize={38} />
|
|
</div>
|
|
) : (
|
|
<div
|
|
onClick={(e) => { e.stopPropagation(); setModalResource(responseResource) }}
|
|
style={{ position: 'relative', height: 80, borderRadius: 8, overflow: 'hidden', marginBottom: responseText ? 8 : 0, cursor: 'pointer' }}
|
|
>
|
|
<Image src={responseResource.url} alt="" fill className="object-contain" sizes="100vw" />
|
|
</div>
|
|
)
|
|
)}
|
|
{responseText && (
|
|
<div dangerouslySetInnerHTML={{ __html: responseText }} />
|
|
)}
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Restart button (review only) */}
|
|
{isReview && (
|
|
<button
|
|
onClick={restart}
|
|
style={{
|
|
width: '100%', border: 'none', cursor: 'pointer',
|
|
background: 'var(--color-primary)', color: 'var(--color-on-primary)',
|
|
padding: 16, borderRadius: 18, fontSize: 16, fontWeight: 700,
|
|
}}
|
|
>
|
|
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, height, playSize }: { resource: ResourceDTO; height: number; playSize: number }) {
|
|
const thumbnail = resource.url ? youtubeThumbnail(resource.url) : null
|
|
return (
|
|
<div style={{ position: 'relative', width: '100%', height, 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 getAnswerStyle(isReview: boolean, isSelected: boolean, isCorrect?: boolean): {
|
|
bg: string; text: string; border: string; filled: boolean; badgeBg: string; badgeText: string
|
|
} {
|
|
const neutral = { bg: '#fff', text: '#1E2A33', border: '#E2DCD2', filled: false, badgeBg: '#E8E3DA', badgeText: '#54636E' }
|
|
const filledBadge = { badgeBg: 'rgba(255,255,255,0.22)', badgeText: '#fff' }
|
|
if (!isReview) {
|
|
if (isSelected) return { bg: 'var(--color-primary)', text: '#fff', border: 'var(--color-primary)', filled: true, ...filledBadge }
|
|
return neutral
|
|
}
|
|
if (isCorrect) return { bg: '#2E9E6B', text: '#fff', border: '#2E9E6B', filled: true, ...filledBadge }
|
|
if (isSelected) return { bg: '#D14343', text: '#fff', border: '#D14343', filled: true, ...filledBadge }
|
|
return neutral
|
|
}
|
|
|
|
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]
|
|
)
|
|
}
|