Wip working version
This commit is contained in:
parent
931f35c818
commit
57ee68e72b
34
BUGS.md
Normal file
34
BUGS.md
Normal 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
|
||||||
@ -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,14 +28,17 @@ 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
|
<>
|
||||||
sections={activeSections}
|
<SectionList
|
||||||
slug={slug}
|
sections={activeSections}
|
||||||
configId={configId}
|
slug={slug}
|
||||||
configTitle={config.title}
|
configId={configId}
|
||||||
configImageSource={config.imageSource}
|
configTitle={config.title}
|
||||||
configPrimaryColor={config.primaryColor}
|
configImageSource={config.imageSource}
|
||||||
languages={config.languages ?? ['FR']}
|
configPrimaryColor={config.primaryColor}
|
||||||
/>
|
languages={config.languages ?? ['FR']}
|
||||||
|
/>
|
||||||
|
<QRScannerButton slug={slug} configurationId={configId} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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,51 +108,64 @@ 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) => {
|
||||||
|
const img = eventImage(event)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={event.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(event)
|
||||||
|
if (instanceId) {
|
||||||
|
trackEvent({
|
||||||
|
instanceId, configurationId: configId, sectionId: section.id,
|
||||||
|
eventType: 'AgendaEventTap', language,
|
||||||
|
metadata: JSON.stringify({ eventAgendaId: event.id, title: tPlain(event.label, language) }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-2xl overflow-hidden text-left flex flex-col"
|
||||||
|
style={{ background: 'var(--color-surface)', boxShadow: '0 2px 8px rgba(0,0,0,0.10)' }}
|
||||||
|
>
|
||||||
|
{/* Image */}
|
||||||
|
<div className="relative w-full aspect-video">
|
||||||
|
{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>
|
||||||
|
{/* Title */}
|
||||||
|
<div className="p-3 flex-1 flex items-center justify-center">
|
||||||
|
<p
|
||||||
|
className="text-sm font-semibold text-center w-full [&_p]:m-0 line-clamp-3"
|
||||||
|
style={{ color: 'var(--color-text)' }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: t(event.label, language) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{filtered.map((event) => {
|
|
||||||
const img = eventImage(event)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={event.id}
|
|
||||||
onClick={() => {
|
|
||||||
setSelected(event)
|
|
||||||
if (instanceId) {
|
|
||||||
trackEvent({
|
|
||||||
instanceId, configurationId: configId, sectionId: section.id,
|
|
||||||
eventType: 'AgendaEventTap', language,
|
|
||||||
metadata: JSON.stringify({ eventAgendaId: event.id, title: tPlain(event.label, language) }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full rounded-2xl overflow-hidden text-left flex gap-0"
|
|
||||||
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
|
|
||||||
>
|
|
||||||
{img && (
|
|
||||||
<div className="relative w-20 shrink-0">
|
|
||||||
<Image src={img} alt="" fill className="object-cover" sizes="80px" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="p-3 flex-1">
|
|
||||||
<p
|
|
||||||
className="font-semibold text-sm [&_p]:m-0"
|
|
||||||
style={{ color: 'var(--color-text)' }}
|
|
||||||
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>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Event detail popup */}
|
{/* Event detail popup */}
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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)} />
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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' }}>
|
>
|
||||||
{Math.round(selected.maxTemp)}°
|
<TempChart forecasts={selected.forecasts} />
|
||||||
</span>
|
</div>
|
||||||
<span className="text-3xl font-light" style={{ color: '#9CA3AF' }}>
|
|
||||||
/{Math.round(selected.minTemp)}°
|
{/* Selected day — main info */}
|
||||||
</span>
|
<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)}°
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-light pb-1" style={{ color: '#9CA3AF' }}>
|
||||||
|
/{Math.round(selected.minTemp)}°
|
||||||
|
</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}
|
||||||
/>
|
/>
|
||||||
|
{desc && (
|
||||||
|
<p className="text-xs font-medium text-center" style={{ color: 'var(--color-primary)', maxWidth: 100 }}>
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
{desc && (
|
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--color-primary)' }}>
|
|
||||||
{desc.charAt(0).toUpperCase() + desc.slice(1)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)}
|
||||||
{availableLanguages.map((lang) => (
|
className="text-xs font-semibold px-2 py-1 rounded-lg transition-all"
|
||||||
<option key={lang} value={lang} style={{ color: '#1a1a1a' }}>
|
style={{
|
||||||
{FLAG[lang] ?? lang} {lang}
|
background: active ? 'rgba(255,255,255,0.25)' : 'transparent',
|
||||||
</option>
|
color: 'var(--color-on-primary)',
|
||||||
))}
|
opacity: active ? 1 : 0.6,
|
||||||
</select>
|
}}
|
||||||
|
>
|
||||||
|
{FLAG[lang] ?? lang}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -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[]> {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 |
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user