Wip sections
This commit is contained in:
parent
1afa3fe51e
commit
474cd61244
1
.env.production
Normal file
1
.env.production
Normal file
@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_URL=https://api.myinfomate.be
|
||||
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal 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
5
AGENTS.md
Normal 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
97
CLAUDE.md
Normal 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
|
||||
12
README.md
12
README.md
@ -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
|
||||
|
||||
```bash
|
||||
|
||||
22
next.config.ts
Normal file
22
next.config.ts
Normal 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
2409
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal 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
7
postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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 |
25
src/app/[slug]/[configId]/layout.tsx
Normal file
25
src/app/[slug]/[configId]/layout.tsx
Normal 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>
|
||||
}
|
||||
40
src/app/[slug]/[configId]/page.tsx
Normal file
40
src/app/[slug]/[configId]/page.tsx
Normal 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']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
58
src/app/[slug]/[configId]/sections/[sectionId]/page.tsx
Normal file
58
src/app/[slug]/[configId]/sections/[sectionId]/page.tsx
Normal 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
31
src/app/[slug]/layout.tsx
Normal 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
21
src/app/[slug]/page.tsx
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
34
src/app/globals.css
Normal file
34
src/app/globals.css
Normal 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
33
src/app/layout.tsx
Normal 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
65
src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/components/ConfigurationGrid.tsx
Normal file
89
src/components/ConfigurationGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
244
src/components/SectionList.tsx
Normal file
244
src/components/SectionList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
src/components/sections/AgendaSection.tsx
Normal file
133
src/components/sections/AgendaSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
161
src/components/sections/ArticleSection.tsx
Normal file
161
src/components/sections/ArticleSection.tsx
Normal 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')}`
|
||||
}
|
||||
5
src/components/sections/MapSection.tsx
Normal file
5
src/components/sections/MapSection.tsx
Normal 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>
|
||||
}
|
||||
108
src/components/sections/MenuSection.tsx
Normal file
108
src/components/sections/MenuSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
src/components/sections/PdfSection.tsx
Normal file
73
src/components/sections/PdfSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
254
src/components/sections/QuizSection.tsx
Normal file
254
src/components/sections/QuizSection.tsx
Normal 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'
|
||||
}
|
||||
132
src/components/sections/SliderSection.tsx
Normal file
132
src/components/sections/SliderSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
src/components/sections/VideoSection.tsx
Normal file
108
src/components/sections/VideoSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
211
src/components/sections/WeatherSection.tsx
Normal file
211
src/components/sections/WeatherSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
src/components/sections/WebSection.tsx
Normal file
75
src/components/sections/WebSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
src/components/ui/AppBar.tsx
Normal file
62
src/components/ui/AppBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/context/VisitorContext.tsx
Normal file
42
src/context/VisitorContext.tsx
Normal 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
31
src/lib/api/client.ts
Normal 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
245
src/lib/api/types.ts
Normal 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
19
src/lib/i18n.ts
Normal 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
40
src/lib/theme.ts
Normal 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
34
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user