init import projet
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
npm-debug.log
|
||||
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ["192.168.1.172"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 787 KiB |
|
After Width: | Height: | Size: 720 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 787 KiB |
|
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 |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'à 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'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'échographie pour {getBabyLabel(babyIndex)}. Tous les champs sont optionnels.
|
||||
</p>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor={`${k}-date`}>Date de l'é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 été 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'oeil avant d'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'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'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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'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'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'indices</span>
|
||||
<h1>Petites pistes de famille</h1>
|
||||
<p>
|
||||
Retrouvez les infos de maman et papa à leur naissance, puis l'é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'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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & 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'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'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 !</> : null}
|
||||
{nameFields[1] ? <><br />{nameFields[1].points} points un secondaire !</> : 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'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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||