Wip working version

This commit is contained in:
Thomas Fransolet 2026-05-07 15:28:48 +02:00
parent 931f35c818
commit 57ee68e72b
17 changed files with 582 additions and 196 deletions

34
BUGS.md Normal file
View File

@ -0,0 +1,34 @@
# Bugs & problèmes connus — visitapp-web
## Sections — données non récupérées
> **Fix appliqué** : le backend retourne un JSON plat (toutes les DTOs héritent de SectionDTO),
> mais le TypeScript attendait une structure imbriquée (`section.map.points` etc.).
> `normalizeSectionDTO()` dans `client.ts` restructure la réponse. À valider en prod.
- [x] **Section Carte** — fix normalization + `CategorieDTO.label` (était `name`)
- [x] **Section Slider** — fix normalization
- [x] **Section Menu** — fix normalization (récursif pour les sous-sections)
- [x] **Section Quiz** — fix normalization
- [x] **Section Agenda** — fix normalization
- [x] **Section Météo** — fix normalization
## Affichage
- [x] **Section Agenda — visuel des cartes** — liste horizontale minimaliste remplacée par une grille 2 colonnes avec image pleine largeur + badge date (calquée sur Flutter `event_list_item.dart`)
- [x] **Section PDF** — remplacé `<iframe>` par `<embed type="application/pdf">` avec `#toolbar=0&navpanes=0` pour masquer la barre Adobe/Chrome
- [ ] **Contrastes section detail** — les titres et le bouton "back" sont peu visibles (contraste insuffisant)
- [x] **Bouton scanner** dans une configuration detail — ombre renforcée + anneau blanc semi-transparent pour contraster quel que soit le fond
## Section Menu
- [ ] **Images non affichées** — les images des items du menu ne s'affichent pas
- [ ] **Clic sur un enfant → 404** — la navigation vers une sous-section du menu aboutit à une page 404
## Section Quiz
- [ ] **Questions non affichées** — aucune question n'apparaît dans la section Quiz
## UX
- [ ] **Puzzle** — l'auto-assignation des pièces manque d'aide visuelle pour l'utilisateur ; ajouter des indications / guides pour faciliter la prise en main

View File

@ -1,6 +1,7 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getInstanceBySlug, getConfiguration, getSections } from '@/lib/api/client' import { getInstanceBySlug, getConfiguration, getSections } from '@/lib/api/client'
import SectionList from '@/components/SectionList' import SectionList from '@/components/SectionList'
import QRScannerButton from '@/components/QRScannerButton'
export default async function ConfigPage({ export default async function ConfigPage({
params, params,
@ -27,6 +28,7 @@ export default async function ConfigPage({
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
return ( return (
<>
<SectionList <SectionList
sections={activeSections} sections={activeSections}
slug={slug} slug={slug}
@ -36,5 +38,7 @@ export default async function ConfigPage({
configPrimaryColor={config.primaryColor} configPrimaryColor={config.primaryColor}
languages={config.languages ?? ['FR']} languages={config.languages ?? ['FR']}
/> />
<QRScannerButton slug={slug} configurationId={configId} />
</>
) )
} }

View File

@ -49,7 +49,7 @@ export default async function SectionPage({
} }
// Escape Games also expose guided paths // Escape Games also expose guided paths
if (section.type === 'Game' && section.game?.gameType?.toLowerCase().includes('escape')) { if (section.type === 'Game' && section.gameType === 'Escape') {
try { try {
const paths = await getGuidedPathsForGame(section.id, instance.publicApiKey!) const paths = await getGuidedPathsForGame(section.id, instance.publicApiKey!)
section.game = { ...section.game, guidedPaths: paths } section.game = { ...section.game, guidedPaths: paths }
@ -78,7 +78,8 @@ export default async function SectionPage({
case 'Slider': content = <SliderSection {...props} />; break case 'Slider': content = <SliderSection {...props} />; break
case 'Video': content = <VideoSection {...props} />; break case 'Video': content = <VideoSection {...props} />; break
case 'Map': content = <MapSection {...props} />; break case 'Map': content = <MapSection {...props} />; break
case 'Pdf': content = <PdfSection {...props} />; break case 'Pdf':
case 'PDF': content = <PdfSection {...props} />; break
case 'Weather': content = <WeatherSection {...props} />; break case 'Weather': content = <WeatherSection {...props} />; break
case 'Quiz': content = <QuizSection {...props} />; break case 'Quiz': content = <QuizSection {...props} />; break
case 'Game': content = <GameSection {...props} />; break case 'Game': content = <GameSection {...props} />; break

View File

@ -87,9 +87,9 @@ export default function QRScannerButton({ slug, configurationId }: Props) {
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="fixed bottom-5 right-5 w-14 h-14 rounded-full flex items-center justify-center z-40" className="fixed bottom-5 right-5 w-14 h-14 rounded-full flex items-center justify-center z-40"
style={{ style={{
background: 'var(--color-primary)', background: 'rgba(255,255,255,0.95)',
color: 'var(--color-on-primary)', color: 'var(--color-primary)',
boxShadow: '0 6px 16px rgba(0,0,0,0.3)', boxShadow: '0 4px 20px rgba(0,0,0,0.35)',
}} }}
aria-label="Scanner un QR code" aria-label="Scanner un QR code"
> >

View File

@ -26,7 +26,10 @@ interface Props {
languages: string[] languages: string[]
} }
const MONTHS = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc'] function formatMonthHeader(year: number, month: number, lang: string): string {
const name = new Date(year, month, 1).toLocaleDateString(lang.toLowerCase(), { month: 'long' })
return name.charAt(0).toUpperCase() + name.slice(1) + ' ' + year
}
function eventImage(e: EventAgendaDTO): string | undefined { function eventImage(e: EventAgendaDTO): string | undefined {
return e.resource?.url return e.resource?.url
@ -96,7 +99,7 @@ export default function AgendaSection({ section, configId, languages }: Props) {
</svg> </svg>
</button> </button>
<span className="font-semibold text-sm" style={{ color: 'var(--color-text)' }}> <span className="font-semibold text-sm" style={{ color: 'var(--color-text)' }}>
{MONTHS[selectedMonth]} {selectedYear} {formatMonthHeader(selectedYear, selectedMonth, language)}
</span> </span>
<button onClick={nextMonth} className="p-1"> <button onClick={nextMonth} className="p-1">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-primary)' }}> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-primary)' }}>
@ -105,13 +108,14 @@ export default function AgendaSection({ section, configId, languages }: Props) {
</button> </button>
</div> </div>
{/* Events list */} {/* Events grid */}
<main className="flex-1 overflow-y-auto p-4 flex flex-col gap-3"> <main className="flex-1 overflow-y-auto p-3">
{filtered.length === 0 && ( {filtered.length === 0 ? (
<p className="text-center py-12 text-sm" style={{ color: 'var(--color-text-muted)' }}> <p className="text-center py-12 text-sm" style={{ color: 'var(--color-text-muted)' }}>
Aucun événement ce mois-ci Aucun événement ce mois-ci
</p> </p>
)} ) : (
<div className="grid grid-cols-2 gap-3">
{filtered.map((event) => { {filtered.map((event) => {
const img = eventImage(event) const img = eventImage(event)
return ( return (
@ -127,29 +131,41 @@ export default function AgendaSection({ section, configId, languages }: Props) {
}) })
} }
}} }}
className="w-full rounded-2xl overflow-hidden text-left flex gap-0" className="rounded-2xl overflow-hidden text-left flex flex-col"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }} style={{ background: 'var(--color-surface)', boxShadow: '0 2px 8px rgba(0,0,0,0.10)' }}
> >
{img && ( {/* Image */}
<div className="relative w-20 shrink-0"> <div className="relative w-full aspect-video">
<Image src={img} alt="" fill className="object-cover" sizes="80px" /> {img ? (
<Image src={img} alt="" fill className="object-cover" sizes="50vw" />
) : (
<div className="w-full h-full" style={{ background: 'var(--color-primary-light)' }} />
)}
{event.dateFrom && (
<div
className="absolute bottom-0 right-0 flex items-center gap-1.5 px-2.5 py-1.5 text-sm font-bold whitespace-nowrap"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)', borderTopLeftRadius: 10 }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ flexShrink: 0 }}>
<path d="M19 3h-1V1h-2v2H8V1H6v2H5a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 18H5V8h14v13z"/>
</svg>
{new Date(event.dateFrom).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'short' })}
</div> </div>
)} )}
<div className="p-3 flex-1"> </div>
{/* Title */}
<div className="p-3 flex-1 flex items-center justify-center">
<p <p
className="font-semibold text-sm [&_p]:m-0" className="text-sm font-semibold text-center w-full [&_p]:m-0 line-clamp-3"
style={{ color: 'var(--color-text)' }} style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(event.label, language) }} dangerouslySetInnerHTML={{ __html: t(event.label, language) }}
/> />
{event.dateFrom && (
<p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
{new Date(event.dateFrom).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}
</p>
)}
</div> </div>
</button> </button>
) )
})} })}
</div>
)}
</main> </main>
{/* Event detail popup */} {/* Event detail popup */}

View File

@ -7,6 +7,7 @@ import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO } from '@/lib/api/types' import type { SectionDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar' import AppBar from '@/components/ui/AppBar'
import { trackEvent } from '@/lib/stats'
interface Props { interface Props {
section: SectionDTO section: SectionDTO
@ -15,17 +16,39 @@ interface Props {
languages: string[] languages: string[]
} }
export default function ArticleSection({ section, slug, configId, languages }: Props) { export default function ArticleSection({ section, configId, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor() const { language, setAvailableLanguages, instanceId } = useVisitor()
const router = useRouter() const router = useRouter()
const article = section.article const article = section.article
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0) const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0) const [duration, setDuration] = useState(0)
const audioRef = useRef<HTMLAudioElement>(null) const audioRef = useRef<HTMLAudioElement>(null)
const scrollRef = useRef<HTMLElement>(null)
const articleReadTrackedRef = useRef(false)
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages(languages) }, [languages])
useEffect(() => {
const el = scrollRef.current
if (!el || !instanceId) return
const onScroll = () => {
if (articleReadTrackedRef.current) return
const total = el.scrollHeight - el.clientHeight
if (total <= 0) return
const ratio = el.scrollTop / total
if (ratio >= 0.8) {
articleReadTrackedRef.current = true
trackEvent({
instanceId, configurationId: configId, sectionId: section.id,
eventType: 'ArticleRead', language,
})
}
}
el.addEventListener('scroll', onScroll, { passive: true })
return () => el.removeEventListener('scroll', onScroll)
}, [instanceId, configId, section.id, language])
const audioUrl = article?.audioIds?.find((a) => a.language === language)?.value const audioUrl = article?.audioIds?.find((a) => a.language === language)?.value
?? article?.audioIds?.[0]?.value ?? article?.audioIds?.[0]?.value
@ -49,7 +72,7 @@ export default function ArticleSection({ section, slug, configId, languages }: P
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}> <div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} /> <AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
<main className="flex-1 overflow-y-auto pb-24"> <main ref={scrollRef} className="flex-1 overflow-y-auto pb-24">
{/* Carousel images */} {/* Carousel images */}
{contents.length > 0 && ( {contents.length > 0 && (
<ImageCarousel contents={contents} language={language} /> <ImageCarousel contents={contents} language={language} />

View File

@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO } from '@/lib/api/types' import type { SectionDTO, TranslationDTO } from '@/lib/api/types'
import PuzzleGame from './game/PuzzleGame' import PuzzleGame from './game/PuzzleGame'
import SlidingPuzzle from './game/SlidingPuzzle' import SlidingPuzzle from './game/SlidingPuzzle'
import MessageDialog from './game/MessageDialog' import MessageDialog from './game/MessageDialog'
@ -22,12 +22,9 @@ type GameKind = 'Puzzle' | 'SlidingPuzzle' | 'Escape' | 'Unknown'
function detectKind(raw?: string): GameKind { function detectKind(raw?: string): GameKind {
if (!raw) return 'Unknown' if (!raw) return 'Unknown'
const v = raw.toString().toLowerCase() if (raw === 'SlidingPuzzle') return 'SlidingPuzzle'
if (v.includes('slid')) return 'SlidingPuzzle' if (raw === 'Escape') return 'Escape'
if (v.includes('escape')) return 'Escape' if (raw === 'Puzzle') return 'Puzzle'
if (v.includes('puzzle') || v === '0') return 'Puzzle'
if (v === '1') return 'SlidingPuzzle'
if (v === '2') return 'Escape'
return 'Unknown' return 'Unknown'
} }
@ -37,14 +34,13 @@ export default function GameSection({ section, configId, languages }: Props) {
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages(languages) }, [languages])
const game = section.game const kind = detectKind(section.gameType)
const kind = detectKind(game?.gameType) const rows = Math.max(2, section.rows ?? 3)
const rows = Math.max(2, game?.rows ?? 3) const cols = Math.max(2, section.cols ?? 3)
const cols = Math.max(2, game?.cols ?? 3) const puzzleImage = section.puzzleImage?.url
const puzzleImage = game?.puzzleImage
const startMsg = t(game?.messageDebut, language) const startMsg = t(section.messageDebut as TranslationDTO[], language)
const endMsg = t(game?.messageFin, language) const endMsg = t(section.messageFin as TranslationDTO[], language)
const [showStart, setShowStart] = useState<boolean>(!!startMsg) const [showStart, setShowStart] = useState<boolean>(!!startMsg)
const [showEnd, setShowEnd] = useState(false) const [showEnd, setShowEnd] = useState(false)
@ -138,8 +134,8 @@ export default function GameSection({ section, configId, languages }: Props) {
/> />
)} )}
{kind === 'Escape' && ( {kind === 'Escape' && (
(game?.guidedPaths?.length ?? 0) > 0 ? ( (section.guidedPaths?.length ?? 0) > 0 ? (
<EscapeProgression paths={game!.guidedPaths!} language={language} /> <EscapeProgression paths={section.guidedPaths!} language={language} />
) : ( ) : (
<EscapeStub title={tPlain(section.title, language)} description={t(section.description, language)} /> <EscapeStub title={tPlain(section.title, language)} description={t(section.description, language)} />
) )

View File

@ -350,7 +350,7 @@ export default function MapSection({ section, configId, languages }: Props) {
className="w-2 h-2 rounded-full" className="w-2 h-2 rounded-full"
style={{ background: active ? 'white' : color }} style={{ background: active ? 'white' : color }}
/> />
{tPlain(cat.name, language) || `Cat. ${cat.id}`} {tPlain(cat.label, language) || `Cat. ${cat.id}`}
</button> </button>
) )
})} })}

View File

@ -8,6 +8,7 @@ import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO } from '@/lib/api/types' import type { SectionDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar' import AppBar from '@/components/ui/AppBar'
import { trackEvent } from '@/lib/stats'
interface Props { interface Props {
section: SectionDTO section: SectionDTO
@ -17,10 +18,19 @@ interface Props {
} }
export default function MenuSection({ section, slug, configId, languages }: Props) { export default function MenuSection({ section, slug, configId, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor() const { language, setAvailableLanguages, instanceId } = useVisitor()
const router = useRouter() const router = useRouter()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
function handleItemClick(sub: SectionDTO) {
if (!instanceId) return
trackEvent({
instanceId, configurationId: configId, sectionId: section.id,
eventType: 'MenuItemTap', language,
metadata: JSON.stringify({ targetSectionId: sub.id, title: tPlain(sub.title, language) }),
})
}
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages(languages) }, [languages])
const subsections = [...(section.menu?.sections ?? [])] const subsections = [...(section.menu?.sections ?? [])]
@ -73,6 +83,7 @@ export default function MenuSection({ section, slug, configId, languages }: Prop
<Link <Link
key={sub.id} key={sub.id}
href={`/${slug}/${configId}/sections/${sub.id}`} href={`/${slug}/${configId}/sections/${sub.id}`}
onClick={() => handleItemClick(sub)}
className="rounded-2xl overflow-hidden block relative" className="rounded-2xl overflow-hidden block relative"
style={{ background: 'var(--color-surface)', minHeight: 120 }} style={{ background: 'var(--color-surface)', minHeight: 120 }}
> >

View File

@ -20,7 +20,7 @@ export default function PdfSection({ section, slug, configId, languages }: Props
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages(languages) }, [languages])
const pdfs = [...(section.pdf?.pdfs ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) const pdfs = [...(section.pdfs ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
function getPdfUrl(pdf: OrderedTranslationAndResourceDTO): string | undefined { function getPdfUrl(pdf: OrderedTranslationAndResourceDTO): string | undefined {
@ -45,8 +45,9 @@ export default function PdfSection({ section, slug, configId, languages }: Props
onClick={() => setSelectedIndex(i)} onClick={() => setSelectedIndex(i)}
className="shrink-0 px-3 py-1.5 rounded-full text-xs font-medium" className="shrink-0 px-3 py-1.5 rounded-full text-xs font-medium"
style={{ style={{
background: i === selectedIndex ? 'var(--color-primary)' : 'var(--color-surface)', background: i === selectedIndex ? 'var(--color-primary)' : 'transparent',
color: i === selectedIndex ? 'var(--color-on-primary)' : 'var(--color-text)', color: i === selectedIndex ? 'var(--color-on-primary)' : 'var(--color-text)',
border: i === selectedIndex ? 'none' : '1.5px solid var(--color-border)',
}} }}
> >
PDF {i + 1} PDF {i + 1}
@ -61,10 +62,10 @@ export default function PdfSection({ section, slug, configId, languages }: Props
Aucun PDF à afficher Aucun PDF à afficher
</div> </div>
) : ( ) : (
<iframe <embed
src={currentUrl} src={`${currentUrl}#toolbar=0&navpanes=0`}
title={tPlain(section.title, language)} type="application/pdf"
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }} style={{ width: '100%', height: '100%', display: 'block' }}
/> />
)} )}
</div> </div>

View File

@ -5,7 +5,7 @@ import Image from 'next/image'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO, ContentDTO } from '@/lib/api/types' import type { SectionDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar' import AppBar from '@/components/ui/AppBar'
interface Props { interface Props {
@ -15,7 +15,7 @@ interface Props {
languages: string[] languages: string[]
} }
export default function SliderSection({ section, slug, configId, languages }: Props) { export default function SliderSection({ section, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor() const { language, setAvailableLanguages } = useVisitor()
const router = useRouter() const router = useRouter()
const [index, setIndex] = useState(0) const [index, setIndex] = useState(0)
@ -27,9 +27,9 @@ export default function SliderSection({ section, slug, configId, languages }: Pr
if (contents.length === 0) { if (contents.length === 0) {
return ( return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}> <div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: 'var(--color-background)' }}>
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} /> <AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
<div className="flex-1 flex items-center justify-center text-sm" style={{ color: 'var(--color-text-muted)' }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, color: 'var(--color-text-muted)' }}>
Aucun contenu à afficher Aucun contenu à afficher
</div> </div>
</div> </div>
@ -39,16 +39,16 @@ export default function SliderSection({ section, slug, configId, languages }: Pr
const current = contents[index] const current = contents[index]
return ( return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}> <div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: 'var(--color-background)' }}>
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} /> <AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
<main className="flex-1 flex flex-col overflow-hidden"> <main style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Image zone — ~60% height */} {/* Image zone — ~75% of remaining height */}
<div className="relative flex-[3]" style={{ minHeight: 0 }}> <div style={{ flex: 3, minHeight: 0, position: 'relative' }}>
{current?.resource?.url && ( {current?.resource?.url && (
<Image <Image
src={current.resource.url} src={current.resource.url}
alt={t(current.title, language)} alt={tPlain(current.title, language)}
fill fill
className="object-contain" className="object-contain"
sizes="100vw" sizes="100vw"
@ -56,13 +56,12 @@ export default function SliderSection({ section, slug, configId, languages }: Pr
)} )}
{/* Title overlay */} {/* Title overlay */}
{t(current?.title, language) && ( {tPlain(current?.title, language) && (
<div <div
className="absolute bottom-0 left-0 right-0 px-4 py-3" style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: 'linear-gradient(to top, rgba(0,0,0,0.6), transparent)' }}
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.6), transparent)' }}
> >
<p className="text-white text-base font-semibold"> <p style={{ color: 'white', fontSize: 16, fontWeight: 600, margin: 0 }}>
{t(current?.title, language)} {tPlain(current?.title, language)}
</p> </p>
</div> </div>
)} )}
@ -73,7 +72,7 @@ export default function SliderSection({ section, slug, configId, languages }: Pr
<button <button
onClick={() => setIndex((i) => Math.max(0, i - 1))} onClick={() => setIndex((i) => Math.max(0, i - 1))}
disabled={index === 0} disabled={index === 0}
className="absolute left-2 top-1/2 -translate-y-1/2 bg-black/40 text-white rounded-full p-2 disabled:opacity-20" style={{ position: 'absolute', left: 8, top: '50%', transform: 'translateY(-50%)', background: 'rgba(0,0,0,0.4)', color: 'white', borderRadius: '50%', padding: 8, border: 'none', cursor: 'pointer', opacity: index === 0 ? 0.2 : 1, display: 'flex' }}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/> <path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
@ -82,7 +81,7 @@ export default function SliderSection({ section, slug, configId, languages }: Pr
<button <button
onClick={() => setIndex((i) => Math.min(contents.length - 1, i + 1))} onClick={() => setIndex((i) => Math.min(contents.length - 1, i + 1))}
disabled={index === contents.length - 1} disabled={index === contents.length - 1}
className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/40 text-white rounded-full p-2 disabled:opacity-20" style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'rgba(0,0,0,0.4)', color: 'white', borderRadius: '50%', padding: 8, border: 'none', cursor: 'pointer', opacity: index === contents.length - 1 ? 0.2 : 1, display: 'flex' }}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/> <path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
@ -92,14 +91,10 @@ export default function SliderSection({ section, slug, configId, languages }: Pr
)} )}
</div> </div>
{/* Description card — ~25% height */} {/* Description card */}
{t(current?.description, language) && ( {t(current?.description, language) && (
<div <div
className="flex-[1] overflow-y-auto mx-4 my-3 rounded-2xl p-4" style={{ flex: 1, minHeight: 0, overflowY: 'auto', margin: '12px 16px', borderRadius: 16, padding: 16, background: 'var(--color-surface)', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', flexShrink: 0 }}
style={{
background: 'var(--color-surface)',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
}}
> >
<div <div
className="prose prose-sm text-center max-w-none" className="prose prose-sm text-center max-w-none"
@ -111,16 +106,20 @@ export default function SliderSection({ section, slug, configId, languages }: Pr
{/* Dots indicator */} {/* Dots indicator */}
{contents.length > 1 && ( {contents.length > 1 && (
<div className="flex justify-center gap-1.5 py-3"> <div style={{ flexShrink: 0, display: 'flex', justifyContent: 'center', gap: 6, padding: '12px 0' }}>
{contents.map((_, i) => ( {contents.map((_, i) => (
<button <button
key={i} key={i}
onClick={() => setIndex(i)} onClick={() => setIndex(i)}
className="rounded-full transition-all"
style={{ style={{
borderRadius: 9999,
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
width: i === index ? 20 : 8, width: i === index ? 20 : 8,
height: 8, height: 8,
background: i === index ? 'var(--color-primary)' : 'var(--color-border)', background: i === index ? 'var(--color-primary)' : 'var(--color-border)',
padding: 0,
}} }}
/> />
))} ))}

View File

@ -1,10 +1,9 @@
'use client' 'use client'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { tPlain } from '@/lib/i18n'
import type { SectionDTO, WeatherData, WeatherForecast } from '@/lib/api/types' import type { SectionDTO, WeatherData, WeatherForecast } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar' import AppBar from '@/components/ui/AppBar'
@ -23,6 +22,104 @@ interface DaySummary {
maxTemp: number maxTemp: number
} }
const WEATHER_DESC: Record<string, Record<string, string>> = {
FR: {
'thunderstorm with light rain': 'Orage avec pluie légère',
'thunderstorm with rain': 'Orage avec pluie',
'thunderstorm with heavy rain': 'Orage avec forte pluie',
'light thunderstorm': 'Orage léger',
'thunderstorm': 'Orage',
'heavy thunderstorm': 'Fort orage',
'ragged thunderstorm': 'Orage violent',
'thunderstorm with light drizzle': 'Orage avec bruine légère',
'thunderstorm with drizzle': 'Orage avec bruine',
'thunderstorm with heavy drizzle': 'Orage avec forte bruine',
'light intensity drizzle': 'Bruine légère',
'drizzle': 'Bruine',
'heavy intensity drizzle': 'Forte bruine',
'light intensity drizzle rain': 'Pluie bruine légère',
'drizzle rain': 'Pluie bruine',
'heavy intensity drizzle rain': 'Forte pluie bruine',
'shower rain and drizzle': 'Averse et bruine',
'heavy shower rain and drizzle': 'Forte averse et bruine',
'shower drizzle': 'Averse bruine',
'light rain': 'Pluie légère',
'moderate rain': 'Pluie modérée',
'heavy intensity rain': 'Pluie intense',
'very heavy rain': 'Très forte pluie',
'extreme rain': 'Pluie extrême',
'freezing rain': 'Pluie verglaçante',
'light intensity shower rain': 'Averse légère',
'shower rain': 'Averse',
'heavy intensity shower rain': 'Forte averse',
'ragged shower rain': 'Averse violente',
'light snow': 'Neige légère',
'snow': 'Neige',
'heavy snow': 'Forte neige',
'sleet': 'Grésil',
'light shower sleet': 'Averse de grésil légère',
'shower sleet': 'Averse de grésil',
'light rain and snow': 'Pluie et neige légères',
'rain and snow': 'Pluie et neige',
'light shower snow': 'Averse de neige légère',
'shower snow': 'Averse de neige',
'heavy shower snow': 'Forte averse de neige',
'mist': 'Brume',
'smoke': 'Fumée',
'haze': 'Brume sèche',
'sand/dust whirls': 'Tourbillons de sable',
'fog': 'Brouillard',
'sand': 'Sable',
'dust': 'Poussière',
'volcanic ash': 'Cendres volcaniques',
'squalls': 'Rafales',
'tornado': 'Tornade',
'clear sky': 'Ciel dégagé',
'few clouds': 'Quelques nuages',
'scattered clouds': 'Nuages épars',
'broken clouds': 'Nuages fragmentés',
'overcast clouds': 'Ciel couvert',
},
NL: {
'clear sky': 'Heldere lucht',
'few clouds': 'Enkele wolken',
'scattered clouds': 'Verspreide wolken',
'broken clouds': 'Gebroken bewolking',
'overcast clouds': 'Bewolkt',
'light rain': 'Lichte regen',
'moderate rain': 'Matige regen',
'heavy intensity rain': 'Zware regen',
'thunderstorm': 'Onweer',
'light snow': 'Lichte sneeuw',
'snow': 'Sneeuw',
'mist': 'Mist',
'fog': 'Mist',
'drizzle': 'Motregen',
'shower rain': 'Regenbui',
},
DE: {
'clear sky': 'Klarer Himmel',
'few clouds': 'Wenige Wolken',
'scattered clouds': 'Vereinzelte Wolken',
'broken clouds': 'Aufgelockerte Bewölkung',
'overcast clouds': 'Bedeckt',
'light rain': 'Leichter Regen',
'moderate rain': 'Mäßiger Regen',
'heavy intensity rain': 'Starker Regen',
'thunderstorm': 'Gewitter',
'light snow': 'Leichter Schnee',
'snow': 'Schnee',
'mist': 'Nebel',
'fog': 'Nebel',
'drizzle': 'Nieselregen',
'shower rain': 'Schauer',
},
}
function translateDesc(desc: string, lang: string): string {
return WEATHER_DESC[lang]?.[desc.toLowerCase()] ?? (desc.charAt(0).toUpperCase() + desc.slice(1))
}
const SHORT_DAYS: Record<string, string[]> = { const SHORT_DAYS: Record<string, string[]> = {
FR: ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'], FR: ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'],
NL: ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za'], NL: ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za'],
@ -59,7 +156,6 @@ function buildDays(list: WeatherForecast[]): DaySummary[] {
} }
return Array.from(byDay.entries()).slice(0, 6).map(([dt, forecasts]) => { return Array.from(byDay.entries()).slice(0, 6).map(([dt, forecasts]) => {
const rep = forecasts.find((f) => new Date((f.dt ?? 0) * 1000).getHours() === 12) ?? forecasts[forecasts.length - 1] const rep = forecasts.find((f) => new Date((f.dt ?? 0) * 1000).getHours() === 12) ?? forecasts[forecasts.length - 1]
const temps = forecasts.map((f) => f.main?.temp ?? 0)
return { return {
dt, dt,
forecasts, forecasts,
@ -70,7 +166,59 @@ function buildDays(list: WeatherForecast[]): DaySummary[] {
}) })
} }
export default function WeatherSection({ section, slug, configId, languages }: Props) { function TempChart({ forecasts }: { forecasts: WeatherForecast[] }) {
const temps = forecasts.map((f) => f.main?.temp ?? 0)
const hours = forecasts.map((f) => new Date((f.dt ?? 0) * 1000).getHours())
if (temps.length < 2) return null
const W = 300
const H = 120
const padX = 18
const padTop = 22
const padBottom = 22
const chartH = H - padTop - padBottom
const minT = Math.min(...temps) - 2
const maxT = Math.max(...temps) + 2
const toX = (i: number) => padX + (i / (temps.length - 1)) * (W - padX * 2)
const toY = (t: number) => padTop + chartH - ((t - minT) / (maxT - minT)) * chartH
const pts = temps.map((t, i) => ({ x: toX(i), y: toY(t) }))
// Smooth cubic bezier
let linePath = `M ${pts[0].x} ${pts[0].y}`
for (let i = 0; i < pts.length - 1; i++) {
const dx = (pts[i + 1].x - pts[i].x) / 2.5
linePath += ` C ${pts[i].x + dx} ${pts[i].y} ${pts[i + 1].x - dx} ${pts[i + 1].y} ${pts[i + 1].x} ${pts[i + 1].y}`
}
const areaPath = `${linePath} L ${pts[pts.length - 1].x} ${H - padBottom} L ${pts[0].x} ${H - padBottom} Z`
return (
<svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} style={{ display: 'block' }}>
<defs>
<linearGradient id="tg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3B82F6" stopOpacity="0.18" />
<stop offset="100%" stopColor="#3B82F6" stopOpacity="0" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#tg)" />
<path d={linePath} fill="none" stroke="#3B82F6" strokeWidth="2" strokeLinecap="round" />
{pts.map((p, i) => (
<g key={i}>
<circle cx={p.x} cy={p.y} r="4" fill="white" stroke="#3B82F6" strokeWidth="2" />
<text x={p.x} y={p.y - 8} textAnchor="middle" fontSize="9" fill="#374151" fontWeight="700">
{Math.round(temps[i])}°
</text>
<text x={p.x} y={H - 4} textAnchor="middle" fontSize="8" fill="#9CA3AF">
{String(hours[i]).padStart(2, '0')}h
</text>
</g>
))}
</svg>
)
}
export default function WeatherSection({ section, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor() const { language, setAvailableLanguages } = useVisitor()
const router = useRouter() const router = useRouter()
const [selectedDay, setSelectedDay] = useState(0) const [selectedDay, setSelectedDay] = useState(0)
@ -88,7 +236,7 @@ export default function WeatherSection({ section, slug, configId, languages }: P
if (!weatherData || days.length === 0) { if (!weatherData || days.length === 0) {
return ( return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}> <div className="h-dvh flex flex-col" style={{ background: 'var(--color-background)' }}>
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} /> <AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
<div className="flex-1 flex items-center justify-center text-sm" style={{ color: 'var(--color-text-muted)' }}> <div className="flex-1 flex items-center justify-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
Aucune donnée météo Aucune donnée météo
@ -98,38 +246,34 @@ export default function WeatherSection({ section, slug, configId, languages }: P
} }
const rep = selected.representative const rep = selected.representative
const desc = rep.weather?.[0]?.description ?? '' const desc = translateDesc(rep.weather?.[0]?.description ?? '', language)
const maxRain = Math.round(Math.max(...selected.forecasts.map((f) => (typeof f.pop === 'number' ? f.pop : 0))) * 100)
const humidity = Math.round(rep.main?.humidity ?? 0)
return ( return (
<div <div className="h-dvh flex flex-col overflow-hidden" style={{ background: '#E8F4FD' }}>
className="min-h-screen flex flex-col"
style={{ background: '#E8F4FD' }}
>
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} /> <AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
<main className="flex-1 overflow-y-auto pb-6"> <main className="flex-1 flex flex-col overflow-y-auto pb-6 gap-3 pt-3">
{/* City */}
<p className="px-5 pt-4 text-sm font-medium" style={{ color: '#6B7280' }}> {/* City name */}
<p className="px-5 text-2xl font-bold" style={{ color: '#1F2937' }}>
{section.weather?.city ?? ''} {section.weather?.city ?? ''}
</p> </p>
{/* Day tabs */} {/* Day tabs */}
<div className="flex gap-2 px-3 py-3 overflow-x-auto"> <div className="grid px-3 gap-1" style={{ gridTemplateColumns: `repeat(${days.length}, 1fr)` }}>
{days.map((day, i) => ( {days.map((day, i) => (
<button <button
key={day.dt} key={day.dt}
onClick={() => setSelectedDay(i)} onClick={() => setSelectedDay(i)}
className="shrink-0 flex flex-col items-center px-3 py-2 rounded-2xl transition-all" className="flex flex-col items-center px-1 py-2 rounded-2xl transition-all"
style={{ style={{
background: i === selectedDay ? 'white' : 'transparent', background: i === selectedDay ? 'white' : 'transparent',
boxShadow: i === selectedDay ? '0 2px 8px rgba(0,0,0,0.08)' : 'none', boxShadow: i === selectedDay ? '0 2px 8px rgba(0,0,0,0.08)' : 'none',
minWidth: 64,
}} }}
> >
<span <span className="text-xs font-medium" style={{ color: i === selectedDay ? '#1F2937' : '#6B7280' }}>
className="text-xs font-medium"
style={{ color: i === selectedDay ? '#1F2937' : '#6B7280' }}
>
{shortDay(day.dt, language)} {shortDay(day.dt, language)}
</span> </span>
<img <img
@ -145,66 +289,101 @@ export default function WeatherSection({ section, slug, configId, languages }: P
))} ))}
</div> </div>
{/* Selected day main */} {/* Temperature chart */}
<div className="px-5"> <div
<p className="text-sm" style={{ color: '#6B7280' }}>{fullDay(selected.dt, language)}</p> className="mx-3 rounded-2xl px-3 py-3"
<div className="flex items-center gap-1 mt-1"> style={{ background: 'white', boxShadow: '0 2px 8px rgba(0,0,0,0.05)' }}
<span className="text-6xl font-light" style={{ color: '#1F2937' }}> >
<TempChart forecasts={selected.forecasts} />
</div>
{/* Selected day — main info */}
<div
className="mx-3 rounded-2xl px-4 py-4 flex items-center gap-3"
style={{ background: 'white', boxShadow: '0 2px 8px rgba(0,0,0,0.05)' }}
>
{/* Left: date + temp */}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium capitalize" style={{ color: '#6B7280' }}>
{fullDay(selected.dt, language)}
</p>
<div className="flex items-end gap-1 mt-1">
<span className="text-5xl font-light leading-none" style={{ color: '#1F2937' }}>
{Math.round(selected.maxTemp)}° {Math.round(selected.maxTemp)}°
</span> </span>
<span className="text-3xl font-light" style={{ color: '#9CA3AF' }}> <span className="text-2xl font-light pb-1" style={{ color: '#9CA3AF' }}>
/{Math.round(selected.minTemp)}° /{Math.round(selected.minTemp)}°
</span> </span>
</div>
</div>
{/* Right: icon + description + stats */}
<div className="flex flex-col items-center gap-1 shrink-0">
<img <img
src={`https://openweathermap.org/img/wn/${rep.weather?.[0]?.icon ?? '01d'}@2x.png`} src={`https://openweathermap.org/img/wn/${rep.weather?.[0]?.icon ?? '01d'}@2x.png`}
alt={desc} alt={desc}
width={64} width={56}
height={64} height={56}
/> />
</div>
{desc && ( {desc && (
<p className="text-sm font-medium" style={{ color: 'var(--color-primary)' }}> <p className="text-xs font-medium text-center" style={{ color: 'var(--color-primary)', maxWidth: 100 }}>
{desc.charAt(0).toUpperCase() + desc.slice(1)} {desc}
</p> </p>
)} )}
</div> </div>
{/* Stats */}
<div className="flex flex-col gap-2 shrink-0 text-right">
<div>
<p className="text-xs" style={{ color: '#6B7280' }}>
{language === 'EN' ? 'Humidity' : language === 'NL' ? 'Vochtigheid' : language === 'DE' ? 'Luftfeucht.' : 'Humidité'}
</p>
<p className="text-sm font-semibold" style={{ color: '#1F2937' }}>{humidity}%</p>
</div>
<div>
<p className="text-xs" style={{ color: '#6B7280' }}>
{language === 'EN' ? 'Rain' : language === 'NL' ? 'Neerslag' : language === 'DE' ? 'Regen' : 'Pluie'}
</p>
<p className="text-sm font-semibold" style={{ color: '#3B82F6' }}>{maxRain}%</p>
</div>
</div>
</div>
{/* Hourly */} {/* Hourly */}
<div className="mt-5 px-5"> <div className="mx-3 flex-1 flex flex-col">
<p className="text-sm font-semibold mb-3" style={{ color: '#1F2937' }}> <p className="text-sm font-semibold mb-2 px-1" style={{ color: '#1F2937' }}>
{language === 'EN' ? 'Hourly' : language === 'NL' ? 'Per uur' : language === 'DE' ? 'Stündlich' : 'Par heure'} {language === 'EN' ? 'Hourly' : language === 'NL' ? 'Per uur' : language === 'DE' ? 'Stündlich' : 'Par heure'}
</p> </p>
<div <div
className="rounded-2xl overflow-x-auto" className="flex-1 rounded-2xl overflow-x-auto flex flex-col justify-center"
style={{ background: 'white', boxShadow: '0 2px 8px rgba(0,0,0,0.05)' }} style={{ background: 'white', boxShadow: '0 2px 8px rgba(0,0,0,0.05)', minHeight: 140 }}
> >
<div className="flex px-2 py-4 gap-1" style={{ minWidth: 'max-content' }}> <div className="flex px-3 py-5 gap-0">
{(selected.forecasts.slice(0, 8)).map((f) => { {selected.forecasts.map((f) => {
const hour = new Date((f.dt ?? 0) * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) const hour = new Date((f.dt ?? 0) * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
const pop = Math.round((typeof f.pop === 'number' ? f.pop : 0) * 100) const pop = Math.round((typeof f.pop === 'number' ? f.pop : 0) * 100)
return ( return (
<div key={f.dt} className="flex flex-col items-center gap-1 w-16"> <div key={f.dt} className="flex flex-col items-center gap-2" style={{ flex: '1 0 64px' }}>
<span className="text-sm font-semibold" style={{ color: '#1F2937' }}> <span className="text-base font-bold" style={{ color: '#1F2937' }}>
{Math.round(f.main?.temp ?? 0)}° {Math.round(f.main?.temp ?? 0)}°
</span> </span>
{pop > 0 ? ( {pop > 0 ? (
<span className="text-xs font-medium" style={{ color: '#3B82F6' }}>{pop}%</span> <span className="text-xs font-semibold" style={{ color: '#3B82F6' }}>{pop}%</span>
) : ( ) : (
<span className="text-xs" style={{ minHeight: 16 }} /> <span style={{ height: 16 }} />
)} )}
<img <img
src={`https://openweathermap.org/img/wn/${f.weather?.[0]?.icon ?? '01d'}.png`} src={`https://openweathermap.org/img/wn/${f.weather?.[0]?.icon ?? '01d'}.png`}
alt="" alt=""
width={34} width={40}
height={34} height={40}
/> />
<span className="text-xs" style={{ color: '#6B7280' }}>{hour}</span> <span className="text-xs font-medium" style={{ color: '#6B7280' }}>{hour}</span>
</div> </div>
) )
})} })}
</div> </div>
</div> </div>
</div> </div>
</main> </main>
</div> </div>
) )

View File

@ -22,6 +22,7 @@ export default function AppBar({ title, onBack }: Props) {
background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))', background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))',
minHeight: 56, minHeight: 56,
color: 'var(--color-on-primary)', color: 'var(--color-on-primary)',
textShadow: '0 1px 3px rgba(0,0,0,0.15)',
}} }}
> >
<div className="flex items-center gap-3 flex-1"> <div className="flex items-center gap-3 flex-1">
@ -42,19 +43,24 @@ export default function AppBar({ title, onBack }: Props) {
</div> </div>
{availableLanguages.length > 1 && ( {availableLanguages.length > 1 && (
<div className="relative"> <div className="flex items-center gap-1">
<select {availableLanguages.map((lang) => {
value={language} const active = lang === language
onChange={(e) => setLanguage(e.target.value)} return (
className="appearance-none bg-transparent text-inherit text-sm pl-1 pr-5 py-1 cursor-pointer focus:outline-none" <button
aria-label="Langue" key={lang}
onClick={() => setLanguage(lang)}
className="text-xs font-semibold px-2 py-1 rounded-lg transition-all"
style={{
background: active ? 'rgba(255,255,255,0.25)' : 'transparent',
color: 'var(--color-on-primary)',
opacity: active ? 1 : 0.6,
}}
> >
{availableLanguages.map((lang) => ( {FLAG[lang] ?? lang}
<option key={lang} value={lang} style={{ color: '#1a1a1a' }}> </button>
{FLAG[lang] ?? lang} {lang} )
</option> })}
))}
</select>
</div> </div>
)} )}
</header> </header>

View File

@ -14,6 +14,124 @@ async function apiFetch<T>(path: string, apiKey?: string): Promise<T> {
return res.json() return res.json()
} }
// The backend returns a flat JSON per section (all DTO types extend SectionDTO).
// e.g. a Map section has `points`, `categories`, etc. at the root, not nested under `map`.
// This function restructures the flat response into the nested shape our components expect.
function normalizeSectionDTO(raw: any): SectionDTO {
const base: SectionDTO = {
id: raw.id,
type: raw.type,
label: raw.label,
title: raw.title,
description: raw.description,
imageSource: raw.imageSource,
isActive: raw.isActive,
order: raw.order,
configurationId: raw.configurationId,
isSubSection: raw.isSubSection,
parentId: raw.parentId,
latitude: raw.latitude,
longitude: raw.longitude,
isBeacon: raw.isBeacon,
beaconId: raw.beaconId,
}
switch (raw.type) {
case 'Map':
base.map = {
isListViewEnabled: raw.isListViewEnabled,
zoom: raw.zoom,
mapType: raw.mapType,
mapProvider: raw.mapProvider,
points: raw.points,
categories: raw.categories,
centerLatitude: raw.centerLatitude,
centerLongitude: raw.centerLongitude,
isParcours: raw.isParcours,
guidedPaths: raw.guidedPaths,
}
break
case 'Article':
base.article = {
content: raw.content,
isContentTop: raw.isContentTop,
audioIds: raw.audioIds,
isReadAudioAuto: raw.isReadAudioAuto,
contents: raw.contents,
}
break
case 'Slider':
base.slider = { contents: raw.contents }
break
case 'Quiz':
console.log('[Quiz] raw questions:', raw.questions?.length, 'bad_level:', raw.bad_level)
base.quiz = {
questions: raw.questions?.map((q: any) => ({
...q,
responses: q.responses?.map((r: any) => ({
...r,
isCorrect: r.isGood,
})),
})),
badLevel: raw.bad_level,
mediumLevel: raw.medium_level,
goodLevel: raw.good_level,
greatLevel: raw.great_level,
}
break
case 'Agenda':
base.agenda = {
isOnlineAgenda: raw.isOnlineAgenda,
events: raw.events,
}
break
case 'Menu':
base.menu = {
sections: Array.isArray(raw.sections) ? raw.sections.map(normalizeSectionDTO) : [],
}
break
case 'Video':
base.video = {
source: raw.source,
imageSource: raw.imageSource,
title: raw.title,
}
break
case 'Pdf':
case 'PDF':
base.pdfs = raw.pdfs
break
case 'Weather':
base.weather = { result: raw.result, city: raw.city }
break
case 'Web':
base.web = { source: raw.source }
break
case 'Game':
console.log('[Game] raw.gameType =', raw.gameType)
base.gameType = raw.gameType
base.rows = raw.rows
base.cols = raw.cols
base.puzzleImage = raw.puzzleImage
base.messageDebut = raw.messageDebut
base.messageFin = raw.messageFin
base.guidedPaths = raw.guidedPaths
break
case 'Event':
base.event = {
startDate: raw.startDate ?? raw.StartDate,
endDate: raw.endDate ?? raw.EndDate,
baseSectionMapId: raw.baseSectionMapId ?? raw.BaseSectionMapId,
parcoursIds: raw.parcoursIds ?? raw.ParcoursIds,
globalMapAnnotations: raw.globalMapAnnotations ?? raw.GlobalMapAnnotations,
programme: raw.programme ?? raw.Programme,
}
break
}
return base
}
export async function getInstanceBySlug(slug: string): Promise<ApplicationInstanceDTO> { export async function getInstanceBySlug(slug: string): Promise<ApplicationInstanceDTO> {
return apiFetch(`/api/instance/slug/${slug}`) return apiFetch(`/api/instance/slug/${slug}`)
} }
@ -27,7 +145,8 @@ export async function getConfiguration(configId: string, apiKey: string): Promis
} }
export async function getSections(configId: string, apiKey: string): Promise<SectionDTO[]> { export async function getSections(configId: string, apiKey: string): Promise<SectionDTO[]> {
return apiFetch(`/api/Section/configuration/${configId}/detail`, apiKey) const raw: any[] = await apiFetch(`/api/Section/configuration/${configId}/detail`, apiKey)
return raw.map(normalizeSectionDTO)
} }
export async function getGuidedPaths(sectionMapId: string, apiKey: string): Promise<GuidedPathDTO[]> { export async function getGuidedPaths(sectionMapId: string, apiKey: string): Promise<GuidedPathDTO[]> {

View File

@ -54,6 +54,7 @@ export type SectionType =
| 'Quiz' | 'Quiz'
| 'Article' | 'Article'
| 'Pdf' | 'Pdf'
| 'PDF'
| 'Game' | 'Game'
| 'Agenda' | 'Agenda'
| 'Weather' | 'Weather'
@ -75,14 +76,22 @@ export interface SectionDTO {
longitude?: number longitude?: number
isBeacon?: boolean isBeacon?: boolean
beaconId?: string beaconId?: string
// Typed data per section type // Typed data per section type (fields are flat at root — all sub-DTOs extend SectionDTO)
article?: ArticleDTO article?: ArticleDTO
slider?: SliderDTO slider?: SliderDTO
quiz?: QuizDTO quiz?: QuizDTO
map?: MapDTO map?: MapDTO
agenda?: AgendaDTO agenda?: AgendaDTO
game?: GameDTO // Game fields (GameDTO extends SectionDTO → flat)
pdf?: PdfDTO gameType?: 'Puzzle' | 'SlidingPuzzle' | 'Escape'
rows?: number
cols?: number
puzzleImage?: ResourceDTO
messageDebut?: TranslationAndResourceDTO[]
messageFin?: TranslationAndResourceDTO[]
guidedPaths?: GuidedPathDTO[]
// PDF fields
pdfs?: OrderedTranslationAndResourceDTO[]
menu?: MenuDTO menu?: MenuDTO
video?: VideoDTO video?: VideoDTO
source?: string source?: string
@ -181,8 +190,9 @@ export interface GeoPointDTO {
export interface CategorieDTO { export interface CategorieDTO {
id: number id: number
name?: TranslationDTO[] label?: TranslationDTO[]
color?: string color?: string
icon?: string
} }
export interface MapDTO { export interface MapDTO {
@ -266,16 +276,6 @@ export interface AgendaDTO {
events?: EventAgendaDTO[] events?: EventAgendaDTO[]
} }
export interface GameDTO {
messageDebut?: TranslationDTO[]
messageFin?: TranslationDTO[]
puzzleImage?: string
rows?: number
cols?: number
gameType?: string
guidedPaths?: GuidedPathDTO[]
}
export interface TranslationAndResourceDTO { export interface TranslationAndResourceDTO {
language?: string language?: string
resourceId?: string resourceId?: string
@ -287,9 +287,6 @@ export interface OrderedTranslationAndResourceDTO {
translationAndResourceDTOs?: TranslationAndResourceDTO[] translationAndResourceDTOs?: TranslationAndResourceDTO[]
} }
export interface PdfDTO {
pdfs?: OrderedTranslationAndResourceDTO[]
}
export interface VideoDTO { export interface VideoDTO {
source?: string source?: string

View File

@ -26,7 +26,10 @@ export function buildTheme(primaryColor: string, secondaryColor: string): ThemeC
'--color-primary': primaryColor, '--color-primary': primaryColor,
'--color-secondary': secondaryColor, '--color-secondary': secondaryColor,
'--color-primary-light': hexToRgba(primaryColor, 0.12), '--color-primary-light': hexToRgba(primaryColor, 0.12),
'--color-on-primary': luminance > 0.4 ? '#1A1A1A' : '#FFFFFF', // Threshold ~0.179 is the mathematically correct crossover for equal WCAG contrast
// between white (1.0) and black (0.0) against a given background luminance.
// Using 0.4 was wrong: e.g. luminance 0.35 → white gives only 2.6:1 contrast (fails WCAG AA).
'--color-on-primary': luminance > 0.179 ? '#1A1A1A' : '#FFFFFF',
} }
} }

View File

@ -71,9 +71,7 @@ Page dédiée pour les sections de type Event ([EventSection.tsx](visitapp-web/s
- `instanceId` propagé via `VisitorContext` (depuis le layout `[slug]`) - `instanceId` propagé via `VisitorContext` (depuis le layout `[slug]`)
- Events spécifiques branchés : `MapPoiTap`, `QuizComplete`, `GameComplete`, `AgendaEventTap`, `QrScan` - Events spécifiques branchés : `MapPoiTap`, `QuizComplete`, `GameComplete`, `AgendaEventTap`, `QrScan`
**Reste à brancher (rapide)** : **Tous les events Flutter sont branchés** : `SectionView`, `SectionLeave`, `MapPoiTap`, `QuizComplete`, `GameComplete`, `AgendaEventTap`, `QrScan`, `ArticleRead` (déclenché au scroll ≥80%), `MenuItemTap`.
- [ ] `ArticleRead` quand l'article est scrollé jusqu'à un seuil (~80%)
- [ ] `MenuItemTap` au tap sur un item de menu
--- ---
@ -101,8 +99,7 @@ Page dédiée pour les sections de type Event ([EventSection.tsx](visitapp-web/s
- Gère les erreurs de permission caméra avec message visuel - Gère les erreurs de permission caméra avec message visuel
- Nécessite HTTPS en production (sauf localhost) - Nécessite HTTPS en production (sauf localhost)
**Reste à faire (nice-to-have)** : Le bouton scanner est exposé sur la home (`/[slug]`) et dans la page configuration (`/[slug]/[configId]`).
- [ ] Bouton scanner aussi accessible depuis `ConfigurationGrid` ou `SectionList` (actuellement seulement sur la home)
--- ---
@ -125,7 +122,7 @@ Ces features ne sont **pas portées sur le web** :
|---|---|---| |---|---|---|
| **Event Section** | Haute (gap fonctionnel) | ✅ Fait | | **Event Section** | Haute (gap fonctionnel) | ✅ Fait |
| **Agenda popup enrichi** | Moyenne | ✅ Fait | | **Agenda popup enrichi** | Moyenne | ✅ Fait |
| **Stats tracking** | Moyenne (besoin métier) | ✅ Fait (à compléter : ArticleRead, MenuItemTap) | | **Stats tracking** | Moyenne (besoin métier) | ✅ Fait (tous les events) |
| **Event "à la une" sur la home** | Basse (UX) | ✅ Fait | | **Event "à la une" sur la home** | Basse (UX) | ✅ Fait |
| **QR Scanner** | Basse | ✅ Fait | | **QR Scanner** | Basse | ✅ Fait |