Wip sections

This commit is contained in:
Thomas Fransolet 2026-04-30 16:46:45 +02:00
parent 1afa3fe51e
commit 474cd61244
42 changed files with 4999 additions and 0 deletions

1
.env.production Normal file
View File

@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=https://api.myinfomate.be

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
AGENTS.md Normal file
View File

@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

97
CLAUDE.md Normal file
View File

@ -0,0 +1,97 @@
@AGENTS.md
# visitapp-web
App visiteur web MyInfoMate — `app.myinfomate.be/[slug]`
Next.js (App Router), TypeScript, Tailwind CSS.
## Contexte produit
Partie de l'écosystème MyInfoMate (voir `GITEA/CLAUDE.md` pour la vue d'ensemble).
Cette app est l'équivalent web de `mymuseum-visitapp` (Flutter). Elle doit reproduire
fidèlement le visuel de `home_3.0.dart` et `configuration_page.dart`.
Le backend est `manager-service` (C# / ASP.NET Core) — même API que les apps Flutter.
> **Règle de développement** : pour tout bug ou feature à implémenter, toujours commencer
> par lire l'équivalent Flutter dans `mymuseum-visitapp`. C'est la référence de comportement :
> logique de navigation, appels API, endpoints utilisés — tout doit correspondre.
## Architecture
### Routing
```
src/app/
[slug]/
layout.tsx → fetch instance, inject thème CSS (couleurs instance)
page.tsx → home (grille de configurations)
[configId]/
layout.tsx → fetch ConfigurationDTO, override couleurs si définies
page.tsx → liste des sections
sections/[sectionId]/
page.tsx → dispatcher par type de section
```
### Theming
Couleurs injectées en CSS variables sur `<html>` depuis le backend :
- Home → `ApplicationInstanceDTO.primaryColor / secondaryColor`
- Dans une configuration → `ConfigurationDTO.primaryColor ?? instance.primaryColor`
Variables CSS disponibles partout :
- `--color-primary`, `--color-secondary`
- `--color-primary-light` (dérivée, 12% opacité)
- `--color-on-primary` (blanc ou noir, calculé par contraste WCAG)
- `--color-background`, `--color-surface`, `--color-text`, `--color-text-muted`, `--color-border`
### Multi-langue
- Langue stockée dans `VisitorContext` (React context) + `localStorage`
- Tous les champs texte sont `TranslationDTO[]``{ language: string, value: string }[]`
- Helper `t(translations, lang)` pour résoudre la valeur dans la langue active
### Client API
Généré depuis le Swagger de `manager-service`. Fichiers dans `src/lib/api/`.
### Rendu du contenu textuel
Tous les champs de contenu texte long (article, description, etc.) sont produits par un
éditeur **Quill** dans `manager-app`. Ils contiennent du **HTML** (balises `<p>`, `<strong>`,
`<ul>`, etc.) — toujours les rendre avec `dangerouslySetInnerHTML`, jamais comme texte brut.
C'est le même comportement que `flutter_widget_from_html` dans `mymuseum-visitapp`.
## Commandes
```bash
npm run dev # dev local
npm run build # build production
npm run start # servir le build
```
## Sections supportées
Article, Agenda, Menu, Slider, Video, Map, PDF, Quiz, Game, Weather, Web, GuidedPath
### Layout par type de section
Deux patterns, calqués sur `mymuseum-visitapp` :
**AppBar standard** (`min-h-screen` + scroll) — contenu textuel/liste :
- Article, Agenda, Menu, Slider, Weather, Quiz, Game
**Full-screen** (`position: fixed; inset: 0` + dark AppBar) — contenu immersif :
- Video, Web, PDF, Map
Pour les titres : toujours `tPlain()` dans les AppBar et attributs `alt`.
Pour les contenus longs : toujours `dangerouslySetInnerHTML` (Quill → HTML).
## Hors scope
- Beacons BLE (WebBluetooth non supporté iOS Safari)
- Mode offline
- Push notifications

View File

@ -32,6 +32,18 @@ NEXT_PUBLIC_API_URL=https://api.myinfomate.be
--- ---
## Génération du client API
Le client TypeScript est généré depuis le Swagger de `manager-service`.
```bash
npx @hey-api/openapi-ts -i http://localhost:5000/openapi.json -o src/lib/api/generated
```
À relancer après chaque modification des DTOs ou endpoints dans `manager-service`.
---
## Développement local ## Développement local
```bash ```bash

22
next.config.ts Normal file
View File

@ -0,0 +1,22 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
turbopack: {
root: __dirname,
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
{
protocol: 'http',
hostname: '**',
},
],
},
};
export default nextConfig;

2409
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "visitapp-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"color2k": "^2.0.3",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@hey-api/openapi-ts": "^0.95.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,25 @@
import { notFound } from 'next/navigation'
import { getInstanceBySlug, getConfiguration } from '@/lib/api/client'
import { resolveColors } from '@/lib/theme'
export default async function ConfigLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ slug: string; configId: string }>
}) {
const { slug, configId } = await params
let instance, config
try {
instance = await getInstanceBySlug(slug)
config = await getConfiguration(configId, instance.publicApiKey!)
} catch {
notFound()
}
const theme = resolveColors(instance, config)
return <div style={theme as React.CSSProperties}>{children}</div>
}

View File

@ -0,0 +1,40 @@
import { notFound } from 'next/navigation'
import { getInstanceBySlug, getConfiguration, getSections } from '@/lib/api/client'
import SectionList from '@/components/SectionList'
export default async function ConfigPage({
params,
}: {
params: Promise<{ slug: string; configId: string }>
}) {
const { slug, configId } = await params
let instance, config, sections
try {
instance = await getInstanceBySlug(slug)
;[config, sections] = await Promise.all([
getConfiguration(configId, instance.publicApiKey!),
getSections(configId, instance.publicApiKey!),
])
console.log(`[ConfigPage] sections fetched: ${sections?.length}`, sections?.map(s => ({ id: s.id, isActive: s.isActive, isSubSection: s.isSubSection })))
} catch (e) {
console.error('[ConfigPage] error:', e)
notFound()
}
const activeSections = sections
.filter((s) => s.isActive !== false)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
return (
<SectionList
sections={activeSections}
slug={slug}
configId={configId}
configTitle={config.title}
configImageSource={config.imageSource}
configPrimaryColor={config.primaryColor}
languages={config.languages ?? ['FR']}
/>
)
}

View File

@ -0,0 +1,58 @@
import { notFound } from 'next/navigation'
import { getInstanceBySlug, getConfiguration, getSections } from '@/lib/api/client'
import type { SectionDTO } from '@/lib/api/types'
// Section components
import ArticleSection from '@/components/sections/ArticleSection'
import AgendaSection from '@/components/sections/AgendaSection'
import MenuSection from '@/components/sections/MenuSection'
import SliderSection from '@/components/sections/SliderSection'
import VideoSection from '@/components/sections/VideoSection'
import MapSection from '@/components/sections/MapSection'
import PdfSection from '@/components/sections/PdfSection'
import WeatherSection from '@/components/sections/WeatherSection'
import WebSection from '@/components/sections/WebSection'
import QuizSection from '@/components/sections/QuizSection'
export default async function SectionPage({
params,
}: {
params: Promise<{ slug: string; configId: string; sectionId: string }>
}) {
const { slug, configId, sectionId } = await params
let instance, config, sections
try {
instance = await getInstanceBySlug(slug)
;[config, sections] = await Promise.all([
getConfiguration(configId, instance.publicApiKey!),
getSections(configId, instance.publicApiKey!),
])
} catch {
notFound()
}
const section: SectionDTO | undefined = sections.find((s) => s.id === sectionId)
if (!section || section.isActive === false) notFound()
const props = { section, slug, configId, languages: config.languages ?? ['FR'] }
switch (section.type) {
case 'Article': return <ArticleSection {...props} />
case 'Agenda': return <AgendaSection {...props} />
case 'Menu': return <MenuSection {...props} />
case 'Slider': return <SliderSection {...props} />
case 'Video': return <VideoSection {...props} />
case 'Map': return <MapSection {...props} />
case 'Pdf': return <PdfSection {...props} />
case 'Weather': return <WeatherSection {...props} />
case 'Quiz': return <QuizSection {...props} />
case 'Web': return <WebSection {...props} />
default:
return (
<div className="p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>
Section de type « {section.type} » à venir.
</div>
)
}
}

31
src/app/[slug]/layout.tsx Normal file
View File

@ -0,0 +1,31 @@
import { notFound } from 'next/navigation'
import { getInstanceBySlug } from '@/lib/api/client'
import { resolveColors } from '@/lib/theme'
import { VisitorProvider } from '@/context/VisitorContext'
export default async function SlugLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ slug: string }>
}) {
const { slug } = await params
let instance
try {
instance = await getInstanceBySlug(slug)
} catch {
notFound()
}
const theme = resolveColors(instance)
return (
<VisitorProvider>
<div style={theme as React.CSSProperties}>
{children}
</div>
</VisitorProvider>
)
}

21
src/app/[slug]/page.tsx Normal file
View File

@ -0,0 +1,21 @@
import { getInstanceBySlug, getConfigurations } from '@/lib/api/client'
import { notFound } from 'next/navigation'
import ConfigurationGrid from '@/components/ConfigurationGrid'
export default async function HomePage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
let instance, configurations
try {
instance = await getInstanceBySlug(slug)
configurations = await getConfigurations(instance.id, instance.publicApiKey!)
} catch {
notFound()
}
return <ConfigurationGrid configurations={configurations} slug={slug} />
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

34
src/app/globals.css Normal file
View File

@ -0,0 +1,34 @@
@import "tailwindcss";
:root {
/* Fixed design tokens */
--color-background: #ffffff;
--color-surface: #f8f8f8;
--color-text: #1a1a1a;
--color-text-muted: #6b7280;
--color-border: #e5e7eb;
/* Dynamic tokens — overridden per instance/config via inline style on <html> */
--color-primary: #264863;
--color-secondary: #c2c9d6;
--color-primary-light: rgba(38, 72, 99, 0.12);
--color-on-primary: #ffffff;
}
@theme inline {
--color-primary: var(--color-primary);
--color-secondary: var(--color-secondary);
--color-primary-light: var(--color-primary-light);
--color-on-primary: var(--color-on-primary);
--color-background: var(--color-background);
--color-surface: var(--color-surface);
--color-text: var(--color-text);
--color-text-muted: var(--color-text-muted);
--color-border: var(--color-border);
}
body {
background: var(--color-background);
color: var(--color-text);
font-family: Arial, Helvetica, sans-serif;
}

33
src/app/layout.tsx Normal file
View File

@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

65
src/app/page.tsx Normal file
View File

@ -0,0 +1,65 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,89 @@
'use client'
import Link from 'next/link'
import Image from 'next/image'
import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n'
import type { ConfigurationDTO } from '@/lib/api/types'
interface Props {
configurations: ConfigurationDTO[]
slug: string
}
export default function ConfigurationGrid({ configurations, slug }: Props) {
const { language } = useVisitor()
const active = configurations.filter((c) => !c.isOffline)
return (
<main className="min-h-screen" style={{ background: 'var(--color-background)' }}>
<header
className="flex items-center justify-center px-4 py-4"
style={{
background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))',
minHeight: 56,
}}
>
<span className="text-lg font-semibold" style={{ color: 'var(--color-on-primary)' }}>
MyInfoMate
</span>
</header>
<div className="p-4 columns-2 gap-3">
{active.map((config) => (
<Link
key={config.id}
href={`/${slug}/${config.id}`}
className="break-inside-avoid mb-3 block rounded-2xl overflow-hidden relative"
style={{
background: 'var(--color-surface)',
boxShadow: '0 2px 6px rgba(0,0,0,0.22)',
}}
>
{config.imageSource ? (
<div className="relative w-full" style={{ minHeight: 120 }}>
<Image
src={config.imageSource}
alt={tPlain(config.title, language)}
fill
className="object-cover"
sizes="(max-width: 768px) 50vw, 33vw"
/>
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.72) 0%, transparent 40%)' }}
/>
<div
className="absolute bottom-2 left-3 right-7 text-white text-sm font-semibold leading-tight [&_p]:m-0 [&_p]:leading-tight"
dangerouslySetInnerHTML={{ __html: t(config.title, language) }}
/>
<svg
className="absolute bottom-2 right-2"
width="18" height="18" viewBox="0 0 24 24" fill="white"
>
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</div>
) : (
<div className="p-4 relative" style={{ minHeight: 80 }}>
<div
className="text-sm font-semibold pr-6 [&_p]:m-0"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(config.title, language) }}
/>
<svg
className="absolute bottom-2 right-2"
width="18" height="18" viewBox="0 0 24 24"
fill="currentColor"
style={{ color: 'var(--color-text-muted)' }}
>
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</div>
)}
</Link>
))}
</div>
</main>
)
}

View File

@ -0,0 +1,244 @@
'use client'
import { useEffect, useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain, stripHtml } from '@/lib/i18n'
import type { SectionDTO, TranslationDTO } from '@/lib/api/types'
interface Props {
sections: SectionDTO[]
slug: string
configId: string
configTitle?: TranslationDTO[]
configImageSource?: string
configPrimaryColor?: string
languages: string[]
}
export default function SectionList({
sections, slug, configId, configTitle, configImageSource, configPrimaryColor, languages,
}: Props) {
const { language, setAvailableLanguages } = useVisitor()
const router = useRouter()
const [search, setSearch] = useState('')
const [searchNumber, setSearchNumber] = useState('')
useEffect(() => { setAvailableLanguages(languages) }, [languages])
const filtered = sections.filter((s) => {
if (searchNumber) return (s.order ?? 0) + 1 === parseInt(searchNumber)
if (search) return stripHtml(t(s.title, language)).toLowerCase().includes(search.toLowerCase())
return true
})
const primaryColor = configPrimaryColor ?? 'var(--color-primary)'
return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
{/* Header image — 22vh */}
<div className="relative shrink-0" style={{ height: '22vh' }}>
{configImageSource ? (
<Image src={configImageSource} alt="" fill className="object-cover" style={{ opacity: 0.65 }} sizes="100vw" />
) : (
<div
className="absolute inset-0"
style={{ background: `linear-gradient(to left, var(--color-primary), var(--color-secondary))` }}
/>
)}
{/* Back button */}
<button
onClick={() => router.back()}
className="absolute flex items-center justify-center"
style={{
top: 44, left: 10, width: 50, height: 50,
background: primaryColor,
borderRadius: '50%',
}}
>
<svg width="23" height="23" viewBox="0 0 24 24" fill="white">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
</div>
{/* Search row */}
<div
className="flex gap-2 px-3 py-2 shrink-0"
style={{ background: 'var(--color-background)' }}
>
<div
className="flex items-center gap-2 flex-1 rounded-xl px-3 py-2"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setSearchNumber('') }}
placeholder="Rechercher..."
className="flex-1 bg-transparent text-sm outline-none"
style={{ color: 'var(--color-text)' }}
/>
{search && (
<button onClick={() => setSearch('')} style={{ color: 'var(--color-text-muted)' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
)}
</div>
<div
className="flex items-center rounded-xl px-3 py-2"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', width: 72 }}
>
<input
type="number"
value={searchNumber}
onChange={(e) => { setSearchNumber(e.target.value); setSearch('') }}
placeholder="#"
className="w-full bg-transparent text-sm outline-none text-center"
style={{ color: 'var(--color-text)' }}
/>
</div>
</div>
{/* List */}
<div
className="flex-1 overflow-y-auto pt-4"
style={{
background: 'var(--color-background)',
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
boxShadow: '0 -1px 4px rgba(0,0,0,0.08)',
}}
>
{filtered.length === 0 && (
<p className="text-center py-12 text-sm" style={{ color: 'var(--color-text-muted)' }}>
Aucun résultat
</p>
)}
{filtered.map((section, index) => (
<SectionCard
key={section.id}
section={section}
index={index}
isLast={index === filtered.length - 1}
slug={slug}
configId={configId}
language={language}
/>
))}
</div>
</div>
)
}
function SectionCard({
section, index, isLast, slug, configId, language,
}: {
section: SectionDTO
index: number
isLast: boolean
slug: string
configId: string
language: string
}) {
const accentColor = index % 2 === 0 ? 'var(--color-primary)' : 'var(--color-secondary)'
return (
<Link
href={`/${slug}/${configId}/sections/${section.id}`}
className="block mx-4"
style={{ marginBottom: isLast ? 16 : 12, height: 160, position: 'relative' }}
>
{/* Colored shadow card behind */}
<div
className="absolute inset-0"
style={{
background: accentColor,
borderRadius: 22,
boxShadow: '0 1px 5px rgba(0,0,0,0.15)',
}}
/>
{/* White card offset to right */}
<div
className="absolute inset-0"
style={{
right: 10,
background: 'white',
borderRadius: 22,
border: '0.2px solid var(--color-border)',
overflow: 'hidden',
}}
>
{/* Image on the right */}
{section.imageSource && (
<div
className="absolute top-0 bottom-0"
style={{ right: 0, width: '50%' }}
>
<Image
src={section.imageSource}
alt={tPlain(section.title, language)}
fill
className="object-cover"
sizes="50vw"
style={{ borderTopRightRadius: 20, borderBottomRightRadius: 20 }}
/>
</div>
)}
{/* Title on the left */}
<div
className="absolute top-0 bottom-0 flex flex-col justify-center"
style={{
left: 0,
right: section.imageSource ? '50%' : 0,
padding: '0 16px',
}}
>
<div
className="text-sm font-medium [&_p]:m-0"
style={{ color: '#1a1a1a' }}
dangerouslySetInnerHTML={{ __html: t(section.title, language) }}
/>
</div>
{/* Order badge — bottom-left */}
<div
className="absolute bottom-0 left-0 flex items-center justify-center"
style={{
background: accentColor,
borderBottomLeftRadius: 22,
borderTopRightRadius: 22,
minWidth: 30,
padding: '4px 12px',
}}
>
<span className="text-white text-xs font-medium">{(section.order ?? index) + 1}</span>
</div>
</div>
{/* Chevron — middle right of white card */}
<div
className="absolute flex items-center justify-center"
style={{
right: 10 - 9,
top: '50%',
transform: 'translateY(-50%)',
width: 18,
height: 18,
}}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="white">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</div>
</Link>
)
}

View File

@ -0,0 +1,133 @@
'use client'
import { useEffect, useState } from 'react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO, EventAgendaDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar'
interface Props {
section: SectionDTO
slug: string
configId: string
languages: string[]
}
const MONTHS = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc']
export default function AgendaSection({ section, slug, configId, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor()
const router = useRouter()
const events = section.agenda?.events ?? []
useEffect(() => { setAvailableLanguages(languages) }, [languages])
const now = new Date()
const [selectedMonth, setSelectedMonth] = useState(now.getMonth())
const [selectedYear, setSelectedYear] = useState(now.getFullYear())
const [selected, setSelected] = useState<EventAgendaDTO | null>(null)
const filtered = events.filter((e) => {
if (!e.startDate) return false
const d = new Date(e.startDate)
return d.getMonth() === selectedMonth && d.getFullYear() === selectedYear
})
function prevMonth() {
if (selectedMonth === 0) { setSelectedMonth(11); setSelectedYear(y => y - 1) }
else setSelectedMonth(m => m - 1)
}
function nextMonth() {
if (selectedMonth === 11) { setSelectedMonth(0); setSelectedYear(y => y + 1) }
else setSelectedMonth(m => m + 1)
}
return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
{/* Month selector */}
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--color-border)' }}>
<button onClick={prevMonth} className="p-1">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-primary)' }}>
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
<span className="font-semibold text-sm" style={{ color: 'var(--color-text)' }}>
{MONTHS[selectedMonth]} {selectedYear}
</span>
<button onClick={nextMonth} className="p-1">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-primary)' }}>
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</svg>
</button>
</div>
{/* Events list */}
<main className="flex-1 overflow-y-auto p-4 flex flex-col gap-3">
{filtered.length === 0 && (
<p className="text-center py-12 text-sm" style={{ color: 'var(--color-text-muted)' }}>
Aucun événement ce mois-ci
</p>
)}
{filtered.map((event) => (
<button
key={event.id}
onClick={() => setSelected(event)}
className="w-full rounded-2xl overflow-hidden text-left flex gap-0"
style={{ background: 'var(--color-surface)' }}
>
{event.imageSource && (
<div className="relative w-20 shrink-0">
<Image src={event.imageSource} alt="" fill className="object-cover" sizes="80px" />
</div>
)}
<div className="p-3 flex-1">
<p className="font-semibold text-sm" style={{ color: 'var(--color-text)' }}>
{t(event.title, language)}
</p>
{event.startDate && (
<p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
{new Date(event.startDate).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}
</p>
)}
</div>
</button>
))}
</main>
{/* Event detail popup */}
{selected && (
<div className="fixed inset-0 z-50 flex flex-col" style={{ background: 'var(--color-background)' }}>
<AppBar title={tPlain(selected.title, language)} onBack={() => setSelected(null)} />
<div className="flex-1 overflow-y-auto">
{selected.imageSource && (
<div className="relative w-full h-48">
<Image src={selected.imageSource} alt="" fill className="object-cover" sizes="100vw" />
</div>
)}
<div className="p-4">
{selected.startDate && (
<p className="text-sm mb-3" style={{ color: 'var(--color-primary)' }}>
{new Date(selected.startDate).toLocaleDateString(language.toLowerCase(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
{selected.endDate && selected.endDate !== selected.startDate && (
<> {new Date(selected.endDate).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}</>
)}
</p>
)}
{t(selected.description, language) && (
<div
className="prose prose-sm"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(selected.description, language) }}
/>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,161 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar'
interface Props {
section: SectionDTO
slug: string
configId: string
languages: string[]
}
export default function ArticleSection({ section, slug, configId, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor()
const router = useRouter()
const article = section.article
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const audioRef = useRef<HTMLAudioElement>(null)
useEffect(() => { setAvailableLanguages(languages) }, [languages])
const audioUrl = article?.audioIds?.find((a) => a.language === language)?.value
?? article?.audioIds?.[0]?.value
useEffect(() => {
if (article?.isReadAudioAuto && audioRef.current) {
audioRef.current.play().catch(() => {})
setIsPlaying(true)
}
}, [article?.isReadAudioAuto])
function toggleAudio() {
if (!audioRef.current) return
if (isPlaying) { audioRef.current.pause(); setIsPlaying(false) }
else { audioRef.current.play(); setIsPlaying(true) }
}
const contents = [...(article?.contents ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const htmlContent = t(article?.content, language)
return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
<main className="flex-1 overflow-y-auto pb-24">
{/* Carousel images */}
{contents.length > 0 && (
<ImageCarousel contents={contents} language={language} />
)}
{/* HTML content */}
{htmlContent && (
<div
className="px-4 py-5 prose prose-sm max-w-none"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
)}
</main>
{/* Floating audio player */}
{audioUrl && (
<>
<audio
ref={audioRef}
src={audioUrl}
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)}
onEnded={() => setIsPlaying(false)}
/>
<div
className="fixed bottom-0 left-0 right-0 px-4 py-3 flex items-center gap-3"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
<button onClick={toggleAudio} className="shrink-0">
{isPlaying ? (
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
) : (
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<div className="flex-1">
<div className="w-full h-1 rounded-full" style={{ background: 'var(--color-on-primary)', opacity: 0.3 }}>
<div
className="h-1 rounded-full"
style={{
background: 'var(--color-on-primary)',
width: duration ? `${(currentTime / duration) * 100}%` : '0%',
}}
/>
</div>
</div>
<span className="text-xs shrink-0">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
</>
)}
</div>
)
}
function ImageCarousel({ contents, language }: { contents: NonNullable<SectionDTO['article']>['contents'] & object; language: string }) {
const [index, setIndex] = useState(0)
if (!contents || contents.length === 0) return null
return (
<div className="relative w-full" style={{ height: 240 }}>
{contents[index]?.resource?.url && (
<Image
src={contents[index].resource!.url!}
alt={t(contents[index].title, language)}
fill
className="object-cover"
sizes="100vw"
/>
)}
{contents.length > 1 && (
<>
<button
onClick={() => setIndex((i) => Math.max(0, i - 1))}
disabled={index === 0}
className="absolute left-2 top-1/2 -translate-y-1/2 bg-black/40 text-white rounded-full p-1 disabled:opacity-20"
>
<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"/></svg>
</button>
<button
onClick={() => setIndex((i) => Math.min(contents.length - 1, i + 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-1 disabled:opacity-20"
>
<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"/></svg>
</button>
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-1">
{contents.map((_, i) => (
<div key={i} className="w-1.5 h-1.5 rounded-full" style={{ background: i === index ? 'white' : 'rgba(255,255,255,0.4)' }} />
))}
</div>
</>
)}
</div>
)
}
function formatTime(s: number): string {
if (!s || isNaN(s)) return '0:00'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, '0')}`
}

View File

@ -0,0 +1,5 @@
'use client'
import type { SectionDTO } from '@/lib/api/types'
export default function MapSection({ section }: { section: SectionDTO; slug: string; configId: string; languages: string[] }) {
return <div className="p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>Carte à venir</div>
}

View File

@ -0,0 +1,108 @@
'use client'
import { useEffect, useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar'
interface Props {
section: SectionDTO
slug: string
configId: string
languages: string[]
}
export default function MenuSection({ section, slug, configId, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor()
const router = useRouter()
const [search, setSearch] = useState('')
useEffect(() => { setAvailableLanguages(languages) }, [languages])
const subsections = [...(section.menu?.sections ?? [])]
.filter((s) => s.isActive !== false)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const filtered = subsections.filter((s) => {
if (!search) return true
const title = tPlain(s.title, language).toLowerCase()
return title.includes(search.toLowerCase())
})
return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
{/* Search */}
<div className="px-4 pt-3 pb-2">
<div
className="flex items-center gap-2 rounded-xl px-3 py-2"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-text-muted)' }}>
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Rechercher..."
className="flex-1 bg-transparent text-sm outline-none"
style={{ color: 'var(--color-text)' }}
/>
{search && (
<button onClick={() => setSearch('')} style={{ color: 'var(--color-text-muted)' }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
)}
</div>
</div>
{/* Grid */}
<main className="flex-1 overflow-y-auto p-4 grid grid-cols-2 gap-3">
{filtered.length === 0 && (
<p className="col-span-2 text-center py-12 text-sm" style={{ color: 'var(--color-text-muted)' }}>
Aucun résultat
</p>
)}
{filtered.map((sub) => (
<Link
key={sub.id}
href={`/${slug}/${configId}/sections/${sub.id}`}
className="rounded-2xl overflow-hidden block relative"
style={{ background: 'var(--color-surface)', minHeight: 120 }}
>
{sub.imageSource ? (
<div className="relative w-full h-32">
<Image src={sub.imageSource} alt={tPlain(sub.title, language)} fill className="object-cover" sizes="50vw" />
<div className="absolute inset-0" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 60%)' }} />
<div
className="absolute bottom-2 left-3 right-7 text-white text-sm font-semibold leading-tight [&_p]:m-0 [&_p]:leading-tight"
dangerouslySetInnerHTML={{ __html: t(sub.title, language) }}
/>
<svg className="absolute bottom-2 right-2" width="15" height="15" viewBox="0 0 24 24" fill="white">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</div>
) : (
<div className="p-4 relative" style={{ minHeight: 80 }}>
<div
className="text-sm font-semibold pr-6 [&_p]:m-0"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(sub.title, language) }}
/>
<svg className="absolute bottom-2 right-2" width="15" height="15" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-text-muted)' }}>
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</div>
)}
</Link>
))}
</main>
</div>
)
}

View File

@ -0,0 +1,73 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext'
import { tPlain } from '@/lib/i18n'
import type { SectionDTO, OrderedTranslationAndResourceDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar'
interface Props {
section: SectionDTO
slug: string
configId: string
languages: string[]
}
export default function PdfSection({ section, slug, configId, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor()
const router = useRouter()
useEffect(() => { setAvailableLanguages(languages) }, [languages])
const pdfs = [...(section.pdf?.pdfs ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const [selectedIndex, setSelectedIndex] = useState(0)
function getPdfUrl(pdf: OrderedTranslationAndResourceDTO): string | undefined {
const translations = pdf.translationAndResourceDTOs ?? []
return (
translations.find((t) => t.language === language)?.resource?.url ??
translations[0]?.resource?.url
)
}
const currentUrl = pdfs.length > 0 ? getPdfUrl(pdfs[selectedIndex]) : undefined
return (
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: 'var(--color-background)' }}>
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
{pdfs.length > 1 && (
<div className="flex gap-2 px-4 py-2 overflow-x-auto shrink-0">
{pdfs.map((pdf, i) => (
<button
key={i}
onClick={() => setSelectedIndex(i)}
className="shrink-0 px-3 py-1.5 rounded-full text-xs font-medium"
style={{
background: i === selectedIndex ? 'var(--color-primary)' : 'var(--color-surface)',
color: i === selectedIndex ? 'var(--color-on-primary)' : 'var(--color-text)',
}}
>
PDF {i + 1}
</button>
))}
</div>
)}
<div style={{ flex: 1, borderRadius: '20px 20px 0 0', overflow: 'hidden' }}>
{!currentUrl ? (
<div className="h-full flex items-center justify-center text-sm p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>
Aucun PDF à afficher
</div>
) : (
<iframe
src={currentUrl}
title={tPlain(section.title, language)}
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
/>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,254 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO, QuestionDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar'
interface Props {
section: SectionDTO
slug: string
configId: string
languages: string[]
}
type Phase = 'quiz' | 'result' | 'review'
export default function QuizSection({ section, slug, configId, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor()
const router = useRouter()
useEffect(() => { setAvailableLanguages(languages) }, [languages])
const quiz = section.quiz
const questions = [...(quiz?.questions ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const [phase, setPhase] = useState<Phase>('quiz')
const [currentIndex, setCurrentIndex] = useState(0)
const [answers, setAnswers] = useState<Record<number, number>>({})
if (questions.length === 0) {
return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
<div className="flex-1 flex items-center justify-center text-sm p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>
Aucune question disponible.
</div>
</div>
)
}
const totalQuestions = questions.length
const correctCount = questions.filter((q, i) => {
const chosen = answers[i]
if (chosen === undefined) return false
const sorted = [...(q.responses ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
return sorted[chosen]?.isCorrect === true
}).length
const scorePercent = totalQuestions > 0 ? (correctCount / totalQuestions) * 100 : 0
function getLevel() {
if (scorePercent < 25) return quiz?.badLevel
if (scorePercent < 50) return quiz?.mediumLevel
if (scorePercent < 75) return quiz?.goodLevel
return quiz?.greatLevel
}
function selectAnswer(questionIndex: number, answerIndex: number) {
const newAnswers = { ...answers, [questionIndex]: answerIndex }
setAnswers(newAnswers)
if (questionIndex === totalQuestions - 1) {
setTimeout(() => setPhase('result'), 400)
} else {
setTimeout(() => setCurrentIndex(questionIndex + 1), 500)
}
}
function restart() {
setAnswers({})
setCurrentIndex(0)
setPhase('quiz')
}
// ── RESULT SCREEN ────────────────────────────────────────────────────────
if (phase === 'result') {
const levelText = t(getLevel(), language)
return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
{/* Score */}
<div
className="flex flex-col items-center justify-center rounded-full"
style={{
width: 140, height: 140,
background: 'var(--color-primary)',
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
}}
>
<span className="text-4xl font-bold" style={{ color: 'var(--color-on-primary)' }}>
{correctCount}/{totalQuestions}
</span>
<span className="text-sm mt-1" style={{ color: 'var(--color-on-primary)', opacity: 0.85 }}>
{Math.round(scorePercent)}%
</span>
</div>
{/* Level text */}
{levelText && (
<div
className="w-full rounded-2xl px-5 py-4 text-sm text-center [&_p]:m-0"
style={{
background: 'var(--color-surface)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
}}
dangerouslySetInnerHTML={{ __html: levelText }}
/>
)}
{/* Buttons */}
<div className="flex flex-col gap-3 w-full">
<button
onClick={restart}
className="w-full py-3 rounded-2xl font-semibold text-sm"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
Recommencer
</button>
<button
onClick={() => { setCurrentIndex(0); setPhase('review') }}
className="w-full py-3 rounded-2xl font-semibold text-sm"
style={{
background: 'var(--color-surface)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
}}
>
Voir les réponses
</button>
</div>
</div>
</div>
)
}
// ── QUIZ + REVIEW SCREEN ─────────────────────────────────────────────────
const isReview = phase === 'review'
const question = questions[currentIndex]
const sortedResponses = [...(question.responses ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const chosen = answers[currentIndex]
const canGoNext = isReview || chosen !== undefined
const isLast = currentIndex === totalQuestions - 1
return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
<AppBar
title={isReview ? tPlain(section.title, language) : undefined}
onBack={isReview ? () => { setCurrentIndex(0); setPhase('result') } : () => router.back()}
/>
{/* Question card */}
<div className="flex-1 flex flex-col relative overflow-hidden">
{/* Background image */}
{question.imageBackgroundResourceUrl && (
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${question.imageBackgroundResourceUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
opacity: 0.35,
}}
/>
)}
<div className="relative z-10 flex flex-col h-full p-4 gap-3">
{/* Question text */}
<div
className="rounded-2xl px-4 py-4 text-sm font-medium text-center [&_p]:m-0"
style={{
background: 'white',
color: '#1a1a1a',
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
minHeight: 80,
}}
dangerouslySetInnerHTML={{ __html: t(question.label, language) }}
/>
{/* Answer buttons */}
<div className="flex flex-col gap-2 flex-1">
{sortedResponses.map((response, i) => {
const isSelected = chosen === i
const bg = getAnswerBg(isReview, isSelected, response.isCorrect)
const textColor = isSelected || (isReview && response.isCorrect) ? 'white' : '#1a1a1a'
return (
<button
key={response.id}
onClick={() => !isReview && chosen === undefined && selectAnswer(currentIndex, i)}
className="w-full rounded-2xl px-4 py-3 text-sm font-medium text-left [&_p]:m-0 transition-colors"
style={{
background: bg,
color: textColor,
boxShadow: '0 1px 4px rgba(0,0,0,0.1)',
cursor: isReview || chosen !== undefined ? 'default' : 'pointer',
minHeight: 52,
}}
dangerouslySetInnerHTML={{ __html: t(response.label, language) }}
/>
)
})}
</div>
{/* Counter + navigation */}
<div className="flex items-center justify-between pt-1">
<button
onClick={() => currentIndex > 0 && setCurrentIndex(currentIndex - 1)}
disabled={currentIndex === 0}
className="w-10 h-10 rounded-full flex items-center justify-center transition-opacity"
style={{
background: 'var(--color-primary)',
opacity: currentIndex === 0 ? 0.3 : 1,
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
<span className="text-2xl font-bold" style={{ color: 'var(--color-text)' }}>
{currentIndex + 1}/{totalQuestions}
</span>
<button
onClick={() => canGoNext && !isLast && setCurrentIndex(currentIndex + 1)}
disabled={!canGoNext || isLast}
className="w-10 h-10 rounded-full flex items-center justify-center transition-opacity"
style={{
background: 'var(--color-primary)',
opacity: !canGoNext || isLast ? 0.3 : 1,
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</svg>
</button>
</div>
</div>
</div>
</div>
)
}
function getAnswerBg(isReview: boolean, isSelected: boolean, isCorrect?: boolean): string {
if (!isReview) {
return isSelected ? 'var(--color-primary)' : 'white'
}
if (isCorrect) return '#4caf50'
if (isSelected) return '#f44336'
return '#f5f5f5'
}

View File

@ -0,0 +1,132 @@
'use client'
import { useEffect, useState } from 'react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO, ContentDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar'
interface Props {
section: SectionDTO
slug: string
configId: string
languages: string[]
}
export default function SliderSection({ section, slug, configId, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor()
const router = useRouter()
const [index, setIndex] = useState(0)
useEffect(() => { setAvailableLanguages(languages) }, [languages])
const contents = [...(section.slider?.contents ?? [])]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
if (contents.length === 0) {
return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
<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)' }}>
Aucun contenu à afficher
</div>
</div>
)
}
const current = contents[index]
return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
<main className="flex-1 flex flex-col overflow-hidden">
{/* Image zone — ~60% height */}
<div className="relative flex-[3]" style={{ minHeight: 0 }}>
{current?.resource?.url && (
<Image
src={current.resource.url}
alt={t(current.title, language)}
fill
className="object-contain"
sizes="100vw"
/>
)}
{/* Title overlay */}
{t(current?.title, language) && (
<div
className="absolute bottom-0 left-0 right-0 px-4 py-3"
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.6), transparent)' }}
>
<p className="text-white text-base font-semibold">
{t(current?.title, language)}
</p>
</div>
)}
{/* Prev / Next arrows */}
{contents.length > 1 && (
<>
<button
onClick={() => setIndex((i) => Math.max(0, i - 1))}
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"
>
<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"/>
</svg>
</button>
<button
onClick={() => setIndex((i) => Math.min(contents.length - 1, i + 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"
>
<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"/>
</svg>
</button>
</>
)}
</div>
{/* Description card — ~25% height */}
{t(current?.description, language) && (
<div
className="flex-[1] overflow-y-auto mx-4 my-3 rounded-2xl p-4"
style={{
background: 'var(--color-surface)',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
}}
>
<div
className="prose prose-sm text-center max-w-none"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(current?.description, language) }}
/>
</div>
)}
{/* Dots indicator */}
{contents.length > 1 && (
<div className="flex justify-center gap-1.5 py-3">
{contents.map((_, i) => (
<button
key={i}
onClick={() => setIndex(i)}
className="rounded-full transition-all"
style={{
width: i === index ? 20 : 8,
height: 8,
background: i === index ? 'var(--color-primary)' : 'var(--color-border)',
}}
/>
))}
</div>
)}
</main>
</div>
)
}

View File

@ -0,0 +1,108 @@
'use client'
import { useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext'
import { tPlain } from '@/lib/i18n'
import type { SectionDTO } from '@/lib/api/types'
interface Props {
section: SectionDTO
slug: string
configId: string
languages: string[]
}
type VideoType = 'youtube' | 'vimeo' | 'direct' | 'none'
function detectType(url?: string): VideoType {
if (!url) return 'none'
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube'
if (url.includes('vimeo.com')) return 'vimeo'
return 'direct'
}
function youtubeEmbedUrl(url: string): string {
const match = url.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/)
const id = match?.[1]
return id ? `https://www.youtube.com/embed/${id}?autoplay=0&rel=0` : ''
}
function vimeoEmbedUrl(url: string): string {
const match = url.match(/vimeo\.com\/(?:.*?\/)?(\d+)/)
const id = match?.[1]
return id ? `https://player.vimeo.com/video/${id}` : ''
}
export default function VideoSection({ section, slug, configId, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor()
const router = useRouter()
useEffect(() => { setAvailableLanguages(languages) }, [languages])
const source = section.source ?? section.video?.source
const videoType = useMemo(() => detectType(source), [source])
const title = tPlain(section.title, language)
return (
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: '#000' }}>
{/* Dark AppBar */}
<div
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '0 16px', height: 56, flexShrink: 0,
background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(8px)',
}}
>
<button
onClick={() => router.back()}
style={{
width: 36, height: 36, borderRadius: '50%',
background: 'rgba(255,255,255,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: 'none', cursor: 'pointer', flexShrink: 0,
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
<span style={{ color: 'white', fontWeight: 600, fontSize: 15, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{title || 'Vidéo'}
</span>
</div>
{/* Player */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
{videoType === 'none' && (
<p style={{ color: '#999', fontSize: 14, textAlign: 'center', padding: 32 }}>
La vidéo ne peut pas être affichée, l'URL est incorrecte.
</p>
)}
{videoType === 'youtube' && (
<iframe
src={youtubeEmbedUrl(source!)}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
allowFullScreen
style={{ width: '100%', height: '100%', border: 'none' }}
/>
)}
{videoType === 'vimeo' && (
<iframe
src={vimeoEmbedUrl(source!)}
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
style={{ width: '100%', height: '100%', border: 'none' }}
/>
)}
{videoType === 'direct' && (
<video
src={source}
controls
style={{ width: '100%', height: '100%', background: '#000' }}
/>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,211 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO, WeatherData, WeatherForecast } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar'
interface Props {
section: SectionDTO
slug: string
configId: string
languages: string[]
}
interface DaySummary {
dt: number
forecasts: WeatherForecast[]
representative: WeatherForecast
minTemp: number
maxTemp: number
}
const SHORT_DAYS: Record<string, string[]> = {
FR: ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'],
NL: ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za'],
EN: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
DE: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'],
}
function shortDay(dt: number, lang: string): string {
const date = new Date(dt * 1000)
const now = new Date()
if (date.toDateString() === now.toDateString()) {
return lang === 'EN' ? 'Today' : lang === 'NL' ? 'Vandaag' : lang === 'DE' ? 'Heute' : 'Auj.'
}
return (SHORT_DAYS[lang] ?? SHORT_DAYS.FR)[date.getDay()]
}
function fullDay(dt: number, lang: string): string {
const date = new Date(dt * 1000)
const now = new Date()
if (date.toDateString() === now.toDateString()) {
return lang === 'EN' ? 'Today' : lang === 'NL' ? 'Vandaag' : lang === 'DE' ? 'Heute' : "Aujourd'hui"
}
const locale = lang === 'EN' ? 'en-GB' : lang === 'NL' ? 'nl-NL' : lang === 'DE' ? 'de-DE' : 'fr-FR'
return date.toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long' })
}
function buildDays(list: WeatherForecast[]): DaySummary[] {
const byDay = new Map<number, WeatherForecast[]>()
for (const f of list) {
const date = new Date((f.dt ?? 0) * 1000)
const key = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000
if (!byDay.has(key)) byDay.set(key, [])
byDay.get(key)!.push(f)
}
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 temps = forecasts.map((f) => f.main?.temp ?? 0)
return {
dt,
forecasts,
representative: rep,
minTemp: Math.min(...forecasts.map((f) => f.main?.tempMin ?? f.main?.temp ?? 0)),
maxTemp: Math.max(...forecasts.map((f) => f.main?.tempMax ?? f.main?.temp ?? 0)),
}
})
}
export default function WeatherSection({ section, slug, configId, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor()
const router = useRouter()
const [selectedDay, setSelectedDay] = useState(0)
useEffect(() => { setAvailableLanguages(languages) }, [languages])
const weatherData: WeatherData | null = useMemo(() => {
const raw = section.weather?.result
if (!raw) return null
try { return JSON.parse(raw) } catch { return null }
}, [section.weather?.result])
const days = useMemo(() => buildDays(weatherData?.list ?? []), [weatherData])
const selected = days[selectedDay]
if (!weatherData || days.length === 0) {
return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
<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)' }}>
Aucune donnée météo
</div>
</div>
)
}
const rep = selected.representative
const desc = rep.weather?.[0]?.description ?? ''
return (
<div
className="min-h-screen flex flex-col"
style={{ background: '#E8F4FD' }}
>
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
<main className="flex-1 overflow-y-auto pb-6">
{/* City */}
<p className="px-5 pt-4 text-sm font-medium" style={{ color: '#6B7280' }}>
{section.weather?.city ?? ''}
</p>
{/* Day tabs */}
<div className="flex gap-2 px-3 py-3 overflow-x-auto">
{days.map((day, i) => (
<button
key={day.dt}
onClick={() => setSelectedDay(i)}
className="shrink-0 flex flex-col items-center px-3 py-2 rounded-2xl transition-all"
style={{
background: i === selectedDay ? 'white' : 'transparent',
boxShadow: i === selectedDay ? '0 2px 8px rgba(0,0,0,0.08)' : 'none',
minWidth: 64,
}}
>
<span
className="text-xs font-medium"
style={{ color: i === selectedDay ? '#1F2937' : '#6B7280' }}
>
{shortDay(day.dt, language)}
</span>
<img
src={`https://openweathermap.org/img/wn/${day.representative.weather?.[0]?.icon ?? '01d'}.png`}
alt=""
width={32}
height={32}
/>
<span className="text-xs" style={{ color: i === selectedDay ? '#1F2937' : '#9CA3AF' }}>
{Math.round(day.maxTemp)}°/{Math.round(day.minTemp)}°
</span>
</button>
))}
</div>
{/* Selected day main */}
<div className="px-5">
<p className="text-sm" style={{ color: '#6B7280' }}>{fullDay(selected.dt, language)}</p>
<div className="flex items-center gap-1 mt-1">
<span className="text-6xl font-light" style={{ color: '#1F2937' }}>
{Math.round(selected.maxTemp)}°
</span>
<span className="text-3xl font-light" style={{ color: '#9CA3AF' }}>
/{Math.round(selected.minTemp)}°
</span>
<img
src={`https://openweathermap.org/img/wn/${rep.weather?.[0]?.icon ?? '01d'}@2x.png`}
alt={desc}
width={64}
height={64}
/>
</div>
{desc && (
<p className="text-sm font-medium" style={{ color: 'var(--color-primary)' }}>
{desc.charAt(0).toUpperCase() + desc.slice(1)}
</p>
)}
</div>
{/* Hourly */}
<div className="mt-5 px-5">
<p className="text-sm font-semibold mb-3" style={{ color: '#1F2937' }}>
{language === 'EN' ? 'Hourly' : language === 'NL' ? 'Per uur' : language === 'DE' ? 'Stündlich' : 'Par heure'}
</p>
<div
className="rounded-2xl overflow-x-auto"
style={{ background: 'white', boxShadow: '0 2px 8px rgba(0,0,0,0.05)' }}
>
<div className="flex px-2 py-4 gap-1" style={{ minWidth: 'max-content' }}>
{(selected.forecasts.slice(0, 8)).map((f) => {
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)
return (
<div key={f.dt} className="flex flex-col items-center gap-1 w-16">
<span className="text-sm font-semibold" style={{ color: '#1F2937' }}>
{Math.round(f.main?.temp ?? 0)}°
</span>
{pop > 0 ? (
<span className="text-xs font-medium" style={{ color: '#3B82F6' }}>{pop}%</span>
) : (
<span className="text-xs" style={{ minHeight: 16 }} />
)}
<img
src={`https://openweathermap.org/img/wn/${f.weather?.[0]?.icon ?? '01d'}.png`}
alt=""
width={34}
height={34}
/>
<span className="text-xs" style={{ color: '#6B7280' }}>{hour}</span>
</div>
)
})}
</div>
</div>
</div>
</main>
</div>
)
}

View File

@ -0,0 +1,75 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext'
import { tPlain } from '@/lib/i18n'
import type { SectionDTO } from '@/lib/api/types'
interface Props {
section: SectionDTO
slug: string
configId: string
languages: string[]
}
export default function WebSection({ section, slug, configId, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor()
const router = useRouter()
useEffect(() => { setAvailableLanguages(languages) }, [languages])
const source = section.web?.source ?? section.source
const title = tPlain(section.title, language)
return (
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: '#111' }}>
{/* AppBar sombre garanti — lisible quelle que soit la couleur de l'instance */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '0 16px',
height: 56,
flexShrink: 0,
background: 'rgba(0,0,0,0.75)',
backdropFilter: 'blur(8px)',
}}
>
<button
onClick={() => router.back()}
style={{
width: 36, height: 36, borderRadius: '50%',
background: 'rgba(255,255,255,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: 'none', cursor: 'pointer', flexShrink: 0,
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
<span style={{ color: 'white', fontWeight: 600, fontSize: 15, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{title || 'Web'}
</span>
</div>
{/* iframe */}
<div style={{ flex: 1, borderRadius: '20px 20px 0 0', overflow: 'hidden' }}>
{source ? (
<iframe
src={source}
title={title}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
/>
) : (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999', fontSize: 14, padding: 32, textAlign: 'center' }}>
La page ne peut pas être affichée, l'URL est incorrecte ou vide.
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,62 @@
'use client'
import { useVisitor } from '@/context/VisitorContext'
const FLAG: Record<string, string> = {
FR: '🇫🇷', NL: '🇧🇪', EN: '🇬🇧', DE: '🇩🇪',
IT: '🇮🇹', ES: '🇪🇸', PL: '🇵🇱', CN: '🇨🇳', AR: '🇸🇦', UK: '🇺🇦',
}
interface Props {
title?: string
onBack?: () => void
}
export default function AppBar({ title, onBack }: Props) {
const { language, setLanguage, availableLanguages } = useVisitor()
return (
<header
className="flex items-center justify-between px-4 sticky top-0 z-50"
style={{
background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))',
minHeight: 56,
color: 'var(--color-on-primary)',
}}
>
<div className="flex items-center gap-3 flex-1">
{onBack && (
<button
onClick={onBack}
className="p-1 rounded-full hover:opacity-70 transition-opacity"
aria-label="Retour"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
)}
<span className="text-base font-semibold truncate" style={{ textShadow: '0 1px 3px rgba(0,0,0,0.4)' }}>
{title ?? 'MyInfoMate'}
</span>
</div>
{availableLanguages.length > 1 && (
<div className="relative">
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="appearance-none bg-transparent text-inherit text-sm pl-1 pr-5 py-1 cursor-pointer focus:outline-none"
aria-label="Langue"
>
{availableLanguages.map((lang) => (
<option key={lang} value={lang} style={{ color: '#1a1a1a' }}>
{FLAG[lang] ?? lang} {lang}
</option>
))}
</select>
</div>
)}
</header>
)
}

View File

@ -0,0 +1,42 @@
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
interface VisitorContextValue {
language: string
setLanguage: (lang: string) => void
availableLanguages: string[]
setAvailableLanguages: (langs: string[]) => void
}
const VisitorContext = createContext<VisitorContextValue>({
language: 'FR',
setLanguage: () => {},
availableLanguages: ['FR'],
setAvailableLanguages: () => {},
})
export function VisitorProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguageState] = useState('FR')
const [availableLanguages, setAvailableLanguages] = useState<string[]>(['FR'])
useEffect(() => {
const stored = localStorage.getItem('visitor_language')
if (stored) setLanguageState(stored)
}, [])
function setLanguage(lang: string) {
setLanguageState(lang)
localStorage.setItem('visitor_language', lang)
}
return (
<VisitorContext.Provider value={{ language, setLanguage, availableLanguages, setAvailableLanguages }}>
{children}
</VisitorContext.Provider>
)
}
export function useVisitor() {
return useContext(VisitorContext)
}

31
src/lib/api/client.ts Normal file
View File

@ -0,0 +1,31 @@
import type { ApplicationInstanceDTO, ConfigurationDTO, SectionDTO } from './types'
const BASE_URL = process.env.NEXT_PUBLIC_API_URL
async function apiFetch<T>(path: string, apiKey?: string): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (apiKey) headers['X-Api-Key'] = apiKey
const res = await fetch(`${BASE_URL}${path}`, {
headers,
next: { revalidate: 60 },
})
if (!res.ok) throw new Error(`API error ${res.status}: ${path}`)
return res.json()
}
export async function getInstanceBySlug(slug: string): Promise<ApplicationInstanceDTO> {
return apiFetch(`/api/instance/slug/${slug}`)
}
export async function getConfigurations(instanceId: string, apiKey: string): Promise<ConfigurationDTO[]> {
return apiFetch(`/api/configuration?instanceId=${instanceId}`, apiKey)
}
export async function getConfiguration(configId: string, apiKey: string): Promise<ConfigurationDTO> {
return apiFetch(`/api/configuration/${configId}`, apiKey)
}
export async function getSections(configId: string, apiKey: string): Promise<SectionDTO[]> {
return apiFetch(`/api/Section/configuration/${configId}/detail`, apiKey)
}

245
src/lib/api/types.ts Normal file
View File

@ -0,0 +1,245 @@
export interface TranslationDTO {
language: string
value: string
}
export interface ResourceDTO {
id?: string
url?: string
type?: string
}
export interface ContentDTO {
order: number
title?: TranslationDTO[]
description?: TranslationDTO[]
resourceId?: string
resource?: ResourceDTO
}
export interface ApplicationInstanceDTO {
id: string
label?: string
primaryColor?: string
secondaryColor?: string
mainImageUrl?: string
loaderImageUrl?: string
webSlug?: string
publicApiKey?: string
}
export interface ConfigurationDTO {
id: string
instanceId?: string
label?: string
title?: TranslationDTO[]
imageSource?: string
loaderImageUrl?: string
primaryColor?: string
secondaryColor?: string
languages?: string[]
isOffline?: boolean
sectionIds?: string[]
sections?: SectionDTO[]
}
export type SectionType =
| 'Map'
| 'Slider'
| 'Video'
| 'Web'
| 'Menu'
| 'Quiz'
| 'Article'
| 'Pdf'
| 'Game'
| 'Agenda'
| 'Weather'
| 'Event'
export interface SectionDTO {
id: string
type: SectionType
label?: string
title?: TranslationDTO[]
description?: TranslationDTO[]
imageSource?: string
isActive?: boolean
order?: number
configurationId?: string
isSubSection?: boolean
parentId?: string
latitude?: number
longitude?: number
isBeacon?: boolean
beaconId?: string
// Typed data per section type
article?: ArticleDTO
slider?: SliderDTO
quiz?: QuizDTO
map?: MapDTO
agenda?: AgendaDTO
game?: GameDTO
pdf?: PdfDTO
menu?: MenuDTO
video?: VideoDTO
source?: string
weather?: WeatherDTO
web?: WebDTO
}
export interface ArticleDTO {
content?: TranslationDTO[]
isContentTop?: boolean
audioIds?: TranslationDTO[]
isReadAudioAuto?: boolean
contents?: ContentDTO[]
}
export interface SliderDTO {
contents?: ContentDTO[]
}
export interface MenuDTO {
sections?: SectionDTO[]
}
export interface QuestionResponseDTO {
id: number
label?: TranslationDTO[]
isCorrect?: boolean
order?: number
}
export interface QuestionDTO {
id: number
label?: TranslationDTO[]
responses?: QuestionResponseDTO[]
imageBackgroundResourceUrl?: string
order?: number
}
export interface QuizDTO {
questions?: QuestionDTO[]
badLevel?: TranslationDTO[]
mediumLevel?: TranslationDTO[]
goodLevel?: TranslationDTO[]
greatLevel?: TranslationDTO[]
}
export interface GeometryDTO {
type?: string
coordinates?: number[]
}
export interface GeoPointDTO {
id: number
title?: TranslationDTO[]
description?: TranslationDTO[]
contents?: ContentDTO[]
categorieId?: number
imageUrl?: string
schedules?: TranslationDTO[]
prices?: TranslationDTO[]
phone?: TranslationDTO[]
email?: TranslationDTO[]
site?: TranslationDTO[]
geometry?: GeometryDTO
polyColor?: string
}
export interface CategorieDTO {
id: number
name?: TranslationDTO[]
color?: string
}
export interface MapDTO {
isListViewEnabled?: boolean
zoom?: number
mapType?: string
mapProvider?: string
points?: GeoPointDTO[]
categories?: CategorieDTO[]
centerLatitude?: string
centerLongitude?: string
isParcours?: boolean
}
export interface EventAgendaDTO {
id: string
title?: TranslationDTO[]
description?: TranslationDTO[]
imageSource?: string
startDate?: string
endDate?: string
latitude?: number
longitude?: number
}
export interface AgendaDTO {
isOnlineAgenda?: boolean
events?: EventAgendaDTO[]
}
export interface GameDTO {
messageDebut?: TranslationDTO[]
messageFin?: TranslationDTO[]
puzzleImage?: string
rows?: number
cols?: number
gameType?: string
}
export interface TranslationAndResourceDTO {
language?: string
resourceId?: string
resource?: ResourceDTO
}
export interface OrderedTranslationAndResourceDTO {
order?: number
translationAndResourceDTOs?: TranslationAndResourceDTO[]
}
export interface PdfDTO {
pdfs?: OrderedTranslationAndResourceDTO[]
}
export interface VideoDTO {
source?: string
imageSource?: string
title?: TranslationDTO[]
}
export interface WeatherMain {
temp?: number
tempMin?: number
tempMax?: number
humidity?: number
}
export interface WeatherDescription {
icon?: string
description?: string
}
export interface WeatherForecast {
dt?: number
main?: WeatherMain
weather?: WeatherDescription[]
pop?: number
}
export interface WeatherData {
list?: WeatherForecast[]
}
export interface WeatherDTO {
result?: string
city?: string
}
export interface WebDTO {
source?: string
}

19
src/lib/i18n.ts Normal file
View File

@ -0,0 +1,19 @@
import type { TranslationDTO } from './api/types'
export function t(translations: TranslationDTO[] | undefined, lang: string): string {
if (!translations || translations.length === 0) return ''
return (
translations.find((t) => t.language === lang)?.value ||
translations.find((t) => t.language === 'FR')?.value ||
translations[0]?.value ||
''
)
}
export function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '').trim()
}
export function tPlain(translations: TranslationDTO[] | undefined, lang: string): string {
return stripHtml(t(translations, lang))
}

40
src/lib/theme.ts Normal file
View File

@ -0,0 +1,40 @@
import { getLuminance, parseToRgba } from 'color2k'
export interface ThemeColors {
'--color-primary': string
'--color-secondary': string
'--color-primary-light': string
'--color-on-primary': string
}
function hexToRgba(hex: string, alpha: number): string {
try {
const [r, g, b] = parseToRgba(hex)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
} catch {
return `rgba(0, 0, 0, ${alpha})`
}
}
export function buildTheme(primaryColor: string, secondaryColor: string): ThemeColors {
let luminance = 0
try {
luminance = getLuminance(primaryColor)
} catch {}
return {
'--color-primary': primaryColor,
'--color-secondary': secondaryColor,
'--color-primary-light': hexToRgba(primaryColor, 0.12),
'--color-on-primary': luminance > 0.4 ? '#1A1A1A' : '#FFFFFF',
}
}
export function resolveColors(
instance: { primaryColor?: string; secondaryColor?: string },
config?: { primaryColor?: string; secondaryColor?: string }
): ThemeColors {
const primary = config?.primaryColor || instance.primaryColor || '#264863'
const secondary = config?.secondaryColor || instance.secondaryColor || '#C2C9D6'
return buildTheme(primary, secondary)
}

34
tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}