Thomas Fransolet a5a8ecdb20 Documentation interne MyInfoMate / Unov
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>
2026-08-11 11:17:01 +02:00

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]
)
}