init import projet

This commit is contained in:
2026-05-03 21:58:59 +02:00
parent f3756fdf8d
commit 8d3df9bbbb
179 changed files with 37694 additions and 132 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
.git
.env
npm-debug.log
+41
View File
@@ -0,0 +1,41 @@
# 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 (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+30
View File
@@ -0,0 +1,30 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY apps/api/package.json ./apps/api/package.json
COPY apps/web/package.json ./apps/web/package.json
RUN npm ci
COPY . .
RUN npm run build -w apps/web
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
COPY apps/api/package.json ./apps/api/package.json
COPY apps/web/package.json ./apps/web/package.json
RUN npm ci --omit=dev
COPY --from=builder /app/apps/web/.next ./apps/web/.next
COPY --from=builder /app/apps/web/public ./apps/web/public
COPY --from=builder /app/apps/web/next.config.ts ./apps/web/next.config.ts
EXPOSE 3000
CMD ["npm", "run", "start", "-w", "apps/web", "--", "-H", "0.0.0.0", "-p", "3000"]
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: ["192.168.1.172"],
};
export default nextConfig;
+24
View File
@@ -0,0 +1,24 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --hostname 0.0.0.0",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"typescript": "^5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

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

After

Width:  |  Height:  |  Size: 391 B

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

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

@@ -0,0 +1,3 @@
<svg width="1415" height="142" viewBox="0 0 1415 142" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 41.8835V142H1414.29V16.0802C1395.91 9.71545 1350.59 1.83686 1316.33 21.2409C1281.38 0.59831 1224.27 6.27502 1082.5 34.6586C928.42 6.79108 743.327 21.2409 627.151 48.5923C569.556 27.4337 490.3 26.9176 455.841 55.8172C456.17 51.1726 447.276 37.755 409.076 21.2409C361.326 0.598308 327.851 25.8855 277.148 21.2409C226.444 16.5963 127.043 -35.0102 0 41.8835Z" fill="#A7DFC1"/>
</svg>

After

Width:  |  Height:  |  Size: 494 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 530.279 525" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector 1" d="M57.2881 172.994C34.2891 175.636 12.0331 180.151 1.75699 196C-1.07143 254.019 1.75691 383 1.75701 459C1.75704 509 41.757 524.5 59.757 524.5H382.257C431.352 524.5 535.863 461.446 529.501 265.447C523.14 69.447 385.635 2.88108 327.893 0.767872C270.151 -1.34533 183.048 7.10749 158.092 97.447C133.136 187.786 80.2871 170.352 57.2881 172.994Z" fill="var(--fill-0, #CFEDED)" stroke="var(--stroke-0, #CFEDED)"/>
</svg>

After

Width:  |  Height:  |  Size: 611 B

@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" viewBox="0 0 530.279 525" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M57.2881 172.994C34.2891 175.636 12.0331 180.151 1.75699 196C-1.07143 254.019 1.75691 383 1.75701 459C1.75704 509 41.757 524.5 59.757 524.5H382.257C431.352 524.5 535.863 461.446 529.501 265.447C523.14 69.447 385.635 2.88108 327.893 0.767872C270.151 -1.34533 183.048 7.10749 158.092 97.447C133.136 187.786 80.2871 170.352 57.2881 172.994Z" fill="#FCCDD8" stroke="#FCCDD8"/>
</svg>

After

Width:  |  Height:  |  Size: 543 B

@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" viewBox="0 0 530.279 525" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M57.2881 172.994C34.2891 175.636 12.0331 180.151 1.75699 196C-1.07143 254.019 1.75691 383 1.75701 459C1.75704 509 41.757 524.5 59.757 524.5H382.257C431.352 524.5 535.863 461.446 529.501 265.447C523.14 69.447 385.635 2.88108 327.893 0.767872C270.151 -1.34533 183.048 7.10749 158.092 97.447C133.136 187.786 80.2871 170.352 57.2881 172.994Z" fill="#CFEDED" stroke="#CFEDED"/>
</svg>

After

Width:  |  Height:  |  Size: 543 B

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

After

Width:  |  Height:  |  Size: 1.3 KiB

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

After

Width:  |  Height:  |  Size: 128 B

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

After

Width:  |  Height:  |  Size: 385 B

+389
View File
@@ -0,0 +1,389 @@
.page {
display: grid;
gap: 0.75rem;
}
.header {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
}
.header h1 {
color: var(--ink-0);
font-size: 1.3rem;
font-weight: 800;
}
.actions {
display: flex;
gap: 0.5rem;
}
.projectPanel,
.loginPanel,
.createPanel,
.tablePanel {
padding: 1rem;
display: grid;
gap: 0.8rem;
}
.adminTabs {
padding: 0.55rem;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.adminTab {
border: 1px solid var(--border);
border-radius: var(--radius-btn);
background: var(--panel-warm);
color: var(--ink-1);
padding: 0.5rem 0.75rem;
font-weight: 700;
cursor: pointer;
}
.adminTabActive {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.projectPanelHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.8rem;
}
.projectList {
display: grid;
gap: 0.6rem;
}
.projectItem {
border: 1px solid var(--border);
border-radius: 12px;
background: #f8fbf3;
padding: 0.75rem;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
}
.predictionPanel {
padding: 1.25rem;
display: grid;
gap: 1rem;
}
.predictionPanel h2 {
font-size: 1.35rem;
font-weight: 800;
color: var(--ink-0);
}
.predictionPanel h3 {
font-size: 1rem;
font-weight: 700;
color: var(--ink-1);
padding-top: 0.5rem;
border-top: 1px solid var(--border-light);
}
.formGrid,
.createForm {
display: grid;
gap: 0.7rem;
}
.message {
border: 1px solid var(--border);
border-radius: 10px;
background: #eef3e3;
padding: 0.65rem 0.75rem;
color: var(--ink-1);
}
.tableWrapper {
overflow-x: auto;
}
.tablePanel table {
width: 100%;
border-collapse: collapse;
min-width: 760px;
}
.tablePanel th,
.tablePanel td {
border-bottom: 1px solid var(--border);
text-align: left;
padding: 0.6rem;
vertical-align: top;
}
.tablePanel th {
color: var(--ink-1);
}
.rowActions {
display: grid;
grid-template-columns: repeat(2, max-content);
gap: 0.45rem;
align-items: center;
}
.rowActions input {
grid-column: 1 / -1;
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.55rem 0.65rem;
}
.predictionActions {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
justify-content: flex-start;
}
.statusPill {
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.35rem 0.7rem;
background: var(--accent-soft);
border-color: var(--accent);
color: var(--ink-1);
font-weight: 700;
}
.predictionGrid {
display: grid;
gap: 0.7rem;
}
.helpText {
color: var(--muted);
}
.wizardPanel {
border: 1px solid var(--border);
border-radius: 12px;
background: #f8fbf3;
padding: 0.8rem;
display: grid;
gap: 0.75rem;
}
.wizardHeader {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.5rem;
align-items: center;
}
.stepPill {
border: 1px solid var(--border);
border-radius: 999px;
background: #fff;
color: var(--ink-1);
padding: 0.32rem 0.7rem;
font-weight: 700;
font-size: 0.84rem;
}
.outcomeSummary {
border: 1px solid var(--border);
border-radius: 10px;
background: #ffffff;
padding: 0.65rem;
display: grid;
gap: 0.35rem;
}
.candidateList {
display: grid;
gap: 0.65rem;
}
.candidateCard {
border: 1px solid var(--border);
border-radius: 12px;
background: #ffffff;
padding: 0.7rem;
display: grid;
gap: 0.55rem;
}
.candidateHeader {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.45rem;
align-items: center;
}
.candidateMeta {
color: var(--muted);
font-size: 0.9rem;
}
.scoreInputs {
display: grid;
gap: 0.55rem;
}
.scoreInputRow {
display: grid;
gap: 0.3rem;
}
.scoreInputRow input {
max-width: 220px;
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.45rem 0.55rem;
}
.wizardActions {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
align-items: center;
}
.outcomesForm {
border: 1px solid var(--border);
border-radius: 12px;
background: #f8fbf3;
padding: 0.75rem;
display: grid;
gap: 0.65rem;
}
.scoringList {
display: grid;
gap: 0.6rem;
}
.babyColumnsGrid {
display: grid;
gap: 1rem;
margin: 0.5rem 0;
}
.babyColumn {
display: grid;
gap: 0.6rem;
align-content: start;
}
.babyColumnHeader {
text-align: center;
font-size: 1rem;
font-weight: 700;
color: var(--ink-1);
padding: 0.4rem 0;
border-bottom: 2px solid var(--primary);
}
.scoringCard {
border: 1px solid var(--border);
border-radius: 12px;
background: #f8fbf3;
padding: 0.7rem;
display: grid;
gap: 0.55rem;
}
.scoringRow {
display: grid;
gap: 0.3rem;
}
.scoringRow input {
max-width: 180px;
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.45rem 0.55rem;
}
@media (max-width: 760px) {
.header {
flex-direction: column;
align-items: flex-start;
}
.projectPanelHeader {
flex-direction: column;
}
.adminTabs {
flex-direction: column;
}
.projectItem {
align-items: flex-start;
}
.predictionActions {
flex-direction: column;
align-items: flex-start;
}
.wizardHeader {
align-items: flex-start;
}
.candidateHeader,
.wizardActions {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 520px) {
.predictionPanel {
padding: 1rem 0.9rem;
gap: 0.85rem;
}
.predictionActions {
flex-direction: column;
align-items: stretch;
}
.predictionActions .btn {
width: 100%;
text-align: center;
}
.babyColumnsGrid {
grid-template-columns: 1fr !important;
}
.wizardHeader {
flex-direction: column;
align-items: flex-start;
}
.wizardActions {
flex-direction: column;
align-items: stretch;
}
.wizardActions .btn {
width: 100%;
text-align: center;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
.photoStepGrid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
@@ -0,0 +1,594 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { clearSession, getApiUrl, loadSession } from "@/lib/auth";
import { getMyProjects } from "@/lib/projects-client";
import {
deleteParentPhoto,
deleteTrimesterPhoto,
getProjectIndices,
uploadParentPhoto,
uploadTrimesterPhoto,
upsertBabyIndices,
upsertBabyTrimester,
upsertParentIndices,
} from "@/lib/indices-client";
import type {
BabyIndices,
ParentIndices,
ParentType,
ProjectIndicesResponse,
Trimester,
} from "@/types/indices";
import type { ProjectSummary } from "@/types/projects";
import { PhotoField } from "@/features/indices/components/PhotoField";
import styles from "./page.module.css";
import wizardStyles from "../../new/page.module.css";
const TRIMESTERS: Trimester[] = ["DATATION", "T1", "T2", "T3"];
const TRIMESTER_LABELS: Record<Trimester, string> = {
DATATION: "Datation (1ère échographie)",
T1: "1er trimestre",
T2: "2ème trimestre",
T3: "3ème trimestre",
};
type ParentDraft = {
poids: string;
taille: string;
perimCranien: string;
dateNaissance: string;
};
type TrimesterDraft = {
date: string;
note: string;
poids: string;
taille: string;
perimCranien: string;
};
function emptyParentDraft(): ParentDraft {
return { poids: "", taille: "", perimCranien: "", dateNaissance: "" };
}
function emptyTrimesterDraft(): TrimesterDraft {
return { date: "", note: "", poids: "", taille: "", perimCranien: "" };
}
function parentToForm(p: ParentIndices | undefined): ParentDraft {
if (!p) return emptyParentDraft();
return {
poids: p.poids != null ? String(p.poids) : "",
taille: p.taille != null ? String(p.taille) : "",
perimCranien: p.perimCranien != null ? String(p.perimCranien) : "",
dateNaissance: p.dateNaissance ? p.dateNaissance.slice(0, 10) : "",
};
}
function babyTrimesterToForm(baby: BabyIndices | undefined, tri: Trimester): TrimesterDraft {
const entry = baby?.trimesters.find((t) => t.trimester === tri);
if (!entry) return emptyTrimesterDraft();
return {
date: entry.date ? entry.date.slice(0, 10) : "",
note: entry.note ?? "",
poids: entry.poids != null ? String(entry.poids) : "",
taille: entry.taille != null ? String(entry.taille) : "",
perimCranien: entry.perimCranien != null ? String(entry.perimCranien) : "",
};
}
function parseOptionalFloat(val: string): number | undefined {
const n = parseFloat(val);
return Number.isNaN(n) ? undefined : n;
}
/* ─────────────────────────── Page ─────────────────────────── */
export default function AdminIndicesPage() {
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
const router = useRouter();
const [ready, setReady] = useState(false);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [stepIndex, setStepIndex] = useState(0);
const [project, setProject] = useState<ProjectSummary | null>(null);
const [indices, setIndices] = useState<ProjectIndicesResponse | null>(null);
// Papa
const [papaDraft, setPapaDraft] = useState<ParentDraft>(emptyParentDraft());
const [papaPhotos, setPapaPhotos] = useState<Array<{ id: string; url: string }>>([]);
// Maman
const [mamanDraft, setManamDraft] = useState<ParentDraft>(emptyParentDraft());
const [mamanPhotos, setManamPhotos] = useState<Array<{ id: string; url: string }>>([]);
// Bébés — datation + trimestres + DPA
const [babyDpas, setBabyDpas] = useState<Record<number, string>>({});
const [trimesterDrafts, setTrimesterDrafts] = useState<Record<string, TrimesterDraft>>({});
const [trimesterPhotos, setTrimesterPhotos] = useState<Record<string, Array<{ id: string; url: string }>>>({});
const apiBaseUrl = getApiUrl();
const babyCount = project?.babyCount ?? 1;
// Steps: 0=papa mesures, 1=papa photos, 2=maman mesures, 3=maman photos,
// puis par bébé: 4+4n=datation(+DPA), 4+4n+1=T1, 4+4n+2=T2, 4+4n+3=T3
// Dernier step = recap
const totalBabySteps = babyCount * 4;
const totalSteps = 4 + totalBabySteps + 1; // +1 recap
const recapStepIndex = totalSteps - 1;
const getBabyLabel = useCallback(
(idx: number) => project?.babies?.find((b) => b.babyIndex === idx)?.label ?? `Bébé ${idx}`,
[project],
);
const getBabyStepInfo = (step: number): { babyIndex: number; trimester: Trimester } | null => {
if (step < 4 || step >= 4 + totalBabySteps) return null;
const offset = step - 4;
const babyIndex = Math.floor(offset / 4) + 1;
const triIndex = offset % 4;
return { babyIndex, trimester: TRIMESTERS[triIndex] };
};
const triKey = (babyIndex: number, tri: Trimester) => `${babyIndex}-${tri}`;
/* ─── Init ─── */
useEffect(() => {
const session = loadSession();
if (!session) { router.replace("/"); return; }
if (session.user.role !== "ADMIN") { router.replace("/predictions"); return; }
Promise.all([getMyProjects(), getProjectIndices(projectId)])
.then(([projects, idx]) => {
const proj = projects.find((p) => p.id === projectId) ?? null;
setProject(proj);
setIndices(idx);
// Init papa
const papa = idx.parentIndices.find((p) => p.parentType === "PAPA");
setPapaDraft(parentToForm(papa));
setPapaPhotos(papa?.photos.map((ph) => ({ id: ph.id, url: ph.url })) ?? []);
// Init maman
const maman = idx.parentIndices.find((p) => p.parentType === "MAMAN");
setManamDraft(parentToForm(maman));
setManamPhotos(maman?.photos.map((ph) => ({ id: ph.id, url: ph.url })) ?? []);
// Init bébés
const dpas: Record<number, string> = {};
const triDrafts: Record<string, TrimesterDraft> = {};
const triPh: Record<string, Array<{ id: string; url: string }>> = {};
const bc = proj?.babyCount ?? 1;
for (let i = 1; i <= bc; i++) {
const baby = idx.babyIndices.find((b) => b.babyIndex === i);
dpas[i] = baby?.dpa ? baby.dpa.slice(0, 10) : "";
for (const tri of TRIMESTERS) {
triDrafts[triKey(i, tri)] = babyTrimesterToForm(baby, tri);
const existingTri = baby?.trimesters.find((t) => t.trimester === tri);
triPh[triKey(i, tri)] = existingTri?.photos.map((ph) => ({ id: ph.id, url: ph.url })) ?? [];
}
}
setBabyDpas(dpas);
setTrimesterDrafts(triDrafts);
setTrimesterPhotos(triPh);
})
.catch((err) => {
setMessage(err instanceof Error ? err.message : "Erreur de chargement");
})
.finally(() => setReady(true));
}, [projectId, router]);
/* ─── Save current step ─── */
const saveCurrentStep = useCallback(async () => {
setLoading(true);
setMessage("");
try {
if (stepIndex === 0) {
await upsertParentIndices(projectId, "PAPA", {
poids: parseOptionalFloat(papaDraft.poids),
taille: parseOptionalFloat(papaDraft.taille),
perimCranien: parseOptionalFloat(papaDraft.perimCranien),
dateNaissance: papaDraft.dateNaissance || null,
});
} else if (stepIndex === 2) {
await upsertParentIndices(projectId, "MAMAN", {
poids: parseOptionalFloat(mamanDraft.poids),
taille: parseOptionalFloat(mamanDraft.taille),
perimCranien: parseOptionalFloat(mamanDraft.perimCranien),
dateNaissance: mamanDraft.dateNaissance || null,
});
} else {
const babyStep = getBabyStepInfo(stepIndex);
if (babyStep) {
const { babyIndex, trimester } = babyStep;
const triOffset = stepIndex - 4 - (babyIndex - 1) * 4;
if (triOffset === 0) {
// Datation step: DPA + données de la première échographie
await upsertBabyIndices(projectId, babyIndex, { dpa: babyDpas[babyIndex] || null });
}
const draft = trimesterDrafts[triKey(babyIndex, trimester)] ?? emptyTrimesterDraft();
await upsertBabyTrimester(projectId, babyIndex, trimester, {
date: draft.date || null,
note: draft.note || null,
poids: parseOptionalFloat(draft.poids),
taille: parseOptionalFloat(draft.taille),
perimCranien: parseOptionalFloat(draft.perimCranien),
});
}
}
} catch (err) {
if (err instanceof Error && err.message.includes("Session")) {
clearSession();
router.replace("/");
return false;
}
setMessage(err instanceof Error ? err.message : "Erreur de sauvegarde");
return false;
} finally {
setLoading(false);
}
return true;
}, [stepIndex, projectId, papaDraft, mamanDraft, babyDpas, trimesterDrafts, getBabyStepInfo, router]);
const goNext = async () => {
if (stepIndex === recapStepIndex) return;
const photoStep = stepIndex === 1 || stepIndex === 3;
let ok = true;
if (!photoStep) {
ok = (await saveCurrentStep()) !== false;
}
if (ok) setStepIndex((s) => s + 1);
};
const goBack = () => setStepIndex((s) => Math.max(0, s - 1));
/* ─── Photo upload ─── */
const handleParentPhotosAdd = async (parentType: ParentType, files: File[]) => {
setLoading(true);
try {
for (const file of files) {
const photo = await uploadParentPhoto(projectId, parentType, file);
if (parentType === "PAPA") {
setPapaPhotos((prev) => [...prev, { id: photo.id, url: photo.url }]);
} else {
setManamPhotos((prev) => [...prev, { id: photo.id, url: photo.url }]);
}
}
} catch (err) {
setMessage(err instanceof Error ? err.message : "Erreur upload");
} finally {
setLoading(false);
}
};
const handleTrimesterPhotosAdd = async (babyIndex: number, trimester: Trimester, files: File[]) => {
setLoading(true);
try {
for (const file of files) {
const photo = await uploadTrimesterPhoto(projectId, babyIndex, trimester, file);
const k = triKey(babyIndex, trimester);
setTrimesterPhotos((prev) => ({ ...prev, [k]: [...(prev[k] ?? []), { id: photo.id, url: photo.url }] }));
}
} catch (err) {
setMessage(err instanceof Error ? err.message : "Erreur upload");
} finally {
setLoading(false);
}
};
const removeParentPhoto = async (parentType: ParentType, photoId: string) => {
setLoading(true);
try {
await deleteParentPhoto(projectId, parentType, photoId);
if (parentType === "PAPA") {
setPapaPhotos((prev) => prev.filter((p) => p.id !== photoId));
} else {
setManamPhotos((prev) => prev.filter((p) => p.id !== photoId));
}
} catch (err) {
setMessage(err instanceof Error ? err.message : "Erreur suppression");
} finally {
setLoading(false);
}
};
const removeTrimesterPhoto = async (babyIndex: number, trimester: Trimester, photoId: string) => {
setLoading(true);
try {
await deleteTrimesterPhoto(projectId, babyIndex, trimester, photoId);
const k = triKey(babyIndex, trimester);
setTrimesterPhotos((prev) => ({ ...prev, [k]: (prev[k] ?? []).filter((p) => p.id !== photoId) }));
} catch (err) {
setMessage(err instanceof Error ? err.message : "Erreur suppression");
} finally {
setLoading(false);
}
};
/* ─── Helpers render ─── */
const renderParentForm = (parentType: ParentType, draft: ParentDraft, setDraft: (d: ParentDraft) => void) => {
const label = parentType === "PAPA" ? "Papa" : "Maman";
return (
<div className={wizardStyles.formGrid}>
<p className={wizardStyles.helpText}>
Renseigne les informations physiques de naissance de {label}. Tous les champs sont optionnels.
</p>
<div className="field">
<label htmlFor={`${parentType}-poids`}>Poids de naissance (kg)</label>
<input
id={`${parentType}-poids`}
type="number"
step="0.1"
placeholder="ex: 3.4"
value={draft.poids}
onChange={(e) => setDraft({ ...draft, poids: e.target.value })}
/>
</div>
<div className="field">
<label htmlFor={`${parentType}-taille`}>Taille de naissance (cm)</label>
<input
id={`${parentType}-taille`}
type="number"
step="1"
placeholder="ex: 51"
value={draft.taille}
onChange={(e) => setDraft({ ...draft, taille: e.target.value })}
/>
</div>
<div className="field">
<label htmlFor={`${parentType}-perim`}>Périmètre crânien de naissance (cm)</label>
<input
id={`${parentType}-perim`}
type="number"
step="0.1"
placeholder="ex: 35"
value={draft.perimCranien}
onChange={(e) => setDraft({ ...draft, perimCranien: e.target.value })}
/>
</div>
<div className="field">
<label htmlFor={`${parentType}-dob`}>Date de naissance</label>
<input
id={`${parentType}-dob`}
type="date"
value={draft.dateNaissance}
onChange={(e) => setDraft({ ...draft, dateNaissance: e.target.value })}
/>
</div>
</div>
);
};
const renderPhotoStep = (
parentType: ParentType,
photos: Array<{ id: string; url: string }>,
label: string,
) => (
<div className={styles.photoStepGrid}>
<p className={wizardStyles.helpText}>
Ajoute jusqu&apos;à 5 photos pour {label} (échographies, portraits...).
</p>
<PhotoField
photos={photos}
maxPhotos={5}
loading={loading}
apiBaseUrl={apiBaseUrl}
onAdd={(files) => void handleParentPhotosAdd(parentType, files)}
onRemove={(id) => void removeParentPhoto(parentType, id)}
/>
</div>
);
const renderTrimesterStep = (babyIndex: number, trimester: Trimester) => {
const k = triKey(babyIndex, trimester);
const draft = trimesterDrafts[k] ?? emptyTrimesterDraft();
const photos = trimesterPhotos[k] ?? [];
const triOffset = (stepIndex - 4) % 4;
const isDpaStep = triOffset === 0;
return (
<div className={wizardStyles.formGrid}>
{isDpaStep && (
<div className="field">
<label htmlFor={`dpa-${babyIndex}`}>
Date prévue d&apos;accouchement {getBabyLabel(babyIndex)}
</label>
<input
id={`dpa-${babyIndex}`}
type="date"
value={babyDpas[babyIndex] ?? ""}
onChange={(e) => setBabyDpas((prev) => ({ ...prev, [babyIndex]: e.target.value }))}
/>
<p className={wizardStyles.helpText}>Optionnel peut être renseignée plus tard.</p>
</div>
)}
<p className={wizardStyles.helpText}>
{TRIMESTER_LABELS[trimester]} données d&apos;échographie pour {getBabyLabel(babyIndex)}. Tous les champs sont optionnels.
</p>
<div className="field">
<label htmlFor={`${k}-date`}>Date de l&apos;échographie</label>
<input
id={`${k}-date`}
type="date"
value={draft.date}
onChange={(e) => setTrimesterDrafts((prev) => ({ ...prev, [k]: { ...draft, date: e.target.value } }))}
/>
</div>
<div className="field">
<label htmlFor={`${k}-poids`}>Poids estimé (kg)</label>
<input
id={`${k}-poids`}
type="number"
step="0.001"
placeholder="ex: 0.185"
value={draft.poids}
onChange={(e) => setTrimesterDrafts((prev) => ({ ...prev, [k]: { ...draft, poids: e.target.value } }))}
/>
</div>
<div className="field">
<label htmlFor={`${k}-taille`}>Taille estimée (cm)</label>
<input
id={`${k}-taille`}
type="number"
step="0.1"
placeholder="ex: 16"
value={draft.taille}
onChange={(e) => setTrimesterDrafts((prev) => ({ ...prev, [k]: { ...draft, taille: e.target.value } }))}
/>
</div>
<div className="field">
<label htmlFor={`${k}-perim`}>Périmètre crânien (cm)</label>
<input
id={`${k}-perim`}
type="number"
step="0.1"
placeholder="ex: 12"
value={draft.perimCranien}
onChange={(e) => setTrimesterDrafts((prev) => ({ ...prev, [k]: { ...draft, perimCranien: e.target.value } }))}
/>
</div>
<div className="field">
<label htmlFor={`${k}-note`}>Note / observations</label>
<textarea
id={`${k}-note`}
rows={3}
placeholder="Tout va bien, bébé est en bonne santé..."
value={draft.note}
onChange={(e) => setTrimesterDrafts((prev) => ({ ...prev, [k]: { ...draft, note: e.target.value } }))}
/>
</div>
{/* Photos trimestre */}
<div>
<p className={wizardStyles.helpText}>Photos / échographies (max 5)</p>
<PhotoField
photos={photos}
maxPhotos={5}
loading={loading}
apiBaseUrl={apiBaseUrl}
onAdd={(files) => void handleTrimesterPhotosAdd(babyIndex, trimester, files)}
onRemove={(id) => void removeTrimesterPhoto(babyIndex, trimester, id)}
/>
</div>
</div>
);
};
const getStepTitle = (): string => {
if (stepIndex === 0) return "Papa — informations de naissance";
if (stepIndex === 1) return "Papa — photos";
if (stepIndex === 2) return "Maman — informations de naissance";
if (stepIndex === 3) return "Maman — photos";
if (stepIndex === recapStepIndex) return "Récapitulatif";
const babyStep = getBabyStepInfo(stepIndex);
if (babyStep) {
return `${getBabyLabel(babyStep.babyIndex)}${TRIMESTER_LABELS[babyStep.trimester]}`;
}
return "";
};
if (!ready) {
return (
<main className={`app-shell ${wizardStyles.page}`}>
<section className={`panel ${wizardStyles.panel}`}>
<p>Chargement...</p>
</section>
</main>
);
}
return (
<main className={`app-shell ${wizardStyles.page}`}>
<section className={`panel ${wizardStyles.header}`}>
<div>
<p className="mono">Admin / Projets / {project?.name ?? projectId} / Indices</p>
<h1>Indices de grossesse</h1>
<p>Renseigne les informations physiques de naissance des parents et les données du bébé, trimestre par trimestre.</p>
</div>
<div className={wizardStyles.headerActions}>
<Link className="btn btn-soft" href="/admin">
Retour admin
</Link>
</div>
</section>
<section className={`panel ${wizardStyles.panel}`}>
<div className={wizardStyles.stepHeader}>
<span className={wizardStyles.stepPill}>
Étape {stepIndex + 1} / {totalSteps}
</span>
<strong style={{ fontSize: "0.95rem" }}>{getStepTitle()}</strong>
</div>
{/* ── Étapes ── */}
{stepIndex === 0 && renderParentForm("PAPA", papaDraft, setPapaDraft)}
{stepIndex === 1 && renderPhotoStep("PAPA", papaPhotos, "papa")}
{stepIndex === 2 && renderParentForm("MAMAN", mamanDraft, setManamDraft)}
{stepIndex === 3 && renderPhotoStep("MAMAN", mamanPhotos, "maman")}
{stepIndex >= 4 && stepIndex < 4 + totalBabySteps && (() => {
const babyStep = getBabyStepInfo(stepIndex);
if (!babyStep) return null;
return renderTrimesterStep(babyStep.babyIndex, babyStep.trimester);
})()}
{stepIndex === recapStepIndex && (
<div className={wizardStyles.reviewPanel}>
<h3>Récapitulatif</h3>
<p><strong>Projet :</strong> {project?.name ?? projectId}</p>
<p><strong>Papa (naissance) :</strong> {papaDraft.poids ? `${papaDraft.poids} kg` : "—"}, {papaDraft.taille ? `${papaDraft.taille} cm` : "—"} {papaPhotos.length} photo(s)</p>
<p><strong>Maman (naissance) :</strong> {mamanDraft.poids ? `${mamanDraft.poids} kg` : "—"}, {mamanDraft.taille ? `${mamanDraft.taille} cm` : "—"} {mamanPhotos.length} photo(s)</p>
{Array.from({ length: babyCount }, (_, i) => i + 1).map((bi) => (
<p key={bi}>
<strong>{getBabyLabel(bi)} :</strong>{" "}
DPA {babyDpas[bi] || "—"} {" "}
{TRIMESTERS.map((t) => {
const ph = trimesterPhotos[triKey(bi, t)]?.length ?? 0;
return `${TRIMESTER_LABELS[t]}: ${ph} photo(s)`;
}).join(", ")}
</p>
))}
<p>Les informations ont é sauvegardées au fil des étapes.</p>
</div>
)}
{message ? <p className={wizardStyles.message}>{message}</p> : null}
<div className={wizardStyles.wizardActions}>
<button
type="button"
className="btn btn-soft"
onClick={goBack}
disabled={loading || stepIndex === 0}
>
Retour
</button>
{stepIndex === recapStepIndex ? (
<Link className="btn btn-primary" href="/admin">
Terminer
</Link>
) : (
<button
type="button"
className="btn btn-primary"
disabled={loading}
onClick={() => void goNext()}
>
{loading ? "Sauvegarde..." : "Continuer"}
</button>
)}
</div>
</section>
</main>
);
}
@@ -0,0 +1,478 @@
.page {
display: grid;
gap: 1rem;
padding-bottom: 1.5rem;
}
.header {
padding: 1.1rem 1.2rem;
display: flex;
justify-content: space-between;
gap: 0.9rem;
align-items: flex-start;
background:
radial-gradient(circle at top left, color-mix(in srgb, var(--star-yellow) 24%, #fff) 0, transparent 34%),
linear-gradient(135deg, color-mix(in srgb, var(--accent-soft) 68%, #fff) 0%, #fff 54%, color-mix(in srgb, var(--teal) 18%, #fff) 100%);
}
.header h1 {
color: var(--ink-1);
font-size: clamp(1.3rem, 2.6vw, 1.9rem);
}
.header p {
color: var(--muted);
max-width: 68ch;
}
.headerActions {
display: flex;
gap: 0.45rem;
}
.panel {
padding: 1.15rem 1.2rem;
display: grid;
gap: 1rem;
background:
radial-gradient(circle at top right, color-mix(in srgb, var(--teal) 18%, transparent) 0, transparent 26%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, color-mix(in srgb, var(--panel-warm) 52%, #fff) 100%);
}
.modeSwitch {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.modeButton {
border: 1px solid var(--border);
border-radius: 999px;
background: linear-gradient(180deg, #f8f4e7 0%, #eef4e5 100%);
color: var(--ink-1);
font-weight: 700;
padding: 0.55rem 1rem;
box-shadow: 0 8px 18px rgba(232, 89, 110, 0.08);
}
.modeButtonActive {
border-color: color-mix(in srgb, var(--accent) 52%, var(--border));
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 24%, #fff) 0%, color-mix(in srgb, var(--accent-soft) 68%, #fff) 100%);
}
.stepHeader {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
padding: 0.2rem 0;
}
.stepPill {
border: 1px solid var(--border);
border-radius: 999px;
background: linear-gradient(180deg, #fff8e8 0%, #f2f7e9 100%);
color: var(--ink-1);
font-size: 0.9rem;
font-weight: 700;
padding: 0.45rem 0.85rem;
}
.helpText {
color: var(--muted);
font-size: 0.95rem;
}
.formGrid {
display: grid;
gap: 0.9rem;
grid-template-columns: repeat(2, minmax(240px, 1fr));
align-items: start;
}
.wizardTile {
width: 100%;
background: linear-gradient(180deg, #fffaf1 0%, #fdf2df 100%);
border-color: color-mix(in srgb, var(--border-light) 76%, #ffffff);
}
.wizardTileBody {
gap: 0.9rem;
}
.wizardCountBtn {
min-width: 108px;
min-height: 126px;
width: 100%;
border-radius: 18px;
border: 1.5px solid var(--border-light);
background: linear-gradient(180deg, #ffffff 0%, #fdf9f1 100%);
padding: 0.85rem 0.8rem;
cursor: pointer;
touch-action: manipulation;
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
}
.stepCard {
background: var(--panel);
border: 1px solid var(--border-light);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
padding: 0.95rem;
display: grid;
gap: 0.85rem;
}
.stepCardHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
}
.stepCardHeader h3 {
color: var(--ink-0);
font-size: 1.1rem;
font-weight: 800;
}
.stepCardHeader p {
color: var(--muted);
font-size: 0.95rem;
line-height: 1.45;
margin-top: 0.22rem;
}
.stepCardBadge {
border: 1px solid var(--border);
border-radius: var(--radius-btn);
padding: 0.32rem 0.72rem;
background: var(--panel-warm);
color: var(--ink-1);
font-size: 0.8rem;
font-weight: 700;
white-space: nowrap;
}
.stepCardBody {
display: grid;
gap: 0.85rem;
}
.countChoiceGrid {
display: grid;
grid-template-columns: 1fr;
gap: 0.85rem;
}
.wizardCountBtn:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--teal) 55%, var(--border));
box-shadow: 0 14px 28px rgba(92, 197, 205, 0.18);
}
.wizardCountBtn[class*="optionIconBtnActive"] {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 60%, var(--border));
background: linear-gradient(180deg, color-mix(in srgb, var(--accent-soft) 50%, #fff) 0%, #ffffff 100%);
box-shadow: 0 16px 30px rgba(232, 89, 110, 0.2);
}
.countChoiceCircle {
width: 4.7rem;
height: 4.7rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--border) 72%, #ffffff);
display: grid;
place-items: center;
font-family: var(--font-baloo), system-ui, sans-serif;
font-size: clamp(2rem, 5vw, 2.5rem);
line-height: 1;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85), 0 6px 14px rgba(0, 0, 0, 0.08);
}
.wizardCountBtn:nth-child(1) .countChoiceCircle {
background: linear-gradient(180deg, #ffe8ef 0%, #ffdce8 100%);
}
.wizardCountBtn:nth-child(2) .countChoiceCircle {
background: linear-gradient(180deg, #e8f7fb 0%, #d9f1f6 100%);
}
.wizardCountBtn:nth-child(3) .countChoiceCircle {
background: linear-gradient(180deg, #fff4dc 0%, #ffecbf 100%);
}
.wizardCountBtn[class*="optionIconBtnActive"] .countChoiceCircle {
background: linear-gradient(180deg, color-mix(in srgb, var(--accent-soft) 78%, #fff) 0%, #ffd8e3 100%);
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.countChoiceLabel {
font-size: 1rem;
font-weight: 800;
}
.choiceButton,
.reviewPanel,
.babyIntro {
border: 1px solid var(--border-light);
border-radius: 20px;
background: linear-gradient(180deg, #fff8ea 0%, #f9efdc 100%);
box-shadow: var(--shadow-card);
}
.compactField {
display: grid;
gap: 0.45rem;
padding: 0.1rem 0;
}
.compactField label {
color: var(--ink-1);
font-size: 0.92rem;
font-weight: 700;
display: block;
}
.compactField input {
width: 100%;
min-height: 56px;
border: 1px solid var(--border-light);
border-radius: 16px;
background: rgba(255, 255, 255, 0.98);
padding: 0.9rem 1rem;
color: var(--ink-1);
}
.selectionGrid {
display: grid;
gap: 0.82rem;
grid-template-columns: 1fr;
}
.choiceButton {
width: 100%;
padding: 0.9rem;
display: grid;
text-align: left;
gap: 0.5rem;
align-content: start;
min-height: 118px;
border: 1.5px solid color-mix(in srgb, var(--border-light) 82%, var(--teal));
cursor: pointer;
touch-action: manipulation;
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
}
.choiceIndicator {
width: 1.45rem;
height: 1.45rem;
border-radius: 999px;
border: 1.5px solid color-mix(in srgb, var(--ink-1) 28%, var(--border));
background: linear-gradient(180deg, #fffef9 0%, #f9f0e2 100%);
display: grid;
place-items: center;
}
.choiceIndicator::after {
content: "";
width: 0.45rem;
height: 0.45rem;
border-radius: 999px;
background: transparent;
transition: background 140ms ease;
}
.choiceButton:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--teal) 52%, var(--border));
box-shadow: 0 12px 24px rgba(92, 197, 205, 0.2);
}
.choiceButtonActive {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent) 60%, var(--border));
background: linear-gradient(180deg, color-mix(in srgb, var(--accent-soft) 44%, #fff4f8) 0%, #fde5ec 100%);
box-shadow: 0 16px 28px rgba(232, 89, 110, 0.22);
}
.choiceButtonActive .choiceIndicator {
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
background: linear-gradient(180deg, #fff4f7 0%, #ffd8e4 100%);
}
.choiceButtonActive .choiceIndicator::after {
background: var(--accent-strong);
}
.choiceLabel {
color: var(--ink-0);
font-size: 1rem;
font-weight: 800;
}
.choiceMeta {
color: color-mix(in srgb, var(--ink-1) 65%, var(--muted));
font-size: 0.85rem;
}
.fullSpan {
grid-column: 1 / -1;
}
.babyIntro {
padding: 1rem;
}
.summaryList {
display: grid;
gap: 0.55rem;
}
.summaryList p {
display: grid;
gap: 0.2rem;
padding: 0.8rem 0.9rem;
border: 1px solid var(--border-light);
border-radius: 18px;
background: linear-gradient(180deg, #ffffff 0%, #fdf9f3 100%);
}
.summaryList strong {
color: var(--ink-1);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.summaryList span {
color: var(--ink-0);
font-size: 1rem;
font-weight: 700;
}
.actionStack {
display: grid;
gap: 0.6rem;
}
.photoStepBody {
display: grid;
justify-items: center;
gap: 0.75rem;
}
.photoPreviewFilled,
.photoPreviewEmpty {
width: 96px;
height: 96px;
border-radius: 999px;
display: grid;
place-items: center;
}
.photoPreviewFilled {
overflow: hidden;
border: 3px solid var(--teal);
}
.photoPreviewEmpty {
background: var(--panel-warm);
border: 2px dashed var(--border);
}
.emptyState {
min-height: 132px;
border: 1px dashed var(--border);
border-radius: 20px;
background: linear-gradient(180deg, #ffffff 0%, #fdf9f3 100%);
color: var(--muted);
display: grid;
place-items: center;
text-align: center;
padding: 1rem;
}
.wizardActions {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
padding-top: 0.2rem;
}
.wizardActions :global(.btn) {
min-height: 52px;
touch-action: manipulation;
}
.wizardTile,
.wizardTile h3,
.wizardTile p,
.wizardTile span,
.wizardTile label,
.wizardTile button {
user-select: none;
-webkit-user-select: none;
}
.wizardTile input,
.wizardTile select,
.wizardTile textarea {
user-select: text;
-webkit-user-select: text;
}
.message {
border: 1px solid var(--border);
border-radius: 10px;
background: #eef3e3;
padding: 0.65rem 0.75rem;
color: var(--ink-1);
}
@media (max-width: 900px) {
.formGrid {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
}
.wizardActions {
position: sticky;
bottom: 0.55rem;
background: color-mix(in srgb, var(--panel) 88%, transparent);
backdrop-filter: blur(8px);
padding: 0.55rem;
border: 1px solid var(--border-light);
border-radius: 14px;
}
.wizardActions :global(.btn) {
flex: 1 1 100%;
}
}
@media (min-width: 760px) {
.countChoiceGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.selectionGrid {
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
}
@media (min-width: 1080px) {
.selectionGrid {
grid-template-columns: repeat(3, minmax(180px, 1fr));
}
.stepCardHeader {
flex-direction: column;
}
}
@@ -0,0 +1,689 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
authenticatedFetch,
clearSession,
getApiUrl,
loadSession,
setCurrentProjectId,
} from "@/lib/auth";
import { cloneProject, createProject, getMyProjects } from "@/lib/projects-client";
import type { ProjectStatus, ProjectSummary } from "@/types/projects";
import { ProjectPhotoModal } from "@/features/predictions/components/project-photo-modal";
import predictionStyles from "@/features/predictions/styles/predictions.module.css";
import styles from "./page.module.css";
type UserListItem = {
id: string;
username: string;
displayName: string | null;
role: "ADMIN" | "FAMILY";
};
type WizardMode = "CREATE" | "CLONE";
const legacyCards = [
{ code: "legacy_weight", label: "Poids" },
{ code: "legacy_sex", label: "Sexe" },
{ code: "legacy_names", label: "Prenoms" },
{ code: "legacy_head", label: "Perimetre cranien" },
{ code: "legacy_height", label: "Taille" },
] as const;
function normalizeBabyNickname(label: string, index: number) {
const trimmed = label.trim();
return trimmed.length >= 2 ? trimmed : `Bebe ${index + 1}`;
}
function buildProjectName(babyLabels: string[], babyCount: number) {
const labels = babyLabels
.slice(0, babyCount)
.map((label, index) => normalizeBabyNickname(label, index));
if (labels.length === 1) {
return `Concours ${labels[0]}`;
}
if (labels.length === 2) {
return `Concours ${labels[0]} & ${labels[1]}`;
}
return `Concours ${labels.slice(0, -1).join(", ")} & ${labels.at(-1)}`;
}
function statusLabel(status: ProjectStatus) {
if (status === "OPEN") {
return "Ouvert";
}
if (status === "CLOSED") {
return "Cloture";
}
if (status === "FINALIZED") {
return "Finalise";
}
return "Brouillon";
}
type ChoiceButtonProps = {
label: string;
meta: string;
active: boolean;
onToggle: () => void;
};
function ChoiceButton({ label, meta, active, onToggle }: Readonly<ChoiceButtonProps>) {
return (
<button
type="button"
className={`${styles.choiceButton} ${active ? styles.choiceButtonActive : ""}`}
onClick={onToggle}
aria-pressed={active}
>
<span className={styles.choiceIndicator} aria-hidden="true" />
<span className={styles.choiceLabel}>{label}</span>
<span className={styles.choiceMeta}>{meta}</span>
</button>
);
}
export default function NewProjectWizardPage() {
const router = useRouter();
const submitLockRef = useRef(false);
const [ready, setReady] = useState(false);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [mode, setMode] = useState<WizardMode>("CREATE");
const [stepIndex, setStepIndex] = useState(0);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [users, setUsers] = useState<UserListItem[]>([]);
const [description, setDescription] = useState("");
const [babyCount, setBabyCount] = useState(1);
const [status] = useState<ProjectStatus>("OPEN");
const [babyLabels, setBabyLabels] = useState<string[]>(["Bebe 1"]);
const [enabledLegacyCodes, setEnabledLegacyCodes] = useState<string[]>(legacyCards.map((card) => card.code));
const [selectedParticipantIds, setSelectedParticipantIds] = useState<string[]>([]);
const [cloneSourceProjectId, setCloneSourceProjectId] = useState("");
const [cloneName, setCloneName] = useState("");
const [cloneDescription, setCloneDescription] = useState("");
const [cloneIncludeParticipants, setCloneIncludeParticipants] = useState(false);
const [createdProject, setCreatedProject] = useState<ProjectSummary | null>(null);
const [photoModalOpen, setPhotoModalOpen] = useState(false);
const participantUsers = useMemo(
() => users.filter((user) => user.role === "FAMILY"),
[users],
);
const generatedProjectName = useMemo(() => {
return buildProjectName(babyLabels, babyCount);
}, [babyCount, babyLabels]);
const totalSteps = mode === "CREATE" ? 7 : 2;
useEffect(() => {
const session = loadSession();
if (!session) {
router.replace("/");
return;
}
if (session.user.role !== "ADMIN") {
router.replace("/predictions");
return;
}
Promise.all([
getMyProjects(),
authenticatedFetch(getApiUrl(), "/users").then(async (response) => {
const payload = (await response.json()) as UserListItem[] | { message?: string };
if (!response.ok || !Array.isArray(payload)) {
throw new Error((payload as { message?: string }).message ?? "Impossible de charger les utilisateurs");
}
return payload;
}),
])
.then(([loadedProjects, loadedUsers]) => {
setProjects(loadedProjects);
setUsers(loadedUsers);
if (loadedProjects.length > 0) {
const sourceId = loadedProjects[0].id;
setCloneSourceProjectId(sourceId);
setCloneName(`${loadedProjects[0].name} (copie)`);
}
})
.catch((error) => {
setMessage(error instanceof Error ? error.message : "Impossible de charger le wizard projet");
})
.finally(() => {
setReady(true);
});
}, [router]);
useEffect(() => {
setBabyLabels((current) => {
const next = [...current];
const normalizedCount = Math.max(1, Math.min(3, babyCount));
while (next.length < normalizedCount) {
next.push(`Bebe ${next.length + 1}`);
}
return next.slice(0, normalizedCount);
});
}, [babyCount]);
const canGoNext = useMemo(() => {
if (mode === "CREATE") {
if (stepIndex === 0) {
return true;
}
if (stepIndex === 1) {
return babyLabels.slice(0, babyCount).every((label) => label.trim().length >= 2);
}
if (stepIndex === 2) {
return enabledLegacyCodes.length > 0;
}
return true;
}
if (stepIndex === 0) {
return cloneSourceProjectId.length > 0;
}
return true;
}, [babyCount, babyLabels, cloneSourceProjectId, enabledLegacyCodes.length, mode, stepIndex]);
const onSubmitWizard = async () => {
if (submitLockRef.current) {
return;
}
submitLockRef.current = true;
setLoading(true);
setMessage("");
try {
if (mode === "CREATE") {
const created = await createProject({
name: generatedProjectName,
description: description.trim() || undefined,
babyCount,
status,
babyLabels: babyLabels.slice(0, babyCount).map((label) => label.trim()),
enabledLegacyCardCodes: enabledLegacyCodes,
participantUserIds: selectedParticipantIds,
});
setCurrentProjectId(created.id);
setCreatedProject(created);
setStepIndex(5); // step 5 = indices optionnel
return;
}
const cloned = await cloneProject(cloneSourceProjectId, {
name: cloneName.trim() || undefined,
description: cloneDescription.trim() || undefined,
includeParticipants: cloneIncludeParticipants,
});
setCurrentProjectId(cloned.id);
setMessage(`Projet clone: ${cloned.name}`);
router.push(`/admin?project=${cloned.id}`);
} catch (error) {
if (error instanceof Error && error.message.includes("Session")) {
clearSession();
router.replace("/");
return;
}
setMessage(error instanceof Error ? error.message : "Impossible de finaliser le projet");
} finally {
submitLockRef.current = false;
setLoading(false);
}
};
if (!ready) {
return (
<main className={`app-shell ${styles.page}`}>
<section className={`panel ${styles.panel}`}>
<p>Chargement du wizard projet...</p>
</section>
</main>
);
}
return (
<main className={`app-shell ${styles.page}`}>
<section className={`panel ${styles.header}`}>
<div>
<p className="mono">Le Juste Poids / Admin / Projets</p>
<h1>Assistant de creation de projet</h1>
<p>
Configure ton concours en etapes: infos, bebes, tuiles et participants.
</p>
</div>
<div className={styles.headerActions}>
<Link className="btn btn-soft" href="/admin">
Retour admin
</Link>
</div>
</section>
<section className={`panel ${styles.panel}`}>
<div className={styles.modeSwitch}>
<button
type="button"
className={`${styles.modeButton} ${mode === "CREATE" ? styles.modeButtonActive : ""}`}
onClick={() => {
setMode("CREATE");
setStepIndex(0);
}}
>
Nouveau projet guide
</button>
<button
type="button"
className={`${styles.modeButton} ${mode === "CLONE" ? styles.modeButtonActive : ""}`}
onClick={() => {
setMode("CLONE");
setStepIndex(0);
}}
>
Cloner un projet existant
</button>
</div>
<div className={styles.stepHeader}>
<span className={styles.stepPill}>Etape {stepIndex + 1} / {totalSteps}</span>
<p className={styles.helpText}>
{mode === "CREATE"
? "Configuration rapide du concours"
: "Ideal pour separer famille et amis rapidement"}
</p>
</div>
{mode === "CREATE" && stepIndex === 0 ? (
<article className={`${predictionStyles.tile} ${styles.wizardTile}`}>
<header className={predictionStyles.tileHeader}>
<div>
<h3>Vous attendez combien de bebe ?</h3>
<p>Choisissez simplement le nombre de bebes pour preparer les espaces de pronostic.</p>
</div>
<span className={predictionStyles.badge}>Demarrage</span>
</header>
<div className={`${predictionStyles.tileBody} ${styles.wizardTileBody}`}>
<div className={predictionStyles.optionIconsRow}>
{[1, 2, 3].map((count) => {
const active = babyCount === count;
return (
<button
key={count}
type="button"
className={`${predictionStyles.optionIconBtn} ${styles.wizardCountBtn} ${active ? predictionStyles.optionIconBtnActive : ""}`}
onClick={() => setBabyCount(count)}
aria-pressed={active}
>
<span className={styles.countChoiceCircle}>{count}</span>
<span>{count === 1 ? "1 bebe" : `${count} bebes`}</span>
</button>
);
})}
</div>
<div className={styles.compactField}>
<label htmlFor="projectDescription">Description</label>
<input
id="projectDescription"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Optionnel: concours famille proche, annonce de juillet..."
/>
</div>
</div>
</article>
) : null}
{mode === "CREATE" && stepIndex === 1 ? (
<article className={`${predictionStyles.tile} ${styles.wizardTile}`}>
<header className={predictionStyles.tileHeader}>
<div>
<h3>Quels surnoms voulez-vous afficher pour bebe ?</h3>
<p>Bubulle, Mini-nous, Petit haricot... gardez un nom doux et temporaire si vous voulez faire deviner le prenom.</p>
</div>
<span className={predictionStyles.badge}>Surnoms</span>
</header>
<div className={`${predictionStyles.tileBody} ${styles.wizardTileBody} ${styles.formGrid}`}>
{babyLabels.slice(0, babyCount).map((label, index) => (
<div className="field" key={`baby-label-${index + 1}`}>
<label htmlFor={`babyLabel-${index + 1}`}>Surnom / pseudo bebe {index + 1}</label>
<input
id={`babyLabel-${index + 1}`}
value={label}
onChange={(event) => {
const value = event.target.value;
setBabyLabels((current) => {
const next = [...current];
next[index] = value;
return next;
});
}}
placeholder={index === 0 ? "Ex: Bubulle" : `Ex: Babychou ${index + 1}`}
/>
</div>
))}
</div>
</article>
) : null}
{mode === "CREATE" && stepIndex === 2 ? (
<article className={`${predictionStyles.tile} ${styles.wizardTile}`}>
<header className={predictionStyles.tileHeader}>
<div>
<h3>Quels pronostics voulez-vous ouvrir ?</h3>
<p>Selectionnez les categories a afficher aux proches dans le concours.</p>
</div>
<span className={predictionStyles.badge}>Cartes</span>
</header>
<div className={`${predictionStyles.tileBody} ${styles.wizardTileBody} ${styles.selectionGrid}`}>
{legacyCards.map((card) => {
const checked = enabledLegacyCodes.includes(card.code);
return (
<ChoiceButton
key={card.code}
label={card.label}
meta={checked ? "Active" : "Touchez pour activer"}
active={checked}
onToggle={() => {
setEnabledLegacyCodes((current) => {
if (current.includes(card.code)) {
return current.filter((value) => value !== card.code);
}
return [...current, card.code];
});
}}
/>
);
})}
</div>
</article>
) : null}
{mode === "CREATE" && stepIndex === 3 ? (
<article className={`${predictionStyles.tile} ${styles.wizardTile}`}>
<header className={predictionStyles.tileHeader}>
<div>
<h3>Qui participe des le depart ?</h3>
<p>Ajoutez les proches a pre-affecter maintenant. Vous pourrez toujours les gerer plus tard.</p>
</div>
<span className={predictionStyles.badge}>Participants</span>
</header>
<div className={`${predictionStyles.tileBody} ${styles.wizardTileBody} ${styles.selectionGrid}`}>
{participantUsers.map((user) => {
const checked = selectedParticipantIds.includes(user.id);
return (
<ChoiceButton
key={user.id}
label={user.displayName ?? user.username}
meta={checked ? "Selectionne" : "Disponible"}
active={checked}
onToggle={() => {
setSelectedParticipantIds((current) => {
if (current.includes(user.id)) {
return current.filter((value) => value !== user.id);
}
return [...current, user.id];
});
}}
/>
);
})}
{participantUsers.length === 0 ? <div className={styles.emptyState}>Aucun participant family disponible pour le moment.</div> : null}
</div>
</article>
) : null}
{mode === "CREATE" && stepIndex === 4 ? (
<article className={`${predictionStyles.tile} ${styles.wizardTile}`}>
<header className={predictionStyles.tileHeader}>
<div>
<h3>Verifier avant creation</h3>
<p>Un dernier coup d&apos;oeil avant d&apos;ouvrir le concours.</p>
</div>
<span className={predictionStyles.badge}>Recap</span>
</header>
<div className={styles.summaryList}>
<p><strong>Nom du projet</strong><span>{generatedProjectName}</span></p>
<p><strong>Description</strong><span>{description || "-"}</span></p>
<p><strong>Bebes</strong><span>{babyCount}</span></p>
<p><strong>Statut</strong><span>{statusLabel(status)}</span></p>
<p><strong>Surnoms bebe</strong><span>{babyLabels.slice(0, babyCount).map((label, index) => normalizeBabyNickname(label, index)).join(" / ")}</span></p>
<p>
<strong>Cartes actives</strong><span>{legacyCards
.filter((card) => enabledLegacyCodes.includes(card.code))
.map((card) => card.label)
.join(", ")}
</span>
</p>
<p><strong>Participants pre-affectes</strong><span>{selectedParticipantIds.length}</span></p>
</div>
</article>
) : null}
{mode === "CREATE" && stepIndex === 5 && createdProject ? (
<article className={`${predictionStyles.tile} ${styles.wizardTile}`}>
<header className={predictionStyles.tileHeader}>
<div>
<h3>Configurer les indices de grossesse ?</h3>
<p>Vous pouvez renseigner maintenant les informations parents et echo, ou le faire plus tard depuis l&apos;admin.</p>
</div>
<span className={predictionStyles.badge}>Optionnel</span>
</header>
<div className={`${predictionStyles.tileBody} ${styles.wizardTileBody} ${styles.actionStack}`}>
<button
type="button"
className="btn btn-primary"
onClick={() => router.push(`/admin/projects/${createdProject.id}/indices`)}
>
Configurer les indices maintenant
</button>
<button
type="button"
className="btn btn-soft"
onClick={() => setStepIndex(6)}
>
Passer pour l&apos;instant
</button>
</div>
</article>
) : mode === "CREATE" && stepIndex === 6 && createdProject ? (
<article className={`${predictionStyles.tile} ${styles.wizardTile}`}>
<header className={predictionStyles.tileHeader}>
<div>
<h3>Photo du projet</h3>
<p>Ajoutez une photo ou un avatar pour illustrer le concours dans la banniere.</p>
</div>
<span className={predictionStyles.badge}>Optionnel</span>
</header>
<div className={`${predictionStyles.tileBody} ${styles.wizardTileBody} ${styles.photoStepBody}`}>
{createdProject.projectImageUrl ? (
<div className={styles.photoPreviewFilled} style={{ background: createdProject.projectBgColor ?? "var(--panel-warm)" }}>
<img src={`${getApiUrl()}${createdProject.projectImageUrl}`} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</div>
) : (
<div className={styles.photoPreviewEmpty}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
)}
<button type="button" className="btn btn-soft" onClick={() => setPhotoModalOpen(true)}>
{createdProject.projectImageUrl ? "Modifier la photo" : "Ajouter une photo"}
</button>
</div>
</article>
) : null}
{mode === "CLONE" && stepIndex === 0 ? (
<article className={`${predictionStyles.tile} ${styles.wizardTile}`}>
<header className={predictionStyles.tileHeader}>
<div>
<h3>Cloner un projet existant</h3>
<p>Selectionnez une base existante puis ajustez le nom et la description si besoin.</p>
</div>
<span className={predictionStyles.badge}>Clone</span>
</header>
<div className={`${predictionStyles.tileBody} ${styles.wizardTileBody} ${styles.formGrid}`}>
<div className="field">
<label htmlFor="cloneSource">Projet source</label>
<select
id="cloneSource"
value={cloneSourceProjectId}
onChange={(event) => {
const sourceId = event.target.value;
setCloneSourceProjectId(sourceId);
const source = projects.find((project) => project.id === sourceId);
if (source) {
setCloneName(`${source.name} (copie)`);
}
}}
>
<option value="">Choisir un projet</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="cloneName">Nom du clone</label>
<input
id="cloneName"
value={cloneName}
onChange={(event) => setCloneName(event.target.value)}
/>
</div>
<div className="field">
<label htmlFor="cloneDescription">Description du clone</label>
<input
id="cloneDescription"
value={cloneDescription}
onChange={(event) => setCloneDescription(event.target.value)}
placeholder="Optionnel"
/>
</div>
<div className={styles.fullSpan}>
<ChoiceButton
label="Copier aussi les participants"
meta={cloneIncludeParticipants ? "Active" : "Laisser desactive"}
active={cloneIncludeParticipants}
onToggle={() => setCloneIncludeParticipants((current) => !current)}
/>
</div>
</div>
</article>
) : null}
{mode === "CLONE" && stepIndex === 1 ? (
<article className={`${predictionStyles.tile} ${styles.wizardTile}`}>
<header className={predictionStyles.tileHeader}>
<div>
<h3>Verifier le clone</h3>
<p>Confirmez les informations avant de dupliquer ce projet.</p>
</div>
<span className={predictionStyles.badge}>Recap</span>
</header>
<div className={styles.summaryList}>
<p>
<strong>Source</strong><span>{projects.find((project) => project.id === cloneSourceProjectId)?.name ?? "-"}</span>
</p>
<p><strong>Nouveau nom</strong><span>{cloneName || "(copie auto)"}</span></p>
<p><strong>Description</strong><span>{cloneDescription || "(identique source)"}</span></p>
<p>
<strong>Participants</strong><span>{cloneIncludeParticipants ? "Copie activee" : "Non copies"}</span>
</p>
</div>
</article>
) : null}
<div className={styles.wizardActions}>
<button
type="button"
className="btn btn-soft"
onClick={() => setStepIndex((current) => Math.max(0, current - 1))}
disabled={loading || stepIndex === 0 || stepIndex === 5 || stepIndex === 6}
>
Retour
</button>
{stepIndex === 5 ? null
: stepIndex === 6 && createdProject ? (
<button
type="button"
className="btn btn-primary"
onClick={() => router.push(`/admin?project=${createdProject.id}`)}
>
Terminer
</button>
) : mode === "CREATE" && stepIndex === 4 ? (
<button
type="button"
className="btn btn-primary"
onClick={() => { void onSubmitWizard(); }}
disabled={loading || !canGoNext}
>
{loading ? "Creation..." : "Creer le projet"}
</button>
) : stepIndex < totalSteps - 1 ? (
<button
type="button"
className="btn btn-primary"
onClick={() => setStepIndex((current) => Math.min(totalSteps - 1, current + 1))}
disabled={loading || !canGoNext}
>
Continuer
</button>
) : (
<button
type="button"
className="btn btn-primary"
onClick={() => { void onSubmitWizard(); }}
disabled={loading || !canGoNext}
>
{loading ? "Clonage..." : "Cloner le projet"}
</button>
)}
</div>
{photoModalOpen && createdProject ? (
<ProjectPhotoModal
project={createdProject}
onSuccess={(updated) => setCreatedProject(updated)}
onClose={() => setPhotoModalOpen(false)}
/>
) : null}
{message ? <p className={styles.message}>{message}</p> : null}
</section>
</main>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+144
View File
@@ -0,0 +1,144 @@
:root {
/* Palette baby/pastel inspirée de la maquette */
--bg-0: #fdf6ec;
--bg-1: #fef9f0;
--ink-0: #2d2d2d;
--ink-1: #4a4a4a;
--accent: #e8596e;
--accent-strong: #d14055;
--accent-soft: #fce4e8;
--teal: #8edae0;
--teal-strong: #5cc5cd;
--panel: #ffffff;
--panel-warm: #fef6e8;
--border: #f0e6d6;
--border-light: #f5efe5;
--muted: #9a9a9a;
--star-yellow: #ffd966;
--success-green: #7bc67e;
--shadow-soft: rgba(0, 0, 0, 0.06);
--shadow-card: 0 4px 20px rgba(0, 0, 0, 0.08);
--radius-card: 20px;
--radius-btn: 25px;
--radius-input: 12px;
}
html {
height: 100%;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
min-height: 100%;
display: flex;
flex-direction: column;
color: var(--ink-0);
background: var(--bg-0);
font-family: var(--font-space-grotesk), system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select {
font: inherit;
}
.app-shell {
width: min(720px, 100%);
margin: 0 auto;
padding: 0;
}
.panel {
background: var(--panel);
border: 1px solid var(--border-light);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
}
.btn {
border: none;
border-radius: var(--radius-btn);
padding: 0.75rem 1.5rem;
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
letter-spacing: 0.01em;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background: var(--accent);
color: #fff;
box-shadow: 0 4px 12px rgba(232, 89, 110, 0.3);
}
.btn-primary:hover {
background: var(--accent-strong);
}
.btn-soft {
background: var(--panel-warm);
color: var(--ink-1);
border: 1px solid var(--border);
}
.field {
display: grid;
gap: 0.35rem;
}
.field label {
font-size: 0.85rem;
font-weight: 700;
color: var(--ink-1);
}
.field input,
.field select {
border: 1px solid var(--border);
border-radius: var(--radius-input);
background: #fafafa;
padding: 0.7rem 0.85rem;
font-size: 0.95rem;
}
.field input:focus,
.field select:focus {
outline: 2px solid var(--teal);
outline-offset: 1px;
background: #fff;
}
.mono {
font-family: var(--font-ibm-plex-mono), monospace;
}
@media (max-width: 760px) {
.app-shell {
width: 100%;
margin: 0 auto;
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata, Viewport } from "next";
import { Nunito, IBM_Plex_Mono, Baloo_Bhaijaan_2 } from "next/font/google";
import "./globals.css";
const nunito = Nunito({
variable: "--font-space-grotesk",
subsets: ["latin"],
weight: ["400", "600", "700", "800", "900"],
});
const ibmPlexMono = IBM_Plex_Mono({
variable: "--font-ibm-plex-mono",
weight: ["400", "500"],
subsets: ["latin"],
});
const balooBhaijaan = Baloo_Bhaijaan_2({
variable: "--font-baloo",
subsets: ["latin"],
weight: ["400", "500", "700"],
});
export const metadata: Metadata = {
title: "Le Juste Poids",
description: "Pronostiquez l'arrivée de Bubulle !",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="fr"
className={`${nunito.variable} ${ibmPlexMono.variable} ${balooBhaijaan.variable}`}
>
<body>{children}</body>
</html>
);
}
+405
View File
@@ -0,0 +1,405 @@
.page {
width: min(940px, 100%);
min-height: 100vh;
padding: 1rem;
display: grid;
place-items: center;
background:
linear-gradient(180deg, rgba(232, 246, 248, 0.82) 0%, rgba(253, 246, 236, 0) 52%),
var(--bg-0);
}
.loginShell {
width: 100%;
min-height: min(760px, calc(100vh - 2rem));
display: grid;
grid-template-columns: minmax(0, 1.12fr) minmax(320px, 0.88fr);
overflow: hidden;
border: 1px solid rgba(240, 230, 214, 0.92);
border-radius: 28px;
background: var(--panel);
box-shadow: 0 20px 54px rgba(171, 115, 102, 0.18);
}
.visualPanel {
position: relative;
min-height: 640px;
padding: 2rem clamp(1.4rem, 4vw, 3.25rem) 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.1rem;
overflow: hidden;
background:
linear-gradient(180deg, #e8f8fb 0%, #fff1e7 72%, #ffb5aa 100%);
}
.visualPanel::after {
content: "";
position: absolute;
left: -8%;
right: -8%;
bottom: -74px;
height: 180px;
border-radius: 50% 50% 0 0;
background: #ffb5aa;
opacity: 0.9;
}
.cornerDeco {
position: absolute;
z-index: 1;
width: clamp(92px, 13vw, 148px);
height: auto;
pointer-events: none;
}
.cornerDecoLeft {
top: 0.9rem;
left: 0.95rem;
}
.cornerDecoRight {
top: 0.85rem;
right: 0.75rem;
}
.cloudBadge {
position: relative;
z-index: 2;
width: min(210px, 48vw);
aspect-ratio: 1;
display: grid;
place-items: center;
margin-top: 0.4rem;
border: 7px solid rgba(255, 255, 255, 0.82);
border-radius: 999px;
background: #e4f4fb;
box-shadow: 0 16px 34px rgba(85, 141, 153, 0.22);
}
.cloudBaby {
width: 90%;
height: auto;
object-fit: contain;
transform: translateY(2px);
}
.visualCopy {
position: relative;
z-index: 2;
max-width: 25rem;
display: grid;
gap: 0.45rem;
text-align: center;
}
.visualCopy span,
.loginIntro span {
color: var(--accent-strong);
font-size: 0.86rem;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
.visualCopy h1 {
color: var(--ink-0);
font-family: var(--font-baloo), var(--font-space-grotesk), system-ui, sans-serif;
font-size: clamp(2.45rem, 7vw, 4.4rem);
font-weight: 800;
line-height: 0.95;
}
.visualCopy p {
color: var(--ink-1);
font-size: clamp(1rem, 2.1vw, 1.15rem);
font-weight: 700;
line-height: 1.35;
}
.balanceStage {
position: relative;
z-index: 2;
width: min(420px, 92%);
margin-top: auto;
display: grid;
place-items: end center;
}
.balanceStage::before {
content: "";
position: absolute;
width: 86%;
height: 42%;
left: 7%;
bottom: 8%;
border-radius: 48% 52% 44% 56%;
background: rgba(255, 255, 255, 0.68);
box-shadow: 0 18px 36px rgba(171, 115, 102, 0.16);
}
.balanceBaby {
position: relative;
z-index: 1;
width: 100%;
height: auto;
object-fit: contain;
filter: drop-shadow(0 18px 20px rgba(137, 91, 86, 0.16));
}
.loginPanel {
padding: clamp(2rem, 5vw, 3.4rem);
display: flex;
flex-direction: column;
justify-content: center;
gap: 1.35rem;
background: linear-gradient(180deg, #ffffff 0%, #fffaf4 100%);
}
.loginIntro {
display: grid;
gap: 0.35rem;
}
.loginIntro h2 {
color: var(--ink-0);
font-size: clamp(1.85rem, 4vw, 2.55rem);
font-weight: 900;
line-height: 1.02;
}
.form {
display: flex;
flex-direction: column;
gap: 0.9rem;
width: 100%;
}
.form :global(.field) {
gap: 0.45rem;
}
.form :global(.field label) {
color: var(--ink-0);
font-size: 0.9rem;
}
.form :global(.field input) {
min-height: 3rem;
border: 2px solid #f0e5dc;
border-radius: 16px;
background: #fffdfa;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.form :global(.field input:focus) {
border-color: var(--teal-strong);
outline: 3px solid rgba(142, 218, 224, 0.34);
outline-offset: 1px;
}
.submitButton {
width: 100%;
min-height: 3.15rem;
margin-top: 0.25rem;
border-radius: 999px;
letter-spacing: 0;
box-shadow: 0 12px 22px rgba(232, 89, 110, 0.26);
}
.submitButton:disabled {
cursor: not-allowed;
opacity: 0.58;
box-shadow: none;
}
.message {
padding: 0.75rem 0.9rem;
border: 1px solid #ffc9c2;
border-radius: 16px;
background: #fff0ed;
color: #8e2f2f;
font-size: 0.9rem;
font-weight: 700;
line-height: 1.35;
text-align: center;
}
@media (max-width: 780px) {
.page {
min-height: 100svh;
padding: 0;
place-items: stretch;
}
.loginShell {
min-height: 100svh;
grid-template-columns: 1fr;
grid-template-rows: clamp(220px, 38svh, 310px) minmax(0, 1fr);
border: none;
border-radius: 0;
box-shadow: none;
}
.visualPanel {
min-height: 0;
height: 100%;
padding: 0.85rem 1rem 0;
display: grid;
grid-template-columns: minmax(88px, 0.42fr) minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
align-items: center;
column-gap: 0.75rem;
row-gap: 0;
}
.visualPanel::after {
bottom: -100px;
height: 162px;
}
.cornerDeco {
width: clamp(56px, 17vw, 82px);
}
.cornerDecoLeft {
top: 0.45rem;
left: 0.45rem;
}
.cornerDecoRight {
top: 0.45rem;
right: 0.35rem;
}
.cloudBadge {
grid-column: 1;
grid-row: 1;
justify-self: center;
width: clamp(92px, 29vw, 126px);
margin-top: 0;
border-width: 5px;
}
.visualCopy {
grid-column: 2;
grid-row: 1;
justify-self: stretch;
gap: 0.16rem;
text-align: left;
}
.visualCopy span {
font-size: 0.72rem;
}
.visualCopy h1 {
font-size: clamp(2.05rem, 10vw, 2.85rem);
line-height: 0.92;
}
.visualCopy p {
max-width: 24ch;
font-size: 0.85rem;
line-height: 1.24;
}
.balanceStage {
grid-column: 1 / -1;
grid-row: 2;
place-self: end center;
width: clamp(150px, 50vw, 230px);
margin: -0.4rem auto -1.15rem;
}
.balanceStage::before {
bottom: 7%;
}
.loginPanel {
min-height: 0;
padding: 1.05rem 1.2rem max(1.15rem, env(safe-area-inset-bottom));
justify-content: flex-start;
gap: 0.8rem;
}
.loginIntro {
gap: 0.18rem;
}
.loginIntro span {
font-size: 0.74rem;
}
.loginIntro h2 {
font-size: clamp(1.85rem, 9vw, 2.35rem);
}
.form {
gap: 0.68rem;
}
.form :global(.field) {
gap: 0.34rem;
}
.form :global(.field input) {
min-height: 2.75rem;
padding: 0.62rem 0.8rem;
border-radius: 15px;
}
.submitButton {
min-height: 2.9rem;
margin-top: 0.1rem;
}
}
@media (max-width: 420px) {
.visualPanel {
grid-template-columns: minmax(82px, 0.38fr) minmax(0, 1fr);
column-gap: 0.6rem;
}
.cornerDeco {
width: 58px;
}
.visualCopy h1 {
font-size: clamp(1.9rem, 10vw, 2.28rem);
}
.visualCopy p {
font-size: 0.8rem;
}
.balanceStage {
width: clamp(145px, 52vw, 196px);
margin-bottom: -0.95rem;
}
}
@media (max-width: 420px) and (max-height: 700px) {
.loginShell {
grid-template-rows: 208px minmax(0, 1fr);
}
.visualCopy p {
max-width: 22ch;
}
.balanceStage {
width: 142px;
margin-bottom: -1.25rem;
}
.loginPanel {
padding-top: 0.85rem;
gap: 0.65rem;
}
.form {
gap: 0.55rem;
}
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { getApiUrl, loadSession, saveSession, type SessionPayload } from "@/lib/auth";
import styles from "./page.module.css";
export default function Home() {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const apiUrl = getApiUrl();
useEffect(() => {
const session = loadSession();
if (!session) {
return;
}
if (session.user.role === "ADMIN") {
router.replace("/admin");
return;
}
router.replace("/predictions");
}, [router]);
const canSubmit = useMemo(
() => username.trim().length > 2 && password.length >= 8,
[password.length, username],
);
const submitLogin = async () => {
setLoading(true);
setMessage("");
try {
const response = await fetch(`${apiUrl}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const payload = (await response.json()) as
| SessionPayload
| { message?: string };
if (
!response.ok ||
!("accessToken" in payload) ||
!("refreshToken" in payload)
) {
setMessage((payload as { message?: string }).message ?? "Identifiants invalides");
return;
}
saveSession(payload);
if (payload.user.role === "ADMIN") {
router.push("/admin");
return;
}
router.push("/predictions");
} catch {
setMessage("Erreur reseau, impossible de contacter l'API");
} finally {
setLoading(false);
}
};
return (
<main className={`app-shell ${styles.page}`}>
<section className={styles.loginShell} aria-labelledby="login-title">
<div className={styles.visualPanel}>
<Image
src="/depotARanger/baniere-deco-gauche-haut.png"
alt=""
width={132}
height={132}
className={`${styles.cornerDeco} ${styles.cornerDecoLeft}`}
aria-hidden="true"
/>
<Image
src="/depotARanger/baniere-deco-droite-haut.png"
alt=""
width={150}
height={150}
className={`${styles.cornerDeco} ${styles.cornerDecoRight}`}
aria-hidden="true"
/>
<div className={styles.cloudBadge}>
<Image
src="/depotARanger/bebe-sur-un-nuage.png"
alt="Bubulle sur un nuage"
width={220}
height={220}
className={styles.cloudBaby}
priority
/>
</div>
<div className={styles.visualCopy}>
<span>Bienvenue !</span>
<h1 id="login-title">Le Juste Poids</h1>
<p>Amusez-vous à pronostiquer l&apos;arrivée de Bubulle !</p>
</div>
<div className={styles.balanceStage}>
<Image
src="/depotARanger/bebe-sur-une-balance-girl.png"
alt="Bubulle sur la balance"
width={460}
height={420}
className={styles.balanceBaby}
priority
/>
</div>
</div>
<section className={styles.loginPanel} aria-label="Connexion">
<div className={styles.loginIntro}>
<span>Accès pronostics</span>
<h2>Connexion</h2>
</div>
<form
onSubmit={(event) => {
event.preventDefault();
void submitLogin();
}}
className={styles.form}
>
<div className="field">
<label htmlFor="username">Nom d&apos;utilisateur</label>
<input
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="Votre nom d'utilisateur"
autoComplete="username"
/>
</div>
<div className="field">
<label htmlFor="password">Mot de passe</label>
<input
id="password"
type="password"
value={password}
placeholder="Votre mot de passe"
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
/>
</div>
<button className={`btn btn-primary ${styles.submitButton}`} disabled={!canSubmit || loading}>
{loading ? "Connexion..." : "Connexion"}
</button>
</form>
{message ? <p className={styles.message}>{message}</p> : null}
</section>
</section>
</main>
);
}
@@ -0,0 +1,531 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { MainTabBar } from "@/features/predictions/components/main-tab-bar";
import styles from "@/features/predictions/styles/predictions.module.css";
import historyStyles from "@/features/predictions/styles/history.module.css";
import { getApiUrl, PROJECT_CHANGED_EVENT } from "@/lib/auth";
import { getPredictionActivity } from "@/lib/predictions-client";
import type { PredictionActivity } from "@/types/predictions";
/* ---- helpers ---- */
function formatDate(value: string) {
const date = new Date(value);
return new Intl.DateTimeFormat("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function formatRelativeDate(value: string) {
const date = new Date(value);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMinutes < 1) return "À l'instant";
if (diffMinutes < 60) return `Il y a ${diffMinutes} min`;
if (diffHours < 24) return `Il y a ${diffHours}h`;
if (diffDays === 1) return "Hier";
if (diffDays < 7) return `Il y a ${diffDays} jours`;
return formatDate(value);
}
function getDisplayName(user: NonNullable<PredictionActivity["user"]>) {
return user.displayName ?? user.username;
}
function resolveProfileSrc(apiUrl: string, url: string | null | undefined): string | null {
if (!url) return null;
if (url.startsWith("http")) return url;
return `${apiUrl}${url}`;
}
/**
* Parse les valeurs encodées dans le message du serveur.
* Format : "Nom a change CardTitle: Field1: val1 | Field2: val2"
* Format création : "Nom a pronostique CardTitle" (pas de valeurs)
*/
function parseMessageFieldValues(message: string): Array<{ label: string; rawValue: string }> {
// Cherche le séparateur entre le titre de la carte et les valeurs de champs
// ex: "...Sexe du bebe: Sexe: fille" → après le premier ": " on a "Sexe: fille"
const colonIdx = message.indexOf(": ");
if (colonIdx === -1) return [];
const valuesStr = message.slice(colonIdx + 2);
// Évite de parser des messages sans valeurs structurées (pas de ": " interne)
if (!valuesStr.includes(": ")) return [];
return valuesStr
.split(" | ")
.map((part) => {
const idx = part.indexOf(": ");
if (idx === -1) return null;
return { label: part.slice(0, idx), rawValue: part.slice(idx + 2) };
})
.filter((v): v is { label: string; rawValue: string } => v !== null && v.rawValue !== "(vide)" && v.rawValue.trim() !== "");
}
function isSexField(label: string): boolean {
const l = label.toLowerCase();
return l.includes("sexe") || l.includes("sex") || l.includes("genre");
}
function isWeightField(label: string): boolean {
const l = label.toLowerCase();
return l.includes("poid") || l.includes("weight");
}
function isNamesField(label: string): boolean {
const l = label.toLowerCase();
return l.includes("prenom") || l.includes("name");
}
function getMeasureUnit(label: string): string | null {
const l = label.toLowerCase();
if (l.includes("taille") || l.includes("height") || l.includes("périm") || l.includes("cranien") || l.includes("crânien")) return "cm";
return null;
}
/* ---- Composant : valeur structurée depuis message parsé ---- */
function ParsedValueDisplay({ label, rawValue }: { label: string; rawValue: string }) {
if (isWeightField(label)) {
const num = parseFloat(rawValue);
if (!isNaN(num)) {
const kg = num >= 100 ? num / 1000 : num;
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<span className={styles.recapWeightBadge}>{kg.toFixed(3)}{"\u00A0"}kg</span>
</div>
);
}
}
if (isSexField(label)) {
const isGarcon = rawValue.toLowerCase().includes("gar") || rawValue.toLowerCase() === "garcon" || rawValue.toLowerCase() === "garçon";
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<div
className={`${styles.recapSexAnswer} ${isGarcon ? styles.recapSexAnswerGarcon : styles.recapSexAnswerFille}`}
style={{ padding: "0.3rem 0.65rem", display: "inline-flex" }}
>
<Image
src={isGarcon ? "/images/pictos/choix-garcon.png" : "/images/pictos/choix-fille.png"}
alt=""
width={28}
height={28}
className={styles.recapSexIcon}
/>
<span className={styles.recapSexLabel} style={{ fontSize: "0.95rem" }}>{rawValue}</span>
</div>
</div>
);
}
if (isNamesField(label)) {
const names = rawValue.split(/[,;]+/).map((n) => n.trim()).filter(Boolean);
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<div className={styles.recapParticipantNamesList}>
{names.map((name) => (
<span key={name} className={styles.recapParticipantNameChip}>{name}</span>
))}
</div>
</div>
);
}
const num = parseFloat(rawValue);
if (!isNaN(num) && rawValue.trim() !== "") {
const unit = getMeasureUnit(label);
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<span className={historyStyles.valuePill}>{rawValue}{unit ? `\u00A0${unit}` : ""}</span>
</div>
);
}
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<span className={historyStyles.valuePill}>{rawValue}</span>
</div>
);
}
/* ---- Composant : valeur structurée depuis entry.values (fallback) ---- */
type ActivityEntryValue = NonNullable<PredictionActivity["entry"]>["values"][number];
function EntryValueDisplay({ value }: { value: ActivityEntryValue }) {
const label = value.field.label;
if (isWeightField(label) && value.valueNumber != null) {
const kg = value.valueNumber >= 100 ? value.valueNumber / 1000 : value.valueNumber;
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<span className={styles.recapWeightBadge}>{kg.toFixed(3)} kg</span>
</div>
);
}
if (isSexField(label) && value.valueText) {
const isGarcon = value.valueText.toLowerCase().includes("gar");
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<div className={`${styles.recapSexAnswer} ${isGarcon ? styles.recapSexAnswerGarcon : styles.recapSexAnswerFille}`} style={{ padding: "0.3rem 0.65rem", display: "inline-flex" }}>
<Image src={isGarcon ? "/images/pictos/choix-garcon.png" : "/images/pictos/choix-fille.png"} alt="" width={28} height={28} className={styles.recapSexIcon} />
<span className={styles.recapSexLabel} style={{ fontSize: "0.95rem" }}>{value.valueText}</span>
</div>
</div>
);
}
if (isNamesField(label) && value.valueText) {
const names = value.valueText.split(/[,;]+/).map((n) => n.trim()).filter(Boolean);
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<div className={styles.recapParticipantNamesList}>
{names.map((name) => <span key={name} className={styles.recapParticipantNameChip}>{name}</span>)}
</div>
</div>
);
}
if (value.valueNumber != null) {
const unit = getMeasureUnit(label);
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<span className={historyStyles.valuePill}>{value.valueNumber}{unit ? `\u00A0${unit}` : ""}</span>
</div>
);
}
if (value.valueText) {
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<span className={historyStyles.valuePill}>{value.valueText}</span>
</div>
);
}
return null;
}
type ActivityGlyphKind =
| "prediction"
| "update"
| "score"
| "validated"
| "lock"
| "unlock"
| "target"
| "card"
| "trash"
| "log";
function getActivityGlyphKind(type: string): ActivityGlyphKind {
switch (type) {
case "PREDICTION_CREATED": return "prediction";
case "PREDICTION_UPDATED": return "update";
case "SCORE_SUGGESTED": return "score";
case "SCORE_VALIDATED": return "validated";
case "GAME_CLOSED": return "lock";
case "GAME_OPENED": return "unlock";
case "OUTCOME_SET": return "target";
case "CARD_CREATED":
case "CARD_UPDATED": return "card";
case "CARD_DELETED": return "trash";
default: return "log";
}
}
function ActivityGlyph({ kind }: Readonly<{ kind: ActivityGlyphKind }>) {
const commonProps = {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.9,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
viewBox: "0 0 24 24",
ariaHidden: true,
};
switch (kind) {
case "prediction":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<path d="M4 20l4.3-1.1L18.6 8.6a2.2 2.2 0 0 0-3.1-3.1L5.2 15.8 4 20Z" />
<path d="m13.8 7.2 3 3" />
</svg>
);
case "update":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<path d="M19 8a7 7 0 0 0-11.8-2L5 8" />
<path d="M5 4v4h4" />
<path d="M5 16a7 7 0 0 0 11.8 2L19 16" />
<path d="M19 20v-4h-4" />
</svg>
);
case "score":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<path d="m12 3 2.5 5.1 5.6.8-4 3.9.9 5.5L12 15.8 7 18.3l.9-5.5-4-3.9 5.6-.8Z" />
</svg>
);
case "validated":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<circle cx="12" cy="10" r="5.5" />
<path d="m9.8 10.2 1.5 1.6 3-3.2" />
<path d="M10.2 15.2 8.8 20l3.2-1.9L15.2 20l-1.4-4.8" />
</svg>
);
case "lock":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<rect x="5" y="11" width="14" height="9" rx="2.5" />
<path d="M8 11V8.5a4 4 0 1 1 8 0V11" />
</svg>
);
case "unlock":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<rect x="5" y="11" width="14" height="9" rx="2.5" />
<path d="M16 11V8.5a4 4 0 0 0-7.7-1.4" />
</svg>
);
case "target":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<circle cx="12" cy="12" r="7" />
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3" />
<path d="M22 12h-3" />
<path d="M12 22v-3" />
<path d="M2 12h3" />
</svg>
);
case "card":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<rect x="4" y="6" width="12" height="14" rx="2.5" />
<path d="M8 10h4" />
<path d="M8 14h6" />
<path d="M16 9h2.5a1.5 1.5 0 0 1 1.5 1.5V17" />
</svg>
);
case "trash":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<path d="M4 7h16" />
<path d="M9 4h6" />
<path d="m6 7 1 12h10l1-12" />
<path d="M10 11v5" />
<path d="M14 11v5" />
</svg>
);
default:
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<path d="M7 7h10" />
<path d="M7 12h10" />
<path d="M7 17h6" />
</svg>
);
}
}
function getActivityBadgeClass(type: string): string {
if (type === "GAME_CLOSED" || type === "GAME_OPENED") return historyStyles.badgeSystem;
if (type === "SCORE_VALIDATED") return historyStyles.badgeSuccess;
if (type === "SCORE_SUGGESTED") return historyStyles.badgeWarning;
if (type === "PREDICTION_CREATED" || type === "PREDICTION_UPDATED") return historyStyles.badgePrimary;
return historyStyles.badgeMuted;
}
function UserAvatar({ user, apiUrl }: { user: NonNullable<PredictionActivity["user"]>; apiUrl: string }) {
const name = getDisplayName(user);
const initials = name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("") || "?";
const imageSrc = resolveProfileSrc(apiUrl, user.profileImageUrl);
return (
<div
className={`${styles.recapAvatar} ${styles.recapAvatarSm}`}
style={user.profileBgColor ? { background: user.profileBgColor } : undefined}
aria-hidden="true"
>
{imageSrc ? <img src={imageSrc} alt="" /> : <span>{initials}</span>}
</div>
);
}
export default function PredictionsHistoryPage() {
const apiUrl = getApiUrl();
const [activity, setActivity] = useState<PredictionActivity[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState("");
const [projectRefreshKey, setProjectRefreshKey] = useState(0);
useEffect(() => {
const onProjectChanged = () => {
setProjectRefreshKey((current) => current + 1);
};
window.addEventListener(PROJECT_CHANGED_EVENT, onProjectChanged);
return () => window.removeEventListener(PROJECT_CHANGED_EVENT, onProjectChanged);
}, []);
useEffect(() => {
setLoading(true);
setMessage("");
getPredictionActivity()
.then((payload) => setActivity(payload))
.catch((error) => setMessage(error instanceof Error ? error.message : "Impossible de charger l'historique"))
.finally(() => setLoading(false));
}, [projectRefreshKey]);
return (
<>
<section className={`${styles.recapHero} ${styles.historyHero}`}>
<div className={styles.recapHeroContent}>
<span className={styles.recapHeroEyebrow}>Activité</span>
<h1>Historique des actions</h1>
<p>Toutes les modifications du projet, en temps réel.</p>
<div className={styles.recapHeroStats}>
<div className={styles.recapHeroStatChip}>
<span className={`${styles.recapHeroStatValue} ${historyStyles.heroStatIcon}`}>
<ActivityGlyph kind="prediction" />
</span>
<span className={styles.recapHeroStatLabel}>Pronostics</span>
</div>
<div className={styles.recapHeroStatChip}>
<span className={`${styles.recapHeroStatValue} ${historyStyles.heroStatIcon}`}>
<ActivityGlyph kind="update" />
</span>
<span className={styles.recapHeroStatLabel}>Modifs</span>
</div>
{/* <div className={styles.recapHeroStatChip}>
<span className={styles.recapHeroStatValue}>⭐</span>
<span className={styles.recapHeroStatLabel}>Scores</span>
</div> */}
</div>
</div>
<div className={styles.historyHeroIllustration} aria-hidden="true">
<span className={styles.historyHeroLine} />
<span className={styles.historyHeroDot1} />
<span className={styles.historyHeroDot2} />
<span className={styles.historyHeroDot3} />
</div>
</section>
<MainTabBar />
{!loading && !message && activity.length > 0 && (
<div className={styles.recapSectionHeading} style={{ padding: "0 0.5rem" }}>
<p>{activity.length} événement{activity.length !== 1 ? "s" : ""} enregistré{activity.length !== 1 ? "s" : ""}.</p>
</div>
)}
{loading && (
<div className={styles.loadingScreen}>
<p>Chargement de l'historique…</p>
</div>
)}
{!loading && message && (
<p className={historyStyles.errorMsg}>{message}</p>
)}
{!loading && !message && activity.length === 0 && (
<p className={styles.empty} style={{ margin: "0 0.5rem" }}>Aucun événement pour le moment.</p>
)}
{!loading && !message && activity.length > 0 && (
<div className={historyStyles.timelineList}>
{activity.map((item) => {
// Priorité : parser les valeurs depuis le message (format serveur fiable)
const parsedValues = parseMessageFieldValues(item.message);
// Fallback : entry.values (état courant de l'entrée, peut différer)
const fallbackValues = parsedValues.length === 0 ? (item.entry?.values ?? []) : [];
const hasDisplayValues = parsedValues.length > 0 || fallbackValues.length > 0;
// Extraire le nom de l'auteur depuis le message si item.user est null
// Ex: "Valerie a change X: ..." → "Valerie"
const authorFromMessage = !item.user
? item.message.split(/\s+a\s+(change|pronostique|cree|fixe|suggere|valide)/i)?.[0] ?? null
: null;
const activityGlyphKind = getActivityGlyphKind(item.type);
return (
<article key={item.id} className={historyStyles.timelineCard}>
{/* Left: avatar ou icône système */}
<div className={historyStyles.timelineAvatarCol}>
{item.user ? (
<UserAvatar user={item.user} apiUrl={apiUrl} />
) : (
<div className={historyStyles.systemIcon} aria-hidden="true">
<span className={historyStyles.systemIconGlyph}>
<ActivityGlyph kind={activityGlyphKind} />
</span>
</div>
)}
<div className={historyStyles.timelineLine} aria-hidden="true" />
</div>
{/* Right: contenu */}
<div className={historyStyles.timelineContent}>
<div className={historyStyles.timelineHeader}>
<div className={historyStyles.timelineHeadLeft}>
{(item.user || authorFromMessage) && (
<strong className={historyStyles.userName}>
{item.user ? getDisplayName(item.user) : authorFromMessage}
</strong>
)}
<span className={`${historyStyles.activityLabel} ${getActivityBadgeClass(item.type)}`}>
<span className={historyStyles.activityLabelIcon} aria-hidden="true">
<ActivityGlyph kind={activityGlyphKind} />
</span>
<span className={historyStyles.activityLabelText}>{item.card?.title ?? item.message}</span>
</span>
</div>
<time className={historyStyles.timelineTime} dateTime={item.createdAt} title={formatDate(item.createdAt)}>
{formatRelativeDate(item.createdAt)}
</time>
</div>
{hasDisplayValues && (
<div className={historyStyles.valuesBlock}>
{parsedValues.map((v) => (
<ParsedValueDisplay key={v.label} label={v.label} rawValue={v.rawValue} />
))}
{fallbackValues.map((v) => (
<EntryValueDisplay key={v.fieldId} value={v} />
))}
</div>
)}
</div>
</article>
);
})}
</div>
)}
</>
);
}
@@ -0,0 +1,429 @@
.editBanner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
background: var(--accent-soft);
border: 1px solid var(--accent);
padding: 0.65rem 1rem;
}
.editBanner p {
font-size: 0.88rem;
color: var(--ink-1);
margin: 0;
}
.messagePanel {
padding: 1rem;
}
.section {
padding: 1rem 1.1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sectionHeading {
font-size: 1.05rem;
font-weight: 800;
color: var(--ink-0);
margin: 0;
}
/* ===== PARENT TABS ===== */
.parentTabs {
display: flex;
gap: 0.3rem;
padding: 0.25rem;
background: var(--panel-warm);
border: 1px solid var(--border-light);
border-radius: 999px;
}
.parentTab {
flex: 1;
padding: 0.5rem 0.85rem;
border: none;
border-radius: 999px;
background: transparent;
font-size: 0.9rem;
font-weight: 700;
cursor: pointer;
color: var(--ink-1);
transition: color 120ms, background 120ms, box-shadow 120ms;
}
.parentTabActive {
background: var(--accent);
color: #fff;
box-shadow: 0 4px 12px rgba(232, 89, 110, 0.3);
}
/* ===== CARD BLOCK ===== */
.cardBlock {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.statsGrid {
display: grid;
gap: 0.5rem;
}
.statRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: var(--panel-warm);
border: 1px solid var(--border-light);
border-radius: 10px;
}
.statLabel {
font-size: 0.85rem;
color: var(--ink-1);
font-weight: 600;
}
.statValue {
font-size: 0.95rem;
font-weight: 800;
color: var(--ink-0);
font-family: var(--font-mono, monospace);
}
.emptyHint {
font-size: 0.88rem;
color: var(--muted);
text-align: center;
padding: 0.75rem 0;
}
/* ===== PHOTO GALLERY ===== */
.photoGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
@media (min-width: 480px) {
.photoGrid {
grid-template-columns: repeat(5, 1fr);
}
}
.photoThumb {
aspect-ratio: 1;
border-radius: 10px;
overflow: hidden;
display: block;
background: var(--panel-warm);
border: 1px solid var(--border-light);
cursor: pointer;
padding: 0;
transition: transform 140ms ease, box-shadow 140ms ease;
}
.photoThumb:hover {
transform: translateY(-1px);
box-shadow: 0 8px 20px var(--shadow-soft);
}
.photoThumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* ===== LIGHTBOX ===== */
.lightboxOverlay {
position: fixed;
inset: 0;
z-index: 60;
background: rgb(15 16 20 / 78%);
display: grid;
place-items: center;
padding: 1rem;
}
.lightboxDialog {
width: min(980px, 100%);
max-height: calc(100vh - 2rem);
background: #fff;
border: 1px solid var(--border-light);
border-radius: 18px;
box-shadow: 0 18px 42px rgb(0 0 0 / 28%);
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.lightboxClose {
align-self: flex-end;
border: none;
background: var(--panel-warm);
color: var(--ink-0);
width: 2rem;
height: 2rem;
border-radius: 999px;
font-size: 1.35rem;
line-height: 1;
cursor: pointer;
}
.lightboxImageWrap {
position: relative;
min-height: 280px;
display: grid;
place-items: center;
background: #0f1115;
border-radius: 14px;
overflow: hidden;
touch-action: none;
cursor: grab;
}
.lightboxImageWrap:active {
cursor: grabbing;
}
.lightboxImage {
max-width: 100%;
max-height: min(70vh, 700px);
width: auto;
height: auto;
object-fit: contain;
display: block;
transform-origin: center center;
transition: transform 120ms ease;
user-select: none;
-webkit-user-drag: none;
}
.lightboxNav {
position: absolute;
top: 50%;
transform: translateY(-50%);
border: none;
width: 2.2rem;
height: 2.2rem;
border-radius: 999px;
background: rgb(0 0 0 / 42%);
color: #fff;
font-size: 1.55rem;
line-height: 1;
cursor: pointer;
z-index: 10;
}
.lightboxNavPrev {
left: 0.5rem;
}
.lightboxNavNext {
right: 0.5rem;
}
.lightboxFooter {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
font-size: 0.82rem;
font-weight: 700;
color: var(--ink-1);
}
.lightboxActions {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.lightboxZoomBtn {
width: 2rem;
height: 2rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--panel-warm);
color: var(--ink-0);
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
}
.lightboxZoomValue {
min-width: 3.1rem;
text-align: center;
font-size: 0.8rem;
color: var(--ink-1);
}
@media (max-width: 640px) {
.lightboxDialog {
padding: 0.55rem;
border-radius: 14px;
}
.lightboxImage {
max-height: 62vh;
}
.lightboxFooter {
flex-direction: column;
align-items: stretch;
}
.lightboxActions {
justify-content: space-between;
}
.lightboxNav {
width: 2rem;
height: 2rem;
font-size: 1.35rem;
}
}
/* ===== DPA BADGE ===== */
.dpaBadge {
display: flex;
align-items: center;
justify-content: space-between;
background: #e8f7ee;
border: 1px solid #a3d9b1;
border-radius: 12px;
padding: 0.6rem 0.9rem;
gap: 0.5rem;
flex-wrap: wrap;
}
.dpaLabel {
font-size: 0.85rem;
font-weight: 600;
color: var(--ink-1);
}
.dpaValue {
font-size: 0.95rem;
font-weight: 800;
color: #1a7a3c;
font-family: var(--font-mono, monospace);
}
/* ===== TRIMESTER TABS (mobile-first) ===== */
.trimesterTabsWrapper {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.trimesterTabs {
display: flex;
gap: 0.3rem;
padding: 0.25rem;
background: var(--panel-warm);
border: 1px solid var(--border-light);
border-radius: 999px;
overflow-x: auto;
scrollbar-width: none;
}
.trimesterTabs::-webkit-scrollbar {
display: none;
}
.trimesterTab {
flex: 1 0 auto;
min-width: max-content;
padding: 0.45rem 0.85rem;
border: none;
border-radius: 999px;
background: transparent;
color: var(--ink-1);
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
transition: background 120ms, color 120ms, box-shadow 120ms;
white-space: nowrap;
}
.trimesterTab:hover {
color: var(--ink-0);
}
.trimesterTabActive {
background: var(--panel);
color: var(--accent-strong);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.trimesterPanel {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.85rem 0.95rem;
background: var(--panel);
border: 1px solid var(--border-light);
border-radius: 14px;
}
.trimesterPanelTitle {
font-size: 0.95rem;
font-weight: 800;
color: var(--ink-0);
margin: 0;
}
/* ===== TRIMESTER ACCORDION (legacy, unused) ===== */
.trimesterBlock {
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.trimesterToggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.65rem 0.9rem;
background: var(--panel-warm);
border: none;
cursor: pointer;
font-size: 0.9rem;
font-weight: 700;
color: var(--ink-0);
}
.trimesterChevron {
font-size: 0.7rem;
color: var(--muted);
}
.trimesterContent {
padding: 0.75rem 0.9rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
background: var(--panel);
}
.triNote {
font-size: 0.88rem;
color: var(--ink-1);
line-height: 1.5;
background: var(--panel-warm);
border-left: 3px solid var(--accent);
padding: 0.5rem 0.75rem;
border-radius: 0 8px 8px 0;
}
@@ -0,0 +1,708 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type TouchEvent as ReactTouchEvent,
type WheelEvent as ReactWheelEvent,
} from "react";
import Link from "next/link";
import { MainTabBar } from "@/features/predictions/components/main-tab-bar";
import styles from "@/features/predictions/styles/predictions.module.css";
import indicesStyles from "./page.module.css";
import { getProjectIndices } from "@/lib/indices-client";
import { getCurrentProjectId, getApiUrl, loadSession, PROJECT_CHANGED_EVENT } from "@/lib/auth";
import { getMyProjects } from "@/lib/projects-client";
import type { ParentType, ProjectIndicesResponse, Trimester } from "@/types/indices";
import type { ProjectSummary } from "@/types/projects";
const PARENT_LABELS: Record<ParentType, string> = {
PAPA: "Papa",
MAMAN: "Maman",
};
const TRIMESTER_LABELS: Record<Trimester, string> = {
DATATION: "Datation (1ère échographie)",
T1: "1er trimestre",
T2: "2ème trimestre",
T3: "3ème trimestre",
};
const TRIMESTER_ORDER: Trimester[] = ["DATATION", "T1", "T2", "T3"];
function hasTrimesterData(tri: {
poids: number | null;
taille: number | null;
perimCranien: number | null;
date: string | null;
note: string | null;
photos: Array<{ id: string; trimesterEntryId: string; url: string; sortOrder: number; createdAt: string }>;
}) {
return tri.poids != null || tri.taille != null || tri.perimCranien != null || Boolean(tri.date) || Boolean(tri.note) || tri.photos.length > 0;
}
function formatDate(value: string | null) {
if (!value) return "—";
return new Date(value).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "long",
year: "numeric",
});
}
function StatRow({ label, value }: { label: string; value: string }) {
return (
<div className={indicesStyles.statRow}>
<span className={indicesStyles.statLabel}>{label}</span>
<span className={indicesStyles.statValue}>{value}</span>
</div>
);
}
function PhotoGallery({
urls,
onOpen,
}: {
urls: string[];
onOpen: (images: string[], startIndex: number) => void;
}) {
if (urls.length === 0) return null;
const images = urls.map((url) => `${getApiUrl()}${url}`);
return (
<div className={indicesStyles.photoGrid}>
{images.map((image, index) => (
<button
key={`${image}-${index}`}
type="button"
className={indicesStyles.photoThumb}
onClick={() => onOpen(images, index)}
aria-label={`Ouvrir la photo ${index + 1}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image} alt="" />
</button>
))}
</div>
);
}
export default function IndicesPage() {
const MIN_ZOOM = 1;
const MAX_ZOOM = 4;
const [indices, setIndices] = useState<ProjectIndicesResponse | null>(null);
const [currentProject, setCurrentProject] = useState<ProjectSummary | null>(null);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState("");
const [activeParentTab, setActiveParentTab] = useState<ParentType>("MAMAN");
const [activeTrimesterByBaby, setActiveTrimesterByBaby] = useState<Record<string, Trimester>>({});
const [projectRefreshKey, setProjectRefreshKey] = useState(0);
const [isAdmin, setIsAdmin] = useState(false);
const [lightboxImages, setLightboxImages] = useState<string[]>([]);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [lightboxZoom, setLightboxZoom] = useState(1);
const [lightboxOffset, setLightboxOffset] = useState({ x: 0, y: 0 });
const imageWrapRef = useRef<HTMLDivElement | null>(null);
const activePointerIdRef = useRef<number | null>(null);
const lastPointerRef = useRef<{ x: number; y: number } | null>(null);
const lastPinchDistanceRef = useRef<number | null>(null);
const lastPinchMidpointRef = useRef<{ x: number; y: number } | null>(null);
const lastTouchPointRef = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
const session = loadSession();
if (session?.user.role === "ADMIN") setIsAdmin(true);
}, []);
useEffect(() => {
const handler = () => setProjectRefreshKey((k) => k + 1);
window.addEventListener(PROJECT_CHANGED_EVENT, handler);
return () => window.removeEventListener(PROJECT_CHANGED_EVENT, handler);
}, []);
useEffect(() => {
if (lightboxIndex == null) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setLightboxIndex(null);
setLightboxImages([]);
setLightboxZoom(1);
setLightboxOffset({ x: 0, y: 0 });
return;
}
if (event.key === "+" || event.key === "=") {
event.preventDefault();
setLightboxZoom((current) => Math.min(current + 0.25, MAX_ZOOM));
}
if (event.key === "-") {
event.preventDefault();
setLightboxZoom((current) => {
const next = Math.max(current - 0.25, MIN_ZOOM);
if (next === MIN_ZOOM) setLightboxOffset({ x: 0, y: 0 });
return next;
});
}
if (lightboxImages.length <= 1) return;
if (event.key === "ArrowLeft") {
setLightboxIndex((current) => {
if (current == null) return current;
return (current - 1 + lightboxImages.length) % lightboxImages.length;
});
}
if (event.key === "ArrowRight") {
setLightboxIndex((current) => {
if (current == null) return current;
return (current + 1) % lightboxImages.length;
});
}
};
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", handleKeyDown);
};
}, [lightboxImages.length, lightboxIndex, MAX_ZOOM, MIN_ZOOM]);
useEffect(() => {
if (lightboxZoom <= MIN_ZOOM) {
setLightboxOffset({ x: 0, y: 0 });
return;
}
const wrap = imageWrapRef.current;
if (!wrap) return;
const maxX = ((lightboxZoom - 1) * wrap.clientWidth) / 2;
const maxY = ((lightboxZoom - 1) * wrap.clientHeight) / 2;
setLightboxOffset((current) => ({
x: Math.max(-maxX, Math.min(maxX, current.x)),
y: Math.max(-maxY, Math.min(maxY, current.y)),
}));
}, [lightboxZoom, MIN_ZOOM]);
const loadData = useCallback(async () => {
setLoading(true);
setMessage("");
try {
const projects = await getMyProjects();
const currentProjectId = getCurrentProjectId();
const project = projects.find((p) => p.id === currentProjectId) ?? projects[0] ?? null;
setCurrentProject(project);
if (!project) {
setIndices(null);
return;
}
const data = await getProjectIndices(project.id);
setIndices(data);
} catch (err) {
setMessage(err instanceof Error ? err.message : "Impossible de charger les indices");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadData();
}, [loadData, projectRefreshKey]);
const setActiveTrimester = (babyId: string, trimester: Trimester) => {
setActiveTrimesterByBaby((prev) => ({ ...prev, [babyId]: trimester }));
};
const papaIndices = indices?.parentIndices.find((p) => p.parentType === "PAPA") ?? null;
const mamanIndices = indices?.parentIndices.find((p) => p.parentType === "MAMAN") ?? null;
const activeParent = activeParentTab === "PAPA" ? papaIndices : mamanIndices;
const currentImage = lightboxIndex != null ? lightboxImages[lightboxIndex] : null;
const openLightbox = (images: string[], startIndex: number) => {
setLightboxImages(images);
setLightboxIndex(startIndex);
setLightboxZoom(1);
setLightboxOffset({ x: 0, y: 0 });
};
const closeLightbox = () => {
setLightboxIndex(null);
setLightboxImages([]);
setLightboxZoom(1);
setLightboxOffset({ x: 0, y: 0 });
};
const showPreviousImage = () => {
if (lightboxImages.length <= 1) return;
setLightboxIndex((current) => {
if (current == null) return current;
return (current - 1 + lightboxImages.length) % lightboxImages.length;
});
setLightboxZoom(1);
setLightboxOffset({ x: 0, y: 0 });
};
const showNextImage = () => {
if (lightboxImages.length <= 1) return;
setLightboxIndex((current) => {
if (current == null) return current;
return (current + 1) % lightboxImages.length;
});
setLightboxZoom(1);
setLightboxOffset({ x: 0, y: 0 });
};
const applyPan = (deltaX: number, deltaY: number) => {
const wrap = imageWrapRef.current;
if (!wrap || lightboxZoom <= MIN_ZOOM) return;
const maxX = ((lightboxZoom - 1) * wrap.clientWidth) / 2;
const maxY = ((lightboxZoom - 1) * wrap.clientHeight) / 2;
setLightboxOffset((current) => ({
x: Math.max(-maxX, Math.min(maxX, current.x + deltaX)),
y: Math.max(-maxY, Math.min(maxY, current.y + deltaY)),
}));
};
const updateZoom = (targetZoom: number | ((current: number) => number)) => {
setLightboxZoom((current) => {
const requestedZoom =
typeof targetZoom === "function" ? targetZoom(current) : targetZoom;
const nextZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, requestedZoom));
if (nextZoom <= MIN_ZOOM) {
setLightboxOffset({ x: 0, y: 0 });
}
return nextZoom;
});
};
const handleImageWheel = (event: ReactWheelEvent<HTMLDivElement>) => {
event.preventDefault();
const step = event.deltaY < 0 ? 0.15 : -0.15;
updateZoom((current) => current + step);
};
const handleImagePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === "touch") return;
if (event.pointerType === "mouse" && event.button !== 0) return;
if (event.target instanceof HTMLElement && event.target.closest("button")) return;
if (lightboxZoom <= MIN_ZOOM) return;
activePointerIdRef.current = event.pointerId;
lastPointerRef.current = { x: event.clientX, y: event.clientY };
event.currentTarget.setPointerCapture(event.pointerId);
};
const handleImagePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (activePointerIdRef.current !== event.pointerId || !lastPointerRef.current) return;
const deltaX = event.clientX - lastPointerRef.current.x;
const deltaY = event.clientY - lastPointerRef.current.y;
lastPointerRef.current = { x: event.clientX, y: event.clientY };
applyPan(deltaX, deltaY);
};
const handleImagePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
if (activePointerIdRef.current === event.pointerId) {
activePointerIdRef.current = null;
lastPointerRef.current = null;
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const handleImageTouchStart = (event: ReactTouchEvent<HTMLDivElement>) => {
if (event.target instanceof HTMLElement && event.target.closest("button")) {
lastTouchPointRef.current = null;
return;
}
if (event.touches.length === 1) {
const touch = event.touches[0];
lastTouchPointRef.current = { x: touch.clientX, y: touch.clientY };
lastPinchDistanceRef.current = null;
lastPinchMidpointRef.current = null;
return;
}
if (event.touches.length === 2) {
const [a, b] = [event.touches[0], event.touches[1]];
lastPinchDistanceRef.current = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
lastPinchMidpointRef.current = {
x: (a.clientX + b.clientX) / 2,
y: (a.clientY + b.clientY) / 2,
};
lastTouchPointRef.current = null;
}
};
const handleImageTouchMove = (event: ReactTouchEvent<HTMLDivElement>) => {
if (event.target instanceof HTMLElement && event.target.closest("button")) return;
if (event.touches.length === 1) {
if (lightboxZoom <= MIN_ZOOM) return;
event.preventDefault();
const touch = event.touches[0];
if (!lastTouchPointRef.current) {
lastTouchPointRef.current = { x: touch.clientX, y: touch.clientY };
return;
}
const deltaX = touch.clientX - lastTouchPointRef.current.x;
const deltaY = touch.clientY - lastTouchPointRef.current.y;
lastTouchPointRef.current = { x: touch.clientX, y: touch.clientY };
applyPan(deltaX, deltaY);
return;
}
if (event.touches.length !== 2) {
lastPinchDistanceRef.current = null;
lastPinchMidpointRef.current = null;
return;
}
event.preventDefault();
const [a, b] = [event.touches[0], event.touches[1]];
const distance = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
const midpoint = { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
if (lastPinchDistanceRef.current != null && lastPinchDistanceRef.current > 0) {
const ratio = distance / lastPinchDistanceRef.current;
updateZoom((current) => current * ratio);
}
if (lastPinchMidpointRef.current && lightboxZoom > MIN_ZOOM) {
const moveX = midpoint.x - lastPinchMidpointRef.current.x;
const moveY = midpoint.y - lastPinchMidpointRef.current.y;
applyPan(moveX, moveY);
}
lastPinchDistanceRef.current = distance;
lastPinchMidpointRef.current = midpoint;
lastTouchPointRef.current = null;
};
const handleImageTouchEnd = () => {
lastPinchDistanceRef.current = null;
lastPinchMidpointRef.current = null;
lastTouchPointRef.current = null;
};
const toggleZoom = () => {
if (lightboxZoom > MIN_ZOOM) {
updateZoom(MIN_ZOOM);
return;
}
updateZoom(2);
};
const hasAnyParentData = (parent: typeof papaIndices) =>
parent && (parent.poids || parent.taille || parent.perimCranien || parent.dateNaissance || parent.photos.length > 0);
return (
<>
<section className={`${styles.recapHero} ${styles.indicesHero}`}>
<div className={styles.recapHeroContent}>
<span className={styles.recapHeroEyebrow}>Carnet d&apos;indices</span>
<h1>Petites pistes de famille</h1>
<p>
Retrouvez les infos de maman et papa à leur naissance, puis l&apos;évolution de bébé au fil des trimestres dans le ventre de maman.
</p>
<div className={styles.recapHeroStats} aria-hidden="true">
<div className={styles.recapHeroStatChip}>
<span className={styles.recapHeroStatValue}>Maman</span>
<span className={styles.recapHeroStatLabel}>Naissance</span>
</div>
<div className={styles.recapHeroStatChip}>
<span className={styles.recapHeroStatValue}>Papa</span>
<span className={styles.recapHeroStatLabel}>Naissance</span>
</div>
<div className={styles.recapHeroStatChip}>
<span className={styles.recapHeroStatValue}>T1-T3</span>
<span className={styles.recapHeroStatLabel}>Bébé</span>
</div>
</div>
</div>
<div className={styles.indicesHeroIllustration} aria-hidden="true">
<span className={styles.indicesHeroCardBack} />
<span className={styles.indicesHeroCardMiddle} />
<span className={styles.indicesHeroCardFront}>
<span className={styles.indicesHeroCardAccent} />
<span className={styles.indicesHeroCardLineStrong} />
<span className={styles.indicesHeroCardLineSoft} />
</span>
<span className={styles.indicesHeroCardBadge} />
</div>
</section>
<MainTabBar />
<section className={styles.sectionTitle}>
<h2>Indices de grossesse</h2>
</section>
{isAdmin && currentProject ? (
<section className={`panel ${indicesStyles.editBanner}`}>
<p>Vous êtes administrateur vous pouvez modifier ces informations.</p>
<Link className="btn btn-soft" href={`/admin/projects/${currentProject.id}/indices`}>
Modifier les indices
</Link>
</section>
) : null}
{message ? (
<section className={`panel ${indicesStyles.messagePanel}`}>
<p className={styles.error}>{message}</p>
</section>
) : null}
{loading ? (
<section className={`panel ${indicesStyles.messagePanel}`}>
<p>Chargement des indices...</p>
</section>
) : null}
{!loading && !currentProject ? (
<section className={`panel ${indicesStyles.messagePanel}`}>
<p className={styles.empty}>Aucun projet sélectionné.</p>
</section>
) : null}
{!loading && currentProject && (
<>
{/* ===== PARENTS ===== */}
<section className={`panel ${indicesStyles.section}`}>
<h3 className={indicesStyles.sectionHeading}>Les parents</h3>
<div className={indicesStyles.parentTabs}>
{(["MAMAN", "PAPA"] as ParentType[]).map((type) => (
<button
key={type}
type="button"
className={`${indicesStyles.parentTab} ${activeParentTab === type ? indicesStyles.parentTabActive : ""}`}
onClick={() => setActiveParentTab(type)}
>
{PARENT_LABELS[type]}
</button>
))}
</div>
{activeParent && hasAnyParentData(activeParent) ? (
<div className={indicesStyles.cardBlock}>
<div className={indicesStyles.statsGrid}>
{activeParent.poids != null && (
<StatRow label="Poids" value={`${activeParent.poids} kg`} />
)}
{activeParent.taille != null && (
<StatRow label="Taille" value={`${activeParent.taille} cm`} />
)}
{activeParent.perimCranien != null && (
<StatRow label="Périm. crânien" value={`${activeParent.perimCranien} cm`} />
)}
{activeParent.dateNaissance && (
<StatRow label="Date de naissance" value={formatDate(activeParent.dateNaissance)} />
)}
</div>
{activeParent.photos.length > 0 && (
<PhotoGallery
urls={activeParent.photos.map((p) => p.url)}
onOpen={openLightbox}
/>
)}
</div>
) : (
<p className={indicesStyles.emptyHint}>
{`Aucune information renseignée pour ${PARENT_LABELS[activeParentTab].toLowerCase()} pour l'instant.`}
</p>
)}
</section>
{/* ===== BÉBÉS ===== */}
{(indices?.babyIndices ?? []).map((baby) => {
const babyLabel = currentProject.babies?.find((b) => b.babyIndex === baby.babyIndex)?.label ?? `Bébé ${baby.babyIndex}`;
const visibleTrimesters = baby.trimesters
.filter((tri) => hasTrimesterData(tri))
.sort(
(a, b) =>
TRIMESTER_ORDER.indexOf(a.trimester as Trimester) -
TRIMESTER_ORDER.indexOf(b.trimester as Trimester),
);
return (
<section key={baby.id} className={`panel ${indicesStyles.section}`}>
<h3 className={indicesStyles.sectionHeading}>{babyLabel}</h3>
{baby.dpa && (
<div className={indicesStyles.dpaBadge}>
<span className={indicesStyles.dpaLabel}>Date prévue d&apos;accouchement</span>
<span className={indicesStyles.dpaValue}>{formatDate(baby.dpa)}</span>
</div>
)}
{visibleTrimesters.length === 0 && !baby.dpa ? (
<p className={indicesStyles.emptyHint}>Aucune information renseignée pour ce bébé.</p>
) : null}
{visibleTrimesters.length > 0 ? (() => {
const stored = activeTrimesterByBaby[baby.id];
const fallback = visibleTrimesters[0].trimester as Trimester;
const activeTri = visibleTrimesters.find((t) => t.trimester === stored)?.trimester as Trimester | undefined;
const currentTrimester = activeTri ?? fallback;
const tri = visibleTrimesters.find((t) => t.trimester === currentTrimester) ?? visibleTrimesters[0];
return (
<div className={indicesStyles.trimesterTabsWrapper}>
<div
className={indicesStyles.trimesterTabs}
role="tablist"
aria-label="Choisir un trimestre"
>
{visibleTrimesters.map((t) => {
const isActive = t.trimester === currentTrimester;
return (
<button
key={t.id}
type="button"
role="tab"
aria-selected={isActive}
className={`${indicesStyles.trimesterTab} ${isActive ? indicesStyles.trimesterTabActive : ""}`}
onClick={() => setActiveTrimester(baby.id, t.trimester as Trimester)}
>
{t.trimester === "DATATION" ? "Datation" : t.trimester}
</button>
);
})}
</div>
<div className={indicesStyles.trimesterPanel} role="tabpanel">
<h4 className={indicesStyles.trimesterPanelTitle}>{TRIMESTER_LABELS[tri.trimester as Trimester]}</h4>
<div className={indicesStyles.statsGrid}>
{tri.date && <StatRow label="Date écho" value={formatDate(tri.date)} />}
{tri.poids != null && <StatRow label="Poids estimé" value={`${tri.poids} kg`} />}
{tri.taille != null && <StatRow label="Taille estimée" value={`${tri.taille} cm`} />}
{tri.perimCranien != null && <StatRow label="Périm. crânien" value={`${tri.perimCranien} cm`} />}
</div>
{tri.note && (
<p className={indicesStyles.triNote}>{tri.note}</p>
)}
{tri.photos.length > 0 && (
<PhotoGallery
urls={tri.photos.map((p) => p.url)}
onOpen={openLightbox}
/>
)}
</div>
</div>
);
})() : null}
</section>
);
})}
{/* Si pas de données bébé du tout */}
{(indices?.babyIndices ?? []).length === 0 && (
<section className={`panel ${indicesStyles.messagePanel}`}>
<p className={indicesStyles.emptyHint}>
{`Les informations sur ${currentProject.babyCount > 1 ? "les bébés" : "le bébé"} n'ont pas encore été renseignées.`}
</p>
</section>
)}
</>
)}
{currentImage && lightboxIndex != null && (
<div className={indicesStyles.lightboxOverlay} onClick={closeLightbox}>
<div
className={indicesStyles.lightboxDialog}
role="dialog"
aria-modal="true"
aria-label="Aperçu photo"
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className={indicesStyles.lightboxClose}
onClick={closeLightbox}
aria-label="Fermer"
>
×
</button>
<div
ref={imageWrapRef}
className={indicesStyles.lightboxImageWrap}
onWheel={handleImageWheel}
onPointerDown={handleImagePointerDown}
onPointerMove={handleImagePointerMove}
onPointerUp={handleImagePointerUp}
onPointerCancel={handleImagePointerUp}
onTouchStart={handleImageTouchStart}
onTouchMove={handleImageTouchMove}
onTouchEnd={handleImageTouchEnd}
onTouchCancel={handleImageTouchEnd}
onDoubleClick={toggleZoom}
>
{lightboxImages.length > 1 && (
<button
type="button"
className={`${indicesStyles.lightboxNav} ${indicesStyles.lightboxNavPrev}`}
onClick={showPreviousImage}
aria-label="Photo précédente"
>
</button>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className={indicesStyles.lightboxImage}
style={{ transform: `translate(${lightboxOffset.x}px, ${lightboxOffset.y}px) scale(${lightboxZoom})` }}
src={currentImage}
alt=""
draggable={false}
/>
{lightboxImages.length > 1 && (
<button
type="button"
className={`${indicesStyles.lightboxNav} ${indicesStyles.lightboxNavNext}`}
onClick={showNextImage}
aria-label="Photo suivante"
>
</button>
)}
</div>
<div className={indicesStyles.lightboxFooter}>
<span>{lightboxIndex + 1} / {lightboxImages.length}</span>
<div className={indicesStyles.lightboxActions}>
<button
type="button"
className={indicesStyles.lightboxZoomBtn}
onClick={() => updateZoom((current) => current - 0.25)}
aria-label="Dézoomer"
>
</button>
<span className={indicesStyles.lightboxZoomValue}>{Math.round(lightboxZoom * 100)}%</span>
<button
type="button"
className={indicesStyles.lightboxZoomBtn}
onClick={() => updateZoom((current) => current + 0.25)}
aria-label="Zoomer"
>
+
</button>
<button type="button" className="btn btn-soft" onClick={closeLightbox}>
Retour
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import {
getApiUrl,
loadSession,
logoutSession,
type AuthUser,
} from "@/lib/auth";
import styles from "@/features/predictions/styles/predictions.module.css";
import {
PredictionThemeProvider,
usePredictionTheme,
} from "@/features/predictions/context/prediction-theme-context";
type PredictionsLayoutProps = {
children: React.ReactNode;
};
const BURGER_LINKS = [
{ href: "/predictions", label: "Page Principale", exact: true },
{ href: "/predictions/history", label: "Historique", exact: true },
{ href: "/profile", label: "Mon profil", exact: false },
];
export default function PredictionsLayout({ children }: PredictionsLayoutProps) {
return (
<PredictionThemeProvider>
<PredictionsLayoutContent>{children}</PredictionsLayoutContent>
</PredictionThemeProvider>
);
}
function PredictionsLayoutContent({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<AuthUser | null>(null);
const [ready, setReady] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { theme } = usePredictionTheme();
const logoSrc =
theme === "girl"
? "/depotARanger/bebe-sur-un-nuage-girl.png"
: "/depotARanger/bebe-sur-un-nuage.png";
useEffect(() => {
if (!menuOpen) return;
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [menuOpen]);
useEffect(() => {
const session = loadSession();
if (!session) {
router.replace("/");
return;
}
if (session.user.role === "ADMIN") {
router.replace("/admin");
return;
}
setUser(session.user);
setReady(true);
}, [router]);
const onLogout = async () => {
await logoutSession(getApiUrl());
window.location.replace("/");
};
if (!ready) {
return (
<main className="app-shell">
<section className={styles.loadingScreen}>
<Image src="/images/icons/logo.png" alt="Le Juste Poids" width={80} height={80} />
<p>Chargement...</p>
</section>
</main>
);
}
return (
<main className="app-shell">
{/* Header bar */}
<header className={styles.appHeader}>
<div className={styles.appHeaderLeft}>
<Image src={logoSrc} alt="Logo" width={50} height={50} className={styles.headerLogo} />
<span className={styles.appTitle}>Le juste poids</span>
</div>
<div className={styles.menuWrapper} ref={menuRef}>
<button
className={styles.burgerBtn}
onClick={() => setMenuOpen(!menuOpen)}
aria-label="Menu"
type="button"
>
<span className={styles.burgerLine} />
<span className={styles.burgerLine} />
<span className={styles.burgerLine} />
</button>
{menuOpen && (
<nav className={styles.mobileMenu}>
{BURGER_LINKS.filter(({ href, exact }) => {
const isCurrentPage = exact ? pathname === href : pathname.startsWith(href);
return !isCurrentPage;
}).map(({ href, label }) => (
<Link
key={href}
className={styles.tab}
href={href}
onClick={() => setMenuOpen(false)}
>
{label}
</Link>
))}
<div className={styles.menuDivider} />
<button type="button" className={`btn btn-soft ${styles.logoutBtn}`} onClick={onLogout}>Se déconnecter</button>
</nav>
)}
</div>
</header>
{/* Main tab bar — rendered inline in each page */}
<div className={styles.page}>
{children}
</div>
</main>
);
}
+705
View File
@@ -0,0 +1,705 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { PredictionCardFactory } from "@/features/predictions/components/tiles/prediction-card-factory";
import { MainTabBar } from "@/features/predictions/components/main-tab-bar";
import {
getThemeFromSexValue,
type PredictionTheme,
} from "@/features/predictions/domain/prediction-theme";
import { getCardIcon } from "@/features/predictions/domain/card-icons";
import type { PredictionTileController } from "@/features/predictions/hooks/use-prediction-tile-controller";
import styles from "@/features/predictions/styles/predictions.module.css";
import { usePredictionTheme } from "@/features/predictions/context/prediction-theme-context";
import {
getMyPredictionEntries,
getPredictionBoard,
getPredictionCards,
getPredictionScoreboard,
upsertPredictionEntry,
} from "@/lib/predictions-client";
import { getCurrentProjectId, getApiUrl, PROJECT_CHANGED_EVENT } from "@/lib/auth";
import { getMyProjects } from "@/lib/projects-client";
import type {
PredictionBoardResponse,
PredictionCard,
PredictionEntry,
PredictionScoreboardItem,
} from "@/types/predictions";
import type { ProjectSummary } from "@/types/projects";
type FinalAvatarProps = Readonly<{
apiUrl: string;
className: string;
user: {
displayName: string | null;
username: string;
profileImageUrl: string | null;
profileBgColor: string | null;
};
}>;
function formatNumber(value: number, unit?: string | null) {
const suffix = unit ? ` ${unit}` : "";
return `${value}${suffix}`;
}
function formatDateLabel(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(date);
}
function getDisplayName(user: { displayName: string | null; username: string }) {
return user.displayName?.trim() || user.username;
}
function getInitials(label: string) {
return label
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("") || "?";
}
function resolveImageSrc(apiUrl: string, imageUrl: string | null) {
if (!imageUrl) {
return null;
}
if (/^https?:\/\//i.test(imageUrl) || imageUrl.startsWith("data:")) {
return imageUrl;
}
return `${apiUrl}${imageUrl}`;
}
function formatValueShort(
value: { fieldLabel?: string; field?: { label: string }; valueText: string | null; valueNumber: number | null; valueDate: string | null },
unit?: string | null,
) {
if (value.valueNumber != null) {
return formatNumber(value.valueNumber, unit);
}
if (value.valueText && value.valueText.trim().length > 0) {
return value.valueText;
}
if (value.valueDate) {
return formatDateLabel(value.valueDate.slice(0, 10));
}
return "-";
}
function FinalAvatar({
apiUrl,
className,
user,
}: FinalAvatarProps) {
const label = getDisplayName(user);
const imageSrc = resolveImageSrc(apiUrl, user.profileImageUrl);
const style = imageSrc
? { backgroundColor: user.profileBgColor ?? undefined, backgroundImage: `url("${imageSrc}")` }
: { background: user.profileBgColor ?? undefined };
return (
<div className={className} style={style} aria-hidden="true">
{imageSrc ? null : <span>{getInitials(label)}</span>}
</div>
);
}
export default function PredictionsPage() {
const [projectRefreshKey, setProjectRefreshKey] = useState(0);
const [randomTheme] = useState<PredictionTheme>(() => {
if (typeof window === "undefined") return "girl";
const last = localStorage.getItem("ljp_theme_last") as PredictionTheme | null;
return last === "girl" ? "boy" : "girl";
});
const hasWrittenRandomTheme = useRef(false) as MutableRefObject<boolean>;
useEffect(() => {
if (hasWrittenRandomTheme.current) return;
hasWrittenRandomTheme.current = true;
localStorage.setItem("ljp_theme_last", randomTheme);
}, [randomTheme]);
const [draftSexValuesByBaby, setDraftSexValuesByBaby] = useState<Record<number, string | null>>({});
const [cards, setCards] = useState<PredictionCard[]>([]);
const [entriesByCardId, setEntriesByCardId] = useState<Record<string, PredictionEntry>>({});
const [board, setBoard] = useState<PredictionBoardResponse | null>(null);
const [scoreboard, setScoreboard] = useState<PredictionScoreboardItem[]>([]);
const [currentProject, setCurrentProject] = useState<ProjectSummary | null>(null);
const [activeBabyIndex, setActiveBabyIndex] = useState(1);
const [babyCount, setBabyCount] = useState(1);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState("");
const [globalMessage, setGlobalMessage] = useState("");
const [globalSaving, setGlobalSaving] = useState(false);
const [dirtyVersion, setDirtyVersion] = useState(0);
const controllersRef = useRef<Record<string, PredictionTileController>>({});
const entryKey = useCallback((cardId: string, selectedBabyIndex: number) => {
return `${cardId}::${selectedBabyIndex}`;
}, []);
const isGameClosed = board?.game?.status === "CLOSED";
const isFinalized = Boolean(board?.game?.finalized);
useEffect(() => {
const onProjectChanged = () => {
setProjectRefreshKey((current) => current + 1);
};
window.addEventListener(PROJECT_CHANGED_EVENT, onProjectChanged);
return () => {
window.removeEventListener(PROJECT_CHANGED_EVENT, onProjectChanged);
};
}, []);
useEffect(() => {
setLoading(true);
setMessage("");
Promise.all([
getPredictionCards(),
getMyPredictionEntries(),
getPredictionBoard(),
getPredictionScoreboard(),
getMyProjects(),
])
.then(([nextCards, entries, nextBoard, nextScoreboard, projects]) => {
setCards(nextCards);
setBoard(nextBoard);
setScoreboard(nextScoreboard);
const currentProjectId = getCurrentProjectId();
const selectedProject =
projects.find((project) => project.id === currentProjectId) ?? projects[0] ?? null;
setCurrentProject(selectedProject ?? null);
const nextBabyCount = Math.max(1, selectedProject?.babyCount ?? 1);
setBabyCount(nextBabyCount);
setActiveBabyIndex((current) => Math.min(current, nextBabyCount));
const byCardId = entries.reduce<Record<string, PredictionEntry>>((accumulator, entry) => {
const key = entryKey(entry.cardId, entry.selectedBabyIndex ?? 1);
accumulator[key] = entry;
return accumulator;
}, {});
setEntriesByCardId(byCardId);
setDraftSexValuesByBaby({});
})
.catch((error) => {
setMessage(error instanceof Error ? error.message : "Impossible de charger les pronostics");
})
.finally(() => {
setLoading(false);
});
}, [entryKey, projectRefreshKey]);
const makeCardSaveHandler = useCallback((babyIdx: number) => {
return async (cardId: string, payload: { values: { fieldId: string; valueText?: string; valueNumber?: number; valueDate?: string }[] }) => {
const updated = await upsertPredictionEntry(cardId, {
...payload,
selectedBabyIndex: babyIdx,
});
setEntriesByCardId((current) => ({
...current,
[entryKey(cardId, babyIdx)]: updated,
}));
return updated;
};
}, [entryKey]);
const saveHandler = async (cardId: string, payload: { values: { fieldId: string; valueText?: string; valueNumber?: number; valueDate?: string }[] }) => {
const updated = await upsertPredictionEntry(cardId, {
...payload,
selectedBabyIndex: activeBabyIndex,
});
setEntriesByCardId((current) => ({
...current,
[entryKey(cardId, activeBabyIndex)]: updated,
}));
return updated;
};
const registerController = useCallback(
(key: string, controller: PredictionTileController | null) => {
if (controller) {
controllersRef.current[key] = controller;
} else {
delete controllersRef.current[key];
}
},
[],
);
const notifyDirtyChange = useCallback(() => {
setDirtyVersion((v) => v + 1);
}, []);
const getBabyLabel = useCallback((idx: number): string => {
return currentProject?.babies?.find((b) => b.babyIndex === idx)?.label ?? `Bébé ${idx}`;
}, [currentProject]);
const totalPredictions = useMemo(
() =>
board?.cards.reduce((total, card) => {
const entriesForBaby = card.entries.filter(
(entry) => (entry.selectedBabyIndex ?? 1) === activeBabyIndex,
);
return total + entriesForBaby.length;
}, 0) ?? 0,
[activeBabyIndex, board],
);
const dirtyBabyIndices = useMemo(() => {
const indices = new Set<number>();
for (const key of Object.keys(controllersRef.current)) {
const controller = controllersRef.current[key];
if (controller?.dirty) {
const babyIdx = Number(key.split("::")[1]);
if (!Number.isNaN(babyIdx)) {
indices.add(babyIdx);
}
}
}
return indices;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dirtyVersion]);
const winnerSummary = useMemo(() => {
if (!isFinalized || scoreboard.length === 0) {
return null;
}
const topScore = scoreboard[0].totalPoints;
const winners = scoreboard.filter((item) => item.totalPoints === topScore);
return {
winners,
topScore,
};
}, [isFinalized, scoreboard]);
const mainWinnerId = winnerSummary?.winners[0]?.userId ?? null;
const winnerResponses = useMemo(() => {
if (!isFinalized || !mainWinnerId) {
return [];
}
return (board?.cards ?? []).map((card) => ({
card,
entry:
card.entries.find(
(entry) =>
entry.userId === mainWinnerId && (entry.selectedBabyIndex ?? 1) === activeBabyIndex,
) ?? null,
}));
}, [activeBabyIndex, board?.cards, isFinalized, mainWinnerId]);
const winnerResponsesByCardId = useMemo(() => {
return winnerResponses.reduce<Record<string, typeof winnerResponses[number]>>((accumulator, item) => {
accumulator[item.card.id] = item;
return accumulator;
}, {});
}, [winnerResponses]);
const myResponses = useMemo(() => {
return cards.map((card) => ({
card,
entry: entriesByCardId[entryKey(card.id, activeBabyIndex)] ?? null,
}));
}, [activeBabyIndex, cards, entriesByCardId, entryKey]);
const { setTheme } = usePredictionTheme();
const predictionTheme = useMemo<PredictionTheme>(() => {
const sexCard = cards.find((card) => card.code === "legacy_sex");
if (!sexCard) {
return randomTheme;
}
// Priorité 1 : sélection en brouillon pour le bébé actif
const draftTheme = getThemeFromSexValue(draftSexValuesByBaby[activeBabyIndex]);
if (draftTheme) {
return draftTheme;
}
// Priorité 2 : valeur enregistrée
const sexEntry = entriesByCardId[entryKey(sexCard.id, activeBabyIndex)];
if (sexEntry) {
const savedSexValue =
sexEntry.values.find((value) => value.valueText && value.valueText.trim().length > 0)?.valueText ??
null;
const savedTheme = getThemeFromSexValue(savedSexValue);
if (savedTheme) return savedTheme;
}
return randomTheme;
}, [activeBabyIndex, cards, draftSexValuesByBaby, entriesByCardId, entryKey, randomTheme]);
useEffect(() => {
setTheme(predictionTheme);
}, [predictionTheme, setTheme]);
const heroBabyImage =
predictionTheme === "girl"
? "/depotARanger/bebe-sur-un-nuage-girl.png"
: "/depotARanger/bebe-sur-un-nuage.png";
const heroProjectImageUrl = currentProject?.projectImageUrl
? `${getApiUrl()}${currentProject.projectImageUrl}`
: null;
const heroProjectBg = currentProject?.projectBgColor ?? "#e4f4fb";
const apiUrl = getApiUrl();
const onGlobalSave = async () => {
if (isGameClosed) {
setGlobalMessage("Le jeu est cloture. Les pronostics ne peuvent plus etre modifies.");
return;
}
const dirtyKeys = Object.keys(controllersRef.current).filter(
(key) => controllersRef.current[key]?.dirty,
);
if (dirtyKeys.length === 0) {
setGlobalMessage("Aucune modification a enregistrer.");
return;
}
setGlobalSaving(true);
setGlobalMessage("");
let successCount = 0;
let failureCount = 0;
for (const key of dirtyKeys) {
const controller = controllersRef.current[key];
if (!controller) {
failureCount += 1;
continue;
}
const ok = await controller.submit();
if (ok) {
successCount += 1;
} else {
failureCount += 1;
}
}
if (failureCount === 0) {
setGlobalMessage(`${successCount} categorie(s) enregistree(s) avec succes.`);
} else {
setGlobalMessage(
`${successCount} categorie(s) enregistree(s), ${failureCount} en echec. Verifie les tuiles en erreur.`,
);
}
setGlobalSaving(false);
};
return (
<>
<section className={`${styles.hero} ${predictionTheme === "girl" ? styles.heroSoftPink : ""}`}>
<div className={`${styles.heroCornerDecoFrame} ${styles.heroCornerDecoFrameLeft}`} aria-hidden="true">
<Image
src="/depotARanger/baniere-deco-gauche-haut.png"
alt=""
fill
sizes="128px"
className={`${styles.heroCornerDeco} ${styles.heroCornerDecoLeft}`}
/>
</div>
<div className={`${styles.heroCornerDecoFrame} ${styles.heroCornerDecoFrameRight}`} aria-hidden="true">
<Image
src="/depotARanger/baniere-deco-droite-haut.png"
alt=""
fill
sizes="148px"
className={`${styles.heroCornerDeco} ${styles.heroCornerDecoRight}`}
/>
</div>
<div className={styles.heroInner}>
<h2>
{isFinalized ? "Concours terminé !" : "Bienvenue !"}
</h2>
<p>
{isFinalized
? "Le classement est validé. Retrouve le gagnant et tes réponses."
: "Amusez-vous à pronostiquer l'arrivée de Bubulle !"}
</p>
</div>
<div className={styles.heroProjectPhoto} aria-hidden="true" style={{ background: heroProjectBg }}>
{heroProjectImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={heroProjectImageUrl}
alt=""
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<Image
src={heroBabyImage}
alt=""
fill
sizes="124px"
style={{ objectFit: "cover" }}
/>
)}
</div>
<div className={styles.heroBannerRow}>
<div className={styles.heroBannerImageWrap} aria-hidden="true">
<Image
src="/depotARanger/baniere-familleGauche.png"
alt=""
width={1200}
height={504}
sizes="199px"
className={`${styles.heroBannerImage} ${styles.heroBannerImageLeft}`}
/>
</div>
<div className={styles.heroBannerImageWrap} aria-hidden="true">
<Image
src="/depotARanger/baniere-familleDroite.png"
alt=""
width={1248}
height={624}
sizes="202px"
className={`${styles.heroBannerImage} ${styles.heroBannerImageRight}`}
/>
</div>
</div>
<div className={styles.heroWave} aria-hidden="true">
<svg
viewBox="0 0 1415 142"
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
style={{ width: "100%", height: "50px", display: "block" }}
>
<path d="M0 41.8835V142H1414.29V16.0802C1395.91 9.71545 1350.59 1.83686 1316.33 21.2409C1281.38 0.59831 1224.27 6.27502 1082.5 34.6586C928.42 6.79108 743.327 21.2409 627.151 48.5923C569.556 27.4337 490.3 26.9176 455.841 55.8172C456.17 51.1726 447.276 37.755 409.076 21.2409C361.326 0.598308 327.851 25.8855 277.148 21.2409C226.444 16.5963 127.043 -35.0102 0 41.8835Z" fill={predictionTheme === "girl" ? "#ffb5aa" : "#A7DFC1"}/>
</svg>
</div>
</section>
<MainTabBar />
<section className={styles.sectionTitle}>
<h2>Vos pronostics</h2>
</section>
{babyCount > 1 && !isFinalized ? (
<section className={`panel ${styles.babyBanner}`}>
<p className={styles.babyBannerLabel}>Vous saisissez vos pronostics pour :</p>
<div className={styles.babyTabs}>
{Array.from({ length: babyCount }, (_, index) => index + 1).map((babyIndex) => (
<button
key={babyIndex}
className={`${styles.babyTab} ${babyIndex === activeBabyIndex ? styles.babyTabActive : ""}`}
onClick={() => {
setActiveBabyIndex(babyIndex);
setGlobalMessage("");
}}
type="button"
>
{getBabyLabel(babyIndex)}
{dirtyBabyIndices.has(babyIndex) ? (
<span className={styles.babyTabDirtyDot} title="Modifications non enregistrees" />
) : null}
</button>
))}
</div>
</section>
) : null}
{message ? (
<section className={`panel ${styles.panelBlock}`}>
<p className={styles.error}>{message}</p>
</section>
) : null}
{loading ? (
<section className={`panel ${styles.panelBlock}`}>
<p>Chargement des cartes...</p>
</section>
) : null}
{!loading && cards.length === 0 ? (
<section className={`panel ${styles.panelBlock}`}>
<p className={styles.empty}>Aucune carte active pour le moment.</p>
</section>
) : null}
{!loading && isFinalized ? (
<section className={styles.finalResults}>
{winnerSummary ? (
<div className={styles.finalWinnerCard}>
<div className={styles.finalWinnerMain}>
<span className={styles.recapSectionEyebrow}>Résultats validés</span>
<div className={styles.finalWinnerIdentityRow}>
<FinalAvatar apiUrl={apiUrl} className={styles.finalWinnerAvatar} user={winnerSummary.winners[0]} />
<div>
<p className={styles.finalWinnerKicker}>Champion du concours</p>
<h3>{winnerSummary.winners.map((winner) => getDisplayName(winner)).join(" / ")}</h3>
<p className={styles.finalWinnerSubtitle}>
{winnerSummary.topScore} points au compteur{winnerSummary.winners.length > 1 ? " chacun" : ""}.
</p>
</div>
</div>
</div>
<div className={styles.finalWinnerScoreBadge}>
<strong>{winnerSummary.topScore}</strong>
<span>points</span>
</div>
</div>
) : (
<p className={styles.empty}>Classement indisponible.</p>
)}
{scoreboard.length > 0 ? (
<div className={styles.finalScoreboardList} aria-label="Classement final">
{scoreboard.map((item, index) => (
<article
key={item.userId}
className={`${styles.finalScoreRow} ${index === 0 ? styles.finalScoreRowWinner : ""}`}
>
<span className={styles.finalRank}>#{index + 1}</span>
<FinalAvatar apiUrl={apiUrl} className={styles.finalScoreAvatar} user={item} />
<strong>{getDisplayName(item)}</strong>
<span className={styles.finalScorePoints}>{item.totalPoints} pts</span>
</article>
))}
</div>
) : null}
<div className={styles.finalAnswersSection}>
<div className={styles.recapSectionHeading}>
<div>
<span className={styles.recapSectionEyebrow}>Verdict</span>
<h2>Tes réponses face au résultat</h2>
</div>
</div>
<div className={styles.finalAnswerGrid}>
{myResponses.map((item) => {
const winnerItem = winnerResponsesByCardId[item.card.id];
const winnerValue = winnerItem?.entry?.values.length
? winnerItem.entry.values.map((value) => formatValueShort(value, winnerItem.card.unit)).join(" • ")
: "Pas de réponse";
const myValue = item.entry?.values.length
? item.entry.values.map((value) => formatValueShort(value, item.card.unit)).join(" • ")
: "Aucune réponse";
return (
<article key={item.card.id} className={styles.finalAnswerCard}>
<div className={styles.finalAnswerIconWrap} aria-hidden="true">
<Image src={getCardIcon(item.card.code)} alt="" width={52} height={52} className={styles.finalAnswerIcon} />
</div>
<div className={styles.finalAnswerText}>
<h3>{item.card.title}</h3>
<dl>
<div>
<dt>Résultat</dt>
<dd>{winnerValue}</dd>
</div>
<div>
<dt>Toi</dt>
<dd>{myValue}</dd>
</div>
</dl>
</div>
</article>
);
})}
</div>
</div>
</section>
) : null}
{!loading && !isFinalized && cards.length > 0 ? (
<>
{Array.from({ length: babyCount }, (_, i) => i + 1).map((babyIdx) => (
<section
key={babyIdx}
className={styles.tilesGrid}
style={{ display: babyIdx === activeBabyIndex ? undefined : "none" }}
aria-hidden={babyIdx !== activeBabyIndex}
>
{cards.map((card) => (
<PredictionCardFactory
key={`${card.id}::${babyIdx}`}
card={card}
entry={entriesByCardId[entryKey(card.id, babyIdx)]}
theme={predictionTheme}
disabled={isGameClosed}
showSubmitButton={false}
onSave={makeCardSaveHandler(babyIdx)}
onController={(cardId, controller) =>
registerController(`${cardId}::${babyIdx}`, controller)
}
onDirtyChange={notifyDirtyChange}
onSexSelect={(value) => {
setDraftSexValuesByBaby((current) => ({
...current,
[babyIdx]: value,
}));
}}
/>
))}
</section>
))}
<section className={`panel ${styles.globalSaveBar}`}>
<div>
<strong>Sauvegarde globale</strong>
<p className={styles.activityMeta}>
{babyCount > 1
? "Toutes tes modifications (pour tous les bebes) seront enregistrees."
: "Seules les valeurs changees seront envoyees et historisees."}
</p>
</div>
<button
className="btn btn-primary"
onClick={() => {
void onGlobalSave();
}}
disabled={globalSaving || isGameClosed}
>
{globalSaving ? "Enregistrement..." : "Enregistrer mes pronostics"}
</button>
</section>
{globalMessage ? (
<section className={`panel ${styles.panelBlock}`}>
<p className={failureCountFromMessage(globalMessage) ? styles.error : styles.success}>
{globalMessage}
</p>
</section>
) : null}
</>
) : null}
</>
);
}
function failureCountFromMessage(message: string) {
return message.includes("echec");
}
File diff suppressed because it is too large Load Diff
+581
View File
@@ -0,0 +1,581 @@
.page {
display: grid;
gap: 0.75rem;
padding: 0.75rem;
}
.header {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
background: var(--panel);
border-radius: var(--radius-card);
border: 1px solid var(--border-light);
box-shadow: var(--shadow-card);
}
.header h1 {
color: var(--ink-0);
font-size: 1.3rem;
font-weight: 800;
}
.actions {
display: flex;
gap: 0.5rem;
}
/* === Bloc profil unifié === */
.content {
padding: 1.25rem;
display: grid;
gap: 1.25rem;
}
/* Avatar */
.avatarSection {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
}
.avatarBtn {
position: relative;
width: 90px;
height: 90px;
border-radius: 50%;
border: none;
padding: 0;
cursor: pointer;
background: transparent;
flex-shrink: 0;
overflow: hidden;
}
.avatar {
width: 90px;
height: 90px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--teal);
display: block;
}
.avatarPlaceholder {
width: 90px;
height: 90px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--panel-warm);
color: var(--muted);
border: 2px dashed var(--border);
}
.avatarOverlay {
position: absolute;
inset: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.35);
display: grid;
place-items: center;
opacity: 0;
transition: opacity 0.18s ease;
}
.avatarBtn:hover .avatarOverlay,
.avatarBtn:focus-visible .avatarOverlay {
opacity: 1;
}
.avatarHint {
font-size: 0.72rem;
color: var(--muted);
background: none;
border: none;
cursor: pointer;
padding: 0;
text-decoration: underline;
text-underline-offset: 2px;
}
.avatarHint:hover {
color: var(--accent);
}
/* Identity rows */
.identitySection {
display: grid;
gap: 0.85rem;
}
.infoRow {
display: flex;
align-items: baseline;
justify-content: space-between;
padding-bottom: 0.6rem;
border-bottom: 1px solid var(--border-light);
}
.infoLabel {
font-size: 0.78rem;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.infoValue {
font-weight: 600;
color: var(--ink-0);
}
.pseudoForm {
display: flex;
gap: 0.5rem;
align-items: flex-end;
}
.pseudoField {
display: grid;
gap: 0.35rem;
flex: 1;
}
.pseudoInput {
border: 1px solid var(--border);
border-radius: var(--radius-input);
padding: 0.55rem 0.85rem;
font-size: 0.95rem;
font-family: inherit;
background: var(--bg-1);
color: var(--ink-0);
outline: none;
transition: border-color 0.15s;
width: 100%;
}
.pseudoInput:focus {
border-color: var(--accent);
}
.saveBtn {
white-space: nowrap;
flex-shrink: 0;
}
/* Message feedback */
.message {
border-radius: var(--radius-input);
background: #eef3e3;
padding: 0.6rem 0.85rem;
color: var(--ink-1);
font-size: 0.88rem;
}
/* === Modal photo === */
.modalBackdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: grid;
place-items: center;
z-index: 100;
padding: 1rem;
backdrop-filter: blur(3px);
}
.modal {
background: var(--panel);
border-radius: var(--radius-card);
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.18);
width: 100%;
max-width: 440px;
display: grid;
gap: 0.85rem;
padding: 1.25rem;
max-height: 90vh;
overflow-y: auto;
}
.modalHeader {
display: flex;
justify-content: space-between;
align-items: center;
}
.modalHeader h2 {
font-size: 1.05rem;
font-weight: 800;
color: var(--ink-0);
}
.modalClose {
background: none;
border: none;
font-size: 1rem;
color: var(--muted);
cursor: pointer;
line-height: 1;
padding: 0.25rem;
border-radius: 6px;
transition: color 0.15s;
}
.modalClose:hover {
color: var(--ink-0);
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border-light);
}
.tab {
flex: 1;
padding: 0.55rem 0.5rem;
font-size: 0.85rem;
font-weight: 700;
font-family: inherit;
color: var(--muted);
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
text-align: center;
}
.tab:hover {
color: var(--ink-1);
}
.tabActive {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* Drop zone */
.dropZone {
border: 2px dashed var(--border);
border-radius: var(--radius-card);
padding: 2rem 1rem;
display: grid;
place-items: center;
gap: 0.4rem;
cursor: pointer;
background: var(--bg-1);
text-align: center;
transition: border-color 0.15s, background 0.15s;
min-height: 160px;
}
.dropZone:hover,
.dropZoneActive {
border-color: var(--accent);
background: var(--accent-soft);
}
.dropIcon {
width: 40px;
height: 40px;
color: var(--muted);
}
.dropZoneActive .dropIcon,
.dropZone:hover .dropIcon {
color: var(--accent);
}
.dropText {
font-weight: 700;
color: var(--ink-1);
font-size: 0.95rem;
}
.dropSub {
font-size: 0.78rem;
color: var(--muted);
}
.hiddenInput {
display: none;
}
/* Crop */
.cropContainer {
display: flex;
justify-content: center;
padding: 0.5rem 0;
}
.cropCircle {
border-radius: 50%;
overflow: hidden;
position: relative;
cursor: grab;
touch-action: none;
box-shadow: 0 0 0 3px var(--border-light), 0 2px 12px rgba(0, 0, 0, 0.1);
}
.cropCircle:active {
cursor: grabbing;
}
.cropImg {
position: absolute;
top: 50%;
left: 50%;
transform-origin: center center;
pointer-events: none;
user-select: none;
display: block;
}
/* Zoom slider */
.zoomRow {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0 1rem;
}
.zoomLabel {
font-size: 0.9rem;
color: var(--muted);
font-weight: 700;
user-select: none;
}
.zoomSlider {
flex: 1;
appearance: none;
height: 4px;
border-radius: 2px;
background: var(--border);
outline: none;
}
.zoomSlider::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
}
.zoomSlider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
}
.changeFile {
background: none;
border: none;
cursor: pointer;
font-size: 0.8rem;
color: var(--muted);
text-decoration: underline;
text-underline-offset: 2px;
padding: 0;
text-align: center;
width: 100%;
}
.changeFile:hover {
color: var(--accent);
}
/* Current photo in modal */
.currentPhotoSection {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0;
}
.currentPhotoCircle {
width: 140px;
height: 140px;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 0 0 3px var(--border-light), 0 2px 12px rgba(0, 0, 0, 0.1);
}
.currentPhotoCircle img {
width: 100%;
height: 100%;
object-fit: cover;
}
.currentPhotoHint {
font-size: 0.78rem;
color: var(--muted);
font-weight: 600;
}
.currentPhotoActions {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
}
.deletePhotoBtn {
background: none;
border: none;
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
color: var(--accent-strong);
text-decoration: underline;
text-underline-offset: 2px;
padding: 0;
}
.deletePhotoBtn:hover {
color: var(--accent);
}
.deletePhotoBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Default avatars grid */
.defaultGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 0.65rem;
padding: 0.5rem 0;
}
.defaultItem {
aspect-ratio: 1;
border-radius: 50%;
border: 3px solid transparent;
padding: 0;
cursor: pointer;
overflow: hidden;
display: grid;
place-items: center;
transition: border-color 0.15s, transform 0.15s;
}
.defaultItem img {
width: 65%;
height: 65%;
object-fit: contain;
}
.defaultItem:hover {
transform: scale(1.08);
}
.defaultItemActive {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
/* Color picker */
.colorSection {
display: grid;
gap: 0.5rem;
}
.colorLabel {
font-size: 0.78rem;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.colorRow {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
align-items: center;
}
.colorDot {
width: 30px;
height: 30px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
transition: transform 0.12s, border-color 0.12s;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.colorDot:hover {
transform: scale(1.12);
}
.colorDotActive {
border-color: var(--ink-0);
box-shadow: 0 0 0 2px var(--panel), 0 0 0 4px var(--ink-0);
}
.colorCustom {
background: conic-gradient(
#f99, #ff9, #9f9, #9ff, #99f, #f9f, #f99
);
display: grid;
place-items: center;
}
.colorCustom svg {
opacity: 0.6;
}
.nativeColorPicker {
width: 100%;
height: 36px;
border: 1px solid var(--border);
border-radius: var(--radius-input);
cursor: pointer;
padding: 2px;
background: var(--bg-1);
}
/* Error */
.modalError {
font-size: 0.82rem;
color: var(--accent-strong);
background: var(--accent-soft);
border-radius: var(--radius-input);
padding: 0.5rem 0.75rem;
}
/* Actions */
.modalActions {
display: flex;
gap: 0.65rem;
justify-content: flex-end;
}
/* Responsive */
@media (max-width: 860px) {
.header {
flex-direction: column;
align-items: flex-start;
}
}
+608
View File
@@ -0,0 +1,608 @@
"use client";
import {
DragEvent,
FormEvent,
PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import NextImage from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
authenticatedFetch,
clearSession,
getApiUrl,
loadSession,
logoutSession,
} from "@/lib/auth";
import styles from "./page.module.css";
import navStyles from "@/features/predictions/styles/predictions.module.css";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type ProfileResponse = {
id: string;
username: string;
displayName: string | null;
profileImageUrl: string | null;
profileBgColor: string | null;
role: "ADMIN" | "FAMILY";
createdAt: string;
};
type CropState = { x: number; y: number; scale: number };
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const PASTEL_COLORS = [
"#F9D5E5", // rose
"#D5EAF7", // bleu
"#D5F5E3", // vert
"#FFF3CD", // jaune
"#E8D5F5", // lilas
"#FFE0CC", // peche
];
const CIRCLE_SIZE = 200;
const MIN_SCALE = 0.5;
const MAX_SCALE = 3;
/* ------------------------------------------------------------------ */
/* Page */
/* ------------------------------------------------------------------ */
export default function ProfilePage() {
const router = useRouter();
const apiUrl = getApiUrl();
/* profile state */
const [profile, setProfile] = useState<ProfileResponse | null>(null);
const [displayName, setDisplayName] = useState("");
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [menuOpen]);
/* modal state */
const [modalOpen, setModalOpen] = useState(false);
const [modalTab, setModalTab] = useState<"upload" | "defaults">("upload");
const [modalMessage, setModalMessage] = useState("");
/* upload / crop state */
const [file, setFile] = useState<File | null>(null);
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [imgNaturalSize, setImgNaturalSize] = useState<{ w: number; h: number } | null>(null);
const [baseScale, setBaseScale] = useState(1);
const [crop, setCrop] = useState<CropState>({ x: 0, y: 0, scale: 1 });
const [dragging, setDragging] = useState(false);
const [bgColor, setBgColor] = useState<string>(PASTEL_COLORS[0]);
const [showCustomPicker, setShowCustomPicker] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
/* default avatars */
const [defaultAvatars, setDefaultAvatars] = useState<string[]>([]);
const [selectedDefault, setSelectedDefault] = useState<string | null>(null);
/* pointer drag tracking */
const dragStartRef = useRef<{ px: number; py: number; cx: number; cy: number } | null>(null);
const cropPreviewRef = useRef<HTMLDivElement>(null);
/* canvas ref for rendering final crop */
const canvasRef = useRef<HTMLCanvasElement>(null);
/* ---------------------------------------------------------------- */
/* Data loading */
/* ---------------------------------------------------------------- */
const loadProfile = useCallback(async () => {
const response = await authenticatedFetch(apiUrl, "/users/me");
const payload = (await response.json()) as ProfileResponse | { message?: string };
if (!response.ok || !("id" in payload)) {
throw new Error((payload as { message?: string }).message ?? "Impossible de charger le profil");
}
setProfile(payload);
setDisplayName(payload.displayName ?? "");
if (payload.profileBgColor) setBgColor(payload.profileBgColor);
}, [apiUrl]);
useEffect(() => {
const session = loadSession();
if (!session) { router.push("/"); return; }
loadProfile().catch(() => { clearSession(); router.push("/"); });
}, [router, loadProfile]);
useEffect(() => {
authenticatedFetch(apiUrl, "/users/default-avatars")
.then((r) => r.json())
.then((data) => { if (Array.isArray(data)) setDefaultAvatars(data); })
.catch(() => {});
}, [apiUrl]);
/* ---------------------------------------------------------------- */
/* Pseudo */
/* ---------------------------------------------------------------- */
const onSaveDisplayName = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (displayName.trim().length > 0 && displayName.trim().length < 2) {
setMessage("Le pseudo doit contenir au moins 2 caracteres"); return;
}
setLoading(true); setMessage("");
try {
const response = await authenticatedFetch(apiUrl, "/users/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName: displayName.trim().length >= 2 ? displayName.trim() : undefined }),
});
const payload = (await response.json()) as ProfileResponse | { message?: string };
if (!response.ok || !("id" in payload)) { setMessage((payload as { message?: string }).message ?? "Mise a jour impossible"); return; }
setProfile(payload);
setMessage("Pseudo mis a jour ✓");
} catch { setMessage("Erreur reseau"); }
finally { setLoading(false); }
};
/* ---------------------------------------------------------------- */
/* File picking */
/* ---------------------------------------------------------------- */
const pickFile = (picked: File) => {
setFile(picked);
const src = URL.createObjectURL(picked);
setImageSrc(src);
setCrop({ x: 0, y: 0, scale: 1 });
setImgNaturalSize(null);
setBaseScale(1);
setSelectedDefault(null);
setModalTab("upload");
};
const onDragOver = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); setDragging(true); };
const onDragLeave = () => setDragging(false);
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); setDragging(false);
const dropped = e.dataTransfer.files[0];
if (dropped) pickFile(dropped);
};
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files?.[0]; if (picked) pickFile(picked);
};
/* ---------------------------------------------------------------- */
/* Crop drag */
/* ---------------------------------------------------------------- */
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!imageSrc) return;
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragStartRef.current = { px: e.clientX, py: e.clientY, cx: crop.x, cy: crop.y };
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragStartRef.current) return;
const { px, py, cx, cy } = dragStartRef.current;
setCrop((prev) => ({ ...prev, x: cx + (e.clientX - px), y: cy + (e.clientY - py) }));
};
const onPointerUp = () => { dragStartRef.current = null; };
const onWheel = (e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
setCrop((prev) => {
const delta = e.deltaY > 0 ? -0.05 : 0.05;
return { ...prev, scale: Math.min(MAX_SCALE, Math.max(MIN_SCALE, prev.scale + delta)) };
});
};
/* ---------------------------------------------------------------- */
/* Modal lifecycle */
/* ---------------------------------------------------------------- */
const openModal = () => {
setModalOpen(true);
setModalMessage("");
setFile(null);
setImageSrc(null);
setCrop({ x: 0, y: 0, scale: 1 });
setSelectedDefault(null);
setShowCustomPicker(false);
if (profile?.profileBgColor) setBgColor(profile.profileBgColor);
};
const closeModal = () => { setModalOpen(false); setFile(null); setImageSrc(null); setImgNaturalSize(null); setBaseScale(1); setModalMessage(""); };
/* ---------------------------------------------------------------- */
/* Delete current photo */
/* ---------------------------------------------------------------- */
const onDeletePhoto = async () => {
setLoading(true); setModalMessage("");
try {
const response = await authenticatedFetch(apiUrl, "/users/me/photo", { method: "DELETE" });
const payload = (await response.json()) as ProfileResponse | { message?: string };
if (!response.ok || !("id" in payload)) { setModalMessage((payload as { message?: string }).message ?? "Suppression impossible"); return; }
setProfile(payload);
setAvatarVersion(Date.now());
setMessage("Photo supprimee ✓");
} catch { setModalMessage("Erreur reseau"); }
finally { setLoading(false); }
};
/* ---------------------------------------------------------------- */
/* Render cropped image to blob */
/* ---------------------------------------------------------------- */
const renderCroppedBlob = (): Promise<Blob | null> => {
return new Promise((resolve) => {
if (!imageSrc || !imgNaturalSize) { resolve(null); return; }
const img = new window.Image();
img.onload = () => {
const canvas = canvasRef.current;
if (!canvas) { resolve(null); return; }
const size = 400;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) { resolve(null); return; }
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, size, size);
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, size, size);
// Mirror exactly the preview: baseScale brings image to cover the circle,
// then crop.scale and crop.x/y are applied on top.
const ratio = size / CIRCLE_SIZE;
const scaledW = imgNaturalSize.w * baseScale * crop.scale * ratio;
const scaledH = imgNaturalSize.h * baseScale * crop.scale * ratio;
const dx = (size - scaledW) / 2 + crop.x * ratio;
const dy = (size - scaledH) / 2 + crop.y * ratio;
ctx.drawImage(img, dx, dy, scaledW, scaledH);
canvas.toBlob((blob) => resolve(blob), "image/png");
};
img.src = imageSrc;
});
};
/* ---------------------------------------------------------------- */
/* Upload (custom image) */
/* ---------------------------------------------------------------- */
const onUploadPhoto = async () => {
setLoading(true); setModalMessage("");
try {
const blob = await renderCroppedBlob();
if (!blob) { setModalMessage("Erreur de recadrage."); return; }
const formData = new FormData();
formData.append("file", blob, "avatar.png");
formData.append("bgColor", bgColor);
const response = await authenticatedFetch(apiUrl, "/users/me/photo", { method: "POST", body: formData });
const payload = (await response.json()) as ProfileResponse | { message?: string };
if (!response.ok || !("id" in payload)) { setModalMessage((payload as { message?: string }).message ?? "Upload impossible"); return; }
setProfile(payload);
setAvatarVersion(Date.now());
setMessage("Photo de profil mise a jour ✓");
closeModal();
} catch { setModalMessage("Erreur reseau"); }
finally { setLoading(false); }
};
/* ---------------------------------------------------------------- */
/* Select default avatar */
/* ---------------------------------------------------------------- */
const onSelectDefaultAvatar = async () => {
if (!selectedDefault) return;
setLoading(true); setModalMessage("");
try {
const response = await authenticatedFetch(apiUrl, "/users/me/avatar", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatarUrl: selectedDefault, bgColor }),
});
const payload = (await response.json()) as ProfileResponse | { message?: string };
if (!response.ok || !("id" in payload)) { setModalMessage((payload as { message?: string }).message ?? "Erreur"); return; }
setProfile(payload);
setAvatarVersion(Date.now());
setMessage("Avatar mis a jour ✓");
closeModal();
} catch { setModalMessage("Erreur reseau"); }
finally { setLoading(false); }
};
/* ---------------------------------------------------------------- */
/* Logout */
/* ---------------------------------------------------------------- */
const logout = async () => { await logoutSession(apiUrl); window.location.replace("/"); };
/* ---------------------------------------------------------------- */
/* Derived */
/* ---------------------------------------------------------------- */
const [avatarVersion, setAvatarVersion] = useState(() => Date.now());
const profileImageSrc = profile?.profileImageUrl ? `${apiUrl}${profile.profileImageUrl}?v=${avatarVersion}` : null;
const profileBg = profile?.profileBgColor ?? undefined;
const hasCurrentPhoto = !!profile?.profileImageUrl;
const homeHref = profile?.role === "ADMIN" ? "/admin" : "/predictions";
const homeLabel = profile?.role === "ADMIN" ? "Retour admin" : "Mes pronostics";
const canSave = modalTab === "upload" ? !!imageSrc : !!selectedDefault;
/* ---------------------------------------------------------------- */
/* Render */
/* ---------------------------------------------------------------- */
const isAdmin = profile?.role === "ADMIN";
return (
<main className={`app-shell ${styles.page}`}>
<header className={navStyles.appHeader}>
<div className={navStyles.appHeaderLeft}>
<NextImage src="/images/icons/logo.png" alt="Logo" width={50} height={50} className={navStyles.headerLogo} />
<span className={navStyles.appTitle}>Le juste poids</span>
</div>
{isAdmin ? (
<div className={navStyles.menuWrapper}>
<Link className="btn btn-soft" href={homeHref}>{homeLabel}</Link>
<button type="button" className="btn btn-soft" onClick={logout}>Se déconnecter</button>
</div>
) : (
<div className={navStyles.menuWrapper} ref={menuRef}>
<button
className={navStyles.burgerBtn}
onClick={() => setMenuOpen(!menuOpen)}
aria-label="Menu"
type="button"
>
<span className={navStyles.burgerLine} />
<span className={navStyles.burgerLine} />
<span className={navStyles.burgerLine} />
</button>
{menuOpen && (
<nav className={navStyles.mobileMenu}>
<Link className={navStyles.tab} href="/predictions" onClick={() => setMenuOpen(false)}>Pronostics</Link>
<Link className={navStyles.tab} href="/predictions/history" onClick={() => setMenuOpen(false)}>Historique</Link>
<Link className={navStyles.tab} href="/predictions/recap" onClick={() => setMenuOpen(false)}>Recap &amp; Moyennes</Link>
<Link className={`${navStyles.tab} ${navStyles.tabActive}`} href="/profile" onClick={() => setMenuOpen(false)}>Mon profil</Link>
<div className={navStyles.menuDivider} />
<button type="button" className={`btn btn-soft ${navStyles.logoutBtn}`} onClick={logout}>Se déconnecter</button>
</nav>
)}
</div>
)}
</header>
<section className={`panel ${styles.content}`}>
{/* Avatar */}
<div className={styles.avatarSection}>
<button type="button" className={styles.avatarBtn} onClick={openModal} title="Modifier la photo de profil"
style={profileBg ? { background: profileBg } : undefined}>
{profileImageSrc ? (
<img src={profileImageSrc} alt="Photo de profil" className={styles.avatar} />
) : (
<div className={styles.avatarPlaceholder}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4" /><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
</div>
)}
<div className={styles.avatarOverlay}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
</div>
</button>
<button type="button" className={styles.avatarHint} onClick={openModal}>Modifier la photo</button>
</div>
{/* Identity */}
<div className={styles.identitySection}>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Identifiant</span>
<span className={styles.infoValue}>{profile?.username ?? "-"}</span>
</div>
<form className={styles.pseudoForm} onSubmit={onSaveDisplayName}>
<div className={styles.pseudoField}>
<label htmlFor="displayName" className={styles.infoLabel}>Pseudo</label>
<input id="displayName" className={styles.pseudoInput} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} placeholder="Ton pseudo" />
</div>
<button className={`btn btn-primary ${styles.saveBtn}`} disabled={loading}>Sauver</button>
</form>
</div>
{message ? <p className={styles.message}>{message}</p> : null}
</section>
{/* ============ MODAL ============ */}
{modalOpen && (
<div className={styles.modalBackdrop} onClick={closeModal}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
{/* header */}
<div className={styles.modalHeader}>
<h2>Photo de profil</h2>
<button type="button" className={styles.modalClose} onClick={closeModal} aria-label="Fermer"></button>
</div>
{/* tabs */}
<div className={styles.tabs}>
<button type="button" className={`${styles.tab} ${modalTab === "upload" ? styles.tabActive : ""}`}
onClick={() => setModalTab("upload")}>Ma photo</button>
{defaultAvatars.length > 0 && (
<button type="button" className={`${styles.tab} ${modalTab === "defaults" ? styles.tabActive : ""}`}
onClick={() => setModalTab("defaults")}>Avatars</button>
)}
</div>
{/* ----- tab: upload ----- */}
{modalTab === "upload" && (
<>
{imageSrc ? (
/* --- Crop mode: new image selected --- */
<>
<div className={styles.cropContainer}>
<div ref={cropPreviewRef} className={styles.cropCircle}
style={{ width: CIRCLE_SIZE, height: CIRCLE_SIZE, background: bgColor }}
onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp}
onWheel={onWheel}>
{imageSrc && (
<img
src={imageSrc}
alt=""
className={styles.cropImg}
draggable={false}
onLoad={(e) => {
const el = e.currentTarget;
const bs = Math.max(
CIRCLE_SIZE / el.naturalWidth,
CIRCLE_SIZE / el.naturalHeight
);
setBaseScale(bs);
setImgNaturalSize({ w: el.naturalWidth, h: el.naturalHeight });
}}
style={imgNaturalSize ? {
width: imgNaturalSize.w * baseScale,
height: imgNaturalSize.h * baseScale,
marginLeft: -(imgNaturalSize.w * baseScale) / 2,
marginTop: -(imgNaturalSize.h * baseScale) / 2,
transform: `translate(${crop.x}px, ${crop.y}px) scale(${crop.scale})`,
} : { display: 'none' }}
/>
)}
</div>
</div>
<div className={styles.zoomRow}>
<span className={styles.zoomLabel}></span>
<input type="range" min={MIN_SCALE * 100} max={MAX_SCALE * 100} value={crop.scale * 100}
className={styles.zoomSlider}
onChange={(e) => setCrop((prev) => ({ ...prev, scale: Number(e.target.value) / 100 }))} />
<span className={styles.zoomLabel}>+</span>
</div>
<button type="button" className={styles.changeFile}
onClick={() => fileInputRef.current?.click()}>Changer l&apos;image</button>
</>
) : hasCurrentPhoto ? (
/* --- Current photo exists: show it with delete option --- */
<div className={styles.currentPhotoSection}>
<div className={styles.currentPhotoCircle} style={{ background: profileBg }}>
<img src={profileImageSrc!} alt="Photo actuelle" />
</div>
<p className={styles.currentPhotoHint}>Photo actuelle</p>
<div className={styles.currentPhotoActions}>
<button type="button" className={styles.changeFile}
onClick={() => fileInputRef.current?.click()}>Changer la photo</button>
<button type="button" className={styles.deletePhotoBtn}
onClick={onDeletePhoto} disabled={loading}>
{loading ? "Suppression…" : "Supprimer la photo"}
</button>
</div>
</div>
) : (
/* --- No photo: drag & drop zone --- */
<div className={`${styles.dropZone} ${dragging ? styles.dropZoneActive : ""}`}
onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
onClick={() => fileInputRef.current?.click()}>
<svg className={styles.dropIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
</svg>
<p className={styles.dropText}>Glisse une image ici</p>
<p className={styles.dropSub}>ou clique pour parcourir · PNG, JPG, WEBP</p>
</div>
)}
<input ref={fileInputRef} type="file" accept="image/png,image/jpeg,image/jpg,image/webp"
className={styles.hiddenInput} onChange={onFileInputChange} />
</>
)}
{/* ----- tab: defaults ----- */}
{modalTab === "defaults" && (
<div className={styles.defaultGrid}>
{defaultAvatars.map((url) => (
<button key={url} type="button"
className={`${styles.defaultItem} ${selectedDefault === url ? styles.defaultItemActive : ""}`}
style={{ background: bgColor }}
onClick={() => { setSelectedDefault(url); setFile(null); setImageSrc(null); }}>
<img src={`${apiUrl}${url}`} alt="" />
</button>
))}
</div>
)}
{/* ----- bg color picker — masqué si photo existante sans nouvelle sélection ----- */}
{(imageSrc || selectedDefault || !profile?.profileImageUrl) && (
<div className={styles.colorSection}>
<span className={styles.colorLabel}>Couleur de fond</span>
<div className={styles.colorRow}>
{PASTEL_COLORS.map((c) => (
<button key={c} type="button"
className={`${styles.colorDot} ${bgColor === c ? styles.colorDotActive : ""}`}
style={{ background: c }} onClick={() => setBgColor(c)} />
))}
<button type="button"
className={`${styles.colorDot} ${styles.colorCustom} ${!PASTEL_COLORS.includes(bgColor) ? styles.colorDotActive : ""}`}
style={!PASTEL_COLORS.includes(bgColor) ? { background: bgColor } : undefined}
onClick={() => setShowCustomPicker((v) => !v)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" /><path d="M2 12h20" />
</svg>
</button>
</div>
{showCustomPicker && (
<input type="color" className={styles.nativeColorPicker} value={bgColor}
onChange={(e) => setBgColor(e.target.value)} />
)}
</div>
)}
{/* error */}
{modalMessage && <p className={styles.modalError}>{modalMessage}</p>}
{/* actions */}
<div className={styles.modalActions}>
<button type="button" className="btn btn-soft" onClick={closeModal}>Annuler</button>
<button type="button" className="btn btn-primary" disabled={!canSave || loading}
onClick={modalTab === "upload" ? onUploadPhoto : onSelectDefaultAvatar}>
{loading ? "Envoi…" : "Enregistrer"}
</button>
</div>
</div>
</div>
)}
{/* hidden canvas for crop rendering */}
<canvas ref={canvasRef} style={{ display: "none" }} />
</main>
);
}
@@ -0,0 +1,111 @@
.photoField {
display: flex;
flex-direction: column;
gap: 0.5rem;
border-radius: 14px;
border: 2px dashed var(--border);
padding: 0.75rem;
transition: border-color 150ms, background 150ms;
background: transparent;
}
.photoField.dragOver {
border-color: var(--accent);
background: var(--accent-soft);
}
.photoList {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
@media (min-width: 480px) {
.photoList {
grid-template-columns: repeat(5, 1fr);
}
}
.photoItem {
position: relative;
aspect-ratio: 1;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--panel-warm);
}
.photoItem img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photoRemove {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: none;
cursor: pointer;
font-size: 0.65rem;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.photoAdd {
aspect-ratio: 1;
border-radius: 10px;
border: 2px dashed var(--border);
background: var(--panel-warm);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.2rem;
color: var(--muted);
font-weight: 700;
font-size: 1.4rem;
transition: border-color 120ms, color 120ms;
}
.photoAdd:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.photoField.dragOver .photoAdd {
border-color: var(--accent);
color: var(--accent);
}
.plusIcon {
line-height: 1;
}
.photoAddLabel {
font-size: 0.7rem;
font-weight: 700;
text-align: center;
line-height: 1.2;
}
.dropHint {
font-size: 0.72rem;
color: var(--muted);
text-align: center;
padding-bottom: 0.1rem;
}
.photoField.dragOver .dropHint {
color: var(--accent-strong);
font-weight: 700;
}
@@ -0,0 +1,128 @@
"use client";
import { DragEvent, useCallback, useRef, useState } from "react";
import styles from "./PhotoField.module.css";
type Photo = { id: string; url: string };
type Props = {
photos: Photo[];
maxPhotos?: number;
loading?: boolean;
apiBaseUrl: string;
onAdd: (files: File[]) => void;
onRemove: (photoId: string) => void;
};
export function PhotoField({
photos,
maxPhotos = 5,
loading = false,
apiBaseUrl,
onAdd,
onRemove,
}: Props) {
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const canAdd = photos.length < maxPhotos;
const remaining = maxPhotos - photos.length;
const handleFiles = useCallback(
(rawFiles: FileList | File[]) => {
const accepted = Array.from(rawFiles)
.filter((f) => f.type.startsWith("image/"))
.slice(0, remaining);
if (accepted.length > 0) onAdd(accepted);
},
[remaining, onAdd],
);
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (canAdd) setDragOver(true);
};
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setDragOver(false);
}
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
if (!canAdd || loading) return;
handleFiles(e.dataTransfer.files);
};
const openPicker = () => {
if (!canAdd || loading) return;
fileInputRef.current?.click();
};
return (
<div
className={`${styles.photoField} ${dragOver && canAdd ? styles.dragOver : ""}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
style={{ display: "none" }}
onChange={(e) => {
if (e.target.files) handleFiles(e.target.files);
e.target.value = "";
}}
/>
{/* Thumbnails */}
<div className={styles.photoList}>
{photos.map((ph) => (
<div key={ph.id} className={styles.photoItem}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`${apiBaseUrl}${ph.url}`} alt="" />
<button
type="button"
className={styles.photoRemove}
onClick={() => onRemove(ph.id)}
disabled={loading}
aria-label="Supprimer"
>
</button>
</div>
))}
{/* Add button */}
{canAdd && (
<button
type="button"
className={styles.photoAdd}
disabled={loading}
onClick={openPicker}
>
<span className={styles.plusIcon}>+</span>
<span className={styles.photoAddLabel}>
{remaining > 1 ? `${remaining} restantes` : "Photo"}
</span>
</button>
)}
</div>
{/* Drop hint */}
<p className={styles.dropHint}>
{canAdd
? dragOver
? "Relâchez pour ajouter"
: `Glissez-déposez jusqu'à ${remaining} photo${remaining > 1 ? "s" : ""} ici`
: `Maximum ${maxPhotos} photos atteint`}
</p>
</div>
);
}
@@ -0,0 +1,28 @@
import styles from "@/features/predictions/styles/predictions.module.css";
import type { FieldRendererProps } from "./types";
export function DateFieldRenderer({ card, draftValues, disabled, setDate }: FieldRendererProps) {
return (
<div className={styles.fieldGrid}>
{card.fields.map((field) => {
const draft = draftValues.find((value) => value.fieldId === field.id);
return (
<div key={field.id} className="field">
<label htmlFor={`field-${field.id}`}>
{field.label}
{field.points > 0 ? ` (${field.points} pts)` : ""}
</label>
<input
id={`field-${field.id}`}
type="date"
value={draft?.valueDate ?? ""}
disabled={disabled}
onChange={(event) => setDate(field.id, event.target.value)}
/>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,30 @@
import type { FieldRendererProps } from "./types";
import { DateFieldRenderer } from "./date-field-renderer";
import { MultiTextFieldRenderer } from "./multi-text-field-renderer";
import { NumberFieldRenderer } from "./number-field-renderer";
import { SelectFieldRenderer } from "./select-field-renderer";
import { TextFieldRenderer } from "./text-field-renderer";
export function FieldRendererRegistry(props: FieldRendererProps) {
if (props.card.valueType === "NUMBER") {
return <NumberFieldRenderer {...props} />;
}
if (props.card.valueType === "TEXT") {
return <TextFieldRenderer {...props} />;
}
if (props.card.valueType === "SELECT") {
return <SelectFieldRenderer {...props} />;
}
if (props.card.valueType === "MULTI_TEXT") {
return <MultiTextFieldRenderer {...props} />;
}
if (props.card.valueType === "DATE") {
return <DateFieldRenderer {...props} />;
}
return null;
}
@@ -0,0 +1,29 @@
import styles from "@/features/predictions/styles/predictions.module.css";
import type { FieldRendererProps } from "./types";
export function MultiTextFieldRenderer({ card, draftValues, disabled, setText }: FieldRendererProps) {
return (
<div className={styles.fieldGrid}>
{card.fields.map((field) => {
const draft = draftValues.find((value) => value.fieldId === field.id);
return (
<div key={field.id} className="field">
<label htmlFor={`field-${field.id}`}>
{field.label}
{field.points > 0 ? ` (${field.points} pts)` : ""}
</label>
<input
id={`field-${field.id}`}
type="text"
value={draft?.valueText ?? ""}
disabled={disabled}
onChange={(event) => setText(field.id, event.target.value)}
placeholder={field.isPrimary ? "Entrez vos idées..." : "Autre prénom..."}
/>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,43 @@
import styles from "@/features/predictions/styles/predictions.module.css";
import type { FieldRendererProps } from "./types";
export function NumberFieldRenderer({ card, draftValues, disabled, setNumber }: FieldRendererProps) {
return (
<div className={styles.fieldGrid}>
{card.fields.map((field) => {
const draft = draftValues.find((value) => value.fieldId === field.id);
const step = field.stepNumber ?? 0.01;
return (
<div key={field.id} className="field">
<div className={styles.numberInputRow}>
<input
id={`field-${field.id}`}
type="number"
min={field.minNumber ?? undefined}
max={field.maxNumber ?? undefined}
step={step}
value={draft?.valueNumber ?? ""}
disabled={disabled}
placeholder="..."
onChange={(event) => {
const raw = event.target.value;
if (raw.trim() === "") {
setNumber(field.id, null);
return;
}
const next = Number(raw);
if (Number.isNaN(next)) {
return;
}
setNumber(field.id, next);
}}
/>
{card.unit ? <span className={styles.numberUnit}>{card.unit}</span> : null}
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,70 @@
import Image from "next/image";
import styles from "@/features/predictions/styles/predictions.module.css";
import type { FieldRendererProps } from "./types";
const OPTION_ICONS: Record<string, string> = {
fille: "/images/pictos/choix-fille.png",
garcon: "/images/pictos/choix-garcon.png",
garçon: "/images/pictos/choix-garcon.png",
};
export function SelectFieldRenderer({ card, draftValues, disabled, setText }: FieldRendererProps) {
const hasOptionIcons = card.options.some(
(option) => OPTION_ICONS[option.value.toLowerCase()] != null,
);
return (
<div className={styles.fieldGrid}>
{card.fields.map((field) => {
const draft = draftValues.find((value) => value.fieldId === field.id);
if (hasOptionIcons) {
return (
<div key={field.id} className={styles.optionIconsRow}>
{card.options.map((option) => {
const iconSrc = OPTION_ICONS[option.value.toLowerCase()];
const isSelected = draft?.valueText === option.value;
return (
<button
key={option.id}
type="button"
className={`${styles.optionIconBtn} ${isSelected ? styles.optionIconBtnActive : ""}`}
disabled={disabled}
onClick={() => setText(field.id, option.value)}
>
{iconSrc ? (
<Image src={iconSrc} alt={option.label} width={44} height={44} />
) : null}
<span>{option.label}</span>
</button>
);
})}
</div>
);
}
return (
<div key={field.id} className="field">
<label htmlFor={`field-${field.id}`}>
{field.label}
{field.points > 0 ? ` (${field.points} pts)` : ""}
</label>
<select
id={`field-${field.id}`}
value={draft?.valueText ?? ""}
disabled={disabled}
onChange={(event) => setText(field.id, event.target.value)}
>
<option value="">Choisir...</option>
{card.options.map((option) => (
<option key={option.id} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,29 @@
import styles from "@/features/predictions/styles/predictions.module.css";
import type { FieldRendererProps } from "./types";
export function TextFieldRenderer({ card, draftValues, disabled, setText }: FieldRendererProps) {
return (
<div className={styles.fieldGrid}>
{card.fields.map((field) => {
const draft = draftValues.find((value) => value.fieldId === field.id);
return (
<div key={field.id} className="field">
<label htmlFor={`field-${field.id}`}>
{field.label}
{field.points > 0 ? ` (${field.points} pts)` : ""}
</label>
<input
id={`field-${field.id}`}
type="text"
value={draft?.valueText ?? ""}
disabled={disabled}
onChange={(event) => setText(field.id, event.target.value)}
placeholder="Entrez vos idées..."
/>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,10 @@
import type { PredictionCard, PredictionDraftValue } from "@/types/predictions";
export type FieldRendererProps = {
card: PredictionCard;
draftValues: PredictionDraftValue[];
disabled?: boolean;
setText: (fieldId: string, value: string) => void;
setNumber: (fieldId: string, value: number | null) => void;
setDate: (fieldId: string, value: string) => void;
};
@@ -0,0 +1,131 @@
import { useMemo } from "react";
import type { PredictionTheme } from "@/features/predictions/domain/prediction-theme";
import styles from "@/features/predictions/styles/predictions.module.css";
type WeightDoubleSliderProps = {
valueKg: number | null;
minKg: number;
maxKg: number;
disabled?: boolean;
theme?: PredictionTheme;
labelFontClassName?: string;
valueFontClassName?: string;
metaFontClassName?: string;
onChange: (nextValueKg: number) => void;
};
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function toKgAndGrams(valueKg: number) {
const wholeKg = Math.floor(valueKg);
const grams = Math.round((valueKg - wholeKg) * 1000);
return { wholeKg, grams };
}
function fillPct(value: number, min: number, max: number): string {
const pct = ((value - min) / (max - min)) * 100;
return `${Math.round(pct)}%`;
}
export function WeightDoubleSlider({
valueKg,
minKg,
maxKg,
disabled,
theme = "boy",
labelFontClassName,
valueFontClassName,
metaFontClassName,
onChange,
}: WeightDoubleSliderProps) {
const safeValue = useMemo(() => {
const fallback = clamp(minKg, minKg, maxKg);
if (valueKg == null || Number.isNaN(valueKg)) {
return fallback;
}
return clamp(valueKg, minKg, maxKg);
}, [maxKg, minKg, valueKg]);
const { wholeKg, grams } = toKgAndGrams(safeValue);
const kgFill = fillPct(wholeKg, Math.floor(minKg), Math.floor(maxKg));
const gramsFill = fillPct(Math.round(grams / 5) * 5, 0, 995);
const valueLabelClassName = [styles.weightValueLabel, labelFontClassName].filter(Boolean).join(" ");
const valueBadgeClassName = [
styles.weightValueBadge,
styles.predictionAnswerBadgeSurface,
styles.predictionAnswerBadgeSurfaceVisible,
theme === "girl" ? styles.predictionAnswerBadgeSurfaceGirl : styles.predictionAnswerBadgeSurfaceBoy,
valueFontClassName,
]
.filter(Boolean)
.join(" ");
const metaClassName = [styles.weightSliderMetaValue, metaFontClassName].filter(Boolean).join(" ");
const sliderClassName = [
styles.weightSlider,
theme === "girl" ? styles.weightSliderGirl : "",
]
.filter(Boolean)
.join(" ");
return (
<div className={styles.weightSliderBlock}>
{/* Weight value display */}
<div className={styles.weightValueRow}>
<span className={valueLabelClassName}>Mon pronostic :</span>
<span className={valueBadgeClassName}>{safeValue.toFixed(3)} kg</span>
</div>
{/* Kg slider */}
<div className={styles.weightSliderGroup}>
<input
id="weight-kg"
type="range"
min={Math.floor(minKg)}
max={Math.floor(maxKg)}
step={1}
value={wholeKg}
disabled={disabled}
className={sliderClassName}
style={{ "--fill-pct": kgFill } as React.CSSProperties}
onChange={(event) => {
const nextKg = Number(event.target.value);
if (Number.isNaN(nextKg)) return;
onChange(clamp(nextKg + grams / 1000, minKg, maxKg));
}}
/>
<div className={styles.weightSliderMeta}>
<span className={metaClassName}>{Math.floor(minKg)}kg</span>
<span className={metaClassName}>{Math.floor(maxKg)}kg</span>
</div>
</div>
{/* Grams slider */}
<div className={styles.weightSliderGroup}>
<input
id="weight-grams"
type="range"
min={0}
max={995}
step={5}
value={Math.round(grams / 5) * 5}
disabled={disabled}
className={sliderClassName}
style={{ "--fill-pct": gramsFill } as React.CSSProperties}
onChange={(event) => {
const nextGrams = Number(event.target.value);
if (Number.isNaN(nextGrams)) return;
onChange(clamp(wholeKg + nextGrams / 1000, minKg, maxKg));
}}
/>
<div className={styles.weightSliderMeta}>
<span className={metaClassName}>0g</span>
<span className={metaClassName}>995g</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import styles from "../styles/predictions.module.css";
const MAIN_TABS = [
{ href: "/predictions", label: "Pronostics", exact: true },
{ href: "/predictions/recap", label: "Récap", exact: false },
{ href: "/predictions/indices", label: "Indices", exact: false },
];
export function MainTabBar() {
const pathname = usePathname();
return (
<div className={styles.contentTabBar}>
{MAIN_TABS.map(({ href, label, exact }) => {
const isActive = exact ? pathname === href : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={`${styles.contentTabItem} ${isActive ? styles.contentTabItemActive : ""}`}
aria-current={isActive ? "page" : undefined}
>
{label}
</Link>
);
})}
</div>
);
}
@@ -0,0 +1,435 @@
"use client";
import {
DragEvent,
PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {
deleteProjectPhoto,
getProjectDefaultAvatars,
setProjectDefaultAvatar,
uploadProjectPhoto,
} from "@/lib/projects-client";
import { getApiUrl } from "@/lib/auth";
import type { ProjectSummary } from "@/types/projects";
import styles from "@/app/profile/page.module.css";
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const PASTEL_COLORS = [
"#F9D5E5",
"#D5EAF7",
"#D5F5E3",
"#FFF3CD",
"#E8D5F5",
"#FFE0CC",
];
const CIRCLE_SIZE = 200;
const MIN_SCALE = 0.5;
const MAX_SCALE = 3;
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
type Props = {
project: ProjectSummary;
onSuccess: (updated: ProjectSummary) => void;
onClose: () => void;
};
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function ProjectPhotoModal({ project, onSuccess, onClose }: Props) {
const apiUrl = getApiUrl();
const [modalTab, setModalTab] = useState<"upload" | "defaults">("upload");
const [modalMessage, setModalMessage] = useState("");
const [loading, setLoading] = useState(false);
/* upload / crop */
const [file, setFile] = useState<File | null>(null);
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [imgNaturalSize, setImgNaturalSize] = useState<{ w: number; h: number } | null>(null);
const [baseScale, setBaseScale] = useState(1);
const [crop, setCrop] = useState({ x: 0, y: 0, scale: 1 });
const [dragging, setDragging] = useState(false);
const [bgColor, setBgColor] = useState<string>(project.projectBgColor ?? PASTEL_COLORS[0]);
const [showCustomPicker, setShowCustomPicker] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const cropPreviewRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const dragStartRef = useRef<{ px: number; py: number; cx: number; cy: number } | null>(null);
/* default avatars */
const [defaultAvatars, setDefaultAvatars] = useState<string[]>([]);
const [selectedDefault, setSelectedDefault] = useState<string | null>(null);
useEffect(() => {
getProjectDefaultAvatars(project.id)
.then((data) => { if (Array.isArray(data)) setDefaultAvatars(data); })
.catch(() => {});
}, [project.id]);
/* ---------------------------------------------------------------- */
/* File picking */
/* ---------------------------------------------------------------- */
const pickFile = (picked: File) => {
setFile(picked);
const src = URL.createObjectURL(picked);
setImageSrc(src);
setCrop({ x: 0, y: 0, scale: 1 });
setImgNaturalSize(null);
setBaseScale(1);
setSelectedDefault(null);
setModalTab("upload");
};
const onDragOver = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); setDragging(true); };
const onDragLeave = () => setDragging(false);
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); setDragging(false);
const dropped = e.dataTransfer.files[0];
if (dropped) pickFile(dropped);
};
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files?.[0]; if (picked) pickFile(picked);
};
/* ---------------------------------------------------------------- */
/* Crop drag */
/* ---------------------------------------------------------------- */
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!imageSrc) return;
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragStartRef.current = { px: e.clientX, py: e.clientY, cx: crop.x, cy: crop.y };
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragStartRef.current) return;
const { px, py, cx, cy } = dragStartRef.current;
setCrop((prev) => ({ ...prev, x: cx + (e.clientX - px), y: cy + (e.clientY - py) }));
};
const onPointerUp = () => { dragStartRef.current = null; };
const onWheel = (e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
setCrop((prev) => {
const delta = e.deltaY > 0 ? -0.05 : 0.05;
return { ...prev, scale: Math.min(MAX_SCALE, Math.max(MIN_SCALE, prev.scale + delta)) };
});
};
/* ---------------------------------------------------------------- */
/* Render cropped image to blob */
/* ---------------------------------------------------------------- */
const renderCroppedBlob = useCallback((): Promise<Blob | null> => {
return new Promise((resolve) => {
if (!imageSrc || !imgNaturalSize) { resolve(null); return; }
const img = new window.Image();
img.onload = () => {
const canvas = canvasRef.current;
if (!canvas) { resolve(null); return; }
const size = 400;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) { resolve(null); return; }
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, size, size);
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, size, size);
const ratio = size / CIRCLE_SIZE;
const scaledW = imgNaturalSize.w * baseScale * crop.scale * ratio;
const scaledH = imgNaturalSize.h * baseScale * crop.scale * ratio;
const dx = (size - scaledW) / 2 + crop.x * ratio;
const dy = (size - scaledH) / 2 + crop.y * ratio;
ctx.drawImage(img, dx, dy, scaledW, scaledH);
canvas.toBlob((blob) => resolve(blob), "image/png");
};
img.src = imageSrc;
});
}, [imageSrc, imgNaturalSize, bgColor, baseScale, crop]);
/* ---------------------------------------------------------------- */
/* Actions */
/* ---------------------------------------------------------------- */
const onUploadPhoto = async () => {
setLoading(true); setModalMessage("");
try {
const blob = await renderCroppedBlob();
if (!blob) { setModalMessage("Erreur de recadrage."); return; }
const updated = await uploadProjectPhoto(project.id, blob, bgColor);
onSuccess(updated);
onClose();
} catch (err) {
setModalMessage(err instanceof Error ? err.message : "Erreur réseau");
} finally { setLoading(false); }
};
const onSelectDefaultAvatar = async () => {
if (!selectedDefault) return;
setLoading(true); setModalMessage("");
try {
const updated = await setProjectDefaultAvatar(project.id, selectedDefault, bgColor);
onSuccess(updated);
onClose();
} catch (err) {
setModalMessage(err instanceof Error ? err.message : "Erreur réseau");
} finally { setLoading(false); }
};
const onDeletePhoto = async () => {
setLoading(true); setModalMessage("");
try {
const updated = await deleteProjectPhoto(project.id);
onSuccess(updated);
} catch (err) {
setModalMessage(err instanceof Error ? err.message : "Erreur réseau");
} finally { setLoading(false); }
};
/* ---------------------------------------------------------------- */
/* Derived */
/* ---------------------------------------------------------------- */
const hasCurrentPhoto = !!project.projectImageUrl;
const currentImageSrc = project.projectImageUrl ? `${apiUrl}${project.projectImageUrl}` : null;
const canSave = modalTab === "upload" ? !!imageSrc : !!selectedDefault;
/* ---------------------------------------------------------------- */
/* Render */
/* ---------------------------------------------------------------- */
return (
<div className={styles.modalBackdrop} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<canvas ref={canvasRef} style={{ display: "none" }} />
{/* header */}
<div className={styles.modalHeader}>
<h2>Photo du projet</h2>
<button type="button" className={styles.modalClose} onClick={onClose} aria-label="Fermer"></button>
</div>
{/* tabs */}
<div className={styles.tabs}>
<button
type="button"
className={`${styles.tab} ${modalTab === "upload" ? styles.tabActive : ""}`}
onClick={() => setModalTab("upload")}
>
Ma photo
</button>
{defaultAvatars.length > 0 && (
<button
type="button"
className={`${styles.tab} ${modalTab === "defaults" ? styles.tabActive : ""}`}
onClick={() => setModalTab("defaults")}
>
Avatars
</button>
)}
</div>
{/* ----- tab: upload ----- */}
{modalTab === "upload" && (
<>
{imageSrc ? (
<>
<div className={styles.cropContainer}>
<div
ref={cropPreviewRef}
className={styles.cropCircle}
style={{ width: CIRCLE_SIZE, height: CIRCLE_SIZE, background: bgColor }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onWheel={onWheel}
>
<img
src={imageSrc}
alt=""
className={styles.cropImg}
draggable={false}
onLoad={(e) => {
const el = e.currentTarget;
const bs = Math.max(CIRCLE_SIZE / el.naturalWidth, CIRCLE_SIZE / el.naturalHeight);
setBaseScale(bs);
setImgNaturalSize({ w: el.naturalWidth, h: el.naturalHeight });
}}
style={imgNaturalSize ? {
width: imgNaturalSize.w * baseScale,
height: imgNaturalSize.h * baseScale,
marginLeft: -(imgNaturalSize.w * baseScale) / 2,
marginTop: -(imgNaturalSize.h * baseScale) / 2,
transform: `translate(${crop.x}px, ${crop.y}px) scale(${crop.scale})`,
} : { display: "none" }}
/>
</div>
</div>
<div className={styles.zoomRow}>
<span className={styles.zoomLabel}></span>
<input
type="range"
min={MIN_SCALE * 100}
max={MAX_SCALE * 100}
value={crop.scale * 100}
className={styles.zoomSlider}
onChange={(e) => setCrop((prev) => ({ ...prev, scale: Number(e.target.value) / 100 }))}
/>
<span className={styles.zoomLabel}>+</span>
</div>
<button type="button" className={styles.changeFile} onClick={() => fileInputRef.current?.click()}>
Changer l&apos;image
</button>
</>
) : hasCurrentPhoto ? (
<div className={styles.currentPhotoSection}>
<div className={styles.currentPhotoCircle} style={{ background: project.projectBgColor ?? undefined }}>
<img src={currentImageSrc!} alt="Photo actuelle" />
</div>
<p className={styles.currentPhotoHint}>Photo actuelle</p>
<div className={styles.currentPhotoActions}>
<button type="button" className={styles.changeFile} onClick={() => fileInputRef.current?.click()}>
Changer la photo
</button>
<button
type="button"
className={styles.deletePhotoBtn}
onClick={onDeletePhoto}
disabled={loading}
>
{loading ? "Suppression…" : "Supprimer la photo"}
</button>
</div>
</div>
) : (
<div
className={`${styles.dropZone} ${dragging ? styles.dropZoneActive : ""}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={() => fileInputRef.current?.click()}
>
<svg className={styles.dropIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<p className={styles.dropText}>Glisse une image ici</p>
<p className={styles.dropSub}>ou clique pour parcourir · PNG, JPG, WEBP</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp"
className={styles.hiddenInput}
onChange={onFileInputChange}
/>
</>
)}
{/* ----- tab: defaults ----- */}
{modalTab === "defaults" && (
<div className={styles.defaultGrid}>
{defaultAvatars.map((url) => (
<button
key={url}
type="button"
className={`${styles.defaultItem} ${selectedDefault === url ? styles.defaultItemActive : ""}`}
style={{ background: bgColor }}
onClick={() => { setSelectedDefault(url); setFile(null); setImageSrc(null); }}
>
<img src={`${apiUrl}${url}`} alt="" />
</button>
))}
</div>
)}
{/* color picker (masqué si photo existante sans nouvelle sélection) */}
{(imageSrc || selectedDefault || !project.projectImageUrl) && (
<div className={styles.colorSection}>
<span className={styles.colorLabel}>Couleur de fond</span>
<div className={styles.colorRow}>
{PASTEL_COLORS.map((c) => (
<button
key={c}
type="button"
className={`${styles.colorDot} ${bgColor === c ? styles.colorDotActive : ""}`}
style={{ background: c }}
onClick={() => setBgColor(c)}
/>
))}
<button
type="button"
className={`${styles.colorDot} ${styles.colorCustom} ${!PASTEL_COLORS.includes(bgColor) ? styles.colorDotActive : ""}`}
style={!PASTEL_COLORS.includes(bgColor) ? { background: bgColor } : undefined}
onClick={() => setShowCustomPicker((v) => !v)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" /><path d="M2 12h20" />
</svg>
</button>
</div>
{showCustomPicker && (
<input
type="color"
className={styles.nativeColorPicker}
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
/>
)}
</div>
)}
{modalMessage && <p className={styles.modalError}>{modalMessage}</p>}
{/* actions */}
{canSave && (
<div className={styles.modalActions}>
<button type="button" className="btn btn-soft" onClick={onClose} disabled={loading}>
Annuler
</button>
<button
type="button"
className="btn btn-primary"
onClick={modalTab === "upload" ? onUploadPhoto : onSelectDefaultAvatar}
disabled={loading}
>
{loading ? "Enregistrement…" : "Enregistrer"}
</button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,175 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
getCurrentProjectId,
PROJECT_CHANGED_EVENT,
setCurrentProjectId,
} from "@/lib/auth";
import { getMyProjects } from "@/lib/projects-client";
import type { ProjectSummary } from "@/types/projects";
import styles from "@/features/predictions/styles/predictions.module.css";
type ProjectSwitcherProps = {
showCreateAction?: boolean;
className?: string;
onProjectChanged?: (projectId: string) => void;
};
export function ProjectSwitcher({
showCreateAction = false,
className,
onProjectChanged,
}: ProjectSwitcherProps) {
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [currentProjectId, setCurrentProjectIdState] = useState<string | null>(
getCurrentProjectId(),
);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState("");
const onProjectChangedRef = useRef(onProjectChanged);
useEffect(() => {
onProjectChangedRef.current = onProjectChanged;
}, [onProjectChanged]);
const currentProject = useMemo(
() => projects.find((project) => project.id === currentProjectId) ?? null,
[currentProjectId, projects],
);
const selectProject = useCallback(
(projectId: string) => {
setCurrentProjectId(projectId);
setCurrentProjectIdState(projectId);
onProjectChangedRef.current?.(projectId);
},
[],
);
const reloadProjects = useCallback(() => {
setLoading(true);
setMessage("");
getMyProjects()
.then((loadedProjects) => {
setProjects(loadedProjects);
if (loadedProjects.length === 0) {
setCurrentProjectIdState(null);
setCurrentProjectId(null);
return;
}
const savedProjectId = getCurrentProjectId();
const preferredId = savedProjectId;
const targetProject =
loadedProjects.find((project) => project.id === preferredId) ?? loadedProjects[0];
if (!targetProject) {
return;
}
if (targetProject.id !== savedProjectId) {
selectProject(targetProject.id);
} else {
setCurrentProjectIdState(targetProject.id);
}
})
.catch((error) => {
setMessage(error instanceof Error ? error.message : "Impossible de charger les projets");
})
.finally(() => {
setLoading(false);
});
}, [selectProject]);
useEffect(() => {
reloadProjects();
}, [reloadProjects]);
useEffect(() => {
const handleProjectChange = (event: Event) => {
const customEvent = event as CustomEvent<{ projectId?: string | null }>;
const nextProjectId = customEvent.detail?.projectId ?? null;
setCurrentProjectIdState(nextProjectId);
};
window.addEventListener(PROJECT_CHANGED_EVENT, handleProjectChange as EventListener);
return () => {
window.removeEventListener(PROJECT_CHANGED_EVENT, handleProjectChange as EventListener);
};
}, []);
if (loading) {
return (
<div className={`${styles.projectSwitcher} ${className ?? ""}`.trim()}>
<span className={styles.projectSwitcherMeta}>Chargement des projets...</span>
</div>
);
}
if (message) {
return (
<div className={`${styles.projectSwitcher} ${className ?? ""}`.trim()}>
<span className={styles.projectSwitcherError}>{message}</span>
</div>
);
}
if (projects.length === 0) {
return (
<div className={`${styles.projectSwitcher} ${className ?? ""}`.trim()}>
<span className={styles.projectSwitcherMeta}>Aucun projet disponible</span>
{showCreateAction ? (
<Link className="btn btn-soft" href="/admin/projects/new">
Creer un projet
</Link>
) : null}
</div>
);
}
return (
<div className={`${styles.projectSwitcher} ${className ?? ""}`.trim()}>
<label htmlFor="projectSwitcher" className={styles.projectSwitcherLabel}>Projet actif</label>
<select
id="projectSwitcher"
className={styles.projectSwitcherSelect}
value={currentProjectId ?? ""}
onChange={(event) => {
const nextProjectId = event.target.value;
if (!nextProjectId || nextProjectId === currentProjectId) {
return;
}
selectProject(nextProjectId);
}}
>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name} ({project.babyCount} bebe{project.babyCount > 1 ? "s" : ""})
</option>
))}
</select>
{currentProject ? (
<span className={styles.projectSwitcherMeta}>
{currentProject.status === "OPEN"
? "Ouvert"
: currentProject.status === "CLOSED"
? "Cloture"
: currentProject.status === "FINALIZED"
? "Finalise"
: "Brouillon"}
</span>
) : null}
{showCreateAction ? (
<Link className="btn btn-soft" href="/admin/projects/new">
Nouveau projet
</Link>
) : null}
</div>
);
}
@@ -0,0 +1,46 @@
import { FieldRendererRegistry } from "@/features/predictions/components/fields/field-renderer-registry";
import { PredictionTileBase } from "@/features/predictions/components/tiles/prediction-tile-base";
import { getCardIcon } from "@/features/predictions/domain/card-icons";
import type { PredictionTileController } from "@/features/predictions/hooks/use-prediction-tile-controller";
import type { PredictionCard } from "@/types/predictions";
type CustomPredictionTileProps = {
card: PredictionCard;
controller: PredictionTileController;
disabled?: boolean;
showSubmitButton?: boolean;
};
export function CustomPredictionTile({
card,
controller,
disabled,
showSubmitButton,
}: CustomPredictionTileProps) {
return (
<PredictionTileBase
title={card.title}
description={card.description}
dirty={controller.dirty}
saving={controller.saving}
disabled={disabled}
showSubmitButton={showSubmitButton}
successMessage={controller.successMessage}
errorMessage={controller.errorMessage}
basePoints={card.basePoints}
iconSrc={getCardIcon(card.code)}
onSubmit={() => {
void controller.submit();
}}
>
<FieldRendererRegistry
card={card}
draftValues={controller.draftValues}
disabled={disabled}
setText={controller.setText}
setNumber={controller.setNumber}
setDate={controller.setDate}
/>
</PredictionTileBase>
);
}
@@ -0,0 +1,165 @@
import { useMemo, useState } from "react";
import Image from "next/image";
import type { PredictionTileController } from "@/features/predictions/hooks/use-prediction-tile-controller";
import { predictionTitleFont } from "@/features/predictions/components/tiles/prediction-tile-fonts";
import styles from "@/features/predictions/styles/predictions.module.css";
import type { PredictionCard } from "@/types/predictions";
type LegacyNamesPredictionTileProps = {
card: PredictionCard;
controller: PredictionTileController;
disabled?: boolean;
showSubmitButton?: boolean;
};
export function LegacyNamesPredictionTile({
card,
controller,
disabled,
showSubmitButton,
}: LegacyNamesPredictionTileProps) {
const nameFields = card.fields.slice(0, 6);
const [modalOpen, setModalOpen] = useState(false);
const [localNames, setLocalNames] = useState<string[]>([]);
const enteredNames = useMemo(
() =>
nameFields
.map((field) => controller.draftValues.find((value) => value.fieldId === field.id)?.valueText ?? "")
.map((value) => value.trim())
.filter((value) => value.length > 0),
[controller.draftValues, nameFields],
);
const summary = enteredNames.length > 0 ? enteredNames.join(", ") : "Entrez vos idées...";
const totalPoints = nameFields.reduce((sum, field) => sum + field.points, 0);
if (nameFields.length === 0) {
return null;
}
const openModal = () => {
setLocalNames(
nameFields.map(
(field) => controller.draftValues.find((value) => value.fieldId === field.id)?.valueText ?? "",
),
);
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
};
const saveNames = () => {
nameFields.forEach((field, index) => {
controller.setText(field.id, localNames[index]?.trim() ?? "");
});
setModalOpen(false);
};
return (
<>
<article className={`${styles.tile} ${styles.legacyMiniTile} ${styles.legacyNamesTile}`}>
<div className={styles.legacyMiniHeader}>
<h3 className={`${predictionTitleFont.className} ${styles.legacyNamesTitleCentered}`}>Prénom(s) ?</h3>
<Image
src="/images/pictos/bebe-fille.png"
alt=""
width={2154}
height={1984}
className={styles.legacyNamesTopBaby}
/>
</div>
<button
type="button"
className={`${styles.namesLauncher} ${enteredNames.length > 0 ? styles.namesLauncherFilled : ""}`}
onClick={openModal}
>
<span>{summary}</span>
</button>
<div className={styles.legacyNamesHelper}>
Gagnez :<br />
{nameFields[0] ? <>{nameFields[0].points} Points le principal&nbsp;!</> : null}
{nameFields[1] ? <><br />{nameFields[1].points} points un secondaire&nbsp;!</> : null}
</div>
<Image
src="/images/pictos/jouet-bebe.png"
alt=""
width={44}
height={44}
className={styles.legacyNamesToy}
/>
<div className={styles.legacyMiniFooter}>
{showSubmitButton ? (
<button
className={`btn btn-primary ${styles.legacyMiniButton}`}
onClick={() => void controller.submit()}
disabled={controller.saving || disabled}
type="button"
>
{controller.saving ? "Validation..." : "Valider mon pronostic"}
</button>
) : null}
{controller.dirty ? (
<span className={styles.dirty}>Modifications non enregistrees</span>
) : null}
{controller.successMessage ? <p className={styles.success}>{controller.successMessage}</p> : null}
{controller.errorMessage ? <p className={styles.error}>{controller.errorMessage}</p> : null}
</div>
</article>
{modalOpen ? (
<div className={styles.predictionModalBackdrop} onClick={closeModal}>
<div className={styles.predictionModal} onClick={(event) => event.stopPropagation()}>
<div className={styles.predictionModalHeader}>
<div>
<h3 className={predictionTitleFont.className}>Prenom(s)</h3>
<p>Ajoutez jusqu&apos;a {nameFields.length} prenoms. Le premier reste obligatoire.</p>
</div>
<button
type="button"
className={styles.predictionModalClose}
aria-label="Fermer"
onClick={closeModal}
>
×
</button>
</div>
<div className={styles.predictionModalGrid}>
{nameFields.map((field, index) => (
<label key={field.id} className={styles.predictionModalField}>
<span>Prenom {index + 1}</span>
<input
type="text"
value={localNames[index] ?? ""}
placeholder={index === 0 ? "Ex: Laura" : "Autre idee..."}
onChange={(event) => {
const nextValues = [...localNames];
nextValues[index] = event.target.value;
setLocalNames(nextValues);
}}
/>
</label>
))}
</div>
<div className={styles.predictionModalActions}>
<button type="button" className="btn btn-soft" onClick={closeModal}>
Annuler
</button>
<button type="button" className="btn btn-primary" onClick={saveNames}>
Valider mes prenoms
</button>
</div>
</div>
</div>
) : null}
</>
);
}
@@ -0,0 +1,170 @@
import Image from "next/image";
import type { PredictionTileController } from "@/features/predictions/hooks/use-prediction-tile-controller";
import { predictionTitleFont } from "@/features/predictions/components/tiles/prediction-tile-fonts";
import styles from "@/features/predictions/styles/predictions.module.css";
import type { PredictionCard } from "@/types/predictions";
const MEASURE_CONFIG: Record<
string,
{ title: string; iconSrc: string; imgWidth: number; imgHeight: number; step?: number }
> = {
legacy_head: {
title: "Périmètre Crânien\u00A0?",
iconSrc: "/depotARanger/perimetre-cranien.png",
imgWidth: 1348,
imgHeight: 1542,
step: 0.5,
},
legacy_height: {
title: "Taille ?",
iconSrc: "/depotARanger/taille-baby.png",
imgWidth: 1346,
imgHeight: 1824,
step: 0.5,
},
};
function clampValue(value: number, min?: number, max?: number) {
if (typeof min === "number" && value < min) {
return min;
}
if (typeof max === "number" && value > max) {
return max;
}
return value;
}
function getStepPrecision(step: number) {
const decimals = step.toString().split(".")[1];
return decimals?.length ?? 0;
}
function normalizeMeasureValue(value: number, step: number, min?: number, max?: number) {
const roundedValue = Math.round(value / step) * step;
const clampedValue = clampValue(roundedValue, min, max);
return Number(clampedValue.toFixed(getStepPrecision(step)));
}
type LegacyNumberPredictionTileProps = {
card: PredictionCard;
controller: PredictionTileController;
disabled?: boolean;
showSubmitButton?: boolean;
};
export function LegacyNumberPredictionTile({
card,
controller,
disabled,
showSubmitButton,
}: LegacyNumberPredictionTileProps) {
const mainField = card.fields[0];
const currentValue =
controller.draftValues.find((value) => value.fieldId === mainField?.id)?.valueNumber ?? null;
const measureConfig = card.code ? MEASURE_CONFIG[card.code] : null;
const step = measureConfig?.step ?? mainField?.stepNumber ?? 0.1;
if (!mainField || !measureConfig) {
return null;
}
const minValue = mainField.minNumber ?? undefined;
const maxValue = mainField.maxNumber ?? undefined;
const canDecrease = !disabled && (currentValue ?? minValue ?? 0) > (minValue ?? Number.NEGATIVE_INFINITY);
const canIncrease = !disabled && (currentValue ?? minValue ?? 0) < (maxValue ?? Number.POSITIVE_INFINITY);
const applyStepDelta = (direction: -1 | 1) => {
const baseValue = currentValue ?? minValue ?? 0;
const nextValue = normalizeMeasureValue(baseValue + direction * step, step, minValue, maxValue);
controller.setNumber(mainField.id, nextValue);
};
return (
<article className={`${styles.tile} ${styles.legacyMiniTile} ${styles.legacyMeasureTile}`}>
<div className={styles.legacyMiniHeader}>
<h3 className={predictionTitleFont.className}>{measureConfig.title}</h3>
</div>
<div className={styles.measureEntryRow}>
<Image
src={measureConfig.iconSrc}
alt=""
width={measureConfig.imgWidth}
height={measureConfig.imgHeight}
className={styles.measureEntryIcon}
/>
<div className={styles.measureFieldStack}>
<div className={styles.measureInputWrap}>
<input
type="number"
min={minValue}
max={maxValue}
step={step}
value={currentValue ?? ""}
disabled={disabled}
placeholder="..."
onChange={(event) => {
const raw = event.target.value;
if (raw.trim() === "") {
controller.setNumber(mainField.id, null);
return;
}
const nextValue = Number(raw);
if (Number.isNaN(nextValue)) {
return;
}
controller.setNumber(mainField.id, nextValue);
}}
/>
{card.unit ? <span>{card.unit}</span> : null}
</div>
<div className={styles.measureStepControls}>
<button
type="button"
className={styles.measureStepButton}
onClick={() => applyStepDelta(-1)}
disabled={!canDecrease}
aria-label={`Diminuer ${measureConfig.title}`}
>
<span aria-hidden="true">-</span>
</button>
<button
type="button"
className={`${styles.measureStepButton} ${styles.measureStepButtonAccent}`}
onClick={() => applyStepDelta(1)}
disabled={!canIncrease}
aria-label={`Augmenter ${measureConfig.title}`}
>
<span aria-hidden="true">+</span>
</button>
</div>
</div>
</div>
<div className={styles.legacyMiniPoints}>Gagnez {mainField.points} Points !</div>
<div className={styles.legacyMiniFooter}>
{showSubmitButton ? (
<button
className={`btn btn-primary ${styles.legacyMiniButton}`}
onClick={() => void controller.submit()}
disabled={controller.saving || disabled}
type="button"
>
{controller.saving ? "Validation..." : "Valider mon pronostic"}
</button>
) : null}
{controller.dirty ? (
<span className={styles.dirty}>Modifications non enregistrees</span>
) : null}
{controller.successMessage ? <p className={styles.success}>{controller.successMessage}</p> : null}
{controller.errorMessage ? <p className={styles.error}>{controller.errorMessage}</p> : null}
</div>
</article>
);
}
@@ -0,0 +1,46 @@
import { FieldRendererRegistry } from "@/features/predictions/components/fields/field-renderer-registry";
import { PredictionTileBase } from "@/features/predictions/components/tiles/prediction-tile-base";
import { getCardIcon } from "@/features/predictions/domain/card-icons";
import type { PredictionTileController } from "@/features/predictions/hooks/use-prediction-tile-controller";
import type { PredictionCard } from "@/types/predictions";
type LegacyPredictionTileProps = {
card: PredictionCard;
controller: PredictionTileController;
disabled?: boolean;
showSubmitButton?: boolean;
};
export function LegacyPredictionTile({
card,
controller,
disabled,
showSubmitButton,
}: LegacyPredictionTileProps) {
return (
<PredictionTileBase
title={card.title}
description={card.description}
dirty={controller.dirty}
saving={controller.saving}
disabled={disabled}
showSubmitButton={showSubmitButton}
successMessage={controller.successMessage}
errorMessage={controller.errorMessage}
basePoints={card.basePoints}
iconSrc={getCardIcon(card.code)}
onSubmit={() => {
void controller.submit();
}}
>
<FieldRendererRegistry
card={card}
draftValues={controller.draftValues}
disabled={disabled}
setText={controller.setText}
setNumber={controller.setNumber}
setDate={controller.setDate}
/>
</PredictionTileBase>
);
}
@@ -0,0 +1,145 @@
import Image from "next/image";
import type { PredictionTileController } from "@/features/predictions/hooks/use-prediction-tile-controller";
import {
predictionMetricsFont,
predictionTitleFont,
} from "@/features/predictions/components/tiles/prediction-tile-fonts";
import styles from "@/features/predictions/styles/predictions.module.css";
import type { PredictionCard } from "@/types/predictions";
const OPTION_ICONS: Record<string, string> = {
fille: "/images/pictos/choix-fille.png",
garcon: "/images/pictos/choix-garcon.png",
"garçon": "/images/pictos/choix-garcon.png",
};
const GIRL_VALUES = new Set(["fille", "girl"]);
function formatSexDisplayValue(value: string) {
const normalizedValue = value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
if (normalizedValue === "garcon" || normalizedValue === "boy") {
return "garçon";
}
if (normalizedValue === "fille" || normalizedValue === "girl") {
return "fille";
}
return value;
}
type LegacySexPredictionTileProps = {
card: PredictionCard;
controller: PredictionTileController;
disabled?: boolean;
showSubmitButton?: boolean;
onSelect?: (value: string) => void;
};
export function LegacySexPredictionTile({
card,
controller,
disabled,
showSubmitButton,
onSelect,
}: LegacySexPredictionTileProps) {
const mainField = card.fields[0];
const seenOptionValues = new Set<string>();
const visibleOptions = card.options.filter((option) => {
const normalizedOptionValue = option.value
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
if (seenOptionValues.has(normalizedOptionValue)) {
return false;
}
seenOptionValues.add(normalizedOptionValue);
return true;
});
const selectedValue =
controller.draftValues.find((value) => value.fieldId === mainField?.id)?.valueText ?? "";
const normalizedSelectedValue = selectedValue.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const hasSelection = normalizedSelectedValue.length > 0;
const selectedIsGirl = GIRL_VALUES.has(normalizedSelectedValue);
const selectedDisplayValue = hasSelection ? formatSexDisplayValue(selectedValue) : "Choisissez";
if (!mainField) {
return null;
}
return (
<article className={`${styles.tile} ${styles.legacyMiniTile} ${styles.legacySexTile}`}>
<div className={styles.legacyMiniHeader}>
<h3 className={predictionTitleFont.className}>Sexe ?</h3>
<Image
src="/images/pictos/bebe-garcon.png"
alt=""
width={2162}
height={1984}
className={styles.legacySexTopBaby}
/>
</div>
<div className={styles.sexChoiceRow}>
{visibleOptions.map((option) => {
const optionValue = option.value.toLowerCase();
const iconSrc = OPTION_ICONS[optionValue];
const normalizedOptionValue = optionValue.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const isSelected = normalizedSelectedValue === normalizedOptionValue;
const isGirlOption = GIRL_VALUES.has(normalizedOptionValue);
return (
<button
key={option.id}
type="button"
className={`${styles.sexChoiceButton} ${isSelected ? styles.sexChoiceButtonActive : ""} ${isSelected ? (isGirlOption ? styles.sexChoiceButtonActiveGirl : styles.sexChoiceButtonActiveBoy) : ""}`}
disabled={disabled}
aria-label={option.label}
onClick={() => {
controller.setText(mainField.id, option.value);
onSelect?.(option.value);
}}
>
{iconSrc ? (
<Image src={iconSrc} alt="" width={72} height={72} className={styles.sexChoiceIcon} />
) : null}
</button>
);
})}
</div>
<div className={styles.sexAnswerRow} aria-live="polite">
<span
className={`${styles.sexAnswerBadge} ${styles.predictionAnswerBadgeSurface} ${predictionMetricsFont.className} ${hasSelection ? styles.predictionAnswerBadgeSurfaceVisible : styles.predictionAnswerBadgeSurfacePlaceholder} ${hasSelection ? (selectedIsGirl ? styles.predictionAnswerBadgeSurfaceGirl : styles.predictionAnswerBadgeSurfaceBoy) : ""}`}
>
{selectedDisplayValue}
</span>
</div>
<div className={styles.legacyMiniPoints}>
Gagnez {mainField.points} Points !
</div>
<div className={styles.legacyMiniFooter}>
{showSubmitButton ? (
<button
className={`btn btn-primary ${styles.legacyMiniButton}`}
onClick={() => void controller.submit()}
disabled={controller.saving || disabled}
type="button"
>
{controller.saving ? "Validation..." : "Valider mon pronostic"}
</button>
) : null}
{controller.dirty ? (
<span className={styles.dirty}>Modifications non enregistrees</span>
) : null}
{controller.successMessage ? <p className={styles.success}>{controller.successMessage}</p> : null}
{controller.errorMessage ? <p className={styles.error}>{controller.errorMessage}</p> : null}
</div>
</article>
);
}
@@ -0,0 +1,175 @@
"use client";
import { useEffect, useRef } from "react";
import { CustomPredictionTile } from "@/features/predictions/components/tiles/custom-prediction-tile";
import { LegacyNamesPredictionTile } from "@/features/predictions/components/tiles/legacy-names-prediction-tile";
import { LegacyNumberPredictionTile } from "@/features/predictions/components/tiles/legacy-number-prediction-tile";
import { LegacyPredictionTile } from "@/features/predictions/components/tiles/legacy-prediction-tile";
import { LegacySexPredictionTile } from "@/features/predictions/components/tiles/legacy-sex-prediction-tile";
import { WeightPredictionTile } from "@/features/predictions/components/tiles/weight-prediction-tile";
import type { PredictionTheme } from "@/features/predictions/domain/prediction-theme";
import {
usePredictionTileController,
type PredictionTileController,
} from "@/features/predictions/hooks/use-prediction-tile-controller";
import styles from "@/features/predictions/styles/predictions.module.css";
import type { PredictionCard, PredictionEntry, UpsertPredictionPayload } from "@/types/predictions";
type PredictionCardFactoryProps = {
card: PredictionCard;
entry?: PredictionEntry;
disabled?: boolean;
showSubmitButton?: boolean;
theme?: PredictionTheme;
onSave: (cardId: string, payload: UpsertPredictionPayload) => Promise<PredictionEntry>;
onController?: (cardId: string, controller: PredictionTileController | null) => void;
onDirtyChange?: () => void;
onSexSelect?: (value: string) => void;
};
function resolveTile(
card: PredictionCard,
controller: PredictionTileController,
disabled?: boolean,
showSubmitButton?: boolean,
theme?: PredictionTheme,
onSexSelect?: (value: string) => void,
) {
if (card.code === "legacy_weight") {
return (
<WeightPredictionTile
card={card}
controller={controller}
disabled={disabled}
showSubmitButton={showSubmitButton}
theme={theme}
/>
);
}
if (card.code === "legacy_sex") {
return (
<LegacySexPredictionTile
card={card}
controller={controller}
disabled={disabled}
showSubmitButton={showSubmitButton}
onSelect={onSexSelect}
/>
);
}
if (card.code === "legacy_names") {
return (
<LegacyNamesPredictionTile
card={card}
controller={controller}
disabled={disabled}
showSubmitButton={showSubmitButton}
/>
);
}
if (card.type === "LEGACY" && card.valueType === "NUMBER") {
return (
<LegacyNumberPredictionTile
card={card}
controller={controller}
disabled={disabled}
showSubmitButton={showSubmitButton}
/>
);
}
if (card.type === "LEGACY") {
return (
<LegacyPredictionTile
card={card}
controller={controller}
disabled={disabled}
showSubmitButton={showSubmitButton}
/>
);
}
return (
<CustomPredictionTile
card={card}
controller={controller}
disabled={disabled}
showSubmitButton={showSubmitButton}
/>
);
}
export function PredictionCardFactory({
card,
entry,
disabled,
showSubmitButton,
theme,
onSave,
onController,
onDirtyChange,
onSexSelect,
}: PredictionCardFactoryProps) {
const controller = usePredictionTileController({
card,
entry,
onSave,
disabled,
});
// Keep a stable ref to always expose the latest controller to the parent
const controllerRef = useRef(controller);
controllerRef.current = controller;
// Use a ref so that changing the inline callback in page.tsx doesn't re-trigger the effect
const onControllerRef = useRef(onController);
onControllerRef.current = onController;
const onDirtyChangeRef = useRef(onDirtyChange);
onDirtyChangeRef.current = onDirtyChange;
useEffect(() => {
controller.resetFromEntry(entry);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entry]);
// Register a thin stable proxy so the parent always calls the latest controller
useEffect(() => {
const proxy: PredictionTileController = {
get draftValues() { return controllerRef.current.draftValues; },
get persistedValues() { return controllerRef.current.persistedValues; },
get dirty() { return controllerRef.current.dirty; },
get saving() { return controllerRef.current.saving; },
get errorMessage() { return controllerRef.current.errorMessage; },
get successMessage() { return controllerRef.current.successMessage; },
setText: (...args) => controllerRef.current.setText(...args),
setNumber: (...args) => controllerRef.current.setNumber(...args),
setDate: (...args) => controllerRef.current.setDate(...args),
submit: () => controllerRef.current.submit(),
resetFromEntry: (...args) => controllerRef.current.resetFromEntry(...args),
};
onControllerRef.current?.(card.id, proxy);
return () => {
onControllerRef.current?.(card.id, null);
};
// Only run on mount/unmount (card.id is stable for a given instance)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [card.id]);
// Notify parent when dirty state OR draft values change so it can refresh derived state
useEffect(() => {
onDirtyChangeRef.current?.();
}, [controller.dirty, controller.draftValues]);
const isWeightTile = card.code === "legacy_weight";
return (
<div className={`${styles.tileShell} ${isWeightTile ? styles.tileShellWeight : ""}`}>
{resolveTile(card, controller, disabled, showSubmitButton, theme, onSexSelect)}
</div>
);
}
@@ -0,0 +1,71 @@
import Image from "next/image";
import styles from "@/features/predictions/styles/predictions.module.css";
type PredictionTileBaseProps = {
title: string;
description?: string | null;
badge?: string;
favorite?: boolean;
dirty: boolean;
saving: boolean;
disabled?: boolean;
showSubmitButton?: boolean;
successMessage?: string;
errorMessage?: string;
basePoints?: number;
iconSrc?: string;
onSubmit: () => void;
children: React.ReactNode;
};
export function PredictionTileBase({
title,
description,
badge,
favorite,
dirty,
saving,
disabled,
showSubmitButton = true,
successMessage,
errorMessage,
basePoints,
iconSrc,
onSubmit,
children,
}: PredictionTileBaseProps) {
return (
<article className={`${styles.tile} ${favorite ? styles.tileFavorite : ""}`}>
<header className={styles.tileHeader}>
{iconSrc ? (
<Image src={iconSrc} alt="" width={48} height={48} className={styles.tileIcon} />
) : null}
<div>
<h3>{title}</h3>
{description ? <p>{description}</p> : null}
</div>
{badge ? <span className={styles.badge}>{badge}</span> : null}
</header>
<div className={styles.tileBody}>{children}</div>
<footer className={styles.tileFooter}>
{basePoints && basePoints > 0 ? (
<span className={styles.pointsLabel}>
<Image src="/images/pictos/bebe-fille.png" alt="" width={22} height={22} className={styles.pointsLabelIcon} />
Gagnez {basePoints} Points!
<Image src="/images/pictos/bebe-garcon.png" alt="" width={22} height={22} className={styles.pointsLabelIcon} />
</span>
) : null}
{showSubmitButton ? (
<button className="btn btn-primary" onClick={onSubmit} disabled={saving || disabled}>
{saving ? "Validation..." : "Valider mon pronostic"}
</button>
) : null}
{dirty ? <span className={styles.dirty}>Modifications non enregistrées</span> : null}
{successMessage ? <p className={styles.success}>{successMessage}</p> : null}
{errorMessage ? <p className={styles.error}>{errorMessage}</p> : null}
</footer>
</article>
);
}
@@ -0,0 +1,11 @@
import { Acme, Baloo_Bhaijaan_2 } from "next/font/google";
export const predictionTitleFont = Baloo_Bhaijaan_2({
subsets: ["latin"],
weight: ["700", "800"],
});
export const predictionMetricsFont = Acme({
subsets: ["latin"],
weight: "400",
});
@@ -0,0 +1,129 @@
import Image from "next/image";
import { WeightDoubleSlider } from "@/features/predictions/components/fields/weight-double-slider";
import {
predictionMetricsFont,
predictionTitleFont,
} from "@/features/predictions/components/tiles/prediction-tile-fonts";
import type { PredictionTheme } from "@/features/predictions/domain/prediction-theme";
import type { PredictionTileController } from "@/features/predictions/hooks/use-prediction-tile-controller";
import styles from "@/features/predictions/styles/predictions.module.css";
import type { PredictionCard } from "@/types/predictions";
type WeightPredictionTileProps = {
card: PredictionCard;
controller: PredictionTileController;
disabled?: boolean;
showSubmitButton?: boolean;
theme?: PredictionTheme;
};
export function WeightPredictionTile({
card,
controller,
disabled,
showSubmitButton,
theme = "boy",
}: WeightPredictionTileProps) {
const mainField = card.fields[0];
const pointsLabel =
card.basePoints > 0
? card.basePoints
: (mainField?.points ?? 0) > 0
? (mainField?.points ?? 0)
: card.code === "legacy_weight"
? 100
: 0;
const currentValue =
controller.draftValues.find((value) => value.fieldId === mainField?.id)?.valueNumber ?? null;
const minKg = mainField?.minNumber ?? 1;
const maxKg = mainField?.maxNumber ?? 6;
if (!mainField) {
return null;
}
const balanceBabyImage =
theme === "girl"
? "/depotARanger/bebe-sur-une-balance-girl.png"
: "/depotARanger/bebe-sur-une-balance.png";
return (
<div className={styles.weightTileWrapper}>
{/* Medal: outside the article so it overflows the card */}
<Image
src="/images/pictos/medaille.png"
alt=""
width={44}
height={44}
className={styles.weightMedalIcon}
/>
<article className={`${styles.tile} ${styles.weightTile}`}>
{/* Left: illustration */}
<div className={styles.weightTileLeft}>
<div className={`${styles.weightTileLeftCircle} ${theme === "girl" ? styles.weightTileLeftCircleGirl : ""}`} />
<Image
src={balanceBabyImage}
alt=""
width={460}
height={420}
sizes="(max-width: 520px) 43vw, 22vw"
className={styles.weightTileLeftImg}
/>
</div>
{/* Right: content */}
<div className={styles.weightTileRight}>
{/* Title block */}
<div className={`${styles.weightTitleBlock} ${predictionTitleFont.className}`}>
<p className={styles.weightTitleLine}>LE GRAND DÉFI :</p>
<p className={styles.weightTitleLineSub}>Poid de Bubulle !</p>
{pointsLabel > 0 ? (
<p className={styles.weightTitleLine}>GAGNEZ {pointsLabel} POINTS !</p>
) : null}
</div>
{/* Weight double slider */}
<WeightDoubleSlider
valueKg={currentValue}
minKg={minKg}
maxKg={maxKg}
disabled={disabled}
theme={theme}
labelFontClassName={predictionTitleFont.className}
valueFontClassName={predictionMetricsFont.className}
metaFontClassName={predictionMetricsFont.className}
onChange={(nextValueKg) => {
const rounded = Math.round(nextValueKg * 200) / 200;
controller.setNumber(mainField.id, rounded);
}}
/>
{/* Footer */}
<div className={styles.weightTileFooter}>
{showSubmitButton ? (
<button
className="btn btn-primary"
onClick={() => void controller.submit()}
disabled={controller.saving || disabled}
>
{controller.saving ? "Validation..." : "Valider mon pronostic"}
</button>
) : null}
{controller.dirty ? (
<span className={styles.dirty}>Modifications non enregistrées</span>
) : null}
{controller.successMessage ? (
<p className={styles.success}>{controller.successMessage}</p>
) : null}
{controller.errorMessage ? (
<p className={styles.error}>{controller.errorMessage}</p>
) : null}
</div>
</div>
</article>
</div>
);
}
@@ -0,0 +1,31 @@
"use client";
import { createContext, useContext, useState } from "react";
import type { PredictionTheme } from "@/features/predictions/domain/prediction-theme";
type PredictionThemeContextValue = {
theme: PredictionTheme;
setTheme: (theme: PredictionTheme) => void;
};
const PredictionThemeContext = createContext<PredictionThemeContextValue>({
theme: "boy",
setTheme: () => {},
});
export function PredictionThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
const [theme, setTheme] = useState<PredictionTheme>("boy");
return (
<PredictionThemeContext.Provider value={{ theme, setTheme }}>
{children}
</PredictionThemeContext.Provider>
);
}
export function usePredictionTheme() {
return useContext(PredictionThemeContext);
}
@@ -0,0 +1,15 @@
const CARD_ICON_MAP: Record<string, string> = {
legacy_weight: "/images/pictos/bebe-balance.png",
legacy_sex: "/images/pictos/choix-fille.png",
legacy_names: "/images/pictos/bebe-garcon.png",
legacy_head: "/images/pictos/reglette.png",
legacy_height: "/images/pictos/regle.png",
legacy_birth_date: "/images/pictos/jouet-bebe.png",
};
const DEFAULT_ICON = "/images/pictos/jouet-bebe.png";
export function getCardIcon(code: string | null): string {
if (!code) return DEFAULT_ICON;
return CARD_ICON_MAP[code] ?? DEFAULT_ICON;
}
@@ -0,0 +1,207 @@
import type {
PredictionCard,
PredictionDraftValue,
PredictionEntry,
UpsertPredictionPayload,
} from "@/types/predictions";
function roundWithStep(value: number, step: number) {
if (step <= 0) {
return value;
}
return Math.round(value / step) * step;
}
function getExpectedNumberStep(card: PredictionCard, fieldStep?: number | null) {
if (card.code === "legacy_weight") {
return 0.005;
}
return fieldStep ?? null;
}
export function getDefaultDraftValues(card: PredictionCard): PredictionDraftValue[] {
return card.fields.map((field) => ({
fieldId: field.id,
valueText: "",
valueNumber: null,
valueDate: "",
}));
}
export function getDraftValuesFromEntry(
card: PredictionCard,
entry?: PredictionEntry,
): PredictionDraftValue[] {
const defaults = getDefaultDraftValues(card);
if (!entry) {
return defaults;
}
const valuesByField = new Map(entry.values.map((value) => [value.fieldId, value]));
return defaults.map((draft) => {
const value = valuesByField.get(draft.fieldId);
return {
fieldId: draft.fieldId,
valueText: value?.valueText ?? "",
valueNumber: value?.valueNumber ?? null,
valueDate: value?.valueDate ? value.valueDate.slice(0, 10) : "",
};
});
}
export function validateDraft(card: PredictionCard, draftValues: PredictionDraftValue[]) {
const byField = new Map(draftValues.map((value) => [value.fieldId, value]));
for (const field of card.fields) {
const draft = byField.get(field.id);
if (!draft) {
return { valid: false, message: `Le champ ${field.label} est manquant` };
}
if (!field.isRequired) {
continue;
}
if (card.valueType === "NUMBER") {
if (draft.valueNumber == null) {
return { valid: false, message: `Le champ ${field.label} est obligatoire` };
}
if (field.minNumber != null && draft.valueNumber < field.minNumber) {
return { valid: false, message: `${field.label} doit etre >= ${field.minNumber}` };
}
if (field.maxNumber != null && draft.valueNumber > field.maxNumber) {
return { valid: false, message: `${field.label} doit etre <= ${field.maxNumber}` };
}
const expectedStep = getExpectedNumberStep(card, field.stepNumber);
if (expectedStep != null && expectedStep > 0) {
const rounded = roundWithStep(draft.valueNumber, expectedStep);
const diff = Math.abs(rounded - draft.valueNumber);
if (diff > 0.000001) {
return {
valid: false,
message: `${field.label} doit respecter un pas de ${expectedStep}`,
};
}
}
}
if (
(card.valueType === "TEXT" ||
card.valueType === "SELECT" ||
card.valueType === "MULTI_TEXT") &&
draft.valueText.trim().length === 0
) {
return { valid: false, message: `Le champ ${field.label} est obligatoire` };
}
if (card.valueType === "DATE" && draft.valueDate.trim().length === 0) {
return { valid: false, message: `Le champ ${field.label} est obligatoire` };
}
}
return { valid: true as const };
}
export function serializeDraftToUpsertPayload(
card: PredictionCard,
draftValues: PredictionDraftValue[],
): UpsertPredictionPayload {
const values = draftValues
.map((draft) => {
if (card.valueType === "NUMBER") {
if (draft.valueNumber == null) {
return null;
}
return { fieldId: draft.fieldId, valueNumber: draft.valueNumber };
}
if (card.valueType === "DATE") {
if (!draft.valueDate) {
return null;
}
return { fieldId: draft.fieldId, valueDate: draft.valueDate };
}
const text = draft.valueText.trim();
if (!text) {
return null;
}
return { fieldId: draft.fieldId, valueText: text };
})
.filter((value): value is NonNullable<typeof value> => value != null);
return { values };
}
export function serializeChangedDraftToUpsertPayload(
card: PredictionCard,
draftValues: PredictionDraftValue[],
persistedValues: PredictionDraftValue[],
): UpsertPredictionPayload {
const persistedByField = new Map(persistedValues.map((value) => [value.fieldId, value]));
const changedDraftValues = draftValues.filter((draft) => {
const persisted = persistedByField.get(draft.fieldId);
if (!persisted) {
return true;
}
return (
persisted.valueText.trim() !== draft.valueText.trim() ||
persisted.valueNumber !== draft.valueNumber ||
persisted.valueDate !== draft.valueDate
);
});
const values = changedDraftValues
.map((draft) => {
if (card.valueType === "NUMBER") {
if (draft.valueNumber == null) {
return null;
}
return { fieldId: draft.fieldId, valueNumber: draft.valueNumber };
}
if (card.valueType === "DATE") {
if (!draft.valueDate) {
return null;
}
return { fieldId: draft.fieldId, valueDate: draft.valueDate };
}
const text = draft.valueText.trim();
if (!text) {
return { fieldId: draft.fieldId, valueText: "" };
}
return { fieldId: draft.fieldId, valueText: text };
})
.filter((value): value is NonNullable<typeof value> => value != null);
return { values };
}
export function areDraftValuesEqual(a: PredictionDraftValue[], b: PredictionDraftValue[]) {
if (a.length !== b.length) {
return false;
}
const normalize = (values: PredictionDraftValue[]) =>
[...values]
.sort((left, right) => left.fieldId.localeCompare(right.fieldId))
.map((value) => ({
fieldId: value.fieldId,
valueText: value.valueText.trim(),
valueNumber: value.valueNumber,
valueDate: value.valueDate,
}));
return JSON.stringify(normalize(a)) === JSON.stringify(normalize(b));
}
@@ -0,0 +1,27 @@
export type PredictionTheme = "boy" | "girl";
function normalizeText(value: string) {
return value
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
export function getThemeFromSexValue(value: string | null | undefined): PredictionTheme | null {
if (!value) {
return null;
}
const normalized = normalizeText(value);
if (normalized === "fille" || normalized === "girl") {
return "girl";
}
if (normalized === "garcon" || normalized === "boy") {
return "boy";
}
return null;
}
@@ -0,0 +1,145 @@
import { useCallback, useMemo, useState } from "react";
import {
areDraftValuesEqual,
getDraftValuesFromEntry,
serializeChangedDraftToUpsertPayload,
validateDraft,
} from "@/features/predictions/domain/prediction-serializers";
import type {
PredictionCard,
PredictionDraftValue,
PredictionEntry,
UpsertPredictionPayload,
} from "@/types/predictions";
type SaveHandler = (cardId: string, body: UpsertPredictionPayload) => Promise<PredictionEntry>;
type ControllerInput = {
card: PredictionCard;
entry?: PredictionEntry;
onSave: SaveHandler;
disabled?: boolean;
};
export type PredictionTileController = {
draftValues: PredictionDraftValue[];
persistedValues: PredictionDraftValue[];
dirty: boolean;
saving: boolean;
errorMessage: string;
successMessage: string;
setText: (fieldId: string, value: string) => void;
setNumber: (fieldId: string, value: number | null) => void;
setDate: (fieldId: string, value: string) => void;
submit: () => Promise<boolean>;
resetFromEntry: (nextEntry?: PredictionEntry) => void;
};
function updateDraftValue(
draftValues: PredictionDraftValue[],
fieldId: string,
updater: (current: PredictionDraftValue) => PredictionDraftValue,
) {
return draftValues.map((value) => (value.fieldId === fieldId ? updater(value) : value));
}
export function usePredictionTileController({
card,
entry,
onSave,
disabled = false,
}: ControllerInput): PredictionTileController {
const initialValues = useMemo(() => getDraftValuesFromEntry(card, entry), [card, entry]);
const [draftValues, setDraftValues] = useState<PredictionDraftValue[]>(initialValues);
const [persistedValues, setPersistedValues] = useState<PredictionDraftValue[]>(initialValues);
const [saving, setSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [successMessage, setSuccessMessage] = useState("");
const dirty = useMemo(
() => !areDraftValuesEqual(draftValues, persistedValues),
[draftValues, persistedValues],
);
const setText = useCallback((fieldId: string, value: string) => {
setDraftValues((current) =>
updateDraftValue(current, fieldId, (draft) => ({ ...draft, valueText: value })),
);
}, []);
const setNumber = useCallback((fieldId: string, value: number | null) => {
setDraftValues((current) =>
updateDraftValue(current, fieldId, (draft) => ({ ...draft, valueNumber: value })),
);
}, []);
const setDate = useCallback((fieldId: string, value: string) => {
setDraftValues((current) =>
updateDraftValue(current, fieldId, (draft) => ({ ...draft, valueDate: value })),
);
}, []);
const submit = useCallback(async () => {
if (disabled) {
setErrorMessage("Le jeu est cloture. Les pronostics sont verrouilles.");
return false;
}
const validation = validateDraft(card, draftValues);
if (!validation.valid) {
setErrorMessage(validation.message);
setSuccessMessage("");
return false;
}
const payload = serializeChangedDraftToUpsertPayload(card, draftValues, persistedValues);
if (payload.values.length === 0) {
setErrorMessage("");
setSuccessMessage("Aucun changement a enregistrer");
return true;
}
setSaving(true);
setErrorMessage("");
setSuccessMessage("");
try {
const updatedEntry = await onSave(card.id, payload);
const nextValues = getDraftValuesFromEntry(card, updatedEntry);
setDraftValues(nextValues);
setPersistedValues(nextValues);
setSuccessMessage("Pronostic enregistre");
return true;
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "Erreur de sauvegarde");
return false;
} finally {
setSaving(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [card, disabled, draftValues, persistedValues, onSave]);
const resetFromEntry = useCallback((nextEntry?: PredictionEntry) => {
const nextValues = getDraftValuesFromEntry(card, nextEntry);
setDraftValues(nextValues);
setPersistedValues(nextValues);
setErrorMessage("");
setSuccessMessage("");
}, [card]);
return useMemo(() => ({
draftValues,
persistedValues,
dirty,
saving,
errorMessage,
successMessage,
setText,
setNumber,
setDate,
submit,
resetFromEntry,
}), [draftValues, persistedValues, dirty, saving, errorMessage, successMessage, setText, setNumber, setDate, submit, resetFromEntry]);
}
@@ -0,0 +1,260 @@
/* ===== HISTORY / TIMELINE PAGE ===== */
/* ---- Timeline list ---- */
.timelineList {
display: flex;
flex-direction: column;
gap: 0;
margin: 0 0.5rem;
padding-bottom: 2rem;
}
/* ---- Timeline card row ---- */
.timelineCard {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 0.75rem;
position: relative;
padding-bottom: 1.1rem;
}
/* ---- Avatar column ---- */
.timelineAvatarCol {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding-top: 0.15rem;
}
.timelineLine {
flex: 1;
width: 2px;
background: var(--border-light);
border-radius: 2px;
margin-top: 0.35rem;
min-height: 1.5rem;
}
.timelineCard:last-child .timelineLine {
display: none;
}
/* System icon (no user) */
.systemIcon {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--panel-warm);
border: 1.5px solid var(--border);
display: grid;
place-items: center;
color: var(--ink-1);
flex-shrink: 0;
}
.systemIconGlyph,
.activityLabelIcon,
.heroStatIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.systemIconGlyph {
width: 0.95rem;
height: 0.95rem;
}
.systemIconGlyph svg,
.activityLabelIcon svg,
.heroStatIcon svg {
width: 100%;
height: 100%;
}
/* ---- Content ---- */
.timelineContent {
display: grid;
gap: 0.5rem;
padding-top: 0.1rem;
min-width: 0;
}
.timelineHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.timelineHeadLeft {
display: flex;
flex-direction: column;
gap: 0.22rem;
min-width: 0;
}
.userName {
font-size: 0.9rem;
font-weight: 800;
color: var(--ink-0);
line-height: 1.2;
}
.timelineTime {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--muted);
white-space: nowrap;
padding-top: 0.15rem;
}
/* ---- Activity label (titre de la carte + icône) ---- */
.activityLabel {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.22rem 0.6rem;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 700;
line-height: 1.3;
width: fit-content;
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.activityLabelIcon {
width: 0.95rem;
height: 0.95rem;
color: currentColor;
opacity: 0.92;
}
.activityLabelText {
min-width: 0;
}
.heroStatIcon {
width: 2rem;
height: 2rem;
border-radius: 14px;
color: #62338d;
}
.badgePrimary {
background: var(--accent-soft);
color: var(--accent-strong);
}
.badgeSuccess {
background: #e4f8e8;
color: #2e7d36;
}
.badgeWarning {
background: #fff8db;
color: #7a5800;
}
.badgeSystem {
background: var(--border-light);
color: var(--ink-1);
}
.badgeMuted {
background: var(--panel-warm);
border: 1px solid var(--border);
color: var(--ink-1);
}
/* ---- Card chip ---- */
.cardChip {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.55rem;
border-radius: 999px;
background: var(--panel);
border: 1px solid var(--border);
font-size: 0.75rem;
font-weight: 700;
color: var(--muted);
letter-spacing: 0.02em;
text-transform: uppercase;
width: fit-content;
}
/* ---- Values block ---- */
.valuesBlock {
display: grid;
gap: 0.45rem;
background: var(--panel);
border: 1px solid var(--border-light);
border-radius: 16px;
padding: 0.7rem 0.85rem;
box-shadow: var(--shadow-card);
}
.valueRow {
display: flex;
align-items: center;
gap: 0.55rem;
flex-wrap: wrap;
}
.fieldLabel {
font-size: 0.74rem;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
flex-shrink: 0;
}
.valuePill {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 999px;
background: var(--bg-1);
border: 1px solid var(--border);
font-size: clamp(0.82rem, 3.2vw, 0.9rem);
font-weight: 700;
color: var(--ink-0);
white-space: nowrap;
}
/* ---- Error ---- */
.errorMsg {
color: var(--accent);
font-size: 0.9rem;
padding: 0 0.5rem;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 480px) {
.timelineCard {
grid-template-columns: 34px minmax(0, 1fr);
gap: 0.6rem;
}
.timelineHeader {
flex-direction: column;
gap: 0.25rem;
}
.timelineTime {
align-self: flex-start;
}
.heroStatIcon {
width: 1.8rem;
height: 1.8rem;
}
}
File diff suppressed because it is too large Load Diff
+215
View File
@@ -0,0 +1,215 @@
export type UserRole = "ADMIN" | "FAMILY";
export type AuthUser = {
id: string;
username: string;
displayName: string | null;
profileImageUrl: string | null;
workspaceId: string | null;
currentProjectId: string | null;
role: UserRole;
createdAt: string;
};
export type SessionPayload = {
accessToken: string;
refreshToken: string;
user: AuthUser;
};
export const ACCESS_TOKEN_KEY = "ljp_access_token";
export const REFRESH_TOKEN_KEY = "ljp_refresh_token";
export const USER_KEY = "ljp_user";
export const CURRENT_PROJECT_KEY = "ljp_current_project";
export const PROJECT_CHANGED_EVENT = "ljp_project_changed";
export function getApiUrl() {
const configuredApiUrl = process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "");
if (configuredApiUrl) {
return configuredApiUrl;
}
if (typeof window !== "undefined") {
const { hostname, protocol } = window.location;
if (hostname && hostname !== "localhost" && hostname !== "127.0.0.1") {
return `${protocol}//${hostname}:3001`;
}
}
return "http://localhost:3001";
}
export function loadSession() {
if (typeof window === "undefined") {
return null;
}
const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
const rawUser = localStorage.getItem(USER_KEY);
if (!accessToken || !refreshToken || !rawUser) {
return null;
}
try {
const parsedUser = JSON.parse(rawUser) as Partial<AuthUser>;
const persistedProjectId = localStorage.getItem(CURRENT_PROJECT_KEY);
const user: AuthUser = {
id: parsedUser.id ?? "",
username: parsedUser.username ?? "",
displayName: parsedUser.displayName ?? null,
profileImageUrl: parsedUser.profileImageUrl ?? null,
workspaceId: parsedUser.workspaceId ?? null,
currentProjectId: persistedProjectId ?? parsedUser.currentProjectId ?? null,
role: (parsedUser.role as UserRole | undefined) ?? "FAMILY",
createdAt: parsedUser.createdAt ?? new Date().toISOString(),
};
if (!user.id || !user.username) {
clearSession();
return null;
}
return { accessToken, refreshToken, user };
} catch {
clearSession();
return null;
}
}
export function saveSession(payload: SessionPayload) {
if (typeof window === "undefined") {
return;
}
const sessionUser: AuthUser = {
...payload.user,
workspaceId: payload.user.workspaceId ?? null,
currentProjectId: payload.user.currentProjectId ?? null,
};
localStorage.setItem(ACCESS_TOKEN_KEY, payload.accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, payload.refreshToken);
localStorage.setItem(USER_KEY, JSON.stringify(sessionUser));
if (sessionUser.currentProjectId) {
localStorage.setItem(CURRENT_PROJECT_KEY, sessionUser.currentProjectId);
} else {
localStorage.removeItem(CURRENT_PROJECT_KEY);
}
}
export function clearSession() {
if (typeof window === "undefined") {
return;
}
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_KEY);
localStorage.removeItem(CURRENT_PROJECT_KEY);
}
export async function logoutSession(apiUrl: string) {
try {
await authenticatedFetch(apiUrl, "/auth/logout", { method: "POST" });
} catch {
// no-op
}
clearSession();
}
export function getCurrentProjectId() {
if (typeof window === "undefined") {
return null;
}
const session = loadSession();
return session?.user.currentProjectId ?? null;
}
export function setCurrentProjectId(projectId: string | null) {
if (typeof window === "undefined") {
return;
}
const session = loadSession();
if (!session) {
return;
}
const nextUser: AuthUser = {
...session.user,
currentProjectId: projectId,
};
localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
if (projectId) {
localStorage.setItem(CURRENT_PROJECT_KEY, projectId);
} else {
localStorage.removeItem(CURRENT_PROJECT_KEY);
}
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(PROJECT_CHANGED_EVENT, {
detail: { projectId },
}),
);
}
}
async function requestTokenRefresh(apiUrl: string, refreshToken: string) {
const response = await fetch(`${apiUrl}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
const payload = (await response.json()) as SessionPayload | { message?: string };
if (!response.ok || !("accessToken" in payload) || !("refreshToken" in payload)) {
throw new Error((payload as { message?: string }).message ?? "Session expiree");
}
saveSession(payload);
return payload;
}
export async function authenticatedFetch(
apiUrl: string,
path: string,
init: RequestInit = {},
): Promise<Response> {
const session = loadSession();
if (!session) {
throw new Error("Session manquante");
}
const headers = new Headers(init.headers ?? {});
headers.set("Authorization", `Bearer ${session.accessToken}`);
const firstResponse = await fetch(`${apiUrl}${path}`, {
...init,
headers,
});
if (firstResponse.status !== 401) {
return firstResponse;
}
const refreshedSession = await requestTokenRefresh(apiUrl, session.refreshToken);
const retryHeaders = new Headers(init.headers ?? {});
retryHeaders.set("Authorization", `Bearer ${refreshedSession.accessToken}`);
return fetch(`${apiUrl}${path}`, {
...init,
headers: retryHeaders,
});
}
+140
View File
@@ -0,0 +1,140 @@
import { authenticatedFetch, getApiUrl } from "@/lib/auth";
import type {
BabyIndices,
BabyTrimesterEntry,
ParentIndexPhoto,
ParentIndices,
ParentType,
ProjectIndicesResponse,
Trimester,
UpsertBabyIndicesPayload,
UpsertBabyTrimesterPayload,
UpsertParentIndicesPayload,
} from "@/types/indices";
function getErrorMessage(payload: unknown, fallback: string) {
if (payload && typeof payload === "object" && "message" in payload) {
const { message } = payload as { message?: unknown };
if (typeof message === "string" && message.trim().length > 0) {
return message;
}
}
return fallback;
}
async function apiCall<T>(path: string, init?: RequestInit, fallbackError = "Erreur API") {
const response = await authenticatedFetch(getApiUrl(), path, init);
let payload: unknown = null;
try {
payload = await response.json();
} catch {
// ignore
}
if (!response.ok) {
throw new Error(getErrorMessage(payload, fallbackError));
}
return payload as T;
}
export function getProjectIndices(projectId: string) {
return apiCall<ProjectIndicesResponse>(
`/projects/${projectId}/indices`,
undefined,
"Impossible de charger les indices",
);
}
export function upsertParentIndices(
projectId: string,
parentType: ParentType,
payload: UpsertParentIndicesPayload,
) {
return apiCall<ParentIndices>(
`/projects/${projectId}/indices/parents/${parentType}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
"Impossible de sauvegarder les indices parent",
);
}
export function uploadParentPhoto(projectId: string, parentType: ParentType, blob: Blob) {
const formData = new FormData();
formData.append("file", blob, "indices-photo.jpg");
return apiCall<ParentIndexPhoto>(
`/projects/${projectId}/indices/parents/${parentType}/photos`,
{ method: "POST", body: formData },
"Impossible d'uploader la photo",
);
}
export function deleteParentPhoto(projectId: string, parentType: ParentType, photoId: string) {
return apiCall<{ deleted: boolean }>(
`/projects/${projectId}/indices/parents/${parentType}/photos/${photoId}`,
{ method: "DELETE" },
"Impossible de supprimer la photo",
);
}
export function upsertBabyIndices(
projectId: string,
babyIndex: number,
payload: UpsertBabyIndicesPayload,
) {
return apiCall<BabyIndices>(
`/projects/${projectId}/indices/babies/${babyIndex}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
"Impossible de sauvegarder les indices bébé",
);
}
export function upsertBabyTrimester(
projectId: string,
babyIndex: number,
trimester: Trimester,
payload: UpsertBabyTrimesterPayload,
) {
return apiCall<BabyTrimesterEntry>(
`/projects/${projectId}/indices/babies/${babyIndex}/trimesters/${trimester}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
"Impossible de sauvegarder les données du trimestre",
);
}
export function uploadTrimesterPhoto(
projectId: string,
babyIndex: number,
trimester: Trimester,
blob: Blob,
) {
const formData = new FormData();
formData.append("file", blob, "indices-photo.jpg");
return apiCall<{ id: string; url: string; sortOrder: number; createdAt: string }>(
`/projects/${projectId}/indices/babies/${babyIndex}/trimesters/${trimester}/photos`,
{ method: "POST", body: formData },
"Impossible d'uploader la photo",
);
}
export function deleteTrimesterPhoto(
projectId: string,
babyIndex: number,
trimester: Trimester,
photoId: string,
) {
return apiCall<{ deleted: boolean }>(
`/projects/${projectId}/indices/babies/${babyIndex}/trimesters/${trimester}/photos/${photoId}`,
{ method: "DELETE" },
"Impossible de supprimer la photo",
);
}

Some files were not shown because too many files have changed in this diff Show More