Compare commits
79 commits
fix/ci-lin
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cd8c04977f | |||
|
|
06b01f65e2 | ||
| 07301ae997 | |||
|
|
62833ee4e6 | ||
| 5845a6b950 | |||
|
|
a6ea488732 | ||
| 9bdb3666a0 | |||
|
|
18d19538d3 | ||
| eee052b2a8 | |||
|
|
5be62f012f | ||
| 58fd65a4d0 | |||
|
|
0dc560385d | ||
| 73d24b70f7 | |||
|
|
c564028ca9 | ||
| 7a12848b5b | |||
|
|
3a557b6de5 | ||
| 2b8d786cf9 | |||
|
|
ea0e606735 | ||
| ab1bbb5484 | |||
|
|
caa3d5214f | ||
| 03b740dfff | |||
|
|
74ea280f28 | ||
| 3d77632ba0 | |||
|
|
63a29d9ade | ||
| 8609c3c98b | |||
|
|
946dd8d5d2 | ||
| d24e3b4af7 | |||
|
|
9da58288dc | ||
| d42584cc4c | |||
|
|
15f41a7e2a | ||
| 740e9958aa | |||
|
|
5607a51980 | ||
| 0723e50189 | |||
|
|
91b4d918ea | ||
| 1165f32a63 | |||
|
|
59786e5365 | ||
| 8d7e9cfdc2 | |||
|
|
f31fb8a32c | ||
| 1dd2d65626 | |||
|
|
90cc7a94af | ||
| 46d3c2d3ab | |||
|
|
e2f3f070fa | ||
| d2dcc698e9 | |||
|
|
4901bb950e | ||
| 1f8250ad7e | |||
|
|
dc2b07507f | ||
| 153d0671c0 | |||
|
|
d5732917e3 | ||
| 5449ec9047 | |||
|
|
bc158ca144 | ||
| b8b421e839 | |||
|
|
4fb7c948ad | ||
| 3a7c325373 | |||
|
|
e2d3b6a686 | ||
| e542a853fa | |||
|
|
701a1f02bd | ||
| 403e21fe0a | |||
|
|
2545a5e1a8 | ||
| a575d40163 | |||
|
|
2914e5605a | ||
| 8285909178 | |||
|
|
71dd8c1dad | ||
| 444fd1e6fd | |||
|
|
92deffa109 | ||
| cf9ee2bd1e | |||
|
|
a373bd60ad | ||
| f1fb06b0af | |||
|
|
55c0244336 | ||
| d1a1bb04de | |||
|
|
1e6acf29b9 | ||
| 3e109fb7b4 | |||
|
|
a58815ec9c | ||
| 61ccb05c75 | |||
|
|
a6df96db7e | ||
| 0b5e5408e8 | |||
|
|
31aa7a4865 | ||
| 231416dd08 | |||
|
|
3bc52b2b60 | ||
| 4e8b88ab34 |
201 changed files with 17705 additions and 328 deletions
128
package-lock.json
generated
128
package-lock.json
generated
|
|
@ -10,15 +10,23 @@
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1056.0",
|
"@aws-sdk/client-s3": "^3.1056.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.1058.0",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"next-auth": "^5.0.0-beta.31",
|
"next-auth": "^5.0.0-beta.31",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"resend": "^4.8.0",
|
"resend": "^4.8.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^18.3.0"
|
"stripe": "^18.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -506,6 +514,23 @@
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@aws-sdk/s3-request-presigner": {
|
||||||
|
"version": "3.1058.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1058.0.tgz",
|
||||||
|
"integrity": "sha512-IRgNfn8U3zfsZ0JkpmwjS59R/XyHMHxpuwW6HVuJhik+FsbClhNkujEO0w1WqJvXrF4FX+7qIAwUrvlwNvaZ7Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.974.15",
|
||||||
|
"@aws-sdk/signature-v4-multi-region": "^3.996.30",
|
||||||
|
"@aws-sdk/types": "^3.973.9",
|
||||||
|
"@smithy/core": "^3.24.5",
|
||||||
|
"@smithy/types": "^4.14.2",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||||
"version": "3.996.30",
|
"version": "3.996.30",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz",
|
||||||
|
|
@ -836,6 +861,59 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@electric-sql/pglite": {
|
"node_modules/@electric-sql/pglite": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz",
|
||||||
|
|
@ -1569,7 +1647,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -2757,6 +2834,17 @@
|
||||||
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
|
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-leaflet/core": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.60.4",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
|
||||||
|
|
@ -3609,6 +3697,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
|
|
@ -3623,6 +3717,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.41",
|
"version": "20.19.41",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
||||||
|
|
@ -5295,7 +5398,6 @@
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
|
|
@ -7597,6 +7699,12 @@
|
||||||
"url": "https://ko-fi.com/killymxi"
|
"url": "https://ko-fi.com/killymxi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
|
|
@ -9054,6 +9162,20 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-leaflet": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-promise-suspense": {
|
"node_modules/react-promise-suspense": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
|
||||||
|
|
@ -9451,7 +9573,6 @@
|
||||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"@img/colour": "^1.0.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
|
|
@ -9495,7 +9616,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -14,15 +14,23 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1056.0",
|
"@aws-sdk/client-s3": "^3.1056.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.1058.0",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"next-auth": "^5.0.0-beta.31",
|
"next-auth": "^5.0.0-beta.31",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"resend": "^4.8.0",
|
"resend": "^4.8.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^18.3.0"
|
"stripe": "^18.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -39,4 +47,4 @@
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
CREATE TABLE "PasswordResetToken" (
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("tokenHash")
|
||||||
|
);
|
||||||
|
CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId");
|
||||||
|
CREATE INDEX "PasswordResetToken_expiresAt_idx" ON "PasswordResetToken"("expiresAt");
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
CREATE TYPE "RoadAccess" AS ENUM ('NONE', 'DRY_SEASON_ONLY', 'ALL_YEAR');
|
||||||
|
CREATE TYPE "Electricity" AS ENUM ('NONE', 'SOLAR', 'GENERATOR_READY', 'EDF');
|
||||||
|
|
||||||
|
ALTER TABLE "Carbet" ADD COLUMN "roadAccess" "RoadAccess";
|
||||||
|
ALTER TABLE "Carbet" ADD COLUMN "electricity" "Electricity";
|
||||||
|
ALTER TABLE "Carbet" ADD COLUMN "gsmAtCarbet" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "Carbet" ADD COLUMN "gsmExitDistanceKm" DECIMAL(4,2);
|
||||||
|
|
||||||
|
-- Seed des 6 carbets démo avec valeurs réalistes
|
||||||
|
UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 1.5 WHERE id = 'demo-carbet-awara';
|
||||||
|
UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-kourou';
|
||||||
|
UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-mahury';
|
||||||
|
UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'GENERATOR_READY', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 4.0 WHERE id = 'demo-carbet-maripa';
|
||||||
|
UPDATE "Carbet" SET "roadAccess" = 'DRY_SEASON_ONLY', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 0.5 WHERE id = 'demo-carbet-paripou';
|
||||||
|
UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-wapa';
|
||||||
8
prisma/migrations/20260602100000_favorite/migration.sql
Normal file
8
prisma/migrations/20260602100000_favorite/migration.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
CREATE TABLE "Favorite" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"carbetId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "Favorite_pkey" PRIMARY KEY ("userId", "carbetId")
|
||||||
|
);
|
||||||
|
CREATE INDEX "Favorite_userId_idx" ON "Favorite"("userId");
|
||||||
|
CREATE INDEX "Favorite_carbetId_idx" ON "Favorite"("carbetId");
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
-- UserRole : ajouter RENTAL_PROVIDER
|
||||||
|
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'RENTAL_PROVIDER';
|
||||||
|
|
||||||
|
-- Enums dédiés
|
||||||
|
CREATE TYPE "RentalCategory" AS ENUM ('SLEEP', 'NAVIGATION', 'FISHING', 'COOKING', 'SAFETY');
|
||||||
|
CREATE TYPE "RentalBookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'HANDED_OVER', 'RETURNED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- RentalProvider
|
||||||
|
CREATE TABLE "RentalProvider" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"isSystemD" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"managedByUserId" TEXT,
|
||||||
|
"contactEmail" TEXT,
|
||||||
|
"contactPhone" TEXT,
|
||||||
|
"rivers" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"description" TEXT,
|
||||||
|
"commissionPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"approved" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"approvedAt" TIMESTAMP(3),
|
||||||
|
"approvedBy" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "RentalProvider_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "RentalProvider_managedByUserId_fkey" FOREIGN KEY ("managedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX "RentalProvider_active_approved_idx" ON "RentalProvider"("active", "approved");
|
||||||
|
CREATE INDEX "RentalProvider_managedByUserId_idx" ON "RentalProvider"("managedByUserId");
|
||||||
|
|
||||||
|
-- RentalItem
|
||||||
|
CREATE TABLE "RentalItem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"providerId" TEXT NOT NULL,
|
||||||
|
"category" "RentalCategory" NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"pricePerDay" DECIMAL(8,2) NOT NULL,
|
||||||
|
"pricePerWeek" DECIMAL(8,2),
|
||||||
|
"deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
|
||||||
|
"totalQty" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"withMotor" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"fuelIncluded" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"requiresLicense" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "RentalItem_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "RentalItem_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX "RentalItem_providerId_idx" ON "RentalItem"("providerId");
|
||||||
|
CREATE INDEX "RentalItem_category_active_idx" ON "RentalItem"("category", "active");
|
||||||
|
|
||||||
|
-- RentalItemAvailability
|
||||||
|
CREATE TABLE "RentalItemAvailability" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"itemId" TEXT NOT NULL,
|
||||||
|
"startDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"endDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"qty" INTEGER NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL,
|
||||||
|
"rentalBookingId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "RentalItemAvailability_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "RentalItemAvailability_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX "RentalItemAvailability_itemId_startDate_endDate_idx" ON "RentalItemAvailability"("itemId", "startDate", "endDate");
|
||||||
|
CREATE INDEX "RentalItemAvailability_rentalBookingId_idx" ON "RentalItemAvailability"("rentalBookingId");
|
||||||
|
|
||||||
|
-- RentalBooking
|
||||||
|
CREATE TABLE "RentalBooking" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"bookingId" TEXT,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"providerId" TEXT NOT NULL,
|
||||||
|
"startDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"endDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"status" "RentalBookingStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"itemsTotal" DECIMAL(10,2) NOT NULL,
|
||||||
|
"depositTotal" DECIMAL(10,2) NOT NULL,
|
||||||
|
"commissionAmount" DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
"amount" DECIMAL(10,2) NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
"stripeSessionId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "RentalBooking_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "RentalBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "RentalBooking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "RentalBooking_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX "RentalBooking_tenantId_status_idx" ON "RentalBooking"("tenantId", "status");
|
||||||
|
CREATE INDEX "RentalBooking_providerId_status_idx" ON "RentalBooking"("providerId", "status");
|
||||||
|
CREATE INDEX "RentalBooking_bookingId_idx" ON "RentalBooking"("bookingId");
|
||||||
|
CREATE INDEX "RentalBooking_startDate_endDate_idx" ON "RentalBooking"("startDate", "endDate");
|
||||||
|
|
||||||
|
-- RentalLine
|
||||||
|
CREATE TABLE "RentalLine" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"rentalBookingId" TEXT NOT NULL,
|
||||||
|
"itemId" TEXT NOT NULL,
|
||||||
|
"qty" INTEGER NOT NULL,
|
||||||
|
"pricePerDay" DECIMAL(8,2) NOT NULL,
|
||||||
|
"deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
|
||||||
|
"lineTotal" DECIMAL(10,2) NOT NULL,
|
||||||
|
CONSTRAINT "RentalLine_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "RentalLine_rentalBookingId_fkey" FOREIGN KEY ("rentalBookingId") REFERENCES "RentalBooking"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "RentalLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX "RentalLine_rentalBookingId_idx" ON "RentalLine"("rentalBookingId");
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- Sprint F : RentalItemMedia (photos & vidéos pour items rental).
|
||||||
|
-- Mêmes conventions que Media (carbet) : MediaType enum existant, s3Key/s3Url,
|
||||||
|
-- sortOrder pour cover (0). Cascade sur RentalItem.
|
||||||
|
|
||||||
|
CREATE TABLE "RentalItemMedia" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"itemId" TEXT NOT NULL,
|
||||||
|
"type" "MediaType" NOT NULL,
|
||||||
|
"s3Key" TEXT NOT NULL,
|
||||||
|
"s3Url" TEXT NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "RentalItemMedia_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "RentalItemMedia_itemId_sortOrder_idx"
|
||||||
|
ON "RentalItemMedia"("itemId", "sortOrder");
|
||||||
|
|
||||||
|
ALTER TABLE "RentalItemMedia"
|
||||||
|
ADD CONSTRAINT "RentalItemMedia_itemId_fkey"
|
||||||
|
FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
54
prisma/migrations/20260603200000_ce_management/migration.sql
Normal file
54
prisma/migrations/20260603200000_ce_management/migration.sql
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
-- Sprint G : CE management.
|
||||||
|
-- * Organization gagne le workflow d'approbation (approved + approvedAt + approvedBy)
|
||||||
|
-- + un contactEmail dédié pour les notifications admin.
|
||||||
|
-- * Nouveau modèle OrganizationCarbetMembership : co-gestion des carbets par les
|
||||||
|
-- CE_MANAGERs d'une org liée. Pas de unique sur carbet → un Carbet pourrait être
|
||||||
|
-- co-publié par plusieurs orgs (cas rare mais autorisé).
|
||||||
|
-- * RentalProvider gagne organizationId (nullable) : un CE peut posséder son provider.
|
||||||
|
|
||||||
|
ALTER TABLE "Organization"
|
||||||
|
ADD COLUMN "contactEmail" TEXT,
|
||||||
|
ADD COLUMN "approved" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN "approvedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "approvedBy" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX "Organization_approved_idx" ON "Organization"("approved");
|
||||||
|
|
||||||
|
-- Backfill : toutes les orgs existantes sont considérées validées.
|
||||||
|
-- (Aujourd'hui : CMCK uniquement. Les futures orgs créées via signup arriveront
|
||||||
|
-- en approved=false par défaut.)
|
||||||
|
UPDATE "Organization"
|
||||||
|
SET "approved" = TRUE,
|
||||||
|
"approvedAt" = NOW()
|
||||||
|
WHERE "approved" = FALSE;
|
||||||
|
|
||||||
|
CREATE TABLE "OrganizationCarbetMembership" (
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"carbetId" TEXT NOT NULL,
|
||||||
|
"addedByUserId" TEXT,
|
||||||
|
"addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "OrganizationCarbetMembership_pkey" PRIMARY KEY ("organizationId", "carbetId")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "OrganizationCarbetMembership_carbetId_idx"
|
||||||
|
ON "OrganizationCarbetMembership"("carbetId");
|
||||||
|
|
||||||
|
ALTER TABLE "OrganizationCarbetMembership"
|
||||||
|
ADD CONSTRAINT "OrganizationCarbetMembership_organizationId_fkey"
|
||||||
|
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "OrganizationCarbetMembership"
|
||||||
|
ADD CONSTRAINT "OrganizationCarbetMembership_carbetId_fkey"
|
||||||
|
FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "RentalProvider"
|
||||||
|
ADD COLUMN "organizationId" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX "RentalProvider_organizationId_idx" ON "RentalProvider"("organizationId");
|
||||||
|
|
||||||
|
ALTER TABLE "RentalProvider"
|
||||||
|
ADD CONSTRAINT "RentalProvider_organizationId_fkey"
|
||||||
|
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- Sprint K : tokens d'invitation CE_MEMBER.
|
||||||
|
-- Le CE_MANAGER génère un lien /inscription?invite=TOKEN, le destinataire s'inscrit
|
||||||
|
-- automatiquement comme CE_MEMBER de l'organisation. usedAt à la consommation.
|
||||||
|
|
||||||
|
CREATE TABLE "OrgInviteToken" (
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"email" TEXT,
|
||||||
|
"createdByUserId" TEXT,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"usedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "OrgInviteToken_pkey" PRIMARY KEY ("tokenHash")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "OrgInviteToken_organizationId_idx" ON "OrgInviteToken"("organizationId");
|
||||||
|
CREATE INDEX "OrgInviteToken_expiresAt_idx" ON "OrgInviteToken"("expiresAt");
|
||||||
|
|
||||||
|
ALTER TABLE "OrgInviteToken"
|
||||||
|
ADD CONSTRAINT "OrgInviteToken_organizationId_fkey"
|
||||||
|
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
-- Sprint O : reversements prestataires.
|
||||||
|
-- RentalPayoutMark trace les virements bancaires manuels effectués par System D
|
||||||
|
-- vers les RentalProvider tiers (le marketplace encaisse centralisé, redistribue
|
||||||
|
-- hors plateforme une fois par mois). Unique (provider, mois) pour empêcher
|
||||||
|
-- les marquages en doublon.
|
||||||
|
|
||||||
|
CREATE TABLE "RentalPayoutMark" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"providerId" TEXT NOT NULL,
|
||||||
|
"periodMonth" TIMESTAMP(3) NOT NULL,
|
||||||
|
"amount" DECIMAL(10, 2) NOT NULL,
|
||||||
|
"reference" TEXT,
|
||||||
|
"paidAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"paidByEmail" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "RentalPayoutMark_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "RentalPayoutMark_providerId_periodMonth_key"
|
||||||
|
ON "RentalPayoutMark"("providerId", "periodMonth");
|
||||||
|
|
||||||
|
CREATE INDEX "RentalPayoutMark_periodMonth_idx"
|
||||||
|
ON "RentalPayoutMark"("periodMonth");
|
||||||
|
|
||||||
|
ALTER TABLE "RentalPayoutMark"
|
||||||
|
ADD CONSTRAINT "RentalPayoutMark_providerId_fkey"
|
||||||
|
FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -13,6 +13,7 @@ enum UserRole {
|
||||||
CE_MEMBER
|
CE_MEMBER
|
||||||
TOURIST
|
TOURIST
|
||||||
ADMIN
|
ADMIN
|
||||||
|
RENTAL_PROVIDER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CarbetStatus {
|
enum CarbetStatus {
|
||||||
|
|
@ -71,16 +72,59 @@ enum TransportMode {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
description String?
|
description String?
|
||||||
createdAt DateTime @default(now())
|
contactEmail String?
|
||||||
updatedAt DateTime @updatedAt
|
approved Boolean @default(false)
|
||||||
|
approvedAt DateTime?
|
||||||
|
approvedBy String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
members User[]
|
members User[]
|
||||||
|
carbetMemberships OrganizationCarbetMembership[]
|
||||||
|
rentalProviders RentalProvider[]
|
||||||
|
invites OrgInviteToken[]
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
|
@@index([approved])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token d'invitation pour rejoindre une organisation comme CE_MEMBER.
|
||||||
|
/// Le CE_MANAGER génère un lien, le destinataire s'inscrit via /inscription?invite=TOKEN.
|
||||||
|
/// Pas de unique sur email pour permettre plusieurs invites pendants par destinataire.
|
||||||
|
model OrgInviteToken {
|
||||||
|
tokenHash String @id
|
||||||
|
organizationId String
|
||||||
|
email String?
|
||||||
|
createdByUserId String?
|
||||||
|
expiresAt DateTime
|
||||||
|
usedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([organizationId])
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Co-gestion des carbets côté CE. Un Carbet a toujours un ownerId (créateur initial),
|
||||||
|
/// et zéro ou plusieurs orgs liées : un CE_MANAGER d'une org liée peut gérer le carbet
|
||||||
|
/// en plus de l'owner. Pour un hôte individuel : aucune membership ; pour un carbet CE :
|
||||||
|
/// 1 membership pour l'org du créateur. Plusieurs orgs possibles si co-publication.
|
||||||
|
model OrganizationCarbetMembership {
|
||||||
|
organizationId String
|
||||||
|
carbetId String
|
||||||
|
addedByUserId String?
|
||||||
|
addedAt DateTime @default(now())
|
||||||
|
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([organizationId, carbetId])
|
||||||
|
@@index([carbetId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
|
|
@ -97,11 +141,13 @@ model User {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
||||||
carbets Carbet[] @relation("CarbetOwner")
|
carbets Carbet[] @relation("CarbetOwner")
|
||||||
bookings Booking[] @relation("BookingTenant")
|
bookings Booking[] @relation("BookingTenant")
|
||||||
reviews Review[] @relation("ReviewAuthor")
|
reviews Review[] @relation("ReviewAuthor")
|
||||||
subscriptions Subscription[]
|
subscriptions Subscription[]
|
||||||
|
rentalProviders RentalProvider[]
|
||||||
|
rentalBookings RentalBooking[] @relation("RentalBookingTenant")
|
||||||
|
|
||||||
@@index([organizationId])
|
@@index([organizationId])
|
||||||
@@index([role])
|
@@index([role])
|
||||||
|
|
@ -124,6 +170,11 @@ model Carbet {
|
||||||
// Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste).
|
// Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste).
|
||||||
roadAccessNote String?
|
roadAccessNote String?
|
||||||
capacity Int
|
capacity Int
|
||||||
|
// 4 critères opérationnels dealbreakers (dispo en filtres + badges UI)
|
||||||
|
roadAccess RoadAccess?
|
||||||
|
electricity Electricity?
|
||||||
|
gsmAtCarbet Boolean @default(false)
|
||||||
|
gsmExitDistanceKm Decimal? @db.Decimal(4, 2)
|
||||||
// Prix par nuit pour le carbet entier (toute capacité). En euros.
|
// Prix par nuit pour le carbet entier (toute capacité). En euros.
|
||||||
nightlyPrice Decimal @db.Decimal(10, 2) @default(0)
|
nightlyPrice Decimal @db.Decimal(10, 2) @default(0)
|
||||||
// Contraintes séjour (plugin min-stay). null = pas de contrainte.
|
// Contraintes séjour (plugin min-stay). null = pas de contrainte.
|
||||||
|
|
@ -149,6 +200,7 @@ model Carbet {
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
reviews Review[]
|
reviews Review[]
|
||||||
subscriptions Subscription[]
|
subscriptions Subscription[]
|
||||||
|
organizations OrganizationCarbetMembership[]
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
|
@ -244,7 +296,8 @@ model Booking {
|
||||||
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
|
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
|
||||||
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
|
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
review Review?
|
review Review?
|
||||||
|
rentalBookings RentalBooking[]
|
||||||
|
|
||||||
@@index([carbetId])
|
@@index([carbetId])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
|
|
@ -361,3 +414,206 @@ model Translation {
|
||||||
@@id([key, lang])
|
@@id([key, lang])
|
||||||
@@index([lang])
|
@@index([lang])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PasswordResetToken {
|
||||||
|
tokenHash String @id
|
||||||
|
userId String
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Favorite {
|
||||||
|
userId String
|
||||||
|
carbetId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@id([userId, carbetId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([carbetId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RoadAccess {
|
||||||
|
NONE
|
||||||
|
DRY_SEASON_ONLY
|
||||||
|
ALL_YEAR
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Electricity {
|
||||||
|
NONE
|
||||||
|
SOLAR
|
||||||
|
GENERATOR_READY
|
||||||
|
EDF
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RentalCategory {
|
||||||
|
SLEEP
|
||||||
|
NAVIGATION
|
||||||
|
FISHING
|
||||||
|
COOKING
|
||||||
|
SAFETY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RentalBookingStatus {
|
||||||
|
PENDING
|
||||||
|
CONFIRMED
|
||||||
|
HANDED_OVER
|
||||||
|
RETURNED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
model RentalProvider {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
isSystemD Boolean @default(false)
|
||||||
|
managedByUserId String?
|
||||||
|
/// Si renseigné, le provider appartient à une organisation (CE) ; tout CE_MANAGER
|
||||||
|
/// membre de l'org peut gérer items et réservations en plus du manager nominal.
|
||||||
|
organizationId String?
|
||||||
|
contactEmail String?
|
||||||
|
contactPhone String?
|
||||||
|
rivers String[] @default([])
|
||||||
|
description String?
|
||||||
|
commissionPct Decimal @db.Decimal(5, 2) @default(0)
|
||||||
|
active Boolean @default(true)
|
||||||
|
approved Boolean @default(false)
|
||||||
|
approvedAt DateTime?
|
||||||
|
approvedBy String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
manager User? @relation(fields: [managedByUserId], references: [id], onDelete: SetNull)
|
||||||
|
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
||||||
|
items RentalItem[]
|
||||||
|
rentalBookings RentalBooking[]
|
||||||
|
payoutMarks RentalPayoutMark[]
|
||||||
|
|
||||||
|
@@index([active, approved])
|
||||||
|
@@index([managedByUserId])
|
||||||
|
@@index([organizationId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trace les reversements bancaires manuels (System D paie le provider hors plateforme).
|
||||||
|
/// La période est représentée par le mois (1er du mois minuit UTC) ; unique par
|
||||||
|
/// (provider, période) pour empêcher de marquer 2 fois le même mois.
|
||||||
|
model RentalPayoutMark {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
providerId String
|
||||||
|
/// 1er du mois minuit UTC — sert de clé de période.
|
||||||
|
periodMonth DateTime
|
||||||
|
/// Montant effectivement viré au provider, en euros.
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
/// Référence de virement (optionnelle, à coller depuis la banque).
|
||||||
|
reference String?
|
||||||
|
paidAt DateTime @default(now())
|
||||||
|
paidByEmail String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([providerId, periodMonth])
|
||||||
|
@@index([periodMonth])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RentalItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
providerId String
|
||||||
|
category RentalCategory
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
imageUrl String?
|
||||||
|
pricePerDay Decimal @db.Decimal(8, 2)
|
||||||
|
pricePerWeek Decimal? @db.Decimal(8, 2)
|
||||||
|
deposit Decimal @db.Decimal(8, 2) @default(0)
|
||||||
|
totalQty Int @default(1)
|
||||||
|
withMotor Boolean @default(false)
|
||||||
|
fuelIncluded Boolean @default(false)
|
||||||
|
requiresLicense Boolean @default(false)
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
|
||||||
|
availabilities RentalItemAvailability[]
|
||||||
|
lines RentalLine[]
|
||||||
|
media RentalItemMedia[]
|
||||||
|
|
||||||
|
@@index([providerId])
|
||||||
|
@@index([category, active])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RentalItemMedia {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
itemId String
|
||||||
|
type MediaType
|
||||||
|
s3Key String
|
||||||
|
s3Url String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([itemId, sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RentalItemAvailability {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
itemId String
|
||||||
|
startDate DateTime
|
||||||
|
endDate DateTime
|
||||||
|
qty Int
|
||||||
|
reason String
|
||||||
|
rentalBookingId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([itemId, startDate, endDate])
|
||||||
|
@@index([rentalBookingId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RentalBooking {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
bookingId String?
|
||||||
|
tenantId String
|
||||||
|
providerId String
|
||||||
|
startDate DateTime
|
||||||
|
endDate DateTime
|
||||||
|
status RentalBookingStatus @default(PENDING)
|
||||||
|
paymentStatus PaymentStatus @default(PENDING)
|
||||||
|
itemsTotal Decimal @db.Decimal(10, 2)
|
||||||
|
depositTotal Decimal @db.Decimal(10, 2)
|
||||||
|
commissionAmount Decimal @db.Decimal(10, 2) @default(0)
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
currency String @default("EUR")
|
||||||
|
stripeSessionId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
|
||||||
|
tenant User @relation("RentalBookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
|
||||||
|
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Restrict)
|
||||||
|
lines RentalLine[]
|
||||||
|
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@index([providerId, status])
|
||||||
|
@@index([bookingId])
|
||||||
|
@@index([startDate, endDate])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RentalLine {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
rentalBookingId String
|
||||||
|
itemId String
|
||||||
|
qty Int
|
||||||
|
pricePerDay Decimal @db.Decimal(8, 2)
|
||||||
|
deposit Decimal @db.Decimal(8, 2) @default(0)
|
||||||
|
lineTotal Decimal @db.Decimal(10, 2)
|
||||||
|
|
||||||
|
rentalBooking RentalBooking @relation(fields: [rentalBookingId], references: [id], onDelete: Cascade)
|
||||||
|
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([rentalBookingId])
|
||||||
|
}
|
||||||
|
|
|
||||||
BIN
public/icons/apple-touch-icon.png
Normal file
BIN
public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1 KiB |
BIN
public/icons/favicon-32.png
Normal file
BIN
public/icons/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 B |
BIN
public/icons/icon-192-maskable.png
Normal file
BIN
public/icons/icon-192-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1 KiB |
BIN
public/icons/icon-192.png
Normal file
BIN
public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/icons/icon-512-maskable.png
Normal file
BIN
public/icons/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3 KiB |
BIN
public/icons/icon-512.png
Normal file
BIN
public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
60
public/manifest.webmanifest
Normal file
60
public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
"name": "Karbé — carbets fluviaux de Guyane",
|
||||||
|
"short_name": "Karbé",
|
||||||
|
"description": "Au fil de l'eau : louez des carbets le long des fleuves de Guyane.",
|
||||||
|
"start_url": "/decouvrir",
|
||||||
|
"id": "/decouvrir",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#000000",
|
||||||
|
"theme_color": "#059669",
|
||||||
|
"lang": "fr",
|
||||||
|
"categories": ["travel", "lifestyle"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192-maskable.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512-maskable.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Au fil de l'eau",
|
||||||
|
"short_name": "Découvrir",
|
||||||
|
"url": "/decouvrir",
|
||||||
|
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mes favoris",
|
||||||
|
"short_name": "Favoris",
|
||||||
|
"url": "/mes-favoris",
|
||||||
|
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mon compte",
|
||||||
|
"short_name": "Compte",
|
||||||
|
"url": "/mon-compte",
|
||||||
|
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
54
scripts/backup-postgres.sh
Executable file
54
scripts/backup-postgres.sh
Executable file
|
|
@ -0,0 +1,54 @@
|
||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Backup nightly du PostgreSQL Karbé vers MinIO.
|
||||||
|
# Lancé par un systemd timer (karbe-backup.timer).
|
||||||
|
#
|
||||||
|
# Rétention 30 jours côté MinIO (s'appuyer sur une lifecycle policy ou un
|
||||||
|
# nettoyage côté `mc rm` planifié — TODO si on veut être propre).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STAMP=$(date -u +%Y%m%d-%H%M%S)
|
||||||
|
DUMP_DIR=/tmp/karbe-backup
|
||||||
|
DUMP_FILE="$DUMP_DIR/karbe-${STAMP}.sql.gz"
|
||||||
|
BUCKET_DEST="karbe-backups/postgres/karbe-${STAMP}.sql.gz"
|
||||||
|
|
||||||
|
mkdir -p "$DUMP_DIR"
|
||||||
|
|
||||||
|
# Dump compressé depuis le conteneur postgres
|
||||||
|
docker compose -f /home/ubuntu/karbe/docker-compose.prod.yml \
|
||||||
|
-f /home/ubuntu/karbe/docker-compose.override.yml \
|
||||||
|
exec -T postgres pg_dump -U karbe -d karbe \
|
||||||
|
| gzip > "$DUMP_FILE"
|
||||||
|
|
||||||
|
SIZE=$(stat -c %s "$DUMP_FILE")
|
||||||
|
echo "[$(date -u +%FT%TZ)] dump created size=${SIZE}B path=${DUMP_FILE}"
|
||||||
|
|
||||||
|
# Push vers MinIO via mc Docker
|
||||||
|
docker run --rm --network karbe-net \
|
||||||
|
--entrypoint /bin/sh \
|
||||||
|
-v "$DUMP_DIR:/dump" \
|
||||||
|
-e MINIO_ROOT_USER \
|
||||||
|
-e MINIO_ROOT_PASSWORD \
|
||||||
|
minio/mc:latest -c "
|
||||||
|
mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \
|
||||||
|
mc mb karbe/karbe-backups --ignore-existing >/dev/null 2>&1 && \
|
||||||
|
mc cp /dump/karbe-${STAMP}.sql.gz karbe/${BUCKET_DEST}
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "[$(date -u +%FT%TZ)] uploaded to karbe/${BUCKET_DEST}"
|
||||||
|
|
||||||
|
# Nettoyage local
|
||||||
|
rm -f "$DUMP_FILE"
|
||||||
|
|
||||||
|
# Rétention : supprime les backups > 30 jours dans MinIO
|
||||||
|
docker run --rm --network karbe-net \
|
||||||
|
--entrypoint /bin/sh \
|
||||||
|
-e MINIO_ROOT_USER \
|
||||||
|
-e MINIO_ROOT_PASSWORD \
|
||||||
|
minio/mc:latest -c "
|
||||||
|
mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \
|
||||||
|
mc rm --recursive --force --older-than 30d karbe/karbe-backups/ 2>/dev/null || true
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "[$(date -u +%FT%TZ)] retention sweep done (>30d removed)"
|
||||||
60
src/app/accueil/page.tsx
Normal file
60
src/app/accueil/page.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { IfPluginEnabled } from "@/components/IfPluginEnabled";
|
||||||
|
import { HeroSection } from "@/components/landing/HeroSection";
|
||||||
|
import { ExperiencesSection } from "@/components/landing/ExperiencesSection";
|
||||||
|
import { HowItWorksSection } from "@/components/landing/HowItWorksSection";
|
||||||
|
import { CESection } from "@/components/landing/CESection";
|
||||||
|
import { TestimonialsSection } from "@/components/landing/TestimonialsSection";
|
||||||
|
import { LandingFooter } from "@/components/landing/Footer";
|
||||||
|
|
||||||
|
export const metadata = { title: "Accueil — Karbé" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing « marketing » historique (hero + sections + footer riche). Conservée
|
||||||
|
* à /accueil après la promotion de /decouvrir comme nouvelle page d'index.
|
||||||
|
*/
|
||||||
|
export default function LandingPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IfPluginEnabled
|
||||||
|
plugin="landing-hero"
|
||||||
|
fallback={
|
||||||
|
<div className="flex flex-1 items-center justify-center bg-zinc-50 px-6 dark:bg-black">
|
||||||
|
<main className="flex w-full max-w-2xl flex-col items-center gap-6 text-center">
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight text-black sm:text-5xl dark:text-zinc-50">
|
||||||
|
Karbé — carbets fluviaux de Guyane
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-xl text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||||
|
La marketplace pour louer des carbets le long des fleuves de Guyane.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/decouvrir"
|
||||||
|
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Au fil de l'eau
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/carbets"
|
||||||
|
className="rounded-md border border-zinc-300 px-5 py-2.5 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-900"
|
||||||
|
>
|
||||||
|
Catalogue
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<HeroSection />
|
||||||
|
</IfPluginEnabled>
|
||||||
|
|
||||||
|
<IfPluginEnabled plugin="landing-sections">
|
||||||
|
<ExperiencesSection />
|
||||||
|
<HowItWorksSection />
|
||||||
|
<CESection />
|
||||||
|
<TestimonialsSection />
|
||||||
|
<LandingFooter />
|
||||||
|
</IfPluginEnabled>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
src/app/admin/analytics/page.tsx
Normal file
169
src/app/admin/analytics/page.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { MonthlyRevenueChart } from "@/components/analytics/MonthlyRevenueChart";
|
||||||
|
import { getAdminGlobalKpis, getMonthlyRevenueSeries } from "@/lib/analytics";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "Analytics globaux — Karbé admin" };
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<string, string> = {
|
||||||
|
ADMIN: "Admin",
|
||||||
|
OWNER: "Hôte",
|
||||||
|
RENTAL_PROVIDER: "Loueur matériel",
|
||||||
|
CE_MANAGER: "CE Manager",
|
||||||
|
CE_MEMBER: "CE Membre",
|
||||||
|
TOURIST: "Voyageur",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtEur(n: number): string {
|
||||||
|
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminAnalyticsPage() {
|
||||||
|
const [kpis, series] = await Promise.all([
|
||||||
|
getAdminGlobalKpis(),
|
||||||
|
getMonthlyRevenueSeries({ monthsBack: 12 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
|
<header className="mt-2">
|
||||||
|
<h1 className="text-2xl font-semibold text-zinc-900">Analytics globaux</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
Vue d'ensemble plateforme : utilisateurs, activité 30 derniers jours, top performers.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<KpiCard label="Utilisateurs" value={kpis.usersTotal} />
|
||||||
|
<KpiCard label="Carbets publiés" value={kpis.carbetsPublished} />
|
||||||
|
<KpiCard label="Bookings 30j" value={kpis.bookings30d} />
|
||||||
|
<KpiCard label="CA 30j" value={fmtEur(kpis.revenue30d)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Utilisateurs par rôle
|
||||||
|
</h2>
|
||||||
|
{kpis.usersTotal === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">Aucun utilisateur.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{Object.entries(kpis.usersByRole)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([role, count]) => {
|
||||||
|
const pct = Math.round((count / kpis.usersTotal) * 100);
|
||||||
|
return (
|
||||||
|
<li key={role}>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<span className="text-zinc-700">{ROLE_LABEL[role] ?? role}</span>
|
||||||
|
<span className="font-mono text-xs text-zinc-700">
|
||||||
|
{count} ({pct}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 h-1.5 overflow-hidden rounded-full bg-zinc-100">
|
||||||
|
<div
|
||||||
|
className="h-full bg-emerald-500"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Activité 30 derniers jours
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
<li className="flex items-baseline justify-between">
|
||||||
|
<span className="text-zinc-700">Bookings carbet</span>
|
||||||
|
<span className="font-mono font-semibold text-zinc-900">{kpis.bookings30d}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-baseline justify-between">
|
||||||
|
<span className="text-zinc-700">Locations matériel</span>
|
||||||
|
<span className="font-mono font-semibold text-zinc-900">{kpis.rentals30d}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-baseline justify-between border-t border-zinc-100 pt-2">
|
||||||
|
<span className="font-semibold text-zinc-900">Total CA 30j</span>
|
||||||
|
<span className="font-mono font-semibold text-emerald-700">
|
||||||
|
{fmtEur(kpis.revenue30d)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Chiffre d'affaires mensuel
|
||||||
|
</h2>
|
||||||
|
<MonthlyRevenueChart data={series} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Top carbets (30j)
|
||||||
|
</h2>
|
||||||
|
{kpis.topCarbets.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">Aucune réservation sur les 30 derniers jours.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{kpis.topCarbets.map((c, i) => (
|
||||||
|
<li key={c.carbetId} className="flex items-baseline justify-between">
|
||||||
|
<span>
|
||||||
|
<span className="mr-2 text-xs text-zinc-500">#{i + 1}</span>
|
||||||
|
<Link href={`/admin/carbets/${c.carbetId}`} className="text-zinc-900 hover:underline">
|
||||||
|
{c.title}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-zinc-700">{fmtEur(c.revenue)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Top prestataires rental (30j)
|
||||||
|
</h2>
|
||||||
|
{kpis.topProviders.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">Aucune location sur les 30 derniers jours.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{kpis.topProviders.map((p, i) => (
|
||||||
|
<li key={p.providerId} className="flex items-baseline justify-between">
|
||||||
|
<span>
|
||||||
|
<span className="mr-2 text-xs text-zinc-500">#{i + 1}</span>
|
||||||
|
<Link
|
||||||
|
href={`/admin/rental-providers/${p.providerId}`}
|
||||||
|
className="text-zinc-900 hover:underline"
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-zinc-700">{fmtEur(p.revenue)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold text-zinc-900 font-mono">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx
Normal file
125
src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
|
||||||
|
type Org = { id: string; name: string; slug: string; approved: boolean };
|
||||||
|
type LinkedOrg = Org & { addedAt: Date };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
carbetId: string;
|
||||||
|
linked: LinkedOrg[];
|
||||||
|
available: Org[];
|
||||||
|
linkAction: (carbetId: string, orgId: string) => Promise<{ ok: true; alreadyLinked: boolean } | { ok: false; error?: string }>;
|
||||||
|
unlinkAction: (carbetId: string, orgId: string) => Promise<{ ok: true } | { ok: false; error?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CarbetMemberships({
|
||||||
|
carbetId,
|
||||||
|
linked,
|
||||||
|
available,
|
||||||
|
linkAction,
|
||||||
|
unlinkAction,
|
||||||
|
}: Props) {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [selectedOrgId, setSelectedOrgId] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Filtre les orgs non encore liées
|
||||||
|
const linkedIds = new Set(linked.map((l) => l.id));
|
||||||
|
const options = available.filter((o) => !linkedIds.has(o.id));
|
||||||
|
|
||||||
|
function link() {
|
||||||
|
if (!selectedOrgId) return;
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await linkAction(carbetId, selectedOrgId);
|
||||||
|
if (!res.ok) setError(res.error || "Échec de la liaison");
|
||||||
|
else setSelectedOrgId("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlink(orgId: string) {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await unlinkAction(carbetId, orgId);
|
||||||
|
if (!res.ok) setError(res.error || "Échec");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{linked.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Aucune organisation liée. Le carbet est géré uniquement par son propriétaire individuel.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-zinc-100 rounded-md border border-zinc-200 bg-white">
|
||||||
|
{linked.map((o) => (
|
||||||
|
<li
|
||||||
|
key={o.id}
|
||||||
|
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-zinc-900">{o.name}</span>
|
||||||
|
<span className="ml-2 text-[11px] text-zinc-500">/{o.slug}</span>
|
||||||
|
{!o.approved ? (
|
||||||
|
<span className="ml-2 rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => unlink(o.id)}
|
||||||
|
className="rounded border border-rose-200 bg-white px-2 py-1 text-[11px] text-rose-700 hover:bg-rose-50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Délier
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{options.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={selectedOrgId}
|
||||||
|
onChange={(e) => setSelectedOrgId(e.target.value)}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">— Choisir une organisation à lier —</option>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.name} {o.approved ? "" : "(pending)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending || !selectedOrgId}
|
||||||
|
onClick={link}
|
||||||
|
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? "…" : "Lier"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-zinc-500">
|
||||||
|
Toutes les organisations existantes sont déjà liées à ce carbet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="text-[11px] text-zinc-500">
|
||||||
|
Une organisation liée signifie que ses CE_MANAGERs peuvent éditer ce carbet en plus du
|
||||||
|
propriétaire nominal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,23 @@
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { MediaUploader } from "@/components/MediaUploader";
|
||||||
|
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||||
import {
|
import {
|
||||||
getCarbetForEdit,
|
getCarbetForEdit,
|
||||||
|
listOrganizationsForLink,
|
||||||
listOwners,
|
listOwners,
|
||||||
listPirogueProviders,
|
listPirogueProviders,
|
||||||
} from "@/lib/admin/carbets";
|
} from "@/lib/admin/carbets";
|
||||||
|
|
||||||
import { CarbetForm } from "../_components/CarbetForm";
|
import { CarbetForm } from "../_components/CarbetForm";
|
||||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
import {
|
||||||
import { MediaManager } from "./_components/MediaManager";
|
linkCarbetToOrganizationAction,
|
||||||
|
unlinkCarbetFromOrganizationAction,
|
||||||
|
updateCarbetAction,
|
||||||
|
} from "../actions";
|
||||||
|
import { CarbetMemberships } from "./_components/CarbetMemberships";
|
||||||
import { StatusActions } from "./_components/StatusActions";
|
import { StatusActions } from "./_components/StatusActions";
|
||||||
import { updateCarbetAction } from "../actions";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|
@ -17,10 +25,11 @@ type PageProps = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
export default async function EditCarbetPage({ params }: PageProps) {
|
export default async function EditCarbetPage({ params }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const [carbet, owners, providers] = await Promise.all([
|
const [carbet, owners, providers, organizations] = await Promise.all([
|
||||||
getCarbetForEdit(id),
|
getCarbetForEdit(id),
|
||||||
listOwners(),
|
listOwners(),
|
||||||
listPirogueProviders(),
|
listPirogueProviders(),
|
||||||
|
listOrganizationsForLink(),
|
||||||
]);
|
]);
|
||||||
if (!carbet) notFound();
|
if (!carbet) notFound();
|
||||||
|
|
||||||
|
|
@ -28,6 +37,14 @@ export default async function EditCarbetPage({ params }: PageProps) {
|
||||||
"use server";
|
"use server";
|
||||||
return await updateCarbetAction(id, fd);
|
return await updateCarbetAction(id, fd);
|
||||||
};
|
};
|
||||||
|
const linkThis = async (carbetId: string, orgId: string) => {
|
||||||
|
"use server";
|
||||||
|
return await linkCarbetToOrganizationAction(carbetId, orgId);
|
||||||
|
};
|
||||||
|
const unlinkThis = async (carbetId: string, orgId: string) => {
|
||||||
|
"use server";
|
||||||
|
return await unlinkCarbetFromOrganizationAction(carbetId, orgId);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
|
@ -61,16 +78,40 @@ export default async function EditCarbetPage({ params }: PageProps) {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<MediaManager
|
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
carbetId={carbet.id}
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
media={carbet.media.map((m) => ({
|
Organisations co-gestionnaires (CE)
|
||||||
id: m.id,
|
</h2>
|
||||||
type: m.type,
|
<CarbetMemberships
|
||||||
s3Key: m.s3Key,
|
carbetId={carbet.id}
|
||||||
s3Url: m.s3Url,
|
linked={carbet.organizations.map((m) => ({
|
||||||
sortOrder: m.sortOrder,
|
id: m.organization.id,
|
||||||
}))}
|
name: m.organization.name,
|
||||||
/>
|
slug: m.organization.slug,
|
||||||
|
approved: m.organization.approved,
|
||||||
|
addedAt: m.addedAt,
|
||||||
|
}))}
|
||||||
|
available={organizations}
|
||||||
|
linkAction={linkThis}
|
||||||
|
unlinkAction={unlinkThis}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Médias
|
||||||
|
</h2>
|
||||||
|
<MediaUploader
|
||||||
|
carbetId={carbet.id}
|
||||||
|
initialMedia={carbet.media.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
type: m.type,
|
||||||
|
s3Key: m.s3Key,
|
||||||
|
s3Url: m.s3Url,
|
||||||
|
sortOrder: m.sortOrder,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<CarbetForm
|
<CarbetForm
|
||||||
owners={owners}
|
owners={owners}
|
||||||
|
|
@ -89,6 +130,10 @@ export default async function EditCarbetPage({ params }: PageProps) {
|
||||||
capacity: carbet.capacity,
|
capacity: carbet.capacity,
|
||||||
nightlyPrice: carbet.nightlyPrice.toString(),
|
nightlyPrice: carbet.nightlyPrice.toString(),
|
||||||
accessType: carbet.accessType,
|
accessType: carbet.accessType,
|
||||||
|
roadAccess: carbet.roadAccess,
|
||||||
|
electricity: carbet.electricity,
|
||||||
|
gsmAtCarbet: carbet.gsmAtCarbet,
|
||||||
|
gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : null,
|
||||||
roadAccessNote: carbet.roadAccessNote,
|
roadAccessNote: carbet.roadAccessNote,
|
||||||
pirogueDurationMin: carbet.pirogueDurationMin,
|
pirogueDurationMin: carbet.pirogueDurationMin,
|
||||||
minStayNights: carbet.minStayNights,
|
minStayNights: carbet.minStayNights,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ export type CarbetFormInitial = {
|
||||||
capacity?: number;
|
capacity?: number;
|
||||||
nightlyPrice?: number | string;
|
nightlyPrice?: number | string;
|
||||||
accessType?: string;
|
accessType?: string;
|
||||||
|
roadAccess?: string | null;
|
||||||
|
electricity?: string | null;
|
||||||
|
gsmAtCarbet?: boolean;
|
||||||
|
gsmExitDistanceKm?: number | string | null;
|
||||||
roadAccessNote?: string | null;
|
roadAccessNote?: string | null;
|
||||||
pirogueDurationMin?: number | null;
|
pirogueDurationMin?: number | null;
|
||||||
minStayNights?: number | null;
|
minStayNights?: number | null;
|
||||||
|
|
@ -189,6 +193,63 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Critères opérationnels */}
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-1 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Critères opérationnels
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4 text-xs text-zinc-500">
|
||||||
|
Les 4 dealbreakers d'un séjour en carbet guyanais. Indispensable pour les filtres recherche.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField label="🛣️ Accès route" hint="Praticabilité de l'accès depuis la route">
|
||||||
|
<select name="roadAccess" defaultValue={initial.roadAccess ?? ""} className={selectCls}>
|
||||||
|
<option value="">— non précisé —</option>
|
||||||
|
<option value="ALL_YEAR">🛣️ Toute saison</option>
|
||||||
|
<option value="DRY_SEASON_ONLY">🟠 Saison sèche uniquement</option>
|
||||||
|
<option value="NONE">🛶 Pirogue uniquement</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="⚡ Électricité" hint="Comment est alimenté le carbet ?">
|
||||||
|
<select name="electricity" defaultValue={initial.electricity ?? ""} className={selectCls}>
|
||||||
|
<option value="">— non précisé —</option>
|
||||||
|
<option value="EDF">⚡ EDF / raccordé réseau</option>
|
||||||
|
<option value="GENERATOR_READY">🔌 Préinstallation groupe électrogène</option>
|
||||||
|
<option value="SOLAR">☀️ Solaire</option>
|
||||||
|
<option value="NONE">🕯️ Aucune électricité</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="📶 Réseau GSM au carbet" hint="Téléphone capte directement sur place ?">
|
||||||
|
<select
|
||||||
|
name="gsmAtCarbet"
|
||||||
|
defaultValue={initial.gsmAtCarbet ? "yes" : "no"}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
<option value="yes">✅ Oui, signal au carbet</option>
|
||||||
|
<option value="no">❌ Non, zone sans réseau</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="📵 Distance pour atteindre le réseau (km)"
|
||||||
|
hint="Si pas de réseau au carbet — sinon laisser vide"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="gsmExitDistanceKm"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={50}
|
||||||
|
step="0.1"
|
||||||
|
defaultValue={initial.gsmExitDistanceKm?.toString() ?? ""}
|
||||||
|
placeholder="ex. 1.5"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Séjour & tarif */}
|
{/* Séjour & tarif */}
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Séjour & tarif</h2>
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Séjour & tarif</h2>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ import { prisma } from "@/lib/prisma";
|
||||||
import {
|
import {
|
||||||
AccessType,
|
AccessType,
|
||||||
CarbetStatus,
|
CarbetStatus,
|
||||||
|
Electricity,
|
||||||
MediaType,
|
MediaType,
|
||||||
|
RoadAccess,
|
||||||
TransportMode,
|
TransportMode,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from "@/generated/prisma/enums";
|
} from "@/generated/prisma/enums";
|
||||||
|
|
@ -29,6 +31,16 @@ const baseCarbetSchema = z.object({
|
||||||
capacity: z.coerce.number().int().min(1).max(100),
|
capacity: z.coerce.number().int().min(1).max(100),
|
||||||
nightlyPrice: z.coerce.number().min(0).max(100000),
|
nightlyPrice: z.coerce.number().min(0).max(100000),
|
||||||
accessType: z.enum([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]),
|
accessType: z.enum([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]),
|
||||||
|
roadAccess: z
|
||||||
|
.enum([RoadAccess.NONE, RoadAccess.DRY_SEASON_ONLY, RoadAccess.ALL_YEAR])
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
electricity: z
|
||||||
|
.enum([Electricity.NONE, Electricity.SOLAR, Electricity.GENERATOR_READY, Electricity.EDF])
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
gsmAtCarbet: z.preprocess((v) => v === "yes" || v === true, z.boolean()),
|
||||||
|
gsmExitDistanceKm: z.coerce.number().min(0).max(50).optional().nullable(),
|
||||||
roadAccessNote: z.string().trim().max(1000).optional().nullable(),
|
roadAccessNote: z.string().trim().max(1000).optional().nullable(),
|
||||||
pirogueDurationMin: z.coerce.number().int().min(0).max(1440).optional().nullable(),
|
pirogueDurationMin: z.coerce.number().int().min(0).max(1440).optional().nullable(),
|
||||||
minStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(),
|
minStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(),
|
||||||
|
|
@ -53,9 +65,11 @@ function parseFromFormData(fd: FormData) {
|
||||||
if (typeof v === "string") obj[k] = v;
|
if (typeof v === "string") obj[k] = v;
|
||||||
}
|
}
|
||||||
// Normalise les champs optionnels nullables
|
// Normalise les champs optionnels nullables
|
||||||
["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId"].forEach(
|
["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId", "roadAccess", "electricity", "gsmExitDistanceKm"].forEach(
|
||||||
(k) => (obj[k] = normalizeNullable(obj[k] as string | null | undefined)),
|
(k) => (obj[k] = normalizeNullable(obj[k] as string | null | undefined)),
|
||||||
);
|
);
|
||||||
|
// gsmAtCarbet : si pas posté, on garde la valeur (sera traité par preprocess Zod)
|
||||||
|
if (!("gsmAtCarbet" in obj)) obj.gsmAtCarbet = "no";
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,6 +213,42 @@ export async function reorderMediaAction(carbetId: string, mediaId: string, dire
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function linkCarbetToOrganizationAction(carbetId: string, organizationId: string) {
|
||||||
|
await requireRole([UserRole.ADMIN]);
|
||||||
|
const session = await auth();
|
||||||
|
const actorEmail = session?.user?.email ?? null;
|
||||||
|
// findFirst pour idempotence : si déjà lié, on ne touche pas + on ne crash pas.
|
||||||
|
const existing = await prisma.organizationCarbetMembership.findUnique({
|
||||||
|
where: { organizationId_carbetId: { organizationId, carbetId } },
|
||||||
|
select: { organizationId: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return { ok: true as const, alreadyLinked: true };
|
||||||
|
}
|
||||||
|
await prisma.organizationCarbetMembership.create({
|
||||||
|
data: {
|
||||||
|
organizationId,
|
||||||
|
carbetId,
|
||||||
|
addedByUserId: session?.user?.id ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await audit("carbet.org.link", carbetId, actorEmail, { organizationId });
|
||||||
|
revalidatePath(`/admin/carbets/${carbetId}`);
|
||||||
|
return { ok: true as const, alreadyLinked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlinkCarbetFromOrganizationAction(carbetId: string, organizationId: string) {
|
||||||
|
await requireRole([UserRole.ADMIN]);
|
||||||
|
const session = await auth();
|
||||||
|
const actorEmail = session?.user?.email ?? null;
|
||||||
|
await prisma.organizationCarbetMembership
|
||||||
|
.delete({ where: { organizationId_carbetId: { organizationId, carbetId } } })
|
||||||
|
.catch(() => {});
|
||||||
|
await audit("carbet.org.unlink", carbetId, actorEmail, { organizationId });
|
||||||
|
revalidatePath(`/admin/carbets/${carbetId}`);
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
async function audit(
|
async function audit(
|
||||||
event: string,
|
event: string,
|
||||||
entityId: string,
|
entityId: string,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
action: () => Promise<{ ok: false; error: string } | { ok: true } | undefined | void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ApproveOrgButton({ action }: Props) {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await action();
|
||||||
|
if (res && (res as { ok?: boolean }).ok === false) {
|
||||||
|
setError((res as { error: string }).error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={run}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{pending ? "Validation…" : "Valider l'organisation"}
|
||||||
|
</button>
|
||||||
|
{error ? <span className="text-xs text-rose-700">{error}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,8 @@ import Link from "next/link";
|
||||||
import { getOrganizationForAdmin } from "@/lib/admin/organizations";
|
import { getOrganizationForAdmin } from "@/lib/admin/organizations";
|
||||||
import { OrgForm } from "../_components/OrgForm";
|
import { OrgForm } from "../_components/OrgForm";
|
||||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||||
import { deleteOrganizationAction, updateOrganizationAction } from "../actions";
|
import { approveOrganizationAction, deleteOrganizationAction, updateOrganizationAction } from "../actions";
|
||||||
|
import { ApproveOrgButton } from "./_components/ApproveOrgButton";
|
||||||
import { DeleteOrgButton } from "./_components/DeleteOrgButton";
|
import { DeleteOrgButton } from "./_components/DeleteOrgButton";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
@ -31,6 +32,10 @@ export default async function EditOrgPage({ params }: PageProps) {
|
||||||
"use server";
|
"use server";
|
||||||
return await deleteOrganizationAction(id);
|
return await deleteOrganizationAction(id);
|
||||||
};
|
};
|
||||||
|
const approveThis = async () => {
|
||||||
|
"use server";
|
||||||
|
return await approveOrganizationAction(id);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
|
@ -39,12 +44,33 @@ export default async function EditOrgPage({ params }: PageProps) {
|
||||||
<Link href="/admin/organizations" className="text-xs text-zinc-500 hover:text-zinc-900">
|
<Link href="/admin/organizations" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
← Toutes les organisations
|
← Toutes les organisations
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">{org.name}</h1>
|
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
||||||
|
{org.name}
|
||||||
|
{org.approved ? (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||||
|
Validée
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
||||||
|
À valider
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
<code>/{org.slug}</code> · {org.members.length} membre{org.members.length > 1 ? "s" : ""}
|
<code>/{org.slug}</code> · {org.members.length} membre{org.members.length > 1 ? "s" : ""} ·{" "}
|
||||||
|
{org._count.carbetMemberships} carbet{org._count.carbetMemberships > 1 ? "s" : ""} co-géré
|
||||||
|
{org._count.carbetMemberships > 1 ? "s" : ""} · {org._count.rentalProviders} provider rental
|
||||||
</p>
|
</p>
|
||||||
|
{org.contactEmail ? (
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Contact : <a href={`mailto:${org.contactEmail}`} className="underline">{org.contactEmail}</a>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!org.approved ? <ApproveOrgButton action={approveThis} /> : null}
|
||||||
|
<DeleteOrgButton action={deleteThis} memberCount={org.members.length} />
|
||||||
</div>
|
</div>
|
||||||
<DeleteOrgButton action={deleteThis} memberCount={org.members.length} />
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { UserRole } from "@/generated/prisma/enums";
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { approveOrganization as approveOrganizationLib } from "@/lib/admin/organizations";
|
||||||
import { requireRole } from "@/lib/authorization";
|
import { requireRole } from "@/lib/authorization";
|
||||||
|
import { sendCeApproved } from "@/lib/email";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
|
||||||
|
|
@ -75,6 +77,38 @@ export async function updateOrganizationAction(id: string, fd: FormData) {
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function approveOrganizationAction(id: string) {
|
||||||
|
await requireRole([UserRole.ADMIN]);
|
||||||
|
const session = await auth();
|
||||||
|
const actor = session?.user?.email ?? null;
|
||||||
|
const res = await approveOrganizationLib(id, actor ?? "admin");
|
||||||
|
if (!res.ok) return res;
|
||||||
|
if (!res.alreadyApproved) {
|
||||||
|
await audit("organization.approve", id, actor, {});
|
||||||
|
// Notifier les CE_MANAGERs de l'org : leur compte vient d'être débloqué.
|
||||||
|
try {
|
||||||
|
const data = await prisma.organization.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
members: {
|
||||||
|
where: { role: UserRole.CE_MANAGER, isActive: true },
|
||||||
|
select: { email: true, firstName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const m of data?.members ?? []) {
|
||||||
|
await sendCeApproved(m.email, m.firstName, data?.name ?? "");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[admin.org.approve] email send failed:", e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/organizations");
|
||||||
|
revalidatePath(`/admin/organizations/${id}`);
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteOrganizationAction(id: string) {
|
export async function deleteOrganizationAction(id: string) {
|
||||||
await requireRole([UserRole.ADMIN]);
|
await requireRole([UserRole.ADMIN]);
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,27 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { listOrganizationsAdmin } from "@/lib/admin/organizations";
|
import { countPendingOrganizations, listOrganizationsAdmin } from "@/lib/admin/organizations";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
searchParams: Promise<{ q?: string }>;
|
searchParams: Promise<{ q?: string; status?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STATUS_VALUES = ["all", "pending", "approved"] as const;
|
||||||
|
type StatusFilter = (typeof STATUS_VALUES)[number];
|
||||||
|
|
||||||
|
function isStatusFilter(s: string | undefined): s is StatusFilter {
|
||||||
|
return STATUS_VALUES.includes(s as StatusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function OrgsAdminPage({ searchParams }: PageProps) {
|
export default async function OrgsAdminPage({ searchParams }: PageProps) {
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const filters = { q: sp.q?.trim() || undefined };
|
const approved = isStatusFilter(sp.status) ? sp.status : "all";
|
||||||
const orgs = await listOrganizationsAdmin(filters);
|
const filters = { q: sp.q?.trim() || undefined, approved };
|
||||||
|
const [orgs, pendingCount] = await Promise.all([
|
||||||
|
listOrganizationsAdmin(filters),
|
||||||
|
countPendingOrganizations(),
|
||||||
|
]);
|
||||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -30,7 +41,35 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<nav className="mb-3 flex flex-wrap gap-2 text-sm">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ key: "all", label: "Toutes" },
|
||||||
|
{ key: "pending", label: pendingCount > 0 ? `À valider (${pendingCount})` : "À valider" },
|
||||||
|
{ key: "approved", label: "Validées" },
|
||||||
|
] as { key: StatusFilter; label: string }[]
|
||||||
|
).map((t) => {
|
||||||
|
const href = `/admin/organizations?status=${t.key}${filters.q ? `&q=${encodeURIComponent(filters.q)}` : ""}`;
|
||||||
|
const active = approved === t.key;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={t.key}
|
||||||
|
href={href}
|
||||||
|
className={
|
||||||
|
"rounded-md px-3 py-1 font-medium " +
|
||||||
|
(active ? "bg-zinc-900 text-white" : "bg-zinc-100 text-zinc-700 hover:bg-zinc-200")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
||||||
|
{approved !== "all" ? (
|
||||||
|
<input type="hidden" name="status" value={approved} />
|
||||||
|
) : null}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="q"
|
name="q"
|
||||||
|
|
@ -53,6 +92,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
|
||||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 text-left font-semibold">Nom</th>
|
<th className="px-4 py-2 text-left font-semibold">Nom</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Statut</th>
|
||||||
<th className="px-4 py-2 text-left font-semibold">Slug</th>
|
<th className="px-4 py-2 text-left font-semibold">Slug</th>
|
||||||
<th className="px-4 py-2 text-right font-semibold">Membres</th>
|
<th className="px-4 py-2 text-right font-semibold">Membres</th>
|
||||||
<th className="px-4 py-2 text-right font-semibold">Créée</th>
|
<th className="px-4 py-2 text-right font-semibold">Créée</th>
|
||||||
|
|
@ -61,7 +101,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
|
||||||
<tbody className="divide-y divide-zinc-100">
|
<tbody className="divide-y divide-zinc-100">
|
||||||
{orgs.length === 0 ? (
|
{orgs.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-4 py-8 text-center text-sm text-zinc-500">
|
<td colSpan={5} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||||
Aucune organisation.
|
Aucune organisation.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -76,6 +116,17 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
|
||||||
<div className="text-[11px] text-zinc-500">{o.description.slice(0, 80)}{o.description.length > 80 ? "…" : ""}</div>
|
<div className="text-[11px] text-zinc-500">{o.description.slice(0, 80)}{o.description.length > 80 ? "…" : ""}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{o.approved ? (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||||
|
Validée
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
||||||
|
À valider
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2 text-zinc-700"><code>/{o.slug}</code></td>
|
<td className="px-4 py-2 text-zinc-700"><code>/{o.slug}</code></td>
|
||||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{o.membersCount}</td>
|
<td className="px-4 py-2 text-right font-mono text-zinc-700">{o.membersCount}</td>
|
||||||
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(o.createdAt)}</td>
|
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(o.createdAt)}</td>
|
||||||
|
|
|
||||||
126
src/app/admin/payouts/_components/MarkPaidForm.tsx
Normal file
126
src/app/admin/payouts/_components/MarkPaidForm.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import type { ProviderPayout } from "@/lib/payouts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
payout: ProviderPayout;
|
||||||
|
markAction: (
|
||||||
|
providerId: string,
|
||||||
|
periodMonthISO: string,
|
||||||
|
fd: FormData,
|
||||||
|
) => Promise<{ ok: false; error: string } | { ok: true; alreadyExists: boolean }>;
|
||||||
|
unmarkAction: (providerId: string, periodMonthISO: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtEur(n: number): string {
|
||||||
|
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkPaidForm({ payout, markAction, unmarkAction }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function onSubmit(fd: FormData) {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await markAction(payout.providerId, payout.periodMonth.toISOString(), fd);
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(res.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpened(false);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUnmark() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await unmarkAction(payout.providerId, payout.periodMonth.toISOString());
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payout.paid) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end gap-1 text-right">
|
||||||
|
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||||
|
Payé {fmtEur(payout.paid.amount)}
|
||||||
|
</span>
|
||||||
|
{payout.paid.reference ? (
|
||||||
|
<span className="font-mono text-[10px] text-zinc-500">Ref : {payout.paid.reference}</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onUnmark}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-[10px] text-zinc-500 hover:text-rose-700"
|
||||||
|
>
|
||||||
|
Annuler marquage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payout.netAmount <= 0) {
|
||||||
|
return <span className="text-[11px] text-zinc-400">—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opened) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpened(true)}
|
||||||
|
className="rounded-md bg-emerald-600 px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Marquer payé
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={onSubmit} className="flex flex-col items-end gap-1 rounded-md border border-emerald-200 bg-emerald-50/50 p-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="amount"
|
||||||
|
step="0.01"
|
||||||
|
min={0}
|
||||||
|
defaultValue={payout.netAmount.toFixed(2)}
|
||||||
|
className="w-24 rounded border border-zinc-300 px-1.5 py-0.5 text-[11px]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="reference"
|
||||||
|
placeholder="Réf. virement"
|
||||||
|
maxLength={100}
|
||||||
|
className="w-32 rounded border border-zinc-300 px-1.5 py-0.5 text-[11px]"
|
||||||
|
/>
|
||||||
|
{error ? <span className="text-[10px] text-rose-700">{error}</span> : null}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpened(false);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-[10px] text-zinc-500 hover:text-zinc-900"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
{pending ? "…" : "Confirmer"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/app/admin/payouts/actions.ts
Normal file
96
src/app/admin/payouts/actions.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
import { requireRole } from "@/lib/authorization";
|
||||||
|
import {
|
||||||
|
createPayoutMark,
|
||||||
|
deletePayoutMark,
|
||||||
|
} from "@/lib/payouts";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendPayoutSent } from "@/lib/email";
|
||||||
|
|
||||||
|
export async function markPayoutPaidAction(
|
||||||
|
providerId: string,
|
||||||
|
periodMonthISO: string,
|
||||||
|
fd: FormData,
|
||||||
|
): Promise<{ ok: false; error: string } | { ok: true; alreadyExists: boolean }> {
|
||||||
|
await requireRole([UserRole.ADMIN]);
|
||||||
|
const session = await auth();
|
||||||
|
const actor = session?.user?.email ?? null;
|
||||||
|
const amount = Number(fd.get("amount") ?? 0);
|
||||||
|
const reference = ((fd.get("reference") as string | null) ?? "").trim() || null;
|
||||||
|
|
||||||
|
if (!Number.isFinite(amount) || amount < 0) {
|
||||||
|
return { ok: false, error: "Montant invalide." };
|
||||||
|
}
|
||||||
|
const periodMonth = new Date(periodMonthISO);
|
||||||
|
if (Number.isNaN(periodMonth.getTime())) {
|
||||||
|
return { ok: false, error: "Période invalide." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await createPayoutMark({
|
||||||
|
providerId,
|
||||||
|
periodMonth,
|
||||||
|
amount,
|
||||||
|
reference,
|
||||||
|
paidByEmail: actor,
|
||||||
|
});
|
||||||
|
if (!res.ok) return res;
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
scope: "admin.payouts",
|
||||||
|
event: res.alreadyExists ? "payout.already_marked" : "payout.mark",
|
||||||
|
target: providerId,
|
||||||
|
actorEmail: actor,
|
||||||
|
details: {
|
||||||
|
periodMonth: periodMonth.toISOString().slice(0, 7),
|
||||||
|
amount,
|
||||||
|
reference,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notif provider best-effort (n'envoie que si on a un contactEmail)
|
||||||
|
if (!res.alreadyExists) {
|
||||||
|
try {
|
||||||
|
const provider = await prisma.rentalProvider.findUnique({
|
||||||
|
where: { id: providerId },
|
||||||
|
select: { name: true, contactEmail: true },
|
||||||
|
});
|
||||||
|
if (provider?.contactEmail) {
|
||||||
|
await sendPayoutSent(
|
||||||
|
provider.contactEmail,
|
||||||
|
provider.name,
|
||||||
|
periodMonth,
|
||||||
|
amount.toFixed(2),
|
||||||
|
reference,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[payouts] email send failed:", e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/payouts");
|
||||||
|
return { ok: true, alreadyExists: res.alreadyExists };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unmarkPayoutPaidAction(providerId: string, periodMonthISO: string) {
|
||||||
|
await requireRole([UserRole.ADMIN]);
|
||||||
|
const session = await auth();
|
||||||
|
const actor = session?.user?.email ?? null;
|
||||||
|
const periodMonth = new Date(periodMonthISO);
|
||||||
|
if (Number.isNaN(periodMonth.getTime())) return;
|
||||||
|
await deletePayoutMark(providerId, periodMonth);
|
||||||
|
await recordAudit({
|
||||||
|
scope: "admin.payouts",
|
||||||
|
event: "payout.unmark",
|
||||||
|
target: providerId,
|
||||||
|
actorEmail: actor,
|
||||||
|
details: { periodMonth: periodMonth.toISOString().slice(0, 7) },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/payouts");
|
||||||
|
}
|
||||||
155
src/app/admin/payouts/page.tsx
Normal file
155
src/app/admin/payouts/page.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { formatMonth, listProviderPayouts } from "@/lib/payouts";
|
||||||
|
|
||||||
|
import { markPayoutPaidAction, unmarkPayoutPaidAction } from "./actions";
|
||||||
|
import { MarkPaidForm } from "./_components/MarkPaidForm";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "Reversements prestataires — Karbé admin" };
|
||||||
|
|
||||||
|
function fmtEur(n: number): string {
|
||||||
|
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PayoutsAdminPage() {
|
||||||
|
const payouts = await listProviderPayouts({ monthsBack: 6 });
|
||||||
|
|
||||||
|
// Group by month
|
||||||
|
const byMonth = new Map<number, typeof payouts>();
|
||||||
|
for (const p of payouts) {
|
||||||
|
const k = p.periodMonth.getTime();
|
||||||
|
if (!byMonth.has(k)) byMonth.set(k, []);
|
||||||
|
byMonth.get(k)!.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Globals
|
||||||
|
const totalDue = payouts
|
||||||
|
.filter((p) => !p.paid && p.netAmount > 0)
|
||||||
|
.reduce((s, p) => s + p.netAmount, 0);
|
||||||
|
const totalPaid = payouts
|
||||||
|
.filter((p) => p.paid)
|
||||||
|
.reduce((s, p) => s + (p.paid!.amount), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
|
<header className="mt-2">
|
||||||
|
<h1 className="text-2xl font-semibold text-zinc-900">Reversements prestataires</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
Le marketplace encaisse centralisé sur System D ; voici les montants à reverser à chaque
|
||||||
|
prestataire pour les locations matériel des 6 derniers mois. System D n'apparaît pas
|
||||||
|
dans la liste (commission 0 %).
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<KpiCard label="À payer" value={fmtEur(totalDue)} highlight />
|
||||||
|
<KpiCard label="Déjà payé" value={fmtEur(totalPaid)} />
|
||||||
|
<KpiCard label="Mois affichés" value={`${byMonth.size}`} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{Array.from(byMonth.entries())
|
||||||
|
.sort((a, b) => b[0] - a[0])
|
||||||
|
.map(([periodTs, rows]) => {
|
||||||
|
const period = new Date(periodTs);
|
||||||
|
const monthDue = rows
|
||||||
|
.filter((r) => !r.paid && r.netAmount > 0)
|
||||||
|
.reduce((s, r) => s + r.netAmount, 0);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={periodTs}
|
||||||
|
className="overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm"
|
||||||
|
>
|
||||||
|
<header className="flex items-baseline justify-between border-b border-zinc-100 px-4 py-2">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-700">
|
||||||
|
{formatMonth(period)}
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
Reste à payer ce mois :{" "}
|
||||||
|
<span className="font-mono font-semibold text-zinc-900">{fmtEur(monthDue)}</span>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-zinc-100 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-1.5 text-left font-semibold">Prestataire</th>
|
||||||
|
<th className="px-3 py-1.5 text-right font-semibold">Résa</th>
|
||||||
|
<th className="px-3 py-1.5 text-right font-semibold">CA brut</th>
|
||||||
|
<th className="px-3 py-1.5 text-right font-semibold">Commission</th>
|
||||||
|
<th className="px-3 py-1.5 text-right font-semibold">Net dû</th>
|
||||||
|
<th className="px-3 py-1.5 text-right font-semibold">Statut</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100">
|
||||||
|
{rows
|
||||||
|
.sort((a, b) => b.netAmount - a.netAmount)
|
||||||
|
.map((p) => (
|
||||||
|
<tr key={`${p.providerId}-${periodTs}`} className="hover:bg-zinc-50">
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<Link
|
||||||
|
href={`/admin/rental-providers/${p.providerId}`}
|
||||||
|
className="text-zinc-900 hover:underline"
|
||||||
|
>
|
||||||
|
{p.providerName}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
|
||||||
|
{p.bookingsCount}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
|
||||||
|
{fmtEur(p.grossAmount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
|
||||||
|
{fmtEur(p.commission)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right font-mono font-semibold text-zinc-900">
|
||||||
|
{fmtEur(p.netAmount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<MarkPaidForm
|
||||||
|
payout={p}
|
||||||
|
markAction={markPayoutPaidAction}
|
||||||
|
unmarkAction={unmarkPayoutPaidAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"rounded-lg border bg-white px-4 py-3 shadow-sm " +
|
||||||
|
(highlight ? "border-emerald-300 bg-emerald-50/40" : "border-zinc-200")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"mt-1 text-2xl font-semibold font-mono " +
|
||||||
|
(highlight ? "text-emerald-700" : "text-zinc-900")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
active: boolean;
|
||||||
|
toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
|
||||||
|
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ItemInlineActions({ active, toggleActiveAction, deleteAction }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await toggleActiveAction(!active);
|
||||||
|
if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function del() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await deleteAction();
|
||||||
|
if (res && (res as { ok?: boolean }).ok === false) {
|
||||||
|
setError((res as { error: string }).error);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={pending}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
|
||||||
|
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{active ? "Désactiver" : "Réactiver"}
|
||||||
|
</button>
|
||||||
|
{confirmDelete ? (
|
||||||
|
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
|
||||||
|
<span className="text-xs text-rose-900">Supprimer ?</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={del}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Oui
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/app/admin/rental-items/[id]/page.tsx
Normal file
92
src/app/admin/rental-items/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||||
|
import { MediaUploader } from "@/components/MediaUploader";
|
||||||
|
import { getRentalItemForAdmin, listProvidersForSelect, RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
|
||||||
|
|
||||||
|
import { ItemForm } from "../_components/ItemForm";
|
||||||
|
import { ItemInlineActions } from "./_components/ItemInlineActions";
|
||||||
|
import { deleteRentalItemAction, toggleRentalItemActiveAction, updateRentalItemAction } from "../actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type PageProps = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export default async function EditRentalItemPage({ params }: PageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const [item, providers] = await Promise.all([getRentalItemForAdmin(id), listProvidersForSelect()]);
|
||||||
|
if (!item) notFound();
|
||||||
|
|
||||||
|
const updateThis = async (fd: FormData) => {
|
||||||
|
"use server";
|
||||||
|
return await updateRentalItemAction(id, fd);
|
||||||
|
};
|
||||||
|
const toggleActiveThis = async (active: boolean) => {
|
||||||
|
"use server";
|
||||||
|
return await toggleRentalItemActiveAction(id, active);
|
||||||
|
};
|
||||||
|
const deleteThis = async () => {
|
||||||
|
"use server";
|
||||||
|
return await deleteRentalItemAction(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<Link href="/admin/rental-items" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
|
← Tous les items
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
||||||
|
{item.name}
|
||||||
|
<StatusBadge status={item.active ? "ACTIVE" : "INACTIVE"} />
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
{RENTAL_CATEGORY_LABEL[item.category]} ·{" "}
|
||||||
|
<Link href={`/admin/rental-providers/${item.provider.id}`} className="text-zinc-900 hover:underline">
|
||||||
|
{item.provider.name}
|
||||||
|
</Link>
|
||||||
|
{item.provider.isSystemD ? " (System D)" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ItemInlineActions
|
||||||
|
active={item.active}
|
||||||
|
toggleActiveAction={toggleActiveThis}
|
||||||
|
deleteAction={deleteThis}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-base font-semibold text-zinc-900">Photos & vidéos</h2>
|
||||||
|
<MediaUploader
|
||||||
|
scope={{ kind: "rental-item", itemId: item.id }}
|
||||||
|
initialMedia={item.media}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<ItemForm
|
||||||
|
providers={providers}
|
||||||
|
action={updateThis}
|
||||||
|
submitLabel="Enregistrer les modifications"
|
||||||
|
initial={{
|
||||||
|
providerId: item.providerId,
|
||||||
|
category: item.category,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
imageUrl: item.imageUrl,
|
||||||
|
pricePerDay: item.pricePerDay.toString(),
|
||||||
|
pricePerWeek: item.pricePerWeek?.toString() ?? null,
|
||||||
|
deposit: item.deposit.toString(),
|
||||||
|
totalQty: item.totalQty,
|
||||||
|
withMotor: item.withMotor,
|
||||||
|
fuelIncluded: item.fuelIncluded,
|
||||||
|
requiresLicense: item.requiresLicense,
|
||||||
|
active: item.active,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/app/admin/rental-items/_components/ItemForm.tsx
Normal file
132
src/app/admin/rental-items/_components/ItemForm.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField";
|
||||||
|
import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
providers: { id: string; name: string; isSystemD: boolean }[];
|
||||||
|
initial?: {
|
||||||
|
providerId?: string;
|
||||||
|
category?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
pricePerDay?: string | number;
|
||||||
|
pricePerWeek?: string | number | null;
|
||||||
|
deposit?: string | number;
|
||||||
|
totalQty?: number;
|
||||||
|
withMotor?: boolean;
|
||||||
|
fuelIncluded?: boolean;
|
||||||
|
requiresLicense?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
||||||
|
submitLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ItemForm({ providers, initial = {}, action, submitLabel = "Enregistrer" }: Props) {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function onSubmit(fd: FormData) {
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await action(fd);
|
||||||
|
if (res && res.ok === false) setError(res.error);
|
||||||
|
else if (res && res.ok === true) setSuccess("Enregistré.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={onSubmit} className="space-y-4">
|
||||||
|
<fieldset disabled={pending} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField label="Prestataire" required>
|
||||||
|
<select name="providerId" defaultValue={initial.providerId ?? ""} required className={selectCls}>
|
||||||
|
<option value="" disabled>— sélectionner —</option>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}{p.isSystemD ? " (System D)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Catégorie" required>
|
||||||
|
<select name="category" defaultValue={initial.category ?? ""} required className={selectCls}>
|
||||||
|
<option value="" disabled>— sélectionner —</option>
|
||||||
|
{RENTAL_CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Nom de l'item" required className="sm:col-span-2">
|
||||||
|
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} placeholder="ex. Hamac coton large, Pirogue 5m avec moteur 15CV" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Description" className="sm:col-span-2">
|
||||||
|
<textarea name="description" rows={3} defaultValue={initial.description ?? ""} maxLength={5000} className={textareaCls} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="URL image" hint="Optionnel, URL publique vers photo MinIO.">
|
||||||
|
<input name="imageUrl" type="url" defaultValue={initial.imageUrl ?? ""} maxLength={500} className={inputCls} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Stock total (qté)" required>
|
||||||
|
<input name="totalQty" type="number" min={1} max={1000} defaultValue={initial.totalQty?.toString() ?? "1"} required className={inputCls} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Prix / jour (€)" required>
|
||||||
|
<input name="pricePerDay" type="number" min={0} step="0.5" defaultValue={initial.pricePerDay?.toString() ?? ""} required className={inputCls} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Prix / semaine (€)" hint="Optionnel — tarif dégressif sur 7+ jours.">
|
||||||
|
<input name="pricePerWeek" type="number" min={0} step="0.5" defaultValue={initial.pricePerWeek?.toString() ?? ""} className={inputCls} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Caution (€)" hint="Dépôt de garantie (bloqué pendant la location).">
|
||||||
|
<input name="deposit" type="number" min={0} step="1" defaultValue={initial.deposit?.toString() ?? "0"} className={inputCls} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Statut">
|
||||||
|
<label className="flex items-center gap-2 px-1 py-2 text-sm">
|
||||||
|
<input type="checkbox" name="active" defaultChecked={initial.active ?? true} className="h-4 w-4 rounded border-zinc-300" />
|
||||||
|
Actif (visible au catalogue)
|
||||||
|
</label>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
|
||||||
|
<legend className="px-1 text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Spécifications navigation
|
||||||
|
</legend>
|
||||||
|
<div className="flex flex-wrap gap-4 pt-1 text-sm">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="withMotor" defaultChecked={initial.withMotor ?? false} className="h-4 w-4 rounded border-zinc-300" />
|
||||||
|
Avec moteur
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="fuelIncluded" defaultChecked={initial.fuelIncluded ?? false} className="h-4 w-4 rounded border-zinc-300" />
|
||||||
|
Essence incluse
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="requiresLicense" defaultChecked={initial.requiresLicense ?? false} className="h-4 w-4 rounded border-zinc-300" />
|
||||||
|
Permis bateau requis
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||||
|
) : null}
|
||||||
|
{success ? (
|
||||||
|
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? "Enregistrement…" : submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/app/admin/rental-items/actions.ts
Normal file
129
src/app/admin/rental-items/actions.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { RentalCategory, UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { requireRole } from "@/lib/authorization";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const itemSchema = z.object({
|
||||||
|
providerId: z.string().min(1),
|
||||||
|
category: z.enum([
|
||||||
|
RentalCategory.SLEEP,
|
||||||
|
RentalCategory.NAVIGATION,
|
||||||
|
RentalCategory.FISHING,
|
||||||
|
RentalCategory.COOKING,
|
||||||
|
RentalCategory.SAFETY,
|
||||||
|
]),
|
||||||
|
name: z.string().trim().min(2).max(200),
|
||||||
|
description: z.string().trim().max(5000).nullable().optional(),
|
||||||
|
imageUrl: z.string().trim().url().max(500).nullable().optional(),
|
||||||
|
pricePerDay: z.coerce.number().min(0).max(10000),
|
||||||
|
pricePerWeek: z.coerce.number().min(0).max(50000).nullable().optional(),
|
||||||
|
deposit: z.coerce.number().min(0).max(10000),
|
||||||
|
totalQty: z.coerce.number().int().min(1).max(1000),
|
||||||
|
withMotor: z.boolean(),
|
||||||
|
fuelIncluded: z.boolean(),
|
||||||
|
requiresLicense: z.boolean(),
|
||||||
|
active: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseFD(fd: FormData) {
|
||||||
|
const get = (k: string) => {
|
||||||
|
const v = (fd.get(k) as string | null) ?? "";
|
||||||
|
return v.trim() === "" ? null : v.trim();
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
providerId: ((fd.get("providerId") as string | null) ?? "").trim(),
|
||||||
|
category: ((fd.get("category") as string | null) ?? "").trim(),
|
||||||
|
name: ((fd.get("name") as string | null) ?? "").trim(),
|
||||||
|
description: get("description"),
|
||||||
|
imageUrl: get("imageUrl"),
|
||||||
|
pricePerDay: fd.get("pricePerDay"),
|
||||||
|
pricePerWeek: get("pricePerWeek"),
|
||||||
|
deposit: fd.get("deposit") ?? "0",
|
||||||
|
totalQty: fd.get("totalQty") ?? "1",
|
||||||
|
withMotor: fd.get("withMotor") === "on",
|
||||||
|
fuelIncluded: fd.get("fuelIncluded") === "on",
|
||||||
|
requiresLicense: fd.get("requiresLicense") === "on",
|
||||||
|
active: fd.get("active") === "on",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRentalItemAction(fd: FormData) {
|
||||||
|
await requireRole([UserRole.ADMIN]);
|
||||||
|
const parsed = itemSchema.safeParse(parseFD(fd));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||||
|
}
|
||||||
|
const session = await auth();
|
||||||
|
const created = await prisma.rentalItem.create({ data: parsed.data });
|
||||||
|
await recordAudit({
|
||||||
|
scope: "admin.rental-items",
|
||||||
|
event: "create",
|
||||||
|
target: created.id,
|
||||||
|
actorEmail: session?.user?.email ?? null,
|
||||||
|
details: { name: created.name, providerId: created.providerId },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/rental-items");
|
||||||
|
redirect(`/admin/rental-items/${created.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRentalItemAction(id: string, fd: FormData) {
|
||||||
|
await requireRole([UserRole.ADMIN]);
|
||||||
|
const parsed = itemSchema.safeParse(parseFD(fd));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||||
|
}
|
||||||
|
const session = await auth();
|
||||||
|
await prisma.rentalItem.update({ where: { id }, data: parsed.data });
|
||||||
|
await recordAudit({
|
||||||
|
scope: "admin.rental-items",
|
||||||
|
event: "update",
|
||||||
|
target: id,
|
||||||
|
actorEmail: session?.user?.email ?? null,
|
||||||
|
details: { name: parsed.data.name },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/rental-items");
|
||||||
|
revalidatePath(`/admin/rental-items/${id}`);
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleRentalItemActiveAction(id: string, active: boolean) {
|
||||||
|
await requireRole([UserRole.ADMIN]);
|
||||||
|
const session = await auth();
|
||||||
|
await prisma.rentalItem.update({ where: { id }, data: { active } });
|
||||||
|
await recordAudit({
|
||||||
|
scope: "admin.rental-items",
|
||||||
|
event: "active.update",
|
||||||
|
target: id,
|
||||||
|
actorEmail: session?.user?.email ?? null,
|
||||||
|
details: { active },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/rental-items");
|
||||||
|
revalidatePath(`/admin/rental-items/${id}`);
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRentalItemAction(id: string) {
|
||||||
|
await requireRole([UserRole.ADMIN]);
|
||||||
|
const session = await auth();
|
||||||
|
const linesCount = await prisma.rentalLine.count({ where: { itemId: id } });
|
||||||
|
if (linesCount > 0) {
|
||||||
|
return { ok: false as const, error: `Impossible : ${linesCount} ligne(s) de réservation pointe(nt) sur cet item.` };
|
||||||
|
}
|
||||||
|
await prisma.rentalItem.delete({ where: { id } });
|
||||||
|
await recordAudit({
|
||||||
|
scope: "admin.rental-items",
|
||||||
|
event: "delete",
|
||||||
|
target: id,
|
||||||
|
actorEmail: session?.user?.email ?? null,
|
||||||
|
details: {},
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/rental-items");
|
||||||
|
redirect("/admin/rental-items");
|
||||||
|
}
|
||||||
31
src/app/admin/rental-items/new/page.tsx
Normal file
31
src/app/admin/rental-items/new/page.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ItemForm } from "../_components/ItemForm";
|
||||||
|
import { createRentalItemAction } from "../actions";
|
||||||
|
import { listProvidersForSelect } from "@/lib/admin/rental-items";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type PageProps = { searchParams: Promise<{ providerId?: string }> };
|
||||||
|
|
||||||
|
export default async function NewRentalItemPage({ searchParams }: PageProps) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const providers = await listProvidersForSelect();
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
|
<header className="mt-2">
|
||||||
|
<Link href="/admin/rental-items" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
|
← Tous les items
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvel item locable</h1>
|
||||||
|
</header>
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<ItemForm
|
||||||
|
providers={providers}
|
||||||
|
action={createRentalItemAction}
|
||||||
|
submitLabel="Créer l'item"
|
||||||
|
initial={{ providerId: sp.providerId, active: true, totalQty: 1 }}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/app/admin/rental-items/page.tsx
Normal file
152
src/app/admin/rental-items/page.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { RentalCategory } from "@/generated/prisma/enums";
|
||||||
|
import {
|
||||||
|
RENTAL_CATEGORY_LABEL,
|
||||||
|
isRentalCategory,
|
||||||
|
listProvidersForSelect,
|
||||||
|
listRentalItemsAdmin,
|
||||||
|
} from "@/lib/admin/rental-items";
|
||||||
|
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
searchParams: Promise<{
|
||||||
|
q?: string;
|
||||||
|
category?: string;
|
||||||
|
providerId?: string;
|
||||||
|
active?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RentalItemsAdminPage({ searchParams }: PageProps) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const filters = {
|
||||||
|
q: sp.q?.trim() || undefined,
|
||||||
|
category: sp.category && isRentalCategory(sp.category) ? sp.category : undefined,
|
||||||
|
providerId: sp.providerId || undefined,
|
||||||
|
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
|
||||||
|
};
|
||||||
|
const [rows, providers] = await Promise.all([listRentalItemsAdmin(filters), listProvidersForSelect()]);
|
||||||
|
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-zinc-900">Catalogue d'items locables</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
{rows.length} item{rows.length > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/rental-items/new"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
+ Nouvel item
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
defaultValue={filters.q ?? ""}
|
||||||
|
placeholder="Recherche nom, description…"
|
||||||
|
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
name="category"
|
||||||
|
defaultValue={filters.category ?? ""}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Toutes catégories</option>
|
||||||
|
{Object.values(RentalCategory).map((c) => (
|
||||||
|
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
name="providerId"
|
||||||
|
defaultValue={filters.providerId ?? ""}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Tous prestataires</option>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
name="active"
|
||||||
|
defaultValue={filters.active ?? ""}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Actifs + inactifs</option>
|
||||||
|
<option value="yes">Actifs</option>
|
||||||
|
<option value="no">Inactifs</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
||||||
|
Filtrer
|
||||||
|
</button>
|
||||||
|
{(filters.q || filters.category || filters.providerId || filters.active) ? (
|
||||||
|
<Link href="/admin/rental-items" className="text-sm text-zinc-500 hover:text-zinc-900">Réinit.</Link>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Nom</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Catégorie</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Prestataire</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">€ / jour</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">Stock</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">Caution</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">État</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||||
|
Aucun item.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{rows.map((i) => (
|
||||||
|
<tr key={i.id} className="hover:bg-zinc-50">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Link href={`/admin/rental-items/${i.id}`} className="font-medium text-zinc-900 hover:underline">
|
||||||
|
{i.name}
|
||||||
|
</Link>
|
||||||
|
<div className="text-[11px] text-zinc-500">
|
||||||
|
{i.withMotor ? "⚙️ moteur · " : ""}
|
||||||
|
{i.requiresLicense ? "🪪 permis · " : ""}
|
||||||
|
{i.fuelIncluded ? "⛽ essence · " : ""}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-zinc-700">{RENTAL_CATEGORY_LABEL[i.category]}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Link href={`/admin/rental-providers/${i.providerId}`} className="text-zinc-900 hover:underline">
|
||||||
|
{i.providerName}
|
||||||
|
</Link>
|
||||||
|
{i.providerIsSystemD ? (
|
||||||
|
<span className="ml-1 rounded-full bg-emerald-100 px-1 py-0 text-[9px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||||
|
SD
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.pricePerDay).toFixed(0)}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i.totalQty}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.deposit).toFixed(0)}</td>
|
||||||
|
<td className="px-4 py-2"><StatusBadge status={i.active ? "ACTIVE" : "INACTIVE"} /></td>
|
||||||
|
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(i.updatedAt)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
approved: boolean;
|
||||||
|
active: boolean;
|
||||||
|
itemsCount: number;
|
||||||
|
approveAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
|
||||||
|
toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
|
||||||
|
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProviderInlineActions({
|
||||||
|
approved,
|
||||||
|
active,
|
||||||
|
itemsCount,
|
||||||
|
approveAction,
|
||||||
|
toggleActiveAction,
|
||||||
|
deleteAction,
|
||||||
|
}: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function approve() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await approveAction();
|
||||||
|
if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function toggle() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await toggleActiveAction(!active);
|
||||||
|
if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function del() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await deleteAction();
|
||||||
|
if (res && (res as { ok?: boolean }).ok === false) {
|
||||||
|
setError((res as { error: string }).error);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{!approved ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={approve}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
✓ Approuver
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={pending}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
|
||||||
|
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{active ? "Désactiver" : "Réactiver"}
|
||||||
|
</button>
|
||||||
|
{itemsCount === 0 ? (
|
||||||
|
confirmDelete ? (
|
||||||
|
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
|
||||||
|
<span className="text-xs text-rose-900">Supprimer ?</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={del}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Oui
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] text-zinc-500">
|
||||||
|
{itemsCount} item(s) — supprimez-les d'abord
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
src/app/admin/rental-providers/[id]/page.tsx
Normal file
136
src/app/admin/rental-providers/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||||
|
import { getRentalProviderForAdmin } from "@/lib/admin/rental-providers";
|
||||||
|
import { RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
|
||||||
|
|
||||||
|
import { ProviderForm } from "../_components/ProviderForm";
|
||||||
|
import { ProviderInlineActions } from "./_components/ProviderInlineActions";
|
||||||
|
import {
|
||||||
|
approveRentalProviderAction,
|
||||||
|
deleteRentalProviderAction,
|
||||||
|
toggleRentalProviderActiveAction,
|
||||||
|
updateRentalProviderAction,
|
||||||
|
} from "../actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type PageProps = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export default async function EditRentalProviderPage({ params }: PageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const p = await getRentalProviderForAdmin(id);
|
||||||
|
if (!p) notFound();
|
||||||
|
|
||||||
|
const updateThis = async (fd: FormData) => {
|
||||||
|
"use server";
|
||||||
|
return await updateRentalProviderAction(id, fd);
|
||||||
|
};
|
||||||
|
const approveThis = async () => {
|
||||||
|
"use server";
|
||||||
|
return await approveRentalProviderAction(id);
|
||||||
|
};
|
||||||
|
const toggleActiveThis = async (active: boolean) => {
|
||||||
|
"use server";
|
||||||
|
return await toggleRentalProviderActiveAction(id, active);
|
||||||
|
};
|
||||||
|
const deleteThis = async () => {
|
||||||
|
"use server";
|
||||||
|
return await deleteRentalProviderAction(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<Link href="/admin/rental-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
|
← Tous les prestataires
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
||||||
|
{p.name}
|
||||||
|
{p.isSystemD ? (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||||
|
System D
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} />
|
||||||
|
{p.approved ? (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||||
|
Approuvé
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
||||||
|
En attente
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
Fleuves : {p.rivers.join(", ") || "—"} · {p._count.items} item(s) · {p._count.rentalBookings} réservation(s) · Commission {Number(p.commissionPct).toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ProviderInlineActions
|
||||||
|
approved={p.approved}
|
||||||
|
active={p.active}
|
||||||
|
itemsCount={p._count.items}
|
||||||
|
approveAction={approveThis}
|
||||||
|
toggleActiveAction={toggleActiveThis}
|
||||||
|
deleteAction={deleteThis}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Informations</h2>
|
||||||
|
<ProviderForm
|
||||||
|
action={updateThis}
|
||||||
|
submitLabel="Enregistrer"
|
||||||
|
initial={{
|
||||||
|
name: p.name,
|
||||||
|
isSystemD: p.isSystemD,
|
||||||
|
contactEmail: p.contactEmail,
|
||||||
|
contactPhone: p.contactPhone,
|
||||||
|
rivers: p.rivers,
|
||||||
|
description: p.description,
|
||||||
|
commissionPct: p.commissionPct.toString(),
|
||||||
|
active: p.active,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-4 flex items-center justify-between text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
<span>Items ({p.items.length})</span>
|
||||||
|
<Link href={`/admin/rental-items?providerId=${p.id}`} className="text-xs normal-case tracking-normal text-zinc-700 underline hover:text-zinc-900">
|
||||||
|
Voir tous les items
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
|
{p.items.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Pas encore d'item.{" "}
|
||||||
|
<Link href={`/admin/rental-items/new?providerId=${p.id}`} className="text-zinc-900 underline">
|
||||||
|
Créer un premier item
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-zinc-100">
|
||||||
|
{p.items.map((i) => (
|
||||||
|
<li key={i.id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||||
|
<Link href={`/admin/rental-items/${i.id}`} className="text-zinc-900 hover:underline">
|
||||||
|
{i.name}
|
||||||
|
<span className="ml-2 text-[11px] text-zinc-500">
|
||||||
|
{RENTAL_CATEGORY_LABEL[i.category]}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<span className="font-mono text-xs text-zinc-700">{Number(i.pricePerDay).toFixed(0)} €/j</span>
|
||||||
|
<span className="text-[11px] text-zinc-500">qty {i.totalQty}</span>
|
||||||
|
<StatusBadge status={i.active ? "ACTIVE" : "INACTIVE"} />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/app/admin/rental-providers/_components/ProviderForm.tsx
Normal file
132
src/app/admin/rental-providers/_components/ProviderForm.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
initial?: {
|
||||||
|
name?: string;
|
||||||
|
isSystemD?: boolean;
|
||||||
|
contactEmail?: string | null;
|
||||||
|
contactPhone?: string | null;
|
||||||
|
rivers?: string[];
|
||||||
|
description?: string | null;
|
||||||
|
commissionPct?: number | string;
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
||||||
|
submitLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProviderForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function onSubmit(fd: FormData) {
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await action(fd);
|
||||||
|
if (res && res.ok === false) setError(res.error);
|
||||||
|
else if (res && res.ok === true) setSuccess("Enregistré.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={onSubmit} className="space-y-4">
|
||||||
|
<fieldset disabled={pending} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField label="Nom du prestataire" required>
|
||||||
|
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Type">
|
||||||
|
<label className="flex items-center gap-2 px-1 py-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="isSystemD"
|
||||||
|
defaultChecked={initial.isSystemD ?? false}
|
||||||
|
className="h-4 w-4 rounded border-zinc-300"
|
||||||
|
/>
|
||||||
|
Fournisseur officiel System D (0 % commission)
|
||||||
|
</label>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Email contact">
|
||||||
|
<input
|
||||||
|
name="contactEmail"
|
||||||
|
type="email"
|
||||||
|
defaultValue={initial.contactEmail ?? ""}
|
||||||
|
maxLength={200}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Téléphone contact">
|
||||||
|
<input
|
||||||
|
name="contactPhone"
|
||||||
|
defaultValue={initial.contactPhone ?? ""}
|
||||||
|
maxLength={50}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Commission (%)" hint="0 pour System D, 5-15 % pour les prestataires externes.">
|
||||||
|
<input
|
||||||
|
name="commissionPct"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={50}
|
||||||
|
step="0.5"
|
||||||
|
defaultValue={initial.commissionPct?.toString() ?? "10"}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Statut">
|
||||||
|
<label className="flex items-center gap-2 px-1 py-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="active"
|
||||||
|
defaultChecked={initial.active ?? true}
|
||||||
|
className="h-4 w-4 rounded border-zinc-300"
|
||||||
|
/>
|
||||||
|
Actif
|
||||||
|
</label>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="Fleuves desservis" required hint="Séparer par virgule, point-virgule ou retour à la ligne.">
|
||||||
|
<input
|
||||||
|
name="rivers"
|
||||||
|
defaultValue={(initial.rivers ?? []).join(", ")}
|
||||||
|
placeholder="Maroni, Approuague, Oyapock"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Description" hint="Présentation, points forts, conditions particulières.">
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
rows={4}
|
||||||
|
defaultValue={initial.description ?? ""}
|
||||||
|
maxLength={5000}
|
||||||
|
className={textareaCls}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||||
|
) : null}
|
||||||
|
{success ? (
|
||||||
|
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? "Enregistrement…" : submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/app/admin/rental-providers/actions.ts
Normal file
150
src/app/admin/rental-providers/actions.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { requireRole } from "@/lib/authorization";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const providerSchema = z.object({
|
||||||
|
name: z.string().trim().min(2).max(200),
|
||||||
|
isSystemD: z.boolean(),
|
||||||
|
managedByUserId: z.string().nullable().optional(),
|
||||||
|
contactEmail: z.string().trim().email().max(200).nullable().optional(),
|
||||||
|
contactPhone: z.string().trim().max(50).nullable().optional(),
|
||||||
|
rivers: z.array(z.string().trim().min(1).max(80)).max(20),
|
||||||
|
description: z.string().trim().max(5000).nullable().optional(),
|
||||||
|
commissionPct: z.coerce.number().min(0).max(50),
|
||||||
|
active: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseFD(fd: FormData) {
|
||||||
|
const riversRaw = (fd.get("rivers") as string | null) ?? "";
|
||||||
|
const rivers = riversRaw
|
||||||
|
.split(/[,;\n]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
const get = (k: string) => {
|
||||||
|
const v = (fd.get(k) as string | null) ?? "";
|
||||||
|
return v.trim() === "" ? null : v.trim();
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
name: ((fd.get("name") as string | null) ?? "").trim(),
|
||||||
|
isSystemD: fd.get("isSystemD") === "on",
|
||||||
|
managedByUserId: get("managedByUserId"),
|
||||||
|
contactEmail: get("contactEmail"),
|
||||||
|
contactPhone: get("contactPhone"),
|
||||||
|
rivers,
|
||||||
|
description: get("description"),
|
||||||
|
commissionPct: fd.get("commissionPct"),
|
||||||
|
active: fd.get("active") === "on",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRentalProviderAction(fd: FormData) {
|
||||||
|
await requireRole([UserRole.ADMIN]);
|
||||||
|
const parsed = providerSchema.safeParse(parseFD(fd));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||||
|
}
|
||||||
|
const session = await auth();
|
||||||
|
const created = await prisma.rentalProvider.create({
|
||||||
|
data: {
|
||||||
|
...parsed.data,
|
||||||
|
approved: true, // créé par admin → approuvé d'office
|
||||||
|
approvedAt: new Date(),
|
||||||
|
approvedBy: session?.user?.email ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recordAudit({
|
||||||
|
scope: "admin.rental-providers",
|
||||||
|
event: "create",
|
||||||
|
target: created.id,
|
||||||
|
actorEmail: session?.user?.email ?? null,
|
||||||
|
details: { name: created.name, isSystemD: created.isSystemD },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/rental-providers");
|
||||||
|
redirect(`/admin/rental-providers/${created.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRentalProviderAction(id: string, fd: FormData) {
|
||||||
|
await requireRole([UserRole.ADMIN]);
|
||||||
|
const parsed = providerSchema.safeParse(parseFD(fd));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||||
|
}
|
||||||
|
const session = await auth();
|
||||||
|
await prisma.rentalProvider.update({ where: { id }, data: parsed.data });
|
||||||
|
await recordAudit({
|
||||||
|
scope: "admin.rental-providers",
|
||||||
|
event: "update",
|
||||||
|
target: id,
|
||||||
|
actorEmail: session?.user?.email ?? null,
|
||||||
|
details: { name: parsed.data.name },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/rental-providers");
|
||||||
|
revalidatePath(`/admin/rental-providers/${id}`);
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveRentalProviderAction(id: string) {
|
||||||
|
await requireRole([UserRole.ADMIN]);
|
||||||
|
const session = await auth();
|
||||||
|
await prisma.rentalProvider.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
approved: true,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
approvedBy: session?.user?.email ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recordAudit({
|
||||||
|
scope: "admin.rental-providers",
|
||||||
|
event: "approve",
|
||||||
|
target: id,
|
||||||
|
actorEmail: session?.user?.email ?? null,
|
||||||
|
details: {},
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/rental-providers");
|
||||||
|
revalidatePath(`/admin/rental-providers/${id}`);
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleRentalProviderActiveAction(id: string, active: boolean) {
|
||||||
|
await requireRole([UserRole.ADMIN]);
|
||||||
|
const session = await auth();
|
||||||
|
await prisma.rentalProvider.update({ where: { id }, data: { active } });
|
||||||
|
await recordAudit({
|
||||||
|
scope: "admin.rental-providers",
|
||||||
|
event: "active.update",
|
||||||
|
target: id,
|
||||||
|
actorEmail: session?.user?.email ?? null,
|
||||||
|
details: { active },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/rental-providers");
|
||||||
|
revalidatePath(`/admin/rental-providers/${id}`);
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRentalProviderAction(id: string) {
|
||||||
|
await requireRole([UserRole.ADMIN]);
|
||||||
|
const session = await auth();
|
||||||
|
const itemsCount = await prisma.rentalItem.count({ where: { providerId: id } });
|
||||||
|
if (itemsCount > 0) {
|
||||||
|
return { ok: false as const, error: `Impossible : ${itemsCount} item(s) attaché(s).` };
|
||||||
|
}
|
||||||
|
await prisma.rentalProvider.delete({ where: { id } });
|
||||||
|
await recordAudit({
|
||||||
|
scope: "admin.rental-providers",
|
||||||
|
event: "delete",
|
||||||
|
target: id,
|
||||||
|
actorEmail: session?.user?.email ?? null,
|
||||||
|
details: {},
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/rental-providers");
|
||||||
|
redirect("/admin/rental-providers");
|
||||||
|
}
|
||||||
21
src/app/admin/rental-providers/new/page.tsx
Normal file
21
src/app/admin/rental-providers/new/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ProviderForm } from "../_components/ProviderForm";
|
||||||
|
import { createRentalProviderAction } from "../actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function NewRentalProviderPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
|
<header className="mt-2">
|
||||||
|
<Link href="/admin/rental-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
|
← Tous les prestataires
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouveau prestataire location</h1>
|
||||||
|
</header>
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<ProviderForm action={createRentalProviderAction} submitLabel="Créer le prestataire" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
src/app/admin/rental-providers/page.tsx
Normal file
149
src/app/admin/rental-providers/page.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { listRentalProvidersAdmin, listRentalProviderRivers } from "@/lib/admin/rental-providers";
|
||||||
|
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
searchParams: Promise<{
|
||||||
|
q?: string;
|
||||||
|
approved?: string;
|
||||||
|
active?: string;
|
||||||
|
river?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RentalProvidersAdminPage({ searchParams }: PageProps) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const filters = {
|
||||||
|
q: sp.q?.trim() || undefined,
|
||||||
|
approved: sp.approved === "yes" || sp.approved === "no" ? (sp.approved as "yes" | "no") : undefined,
|
||||||
|
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
|
||||||
|
river: sp.river || undefined,
|
||||||
|
};
|
||||||
|
const [rows, rivers] = await Promise.all([listRentalProvidersAdmin(filters), listRentalProviderRivers()]);
|
||||||
|
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-zinc-900">Prestataires location matériel</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
{rows.length} résultat{rows.length > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/rental-providers/new"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
+ Nouveau prestataire
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
defaultValue={filters.q ?? ""}
|
||||||
|
placeholder="Recherche nom, email, description…"
|
||||||
|
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
name="approved"
|
||||||
|
defaultValue={filters.approved ?? ""}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Tous statuts approbation</option>
|
||||||
|
<option value="yes">Approuvés</option>
|
||||||
|
<option value="no">En attente</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
name="active"
|
||||||
|
defaultValue={filters.active ?? ""}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Actifs + inactifs</option>
|
||||||
|
<option value="yes">Actifs</option>
|
||||||
|
<option value="no">Inactifs</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
name="river"
|
||||||
|
defaultValue={filters.river ?? ""}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Tous fleuves</option>
|
||||||
|
{rivers.map((r) => (
|
||||||
|
<option key={r} value={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
||||||
|
Filtrer
|
||||||
|
</button>
|
||||||
|
{(filters.q || filters.approved || filters.active || filters.river) ? (
|
||||||
|
<Link href="/admin/rental-providers" className="text-sm text-zinc-500 hover:text-zinc-900">
|
||||||
|
Réinit.
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Nom</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Fleuves</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">Items</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">Comm.</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Approbation</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">État</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||||
|
Aucun prestataire ne correspond aux filtres.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{rows.map((p) => (
|
||||||
|
<tr key={p.id} className="hover:bg-zinc-50">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Link href={`/admin/rental-providers/${p.id}`} className="font-medium text-zinc-900 hover:underline">
|
||||||
|
{p.name}
|
||||||
|
</Link>
|
||||||
|
{p.isSystemD ? (
|
||||||
|
<span className="ml-2 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||||
|
System D
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className="text-[11px] text-zinc-500">{p.contactEmail ?? "—"}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-zinc-700">
|
||||||
|
{p.rivers.length === 0 ? <span className="text-zinc-400">—</span> : p.rivers.join(", ")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono text-zinc-700">{p.itemsCount}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(p.commissionPct).toFixed(1)}%</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{p.approved ? (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||||
|
Approuvé
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
||||||
|
En attente
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2"><StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} /></td>
|
||||||
|
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(p.updatedAt)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/app/admin/rentals/page.tsx
Normal file
141
src/app/admin/rentals/page.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
|
||||||
|
import { listRentalBookingsAdmin, RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
|
||||||
|
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
searchParams: Promise<{
|
||||||
|
q?: string;
|
||||||
|
status?: string;
|
||||||
|
paymentStatus?: string;
|
||||||
|
providerId?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RENTAL_STATUS_VALUES = new Set<string>([
|
||||||
|
RentalBookingStatus.PENDING,
|
||||||
|
RentalBookingStatus.CONFIRMED,
|
||||||
|
RentalBookingStatus.HANDED_OVER,
|
||||||
|
RentalBookingStatus.RETURNED,
|
||||||
|
RentalBookingStatus.CANCELLED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PAYMENT_VALUES = new Set<string>([
|
||||||
|
PaymentStatus.PENDING,
|
||||||
|
PaymentStatus.AUTHORIZED,
|
||||||
|
PaymentStatus.SUCCEEDED,
|
||||||
|
PaymentStatus.FAILED,
|
||||||
|
PaymentStatus.REFUNDED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default async function RentalsAdminPage({ searchParams }: PageProps) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const filters = {
|
||||||
|
q: sp.q?.trim() || undefined,
|
||||||
|
status: RENTAL_STATUS_VALUES.has(sp.status ?? "") ? (sp.status as RentalBookingStatus) : undefined,
|
||||||
|
paymentStatus: PAYMENT_VALUES.has(sp.paymentStatus ?? "") ? (sp.paymentStatus as PaymentStatus) : undefined,
|
||||||
|
providerId: sp.providerId || undefined,
|
||||||
|
};
|
||||||
|
const rows = await listRentalBookingsAdmin(filters);
|
||||||
|
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
<header className="mb-5 mt-2">
|
||||||
|
<h1 className="text-2xl font-semibold text-zinc-900">Réservations matériel</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
{rows.length} résultat{rows.length > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
defaultValue={filters.q ?? ""}
|
||||||
|
placeholder="Recherche ID, email locataire, prestataire…"
|
||||||
|
className="flex-1 min-w-[200px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<select name="status" defaultValue={filters.status ?? ""} className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none">
|
||||||
|
<option value="">Tous statuts</option>
|
||||||
|
{Object.values(RentalBookingStatus).map((s) => (
|
||||||
|
<option key={s} value={s}>{RENTAL_STATUS_LABEL[s]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select name="paymentStatus" defaultValue={filters.paymentStatus ?? ""} className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none">
|
||||||
|
<option value="">Tous paiements</option>
|
||||||
|
{Object.values(PaymentStatus).map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
||||||
|
Filtrer
|
||||||
|
</button>
|
||||||
|
{(filters.q || filters.status || filters.paymentStatus) ? (
|
||||||
|
<Link href="/admin/rentals" className="text-sm text-zinc-500 hover:text-zinc-900">Réinit.</Link>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">ID</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Locataire</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Prestataire</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Items</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Période</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">Montant</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Statut</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Paiement</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||||
|
Aucune réservation matériel.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id} className="hover:bg-zinc-50">
|
||||||
|
<td className="px-4 py-2 font-mono text-[11px] text-zinc-700">{r.id.slice(0, 10)}…</td>
|
||||||
|
<td className="px-4 py-2 text-zinc-700">
|
||||||
|
{r.tenant.firstName} {r.tenant.lastName}
|
||||||
|
<div className="text-[11px] text-zinc-500">{r.tenant.email}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Link href={`/admin/rental-providers/${r.provider.id}`} className="text-zinc-900 hover:underline">
|
||||||
|
{r.provider.name}
|
||||||
|
</Link>
|
||||||
|
{r.provider.isSystemD ? <span className="ml-1 text-[9px] font-semibold text-emerald-700">SD</span> : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-zinc-700">
|
||||||
|
{r.lines.length} ligne{r.lines.length > 1 ? "s" : ""}
|
||||||
|
<div className="text-[11px] text-zinc-500 truncate max-w-[200px]">
|
||||||
|
{r.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ")}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-zinc-700">
|
||||||
|
{dateFmt.format(r.startDate)} → {dateFmt.format(r.endDate)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono text-zinc-900">
|
||||||
|
{Number(r.amount).toFixed(2)} {r.currency}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<StatusBadge status={r.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<StatusBadge status={r.paymentStatus} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
} from "@/lib/booking";
|
} from "@/lib/booking";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendBookingRequestToOwner, sendBookingRequestToTenant } from "@/lib/email";
|
import { sendBookingRequestToOwner, sendBookingRequestToTenant } from "@/lib/email";
|
||||||
|
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
|
@ -28,6 +29,14 @@ type CreateBookingBody = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
const rl = rateLimitRequest(request, "bookings", 60 * 60 * 1000, 10);
|
||||||
|
if (!rl.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,11 @@ export async function POST(
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (session.user.role !== UserRole.OWNER && session.user.role !== UserRole.ADMIN) {
|
if (
|
||||||
|
session.user.role !== UserRole.OWNER &&
|
||||||
|
session.user.role !== UserRole.ADMIN &&
|
||||||
|
session.user.role !== UserRole.CE_MANAGER
|
||||||
|
) {
|
||||||
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
|
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,12 +38,15 @@ export async function POST(
|
||||||
|
|
||||||
const carbet = await prisma.carbet.findUnique({
|
const carbet = await prisma.carbet.findUnique({
|
||||||
where: { id: carbetId },
|
where: { id: carbetId },
|
||||||
select: { ownerId: true },
|
select: {
|
||||||
|
ownerId: true,
|
||||||
|
organizations: { select: { organizationId: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!carbet) {
|
if (!carbet) {
|
||||||
return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
|
return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
|
||||||
}
|
}
|
||||||
if (!canManageCarbet(session, carbet.ownerId)) {
|
if (!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))) {
|
||||||
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
|
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
113
src/app/api/cron/cleanup/route.ts
Normal file
113
src/app/api/cron/cleanup/route.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BookingStatus,
|
||||||
|
PaymentStatus,
|
||||||
|
RentalBookingStatus,
|
||||||
|
} from "@/generated/prisma/enums";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
import { isAuthorizedCronRequest } from "@/lib/cron-auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const INVITE_EXPIRY_GRACE_DAYS = 30;
|
||||||
|
const ABANDONED_PENDING_DAYS = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cron/cleanup
|
||||||
|
*
|
||||||
|
* Purge :
|
||||||
|
* - OrgInviteToken expirés depuis plus de 30j (rétention pour audit court).
|
||||||
|
* - Booking carbet PENDING dont createdAt > 7j et paiement non SUCCEEDED :
|
||||||
|
* status passé à CANCELLED (libère le créneau via cascade des
|
||||||
|
* Availabilities seulement si onDelete CASCADE — ici on flip juste
|
||||||
|
* status pour conserver le log).
|
||||||
|
* - RentalBooking PENDING idem + delete RentalItemAvailability associée
|
||||||
|
* (libère le stock).
|
||||||
|
*
|
||||||
|
* Auth : Bearer CRON_TOKEN.
|
||||||
|
*/
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
if (!isAuthorizedCronRequest(req)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const inviteCutoff = new Date(now.getTime() - INVITE_EXPIRY_GRACE_DAYS * 86_400_000);
|
||||||
|
const abandonedCutoff = new Date(now.getTime() - ABANDONED_PENDING_DAYS * 86_400_000);
|
||||||
|
|
||||||
|
// 1. Invites expirés (expiresAt < cutoff)
|
||||||
|
const { count: invitesDeleted } = await prisma.orgInviteToken.deleteMany({
|
||||||
|
where: { expiresAt: { lt: inviteCutoff } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Bookings carbet PENDING abandonnés
|
||||||
|
const abandonedBookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
status: BookingStatus.PENDING,
|
||||||
|
paymentStatus: { not: PaymentStatus.SUCCEEDED },
|
||||||
|
createdAt: { lt: abandonedCutoff },
|
||||||
|
},
|
||||||
|
select: { id: true, carbetId: true },
|
||||||
|
});
|
||||||
|
let bookingsCancelled = 0;
|
||||||
|
if (abandonedBookings.length > 0) {
|
||||||
|
const { count } = await prisma.booking.updateMany({
|
||||||
|
where: { id: { in: abandonedBookings.map((b) => b.id) } },
|
||||||
|
data: { status: BookingStatus.CANCELLED, paymentStatus: PaymentStatus.FAILED },
|
||||||
|
});
|
||||||
|
bookingsCancelled = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. RentalBookings PENDING abandonnés + delete availability associée
|
||||||
|
const abandonedRentals = await prisma.rentalBooking.findMany({
|
||||||
|
where: {
|
||||||
|
status: RentalBookingStatus.PENDING,
|
||||||
|
paymentStatus: { not: PaymentStatus.SUCCEEDED },
|
||||||
|
createdAt: { lt: abandonedCutoff },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
let rentalsCancelled = 0;
|
||||||
|
let availabilityFreed = 0;
|
||||||
|
if (abandonedRentals.length > 0) {
|
||||||
|
const ids = abandonedRentals.map((r) => r.id);
|
||||||
|
const [rentalRes, availRes] = await prisma.$transaction([
|
||||||
|
prisma.rentalBooking.updateMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
data: {
|
||||||
|
status: RentalBookingStatus.CANCELLED,
|
||||||
|
paymentStatus: PaymentStatus.FAILED,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.rentalItemAvailability.deleteMany({
|
||||||
|
where: { rentalBookingId: { in: ids } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
rentalsCancelled = rentalRes.count;
|
||||||
|
availabilityFreed = availRes.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
scope: "cron",
|
||||||
|
event: "cron.cleanup.run",
|
||||||
|
target: null,
|
||||||
|
actorEmail: "system:cron",
|
||||||
|
details: {
|
||||||
|
invitesDeleted,
|
||||||
|
bookingsCancelled,
|
||||||
|
rentalsCancelled,
|
||||||
|
availabilityFreed,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
invitesDeleted,
|
||||||
|
bookingsCancelled,
|
||||||
|
rentalsCancelled,
|
||||||
|
availabilityFreed,
|
||||||
|
});
|
||||||
|
}
|
||||||
128
src/app/api/cron/reminders/route.ts
Normal file
128
src/app/api/cron/reminders/route.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BookingStatus,
|
||||||
|
RentalBookingStatus,
|
||||||
|
} from "@/generated/prisma/enums";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
import { isAuthorizedCronRequest } from "@/lib/cron-auth";
|
||||||
|
import { sendBookingReminder, sendRentalReminder } from "@/lib/email";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cron/reminders
|
||||||
|
*
|
||||||
|
* Envoie des rappels J-1 (24h avant le début) pour :
|
||||||
|
* - Booking CONFIRMED dont startDate ∈ [now+22h, now+26h]
|
||||||
|
* - RentalBooking CONFIRMED idem
|
||||||
|
*
|
||||||
|
* Idempotent à l'échelle d'une journée : le filtre temporel narrow limite
|
||||||
|
* naturellement le risque de double-envoi (en pratique le cron tourne 1× par
|
||||||
|
* jour à heure fixe). Pour une garantie at-most-once stricte il faudrait
|
||||||
|
* stocker un flag `reminderSentAt` sur Booking/RentalBooking — défensif
|
||||||
|
* mais pas critique pour v1.
|
||||||
|
*
|
||||||
|
* Auth : Bearer CRON_TOKEN.
|
||||||
|
*/
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
if (!isAuthorizedCronRequest(req)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const from = new Date(now.getTime() + 22 * 60 * 60 * 1000);
|
||||||
|
const to = new Date(now.getTime() + 26 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const [carbetBookings, rentalBookings] = await Promise.all([
|
||||||
|
prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
status: BookingStatus.CONFIRMED,
|
||||||
|
startDate: { gte: from, lt: to },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tenant: { select: { email: true, firstName: true } },
|
||||||
|
carbet: { select: { title: true, slug: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.rentalBooking.findMany({
|
||||||
|
where: {
|
||||||
|
status: RentalBookingStatus.CONFIRMED,
|
||||||
|
startDate: { gte: from, lt: to },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tenant: { select: { email: true, firstName: true } },
|
||||||
|
provider: { select: { name: true, contactEmail: true, contactPhone: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let bookingSent = 0;
|
||||||
|
let bookingErrors = 0;
|
||||||
|
for (const b of carbetBookings) {
|
||||||
|
if (!b.tenant.email) continue;
|
||||||
|
try {
|
||||||
|
await sendBookingReminder(
|
||||||
|
b.tenant.email,
|
||||||
|
b.tenant.firstName,
|
||||||
|
b.id,
|
||||||
|
b.carbet.title,
|
||||||
|
b.startDate,
|
||||||
|
b.carbet.slug,
|
||||||
|
);
|
||||||
|
bookingSent++;
|
||||||
|
} catch (e) {
|
||||||
|
bookingErrors++;
|
||||||
|
console.error(
|
||||||
|
"[cron.reminders] booking email failed:",
|
||||||
|
b.id,
|
||||||
|
e instanceof Error ? e.message : e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rentalSent = 0;
|
||||||
|
let rentalErrors = 0;
|
||||||
|
for (const r of rentalBookings) {
|
||||||
|
if (!r.tenant.email) continue;
|
||||||
|
try {
|
||||||
|
await sendRentalReminder(
|
||||||
|
r.tenant.email,
|
||||||
|
r.tenant.firstName,
|
||||||
|
r.id,
|
||||||
|
r.provider.name,
|
||||||
|
r.startDate,
|
||||||
|
{ email: r.provider.contactEmail, phone: r.provider.contactPhone },
|
||||||
|
);
|
||||||
|
rentalSent++;
|
||||||
|
} catch (e) {
|
||||||
|
rentalErrors++;
|
||||||
|
console.error(
|
||||||
|
"[cron.reminders] rental email failed:",
|
||||||
|
r.id,
|
||||||
|
e instanceof Error ? e.message : e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
scope: "cron",
|
||||||
|
event: "cron.reminders.run",
|
||||||
|
target: null,
|
||||||
|
actorEmail: "system:cron",
|
||||||
|
details: {
|
||||||
|
window: { from: from.toISOString(), to: to.toISOString() },
|
||||||
|
booking: { candidates: carbetBookings.length, sent: bookingSent, errors: bookingErrors },
|
||||||
|
rental: { candidates: rentalBookings.length, sent: rentalSent, errors: rentalErrors },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
window: { from: from.toISOString(), to: to.toISOString() },
|
||||||
|
booking: { candidates: carbetBookings.length, sent: bookingSent, errors: bookingErrors },
|
||||||
|
rental: { candidates: rentalBookings.length, sent: rentalSent, errors: rentalErrors },
|
||||||
|
});
|
||||||
|
}
|
||||||
37
src/app/api/cron/run/[task]/route.ts
Normal file
37
src/app/api/cron/run/[task]/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { SCHEDULED_TASKS, type ScheduledTaskName } from "@/lib/scheduled";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function authorized(req: Request): boolean {
|
||||||
|
const secret = (process.env.CRON_TOKEN ?? "").trim();
|
||||||
|
if (!secret) return false;
|
||||||
|
const header = req.headers.get("authorization") ?? "";
|
||||||
|
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
|
||||||
|
return token === secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request, ctx: { params: Promise<{ task: string }> }) {
|
||||||
|
if (!authorized(req)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { task } = await ctx.params;
|
||||||
|
const fn = SCHEDULED_TASKS[task as ScheduledTaskName];
|
||||||
|
if (!fn) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unknown task. Available: ${Object.keys(SCHEDULED_TASKS).join(", ")}` },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
return NextResponse.json({ ok: true, task, result });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e instanceof Error ? e.message : String(e) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/app/api/favorites/route.ts
Normal file
61
src/app/api/favorites/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
carbetId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function requireSelf() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) throw new Error("Unauth");
|
||||||
|
return session.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const userId = await requireSelf();
|
||||||
|
const rows = await prisma.favorite.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { carbetId: true },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ids: rows.map((r) => r.carbetId) });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ ids: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const userId = await requireSelf();
|
||||||
|
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
|
||||||
|
await prisma.favorite.upsert({
|
||||||
|
where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } },
|
||||||
|
create: { userId, carbetId: parsed.data.carbetId },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: Request) {
|
||||||
|
try {
|
||||||
|
const userId = await requireSelf();
|
||||||
|
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
|
||||||
|
await prisma.favorite
|
||||||
|
.delete({ where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } } })
|
||||||
|
.catch(() => null);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/app/api/me/export/route.ts
Normal file
103
src/app/api/me/export/route.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** RGPD article 20 — droit à la portabilité. Renvoie un JSON avec toutes les données utilisateur. */
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const [user, bookings, reviews, carbets, subscriptions] = await Promise.all([
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
phone: true,
|
||||||
|
role: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
organizationId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.booking.findMany({
|
||||||
|
where: { tenantId: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
carbetId: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
guestCount: true,
|
||||||
|
status: true,
|
||||||
|
paymentStatus: true,
|
||||||
|
amount: true,
|
||||||
|
currency: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.review.findMany({
|
||||||
|
where: { authorId: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bookingId: true,
|
||||||
|
carbetId: true,
|
||||||
|
rating: true,
|
||||||
|
comment: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.carbet.findMany({
|
||||||
|
where: { ownerId: userId },
|
||||||
|
select: { id: true, slug: true, title: true, status: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
prisma.subscription.findMany({
|
||||||
|
where: { ownerId: userId },
|
||||||
|
select: { id: true, carbetId: true, status: true, provider: true, startedAt: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
scope: "public.profile",
|
||||||
|
event: "data.export",
|
||||||
|
target: userId,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `karbe-mes-donnees-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
return new NextResponse(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
rgpdNotice:
|
||||||
|
"Conformément à l'article 20 du RGPD. Pour exercer vos autres droits, contactez contact@karbe.cosmolan.fr.",
|
||||||
|
user,
|
||||||
|
bookings,
|
||||||
|
reviews,
|
||||||
|
carbets,
|
||||||
|
subscriptions,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/app/api/media/[id]/route.ts
Normal file
41
src/app/api/media/[id]/route.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
async function requireOwnership(mediaId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) throw new Error("Non authentifié");
|
||||||
|
const m = await prisma.media.findUnique({
|
||||||
|
where: { id: mediaId },
|
||||||
|
select: { id: true, carbetId: true, carbet: { select: { ownerId: true } } },
|
||||||
|
});
|
||||||
|
if (!m) throw new Error("Média introuvable");
|
||||||
|
const isAdmin = session.user.role === UserRole.ADMIN;
|
||||||
|
if (!isAdmin && m.carbet.ownerId !== session.user.id) throw new Error("Accès refusé");
|
||||||
|
return { session, media: m };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
try {
|
||||||
|
const { session, media } = await requireOwnership(id);
|
||||||
|
await prisma.media.delete({ where: { id } });
|
||||||
|
await recordAudit({
|
||||||
|
scope: "uploads",
|
||||||
|
event: "media.delete",
|
||||||
|
target: id,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: { carbetId: media.carbetId },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
const status = msg === "Non authentifié" ? 401 : msg === "Accès refusé" ? 403 : 404;
|
||||||
|
return NextResponse.json({ error: msg }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/app/api/media/reorder/route.ts
Normal file
55
src/app/api/media/reorder/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
carbetId: z.string().min(1),
|
||||||
|
orderedIds: z.array(z.string()).min(1).max(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { carbetId, orderedIds } = parsed.data;
|
||||||
|
const carbet = await prisma.carbet.findUnique({
|
||||||
|
where: { id: carbetId },
|
||||||
|
select: { ownerId: true },
|
||||||
|
});
|
||||||
|
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
|
||||||
|
const isAdmin = session.user.role === UserRole.ADMIN;
|
||||||
|
if (!isAdmin && carbet.ownerId !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const existing = await prisma.media.findMany({
|
||||||
|
where: { carbetId, id: { in: orderedIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing.length !== orderedIds.length) {
|
||||||
|
return NextResponse.json({ error: "Certains médias n'appartiennent pas au carbet." }, { status: 400 });
|
||||||
|
}
|
||||||
|
await prisma.$transaction(
|
||||||
|
orderedIds.map((id, idx) =>
|
||||||
|
prisma.media.update({ where: { id }, data: { sortOrder: idx } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await recordAudit({
|
||||||
|
scope: "uploads",
|
||||||
|
event: "media.reorder",
|
||||||
|
target: carbetId,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: { count: orderedIds.length },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
58
src/app/api/password/reset-request/route.ts
Normal file
58
src/app/api/password/reset-request/route.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createPasswordResetToken } from "@/lib/password-reset";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendPasswordReset } from "@/lib/email";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
email: z.string().trim().toLowerCase().email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const rl = rateLimitRequest(req, "password-reset", 60 * 60 * 1000, 3);
|
||||||
|
if (!rl.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const parsed = schema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
// Réponse générique pour ne pas leak la validité du format à un attaquant.
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: parsed.data.email },
|
||||||
|
select: { id: true, email: true, firstName: true, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user && user.isActive) {
|
||||||
|
const token = await createPasswordResetToken(user.id);
|
||||||
|
const resetUrl = `${SITE_URL}/mot-de-passe-oublie/${token}`;
|
||||||
|
sendPasswordReset(user.email, resetUrl).catch(() => {});
|
||||||
|
await recordAudit({
|
||||||
|
scope: "public.password",
|
||||||
|
event: "reset.request",
|
||||||
|
target: user.id,
|
||||||
|
actorEmail: user.email,
|
||||||
|
details: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réponse identique que l'email existe ou non (énumération-safe).
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
40
src/app/api/password/reset/route.ts
Normal file
40
src/app/api/password/reset/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { consumePasswordResetToken } from "@/lib/password-reset";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
token: z.string().min(20).max(200),
|
||||||
|
password: z.string().min(8).max(200),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const parsed = schema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues.map((i) => i.message).join(" · ") },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await consumePasswordResetToken(parsed.data.token, parsed.data.password);
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json({ error: result.reason }, { status: 400 });
|
||||||
|
}
|
||||||
|
await recordAudit({
|
||||||
|
scope: "public.password",
|
||||||
|
event: "reset.success",
|
||||||
|
target: result.userId,
|
||||||
|
actorEmail: null,
|
||||||
|
details: {},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
39
src/app/api/rental-media/[id]/route.ts
Normal file
39
src/app/api/rental-media/[id]/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { canManageRentalProvider } from "@/lib/rental-access";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const media = await prisma.rentalItemMedia.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, itemId: true, item: { select: { providerId: true } } },
|
||||||
|
});
|
||||||
|
if (!media) return NextResponse.json({ error: "Média introuvable" }, { status: 404 });
|
||||||
|
|
||||||
|
const allowed = await canManageRentalProvider(
|
||||||
|
session.user.id,
|
||||||
|
session.user.role,
|
||||||
|
media.item.providerId,
|
||||||
|
session.user.organizationId,
|
||||||
|
);
|
||||||
|
if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||||
|
|
||||||
|
await prisma.rentalItemMedia.delete({ where: { id } });
|
||||||
|
await recordAudit({
|
||||||
|
scope: "uploads",
|
||||||
|
event: "rental.media.delete",
|
||||||
|
target: id,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: { itemId: media.itemId },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
75
src/app/api/rental-media/reorder/route.ts
Normal file
75
src/app/api/rental-media/reorder/route.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { canManageRentalProvider } from "@/lib/rental-access";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
itemId: z.string().min(1),
|
||||||
|
orderedIds: z.array(z.string()).min(1).max(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { itemId, orderedIds } = parsed.data;
|
||||||
|
|
||||||
|
const item = await prisma.rentalItem.findUnique({
|
||||||
|
where: { id: itemId },
|
||||||
|
select: { providerId: true },
|
||||||
|
});
|
||||||
|
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
|
||||||
|
|
||||||
|
const allowed = await canManageRentalProvider(
|
||||||
|
session.user.id,
|
||||||
|
session.user.role,
|
||||||
|
item.providerId,
|
||||||
|
session.user.organizationId,
|
||||||
|
);
|
||||||
|
if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||||
|
|
||||||
|
const existing = await prisma.rentalItemMedia.findMany({
|
||||||
|
where: { itemId, id: { in: orderedIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing.length !== orderedIds.length) {
|
||||||
|
return NextResponse.json({ error: "Certains médias n'appartiennent pas à l'item." }, { status: 400 });
|
||||||
|
}
|
||||||
|
await prisma.$transaction(
|
||||||
|
orderedIds.map((id, idx) =>
|
||||||
|
prisma.rentalItemMedia.update({ where: { id }, data: { sortOrder: idx } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cover = sortOrder 0 → hydrate imageUrl pour rétro-compat listings
|
||||||
|
const firstId = orderedIds[0];
|
||||||
|
const firstMedia = await prisma.rentalItemMedia.findUnique({
|
||||||
|
where: { id: firstId },
|
||||||
|
select: { s3Url: true, type: true },
|
||||||
|
});
|
||||||
|
if (firstMedia && firstMedia.type === "PHOTO") {
|
||||||
|
await prisma.rentalItem.update({
|
||||||
|
where: { id: itemId },
|
||||||
|
data: { imageUrl: firstMedia.s3Url },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
scope: "uploads",
|
||||||
|
event: "rental.media.reorder",
|
||||||
|
target: itemId,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: { count: orderedIds.length },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
193
src/app/api/rentals/[id]/cancel/route.ts
Normal file
193
src/app/api/rentals/[id]/cancel/route.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import {
|
||||||
|
PaymentStatus,
|
||||||
|
RentalBookingStatus,
|
||||||
|
UserRole,
|
||||||
|
} from "@/generated/prisma/enums";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
import { canManageRentalProvider } from "@/lib/rental-access";
|
||||||
|
import { sendRentalCancelled } from "@/lib/email";
|
||||||
|
import { isStripeConfigured, getStripeClient } from "@/lib/stripe";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { computeRentalRefund } from "@/lib/rental-refund";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const CANCELLABLE_STATUSES: RentalBookingStatus[] = [
|
||||||
|
RentalBookingStatus.PENDING,
|
||||||
|
RentalBookingStatus.CONFIRMED,
|
||||||
|
];
|
||||||
|
|
||||||
|
type Body = { reason?: string };
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
const body: Body = await req.json().catch(() => ({}));
|
||||||
|
const reason = body.reason?.toString().trim().slice(0, 500) ?? null;
|
||||||
|
|
||||||
|
const rb = await prisma.rentalBooking.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
provider: { select: { id: true, name: true, contactEmail: true, organizationId: true } },
|
||||||
|
tenant: { select: { id: true, email: true, firstName: true } },
|
||||||
|
lines: { select: { qty: true, item: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!rb) {
|
||||||
|
return NextResponse.json({ error: "Réservation introuvable." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détecte qui annule pour l'auth + l'email :
|
||||||
|
// - tenant de la booking
|
||||||
|
// - provider's manager (RENTAL_PROVIDER nominal ou CE_MANAGER de l'org du provider)
|
||||||
|
// - admin
|
||||||
|
const role = session.user.role;
|
||||||
|
const isTenant = rb.tenantId === session.user.id;
|
||||||
|
const isAdmin = role === UserRole.ADMIN;
|
||||||
|
const canManage = await canManageRentalProvider(
|
||||||
|
session.user.id,
|
||||||
|
role,
|
||||||
|
rb.providerId,
|
||||||
|
session.user.organizationId,
|
||||||
|
);
|
||||||
|
const cancelledBy: "tenant" | "provider" | "admin" = isAdmin
|
||||||
|
? "admin"
|
||||||
|
: canManage
|
||||||
|
? "provider"
|
||||||
|
: "tenant";
|
||||||
|
|
||||||
|
if (!isAdmin && !canManage && !isTenant) {
|
||||||
|
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CANCELLABLE_STATUSES.includes(rb.status)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Impossible d'annuler une réservation en statut ${rb.status}.` },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcule le remboursement selon la politique
|
||||||
|
const refund = computeRentalRefund({
|
||||||
|
startDate: rb.startDate,
|
||||||
|
itemsTotal: rb.itemsTotal,
|
||||||
|
depositTotal: rb.depositTotal,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stripe refund best-effort si paiement déjà SUCCEEDED + session Stripe existante
|
||||||
|
let stripeRefundId: string | null = null;
|
||||||
|
let stripeRefundError: string | null = null;
|
||||||
|
if (
|
||||||
|
rb.paymentStatus === PaymentStatus.SUCCEEDED &&
|
||||||
|
rb.stripeSessionId &&
|
||||||
|
isStripeConfigured() &&
|
||||||
|
refund.totalRefund.gt(0)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
const sess = await stripe.checkout.sessions.retrieve(rb.stripeSessionId, {
|
||||||
|
expand: ["payment_intent"],
|
||||||
|
});
|
||||||
|
const piId =
|
||||||
|
typeof sess.payment_intent === "string"
|
||||||
|
? sess.payment_intent
|
||||||
|
: sess.payment_intent?.id;
|
||||||
|
if (piId) {
|
||||||
|
const stripeRefund = await stripe.refunds.create({
|
||||||
|
payment_intent: piId,
|
||||||
|
amount: Math.round(Number(refund.totalRefund) * 100),
|
||||||
|
reason: "requested_by_customer",
|
||||||
|
});
|
||||||
|
stripeRefundId = stripeRefund.id;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
stripeRefundError = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("[rental.cancel] Stripe refund failed:", stripeRefundError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction : update booking + delete availability blocks
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.rentalBooking.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: RentalBookingStatus.CANCELLED,
|
||||||
|
paymentStatus:
|
||||||
|
rb.paymentStatus === PaymentStatus.SUCCEEDED
|
||||||
|
? PaymentStatus.REFUNDED
|
||||||
|
: PaymentStatus.FAILED,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.rentalItemAvailability.deleteMany({
|
||||||
|
where: { rentalBookingId: id },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
scope: "rental",
|
||||||
|
event: "rental.cancel",
|
||||||
|
target: id,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: {
|
||||||
|
cancelledBy,
|
||||||
|
reason,
|
||||||
|
policy: refund.policy,
|
||||||
|
itemsRefund: refund.itemsRefund.toString(),
|
||||||
|
depositRefund: refund.depositRefund.toString(),
|
||||||
|
totalRefund: refund.totalRefund.toString(),
|
||||||
|
stripeRefundId,
|
||||||
|
stripeRefundError,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Email best-effort : tenant + provider
|
||||||
|
try {
|
||||||
|
await sendRentalCancelled(
|
||||||
|
rb.tenant.email,
|
||||||
|
rb.tenant.firstName,
|
||||||
|
rb.id,
|
||||||
|
rb.provider.name,
|
||||||
|
refund.totalRefund.toString(),
|
||||||
|
rb.currency,
|
||||||
|
refund.policyLabel,
|
||||||
|
cancelledBy,
|
||||||
|
);
|
||||||
|
if (rb.provider.contactEmail && cancelledBy !== "provider") {
|
||||||
|
await sendRentalCancelled(
|
||||||
|
rb.provider.contactEmail,
|
||||||
|
rb.provider.name,
|
||||||
|
rb.id,
|
||||||
|
rb.provider.name,
|
||||||
|
refund.totalRefund.toString(),
|
||||||
|
rb.currency,
|
||||||
|
refund.policyLabel,
|
||||||
|
cancelledBy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[rental.cancel] email send failed:", e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
rentalBookingId: id,
|
||||||
|
refund: {
|
||||||
|
itemsRefund: refund.itemsRefund.toNumber(),
|
||||||
|
depositRefund: refund.depositRefund.toNumber(),
|
||||||
|
totalRefund: refund.totalRefund.toNumber(),
|
||||||
|
policy: refund.policy,
|
||||||
|
policyLabel: refund.policyLabel,
|
||||||
|
},
|
||||||
|
stripeRefundId,
|
||||||
|
stripeRefundError,
|
||||||
|
});
|
||||||
|
}
|
||||||
361
src/app/api/rentals/checkout/route.ts
Normal file
361
src/app/api/rentals/checkout/route.ts
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
|
||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
import {
|
||||||
|
sendRentalRequestedProvider,
|
||||||
|
sendRentalRequestedTenant,
|
||||||
|
} from "@/lib/email";
|
||||||
|
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { CART_COOKIE, EMPTY_CART, diffDays, parseCart } from "@/lib/rental-cart";
|
||||||
|
import {
|
||||||
|
getStripeClient,
|
||||||
|
isStripeConfigured,
|
||||||
|
toStripeAmountCents,
|
||||||
|
} from "@/lib/stripe";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type LineInput = {
|
||||||
|
itemId: string;
|
||||||
|
qty: number;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
nights: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseDateOnly(s: string): Date {
|
||||||
|
return new Date(s + "T00:00:00Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
if (!(await isPluginEnabled("gear-rental"))) {
|
||||||
|
return NextResponse.json({ error: "Service de location indisponible." }, { status: 404 });
|
||||||
|
}
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id || !session.user.email) {
|
||||||
|
return NextResponse.json({ error: "Connectez-vous pour finaliser." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const jar = await cookies();
|
||||||
|
const cart = parseCart(jar.get(CART_COOKIE)?.value);
|
||||||
|
if (cart.items.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Panier vide." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charge tous les items du panier
|
||||||
|
const itemIds = Array.from(new Set(cart.items.map((e) => e.itemId)));
|
||||||
|
const items = await prisma.rentalItem.findMany({
|
||||||
|
where: { id: { in: itemIds }, active: true },
|
||||||
|
include: {
|
||||||
|
provider: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
active: true,
|
||||||
|
approved: true,
|
||||||
|
commissionPct: true,
|
||||||
|
isSystemD: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const itemById = new Map(items.map((i) => [i.id, i]));
|
||||||
|
|
||||||
|
// Validations préliminaires : items valides + provider actif/approved
|
||||||
|
for (const entry of cart.items) {
|
||||||
|
const it = itemById.get(entry.itemId);
|
||||||
|
if (!it) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Item ${entry.itemId} introuvable ou désactivé.` },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!it.provider.active || !it.provider.approved) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Prestataire ${it.provider.name} indisponible.` },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.qty < 1 || entry.qty > it.totalQty) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Quantité invalide pour « ${it.name} ».` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const start = parseDateOnly(entry.startDate);
|
||||||
|
const end = parseDateOnly(entry.endDate);
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Dates invalides pour « ${it.name} ».` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groupe par provider
|
||||||
|
type Group = {
|
||||||
|
providerId: string;
|
||||||
|
providerName: string;
|
||||||
|
commissionPct: number;
|
||||||
|
lines: LineInput[];
|
||||||
|
itemsTotal: Prisma.Decimal;
|
||||||
|
depositTotal: Prisma.Decimal;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups = new Map<string, Group>();
|
||||||
|
for (const entry of cart.items) {
|
||||||
|
const it = itemById.get(entry.itemId)!;
|
||||||
|
const start = parseDateOnly(entry.startDate);
|
||||||
|
const end = parseDateOnly(entry.endDate);
|
||||||
|
const nights = Math.max(1, diffDays(entry.startDate, entry.endDate));
|
||||||
|
const lineSub = new Prisma.Decimal(it.pricePerDay).mul(entry.qty).mul(nights);
|
||||||
|
const lineDeposit = new Prisma.Decimal(it.deposit).mul(entry.qty);
|
||||||
|
|
||||||
|
let g = groups.get(it.provider.id);
|
||||||
|
if (!g) {
|
||||||
|
g = {
|
||||||
|
providerId: it.provider.id,
|
||||||
|
providerName: it.provider.name,
|
||||||
|
commissionPct: Number(it.provider.commissionPct),
|
||||||
|
lines: [],
|
||||||
|
itemsTotal: new Prisma.Decimal(0),
|
||||||
|
depositTotal: new Prisma.Decimal(0),
|
||||||
|
startDate: start,
|
||||||
|
endDate: end,
|
||||||
|
};
|
||||||
|
groups.set(it.provider.id, g);
|
||||||
|
}
|
||||||
|
g.lines.push({ itemId: it.id, qty: entry.qty, startDate: start, endDate: end, nights });
|
||||||
|
g.itemsTotal = g.itemsTotal.add(lineSub);
|
||||||
|
g.depositTotal = g.depositTotal.add(lineDeposit);
|
||||||
|
if (start < g.startDate) g.startDate = start;
|
||||||
|
if (end > g.endDate) g.endDate = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction : recheck stock + crée RentalBookings + Lines + Availabilities
|
||||||
|
let grandTotal = new Prisma.Decimal(0);
|
||||||
|
let grandDeposit = new Prisma.Decimal(0);
|
||||||
|
let rentalBookingIds: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
rentalBookingIds = await prisma.$transaction(async (tx) => {
|
||||||
|
const created: string[] = [];
|
||||||
|
|
||||||
|
for (const g of groups.values()) {
|
||||||
|
// Recheck stock disponible pour chaque ligne
|
||||||
|
for (const line of g.lines) {
|
||||||
|
const blocked = await tx.rentalItemAvailability.aggregate({
|
||||||
|
where: {
|
||||||
|
itemId: line.itemId,
|
||||||
|
startDate: { lt: line.endDate },
|
||||||
|
endDate: { gt: line.startDate },
|
||||||
|
},
|
||||||
|
_sum: { qty: true },
|
||||||
|
});
|
||||||
|
const item = itemById.get(line.itemId)!;
|
||||||
|
const used = Number(blocked._sum.qty ?? 0);
|
||||||
|
const free = item.totalQty - used;
|
||||||
|
if (line.qty > free) {
|
||||||
|
throw new Error(`Stock insuffisant pour « ${item.name} » sur les dates demandées (libre: ${free}).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commissionAmount = g.itemsTotal
|
||||||
|
.mul(g.commissionPct)
|
||||||
|
.div(100)
|
||||||
|
.toDecimalPlaces(2);
|
||||||
|
const amount = g.itemsTotal.add(g.depositTotal).toDecimalPlaces(2);
|
||||||
|
|
||||||
|
const rb = await tx.rentalBooking.create({
|
||||||
|
data: {
|
||||||
|
tenantId: session.user!.id!,
|
||||||
|
providerId: g.providerId,
|
||||||
|
startDate: g.startDate,
|
||||||
|
endDate: g.endDate,
|
||||||
|
status: RentalBookingStatus.PENDING,
|
||||||
|
paymentStatus: PaymentStatus.PENDING,
|
||||||
|
itemsTotal: g.itemsTotal.toDecimalPlaces(2),
|
||||||
|
depositTotal: g.depositTotal.toDecimalPlaces(2),
|
||||||
|
commissionAmount,
|
||||||
|
amount,
|
||||||
|
currency: "EUR",
|
||||||
|
lines: {
|
||||||
|
create: g.lines.map((line) => {
|
||||||
|
const item = itemById.get(line.itemId)!;
|
||||||
|
const lineTotal = new Prisma.Decimal(item.pricePerDay)
|
||||||
|
.mul(line.qty)
|
||||||
|
.mul(line.nights)
|
||||||
|
.toDecimalPlaces(2);
|
||||||
|
return {
|
||||||
|
itemId: line.itemId,
|
||||||
|
qty: line.qty,
|
||||||
|
pricePerDay: new Prisma.Decimal(item.pricePerDay),
|
||||||
|
deposit: new Prisma.Decimal(item.deposit),
|
||||||
|
lineTotal,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bloque les dispos
|
||||||
|
for (const line of g.lines) {
|
||||||
|
await tx.rentalItemAvailability.create({
|
||||||
|
data: {
|
||||||
|
itemId: line.itemId,
|
||||||
|
startDate: line.startDate,
|
||||||
|
endDate: line.endDate,
|
||||||
|
qty: line.qty,
|
||||||
|
reason: "RENTAL_BOOKING",
|
||||||
|
rentalBookingId: rb.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
created.push(rb.id);
|
||||||
|
grandTotal = grandTotal.add(g.itemsTotal);
|
||||||
|
grandDeposit = grandDeposit.add(g.depositTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e instanceof Error ? e.message : "Erreur lors de la création." },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalAmount = grandTotal.add(grandDeposit).toDecimalPlaces(2);
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
scope: "rental",
|
||||||
|
event: "rental.checkout.created",
|
||||||
|
target: rentalBookingIds.join(","),
|
||||||
|
actorEmail: session.user.email,
|
||||||
|
details: {
|
||||||
|
rentalBookingIds,
|
||||||
|
amount: totalAmount.toNumber(),
|
||||||
|
depositTotal: grandDeposit.toNumber(),
|
||||||
|
providers: Array.from(groups.keys()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emails best-effort : 1 mail au locataire (récap par prestataire) + 1 mail
|
||||||
|
// à chaque prestataire (sa demande). En cas d'échec d'envoi, on ne bloque pas.
|
||||||
|
try {
|
||||||
|
const fullBookings = await prisma.rentalBooking.findMany({
|
||||||
|
where: { id: { in: rentalBookingIds } },
|
||||||
|
include: {
|
||||||
|
provider: { select: { name: true, contactEmail: true } },
|
||||||
|
lines: { include: { item: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const tenantName = session.user.name ?? session.user.email!;
|
||||||
|
for (const rb of fullBookings) {
|
||||||
|
const lineSummary = rb.lines.map((l) => ({ qty: l.qty, itemName: l.item.name }));
|
||||||
|
await sendRentalRequestedTenant(
|
||||||
|
session.user.email!,
|
||||||
|
tenantName,
|
||||||
|
rb.id,
|
||||||
|
rb.provider.name,
|
||||||
|
rb.startDate,
|
||||||
|
rb.endDate,
|
||||||
|
rb.amount.toString(),
|
||||||
|
rb.currency,
|
||||||
|
lineSummary,
|
||||||
|
);
|
||||||
|
if (rb.provider.contactEmail) {
|
||||||
|
await sendRentalRequestedProvider(
|
||||||
|
rb.provider.contactEmail,
|
||||||
|
rb.provider.name,
|
||||||
|
rb.id,
|
||||||
|
tenantName,
|
||||||
|
rb.startDate,
|
||||||
|
rb.endDate,
|
||||||
|
lineSummary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[rental.checkout] email send failed:", e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vide le panier
|
||||||
|
jar.set(CART_COOKIE, JSON.stringify(EMPTY_CART), {
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stripe ou paiement différé
|
||||||
|
if (!isStripeConfigured()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ rentalBookingIds, totalAmount: totalAmount.toNumber() },
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appUrl = process.env.APP_URL;
|
||||||
|
if (!appUrl) {
|
||||||
|
return NextResponse.json({ error: "APP_URL manquante." }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une session Stripe avec une ligne par RentalBooking (agrégée)
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
const bookingDetails = await prisma.rentalBooking.findMany({
|
||||||
|
where: { id: { in: rentalBookingIds } },
|
||||||
|
include: {
|
||||||
|
provider: { select: { name: true } },
|
||||||
|
lines: { select: { qty: true, item: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const line_items = bookingDetails.map((rb) => ({
|
||||||
|
quantity: 1,
|
||||||
|
price_data: {
|
||||||
|
currency: "eur",
|
||||||
|
unit_amount: toStripeAmountCents(Number(rb.amount)),
|
||||||
|
product_data: {
|
||||||
|
name: `Matériel — ${rb.provider.name}`,
|
||||||
|
description: rb.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ").slice(0, 500),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const checkoutSession = await stripe.checkout.sessions.create({
|
||||||
|
mode: "payment",
|
||||||
|
success_url: `${appUrl}/mes-locations?payment=success&ids=${rentalBookingIds.join(",")}`,
|
||||||
|
cancel_url: `${appUrl}/panier?payment=cancel`,
|
||||||
|
customer_email: session.user.email,
|
||||||
|
line_items,
|
||||||
|
metadata: {
|
||||||
|
type: "rental-bundle",
|
||||||
|
rentalBookingIds: rentalBookingIds.join(","),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.rentalBooking.updateMany({
|
||||||
|
where: { id: { in: rentalBookingIds } },
|
||||||
|
data: { stripeSessionId: checkoutSession.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
rentalBookingIds,
|
||||||
|
totalAmount: totalAmount.toNumber(),
|
||||||
|
checkoutSessionId: checkoutSession.id,
|
||||||
|
checkoutUrl: checkoutSession.url,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/app/api/rentals/items/[id]/availability/route.ts
Normal file
31
src/app/api/rentals/items/[id]/availability/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { getItemAvailability } from "@/lib/rentals-public";
|
||||||
|
import { parseIsoDate, normalizeUtcDayStart } from "@/lib/booking";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const from = parseIsoDate(req.nextUrl.searchParams.get("from"));
|
||||||
|
const to = parseIsoDate(req.nextUrl.searchParams.get("to"));
|
||||||
|
if (!from || !to) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Paramètres from et to (YYYY-MM-DD) requis." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const start = normalizeUtcDayStart(from);
|
||||||
|
const end = normalizeUtcDayStart(to);
|
||||||
|
if (end <= start) {
|
||||||
|
return NextResponse.json({ error: "to doit être > from." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const calendar = await getItemAvailability(id, start, end);
|
||||||
|
return NextResponse.json({
|
||||||
|
itemId: id,
|
||||||
|
from: start.toISOString(),
|
||||||
|
to: end.toISOString(),
|
||||||
|
calendar,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,13 @@ import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { UserRole } from "@/generated/prisma/enums";
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { getOrgInviteByToken, markOrgInviteConsumed } from "@/lib/ce-invites";
|
||||||
import { hashPassword } from "@/lib/password";
|
import { hashPassword } from "@/lib/password";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { recordAudit } from "@/lib/admin/audit";
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
import { sendSignupWelcome } from "@/lib/email";
|
import { sendNewCeRequest, sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email";
|
||||||
|
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||||
|
import { slugify } from "@/lib/slug";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
|
@ -15,10 +18,23 @@ const schema = z.object({
|
||||||
firstName: z.string().trim().min(1).max(100),
|
firstName: z.string().trim().min(1).max(100),
|
||||||
lastName: z.string().trim().min(1).max(100),
|
lastName: z.string().trim().min(1).max(100),
|
||||||
phone: z.string().trim().max(40).optional().nullable(),
|
phone: z.string().trim().max(40).optional().nullable(),
|
||||||
role: z.enum([UserRole.TOURIST, UserRole.OWNER]).default(UserRole.TOURIST),
|
role: z
|
||||||
|
.enum([UserRole.TOURIST, UserRole.OWNER, UserRole.RENTAL_PROVIDER, UserRole.CE_MANAGER])
|
||||||
|
.default(UserRole.TOURIST),
|
||||||
|
providerName: z.string().trim().min(2).max(200).optional(),
|
||||||
|
providerRivers: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
|
||||||
|
orgName: z.string().trim().min(2).max(200).optional(),
|
||||||
|
inviteToken: z.string().trim().min(8).max(200).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const rl = rateLimitRequest(req, "signup", 60 * 60 * 1000, 5);
|
||||||
|
if (!rl.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
|
||||||
|
);
|
||||||
|
}
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
|
|
@ -34,35 +50,150 @@ export async function POST(req: Request) {
|
||||||
}
|
}
|
||||||
const data = parsed.data;
|
const data = parsed.data;
|
||||||
|
|
||||||
|
if (data.role === UserRole.RENTAL_PROVIDER && (!data.providerName || data.providerName.trim().length < 2)) {
|
||||||
|
return NextResponse.json({ error: "Nom de votre activité requis." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (data.role === UserRole.CE_MANAGER && (!data.orgName || data.orgName.trim().length < 2)) {
|
||||||
|
return NextResponse.json({ error: "Nom de votre Comité d'Entreprise requis." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invitation CE_MEMBER : si un inviteToken est fourni, on force le rôle CE_MEMBER
|
||||||
|
// et on rattache à l'org du token (org déjà validée — pas de bannière pending).
|
||||||
|
let inviteOrgId: string | null = null;
|
||||||
|
if (data.inviteToken) {
|
||||||
|
const invite = await getOrgInviteByToken(data.inviteToken);
|
||||||
|
if (!invite) {
|
||||||
|
return NextResponse.json({ error: "Lien d'invitation invalide ou expiré." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (invite.email && invite.email.toLowerCase() !== data.email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Ce lien d'invitation est réservé à un autre email." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
inviteOrgId = invite.organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } });
|
const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 });
|
return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(data.password);
|
const passwordHash = await hashPassword(data.password);
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
// CE_MANAGER : transaction atomique User + Organization. Le slug est unique
|
||||||
email: data.email,
|
// sur Organization → on retente avec un suffixe en cas de collision.
|
||||||
passwordHash,
|
let createdProviderId: string | null = null;
|
||||||
firstName: data.firstName,
|
let createdOrgId: string | null = null;
|
||||||
lastName: data.lastName,
|
let user: { id: string; email: string; role: UserRole };
|
||||||
phone: data.phone?.trim() || null,
|
|
||||||
role: data.role,
|
if (inviteOrgId) {
|
||||||
isActive: true,
|
// Branche invite CE_MEMBER : rattache le user à l'org du token, ignore data.role.
|
||||||
},
|
user = await prisma.user.create({
|
||||||
select: { id: true, email: true, role: true },
|
data: {
|
||||||
});
|
email: data.email,
|
||||||
|
passwordHash,
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
phone: data.phone?.trim() || null,
|
||||||
|
role: UserRole.CE_MEMBER,
|
||||||
|
organizationId: inviteOrgId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true, email: true, role: true },
|
||||||
|
});
|
||||||
|
createdOrgId = inviteOrgId;
|
||||||
|
await markOrgInviteConsumed(data.inviteToken!).catch(() => {});
|
||||||
|
} else if (data.role === UserRole.CE_MANAGER) {
|
||||||
|
const orgName = data.orgName!.trim();
|
||||||
|
const baseSlug = slugify(orgName);
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// Trouve un slug libre
|
||||||
|
let candidate = baseSlug || "ce";
|
||||||
|
let suffix = 1;
|
||||||
|
for (;;) {
|
||||||
|
const exists = await tx.organization.findUnique({ where: { slug: candidate }, select: { id: true } });
|
||||||
|
if (!exists) break;
|
||||||
|
suffix += 1;
|
||||||
|
candidate = `${baseSlug}-${suffix}`;
|
||||||
|
}
|
||||||
|
// candidate now holds a free slug
|
||||||
|
const org = await tx.organization.create({
|
||||||
|
data: {
|
||||||
|
name: orgName,
|
||||||
|
slug: candidate,
|
||||||
|
contactEmail: data.email,
|
||||||
|
approved: false,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const u = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
email: data.email,
|
||||||
|
passwordHash,
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
phone: data.phone?.trim() || null,
|
||||||
|
role: UserRole.CE_MANAGER,
|
||||||
|
organizationId: org.id,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true, email: true, role: true },
|
||||||
|
});
|
||||||
|
return { user: u, orgId: org.id };
|
||||||
|
});
|
||||||
|
user = result.user;
|
||||||
|
createdOrgId = result.orgId;
|
||||||
|
sendNewCeRequest(orgName, user.email).catch(() => {});
|
||||||
|
} else {
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: data.email,
|
||||||
|
passwordHash,
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
phone: data.phone?.trim() || null,
|
||||||
|
role: data.role,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true, email: true, role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation.
|
||||||
|
if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) {
|
||||||
|
const provider = await prisma.rentalProvider.create({
|
||||||
|
data: {
|
||||||
|
name: data.providerName,
|
||||||
|
isSystemD: false,
|
||||||
|
managedByUserId: user.id,
|
||||||
|
contactEmail: user.email,
|
||||||
|
contactPhone: data.phone?.trim() || null,
|
||||||
|
rivers: data.providerRivers ?? [],
|
||||||
|
commissionPct: 10, // valeur par défaut, ajustable par admin
|
||||||
|
active: true,
|
||||||
|
approved: false,
|
||||||
|
},
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
createdProviderId = provider.id;
|
||||||
|
sendNewRentalProviderRequest(provider.name, user.email).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await recordAudit({
|
await recordAudit({
|
||||||
scope: "public.signup",
|
scope: "public.signup",
|
||||||
event: "user.create",
|
event: "user.create",
|
||||||
target: user.id,
|
target: user.id,
|
||||||
actorEmail: user.email,
|
actorEmail: user.email,
|
||||||
details: { role: user.role },
|
details: { role: user.role, rentalProviderId: createdProviderId, organizationId: createdOrgId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Best-effort welcome email.
|
|
||||||
sendSignupWelcome(user.email, data.firstName).catch(() => {});
|
sendSignupWelcome(user.email, data.firstName).catch(() => {});
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, userId: user.id });
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
userId: user.id,
|
||||||
|
providerId: createdProviderId,
|
||||||
|
organizationId: createdOrgId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import Stripe from "stripe";
|
||||||
import {
|
import {
|
||||||
BookingStatus,
|
BookingStatus,
|
||||||
PaymentStatus,
|
PaymentStatus,
|
||||||
|
RentalBookingStatus,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
} from "@/generated/prisma/enums";
|
} from "@/generated/prisma/enums";
|
||||||
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
|
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
|
||||||
|
import { sendRentalConfirmed } from "@/lib/email";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe";
|
import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe";
|
||||||
|
|
||||||
|
|
@ -51,6 +53,43 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === "rental-bundle") {
|
||||||
|
const idsRaw = session.metadata?.rentalBookingIds;
|
||||||
|
if (!idsRaw) return;
|
||||||
|
const ids = idsRaw.split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
await prisma.rentalBooking.updateMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
data: {
|
||||||
|
paymentStatus: PaymentStatus.SUCCEEDED,
|
||||||
|
status: RentalBookingStatus.CONFIRMED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const rentals = await prisma.rentalBooking.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
include: {
|
||||||
|
provider: { select: { name: true } },
|
||||||
|
tenant: { select: { email: true, firstName: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const rb of rentals) {
|
||||||
|
if (!rb.tenant.email) continue;
|
||||||
|
await sendRentalConfirmed(
|
||||||
|
rb.tenant.email,
|
||||||
|
rb.tenant.firstName ?? rb.tenant.email,
|
||||||
|
rb.id,
|
||||||
|
rb.provider.name,
|
||||||
|
rb.startDate,
|
||||||
|
rb.endDate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[webhook.rental] email send failed:", e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "owner_subscription") {
|
if (type === "owner_subscription") {
|
||||||
const ownerId = session.metadata?.ownerId;
|
const ownerId = session.metadata?.ownerId;
|
||||||
const carbetId = session.metadata?.carbetId;
|
const carbetId = session.metadata?.carbetId;
|
||||||
|
|
@ -79,6 +118,27 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
|
|
||||||
async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) {
|
async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) {
|
||||||
const bookingId = paymentIntent.metadata?.bookingId;
|
const bookingId = paymentIntent.metadata?.bookingId;
|
||||||
|
const rentalIdsRaw = paymentIntent.metadata?.rentalBookingIds;
|
||||||
|
|
||||||
|
if (rentalIdsRaw) {
|
||||||
|
const ids = rentalIdsRaw.split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
// Marque les paiements échoués + libère les blocages de dispo
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.rentalBooking.updateMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
data: {
|
||||||
|
paymentStatus: PaymentStatus.FAILED,
|
||||||
|
status: RentalBookingStatus.CANCELLED,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.rentalItemAvailability.deleteMany({
|
||||||
|
where: { rentalBookingId: { in: ids } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!bookingId) {
|
if (!bookingId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
89
src/app/api/uploads/finalize/route.ts
Normal file
89
src/app/api/uploads/finalize/route.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { MediaType, UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { classifyMime } from "@/lib/uploads";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
import { generateImageVariants } from "@/lib/variants-server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
carbetId: z.string().min(1),
|
||||||
|
s3Key: z.string().min(5).max(500),
|
||||||
|
s3Url: z.string().url(),
|
||||||
|
mime: z.string().min(3).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Payload invalide" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const kind = classifyMime(parsed.data.mime);
|
||||||
|
if (!kind) return NextResponse.json({ error: "Type non supporté" }, { status: 400 });
|
||||||
|
|
||||||
|
const carbet = await prisma.carbet.findUnique({
|
||||||
|
where: { id: parsed.data.carbetId },
|
||||||
|
select: { id: true, ownerId: true },
|
||||||
|
});
|
||||||
|
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
|
||||||
|
const isOwner = carbet.ownerId === session.user.id;
|
||||||
|
const isAdmin = session.user.role === UserRole.ADMIN;
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3Key doit appartenir au carbet — verrou pour éviter qu'un user finalise une key étrangère.
|
||||||
|
if (!parsed.data.s3Key.startsWith(`carbets/${carbet.id}/`)) {
|
||||||
|
return NextResponse.json({ error: "s3Key invalide pour ce carbet" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCount = await prisma.media.count({ where: { carbetId: carbet.id } });
|
||||||
|
const media = await prisma.media.create({
|
||||||
|
data: {
|
||||||
|
carbetId: carbet.id,
|
||||||
|
type: kind === "photo" ? MediaType.PHOTO : MediaType.VIDEO,
|
||||||
|
s3Key: parsed.data.s3Key,
|
||||||
|
s3Url: parsed.data.s3Url,
|
||||||
|
sortOrder: existingCount,
|
||||||
|
},
|
||||||
|
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
|
||||||
|
});
|
||||||
|
await recordAudit({
|
||||||
|
scope: "uploads",
|
||||||
|
event: "media.finalize",
|
||||||
|
target: media.id,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: { carbetId: carbet.id, kind },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Génération des variantes responsives (best-effort, n'échoue pas la requête).
|
||||||
|
// L'utilisateur attend quelques secondes mais l'expérience derrière est bien meilleure.
|
||||||
|
try {
|
||||||
|
const variants = await generateImageVariants({
|
||||||
|
originalS3Key: parsed.data.s3Key,
|
||||||
|
mime: parsed.data.mime,
|
||||||
|
});
|
||||||
|
if (!variants.skipped) {
|
||||||
|
const okCount = variants.results.filter((r) => r.ok).length;
|
||||||
|
await recordAudit({
|
||||||
|
scope: "uploads",
|
||||||
|
event: "media.variants",
|
||||||
|
target: media.id,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: { generated: okCount, total: variants.results.length },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[uploads] variants generation error:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ media });
|
||||||
|
}
|
||||||
55
src/app/api/uploads/presign/route.ts
Normal file
55
src/app/api/uploads/presign/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { presignCarbetUpload } from "@/lib/uploads";
|
||||||
|
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
carbetId: z.string().min(1),
|
||||||
|
mime: z.string().min(3).max(100),
|
||||||
|
sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const rl = rateLimitRequest(req, "presign", 60_000, 60);
|
||||||
|
if (!rl.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` },
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Payload invalide" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const carbet = await prisma.carbet.findUnique({
|
||||||
|
where: { id: parsed.data.carbetId },
|
||||||
|
select: { id: true, ownerId: true },
|
||||||
|
});
|
||||||
|
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
|
||||||
|
const isOwner = carbet.ownerId === session.user.id;
|
||||||
|
const isAdmin = session.user.role === UserRole.ADMIN;
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await presignCarbetUpload({
|
||||||
|
carbetId: carbet.id,
|
||||||
|
mime: parsed.data.mime,
|
||||||
|
sizeBytes: parsed.data.sizeBytes,
|
||||||
|
});
|
||||||
|
if ("error" in result) {
|
||||||
|
return NextResponse.json({ error: result.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
105
src/app/api/uploads/rental-finalize/route.ts
Normal file
105
src/app/api/uploads/rental-finalize/route.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { MediaType } from "@/generated/prisma/enums";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { canManageRentalProvider } from "@/lib/rental-access";
|
||||||
|
import { classifyMime } from "@/lib/uploads";
|
||||||
|
import { generateImageVariants } from "@/lib/variants-server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
itemId: z.string().min(1),
|
||||||
|
s3Key: z.string().min(5).max(500),
|
||||||
|
s3Url: z.string().url(),
|
||||||
|
mime: z.string().min(3).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Payload invalide" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const kind = classifyMime(parsed.data.mime);
|
||||||
|
if (!kind) return NextResponse.json({ error: "Type non supporté" }, { status: 400 });
|
||||||
|
|
||||||
|
const item = await prisma.rentalItem.findUnique({
|
||||||
|
where: { id: parsed.data.itemId },
|
||||||
|
select: { id: true, providerId: true },
|
||||||
|
});
|
||||||
|
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
|
||||||
|
|
||||||
|
const allowed = await canManageRentalProvider(
|
||||||
|
session.user.id,
|
||||||
|
session.user.role,
|
||||||
|
item.providerId,
|
||||||
|
session.user.organizationId,
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.data.s3Key.startsWith(`rental-items/${item.id}/`)) {
|
||||||
|
return NextResponse.json({ error: "s3Key invalide pour cet item" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCount = await prisma.rentalItemMedia.count({ where: { itemId: item.id } });
|
||||||
|
const media = await prisma.rentalItemMedia.create({
|
||||||
|
data: {
|
||||||
|
itemId: item.id,
|
||||||
|
type: kind === "photo" ? MediaType.PHOTO : MediaType.VIDEO,
|
||||||
|
s3Key: parsed.data.s3Key,
|
||||||
|
s3Url: parsed.data.s3Url,
|
||||||
|
sortOrder: existingCount,
|
||||||
|
},
|
||||||
|
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Si c'est la première photo de l'item, hydrate imageUrl pour rétro-compat
|
||||||
|
// avec les listings (RentalItemCard, /carbets/[slug] panel).
|
||||||
|
if (existingCount === 0 && kind === "photo") {
|
||||||
|
await prisma.rentalItem.update({
|
||||||
|
where: { id: item.id },
|
||||||
|
data: { imageUrl: parsed.data.s3Url },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
scope: "uploads",
|
||||||
|
event: "rental.media.finalize",
|
||||||
|
target: media.id,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: { itemId: item.id, kind },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const variants = await generateImageVariants({
|
||||||
|
originalS3Key: parsed.data.s3Key,
|
||||||
|
mime: parsed.data.mime,
|
||||||
|
});
|
||||||
|
if (!variants.skipped) {
|
||||||
|
const okCount = variants.results.filter((r) => r.ok).length;
|
||||||
|
await recordAudit({
|
||||||
|
scope: "uploads",
|
||||||
|
event: "rental.media.variants",
|
||||||
|
target: media.id,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: { generated: okCount, total: variants.results.length },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[rental-uploads] variants generation error:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ media });
|
||||||
|
}
|
||||||
63
src/app/api/uploads/rental-presign/route.ts
Normal file
63
src/app/api/uploads/rental-presign/route.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { canManageRentalProvider } from "@/lib/rental-access";
|
||||||
|
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||||
|
import { presignRentalItemUpload } from "@/lib/uploads";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
itemId: z.string().min(1),
|
||||||
|
mime: z.string().min(3).max(100),
|
||||||
|
sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const rl = rateLimitRequest(req, "rental-presign", 60_000, 60);
|
||||||
|
if (!rl.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` },
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Payload invalide" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await prisma.rentalItem.findUnique({
|
||||||
|
where: { id: parsed.data.itemId },
|
||||||
|
select: { id: true, providerId: true },
|
||||||
|
});
|
||||||
|
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
|
||||||
|
|
||||||
|
const allowed = await canManageRentalProvider(
|
||||||
|
session.user.id,
|
||||||
|
session.user.role,
|
||||||
|
item.providerId,
|
||||||
|
session.user.organizationId,
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await presignRentalItemUpload({
|
||||||
|
itemId: item.id,
|
||||||
|
mime: parsed.data.mime,
|
||||||
|
sizeBytes: parsed.data.sizeBytes,
|
||||||
|
});
|
||||||
|
if ("error" in result) {
|
||||||
|
return NextResponse.json({ error: result.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
105
src/app/carbets/[slug]/_components/CompleteYourStay.tsx
Normal file
105
src/app/carbets/[slug]/_components/CompleteYourStay.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
river: string;
|
||||||
|
capacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMOJI: Record<string, string> = {
|
||||||
|
SLEEP: "💤",
|
||||||
|
NAVIGATION: "🛶",
|
||||||
|
FISHING: "🎣",
|
||||||
|
COOKING: "🍳",
|
||||||
|
SAFETY: "🦺",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function CompleteYourStay({ river, capacity }: Props) {
|
||||||
|
if (!(await isPluginEnabled("gear-rental"))) return null;
|
||||||
|
const providers = await prisma.rentalProvider.findMany({
|
||||||
|
where: {
|
||||||
|
active: true,
|
||||||
|
approved: true,
|
||||||
|
OR: [
|
||||||
|
{ isSystemD: true },
|
||||||
|
{ rivers: { has: river } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
items: {
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: [{ category: "asc" }, { pricePerDay: "asc" }],
|
||||||
|
take: 24,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
category: true,
|
||||||
|
imageUrl: true,
|
||||||
|
pricePerDay: true,
|
||||||
|
provider: { select: { name: true, isSystemD: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = providers.flatMap((p) => p.items).slice(0, 9);
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="my-8 rounded-lg border border-emerald-200 bg-emerald-50/40 p-5">
|
||||||
|
<header className="flex items-baseline justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-emerald-900">
|
||||||
|
Compléter votre séjour
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-emerald-800">
|
||||||
|
Pour {capacity} voyageur{capacity > 1 ? "s" : ""} sur le {river},
|
||||||
|
pensez à louer hamacs, moustiquaires, pirogue ou kayak auprès des
|
||||||
|
prestataires locaux.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/materiel" className="text-xs font-semibold text-emerald-800 hover:underline">
|
||||||
|
Voir tout →
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{items.map((it) => (
|
||||||
|
<li
|
||||||
|
key={it.id}
|
||||||
|
className="overflow-hidden rounded-md border border-emerald-100 bg-white shadow-sm"
|
||||||
|
>
|
||||||
|
<Link href={`/materiel/${it.id}`} className="block">
|
||||||
|
<div className="flex aspect-video items-center justify-center bg-emerald-50 text-3xl">
|
||||||
|
{it.imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={it.imageUrl} alt={it.name} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span>{EMOJI[it.category] ?? "🎒"}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-2.5 py-1.5">
|
||||||
|
<p className="truncate text-xs font-semibold text-zinc-900">{it.name}</p>
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-zinc-500">
|
||||||
|
<span>{RENTAL_CATEGORY_LABEL[it.category]}</span>
|
||||||
|
<span className="font-mono font-semibold text-emerald-700">
|
||||||
|
{Number(it.pricePerDay).toFixed(0)} €/j
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{it.provider.isSystemD ? (
|
||||||
|
<span className="mt-1 inline-block rounded-full bg-emerald-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-white">
|
||||||
|
Karbé
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,11 +12,16 @@ import {
|
||||||
import { MediaType, UserRole } from "@/generated/prisma/enums";
|
import { MediaType, UserRole } from "@/generated/prisma/enums";
|
||||||
import { formatAverageRating } from "@/lib/reviews";
|
import { formatAverageRating } from "@/lib/reviews";
|
||||||
|
|
||||||
|
import { isStripeConfigured } from "@/lib/stripe";
|
||||||
|
|
||||||
import { BookingForm } from "../_components/booking-form";
|
import { BookingForm } from "../_components/booking-form";
|
||||||
|
import { CompleteYourStay } from "./_components/CompleteYourStay";
|
||||||
import { CarbetGallery } from "../_components/carbet-gallery";
|
import { CarbetGallery } from "../_components/carbet-gallery";
|
||||||
|
import { CarbetMap } from "../_components/carbet-map";
|
||||||
import { ReviewsSection } from "../_components/reviews-section";
|
import { ReviewsSection } from "../_components/reviews-section";
|
||||||
import { StarRating } from "../_components/star-rating";
|
import { StarRating } from "../_components/star-rating";
|
||||||
import { AccessTypeBadge } from "@/components/AccessTypeBadge";
|
import { AccessTypeBadge } from "@/components/AccessTypeBadge";
|
||||||
|
import { OperationalBadges } from "@/components/OperationalBadges";
|
||||||
import { StayConstraints } from "@/components/StayConstraints";
|
import { StayConstraints } from "@/components/StayConstraints";
|
||||||
import { PirogueTransportBlock } from "@/components/PirogueTransportBlock";
|
import { PirogueTransportBlock } from "@/components/PirogueTransportBlock";
|
||||||
|
|
||||||
|
|
@ -110,6 +115,17 @@ export default async function PublicCarbetPage({ params }: PageProps) {
|
||||||
? ` · Route + ${formatPirogueDuration(carbet.pirogueDurationMin)} pirogue depuis ${carbet.embarkPoint}`
|
? ` · Route + ${formatPirogueDuration(carbet.pirogueDurationMin)} pirogue depuis ${carbet.embarkPoint}`
|
||||||
: ` · Route directe (embarquement ${carbet.embarkPoint})`}
|
: ` · Route directe (embarquement ${carbet.embarkPoint})`}
|
||||||
</p>
|
</p>
|
||||||
|
{carbet.organizations.length > 0 ? (
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
|
Géré par le CE{" "}
|
||||||
|
{carbet.organizations.map((o, i) => (
|
||||||
|
<span key={o.id}>
|
||||||
|
<strong className="text-zinc-700">{o.name}</strong>
|
||||||
|
{i < carbet.organizations.length - 1 ? ", " : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{carbet.reviewStats.count > 0 &&
|
{carbet.reviewStats.count > 0 &&
|
||||||
carbet.reviewStats.averageRating !== null ? (
|
carbet.reviewStats.averageRating !== null ? (
|
||||||
<p className="mt-2 flex items-center gap-2 text-sm text-zinc-700">
|
<p className="mt-2 flex items-center gap-2 text-sm text-zinc-700">
|
||||||
|
|
@ -128,6 +144,20 @@ export default async function PublicCarbetPage({ params }: PageProps) {
|
||||||
<CarbetGallery title={carbet.title} media={carbet.media} />
|
<CarbetGallery title={carbet.title} media={carbet.media} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-3 text-base font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Critères opérationnels
|
||||||
|
</h2>
|
||||||
|
<OperationalBadges
|
||||||
|
roadAccess={carbet.roadAccess}
|
||||||
|
capacity={carbet.capacity}
|
||||||
|
electricity={carbet.electricity}
|
||||||
|
gsmAtCarbet={carbet.gsmAtCarbet}
|
||||||
|
gsmExitDistanceKm={carbet.gsmExitDistanceKm}
|
||||||
|
variant="full"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="mt-10 grid gap-10 lg:grid-cols-3">
|
<div className="mt-10 grid gap-10 lg:grid-cols-3">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -144,6 +174,25 @@ export default async function PublicCarbetPage({ params }: PageProps) {
|
||||||
provider={carbet.pirogueProvider}
|
provider={carbet.pirogueProvider}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<section className="mt-10">
|
||||||
|
<h2 className="text-xl font-semibold text-zinc-900">
|
||||||
|
Où se trouve ce carbet
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600">
|
||||||
|
Fleuve <strong>{carbet.river}</strong> · embarquement à{" "}
|
||||||
|
<strong>{carbet.embarkPoint}</strong>
|
||||||
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<CarbetMap
|
||||||
|
latitude={Number(carbet.latitude)}
|
||||||
|
longitude={Number(carbet.longitude)}
|
||||||
|
title={carbet.title}
|
||||||
|
river={carbet.river}
|
||||||
|
embarkPoint={carbet.embarkPoint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{carbet.amenities.length > 0 ? (
|
{carbet.amenities.length > 0 ? (
|
||||||
<section className="mt-10">
|
<section className="mt-10">
|
||||||
<h2 className="text-xl font-semibold text-zinc-900">
|
<h2 className="text-xl font-semibold text-zinc-900">
|
||||||
|
|
@ -235,10 +284,13 @@ export default async function PublicCarbetPage({ params }: PageProps) {
|
||||||
minStayNights={carbet.minStayNights}
|
minStayNights={carbet.minStayNights}
|
||||||
maxStayNights={carbet.maxStayNights}
|
maxStayNights={carbet.maxStayNights}
|
||||||
isAuthenticated={Boolean(viewerId)}
|
isAuthenticated={Boolean(viewerId)}
|
||||||
|
stripeEnabled={isStripeConfigured()}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CompleteYourStay river={carbet.river} capacity={carbet.capacity} />
|
||||||
|
|
||||||
<ReviewsSection
|
<ReviewsSection
|
||||||
stats={carbet.reviewStats}
|
stats={carbet.reviewStats}
|
||||||
reviews={carbet.reviews}
|
reviews={carbet.reviews}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { MiniCalendar } from "./mini-calendar";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
carbetId: string;
|
carbetId: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|
@ -12,6 +14,7 @@ type Props = {
|
||||||
minStayNights: number | null;
|
minStayNights: number | null;
|
||||||
maxStayNights: number | null;
|
maxStayNights: number | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
stripeEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function todayPlus(n: number): string {
|
function todayPlus(n: number): string {
|
||||||
|
|
@ -36,10 +39,11 @@ export function BookingForm({
|
||||||
minStayNights,
|
minStayNights,
|
||||||
maxStayNights,
|
maxStayNights,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
|
stripeEnabled,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [startDate, setStartDate] = useState(todayPlus(7));
|
const [startDate, setStartDate] = useState<string | null>(null);
|
||||||
const [endDate, setEndDate] = useState(todayPlus(7 + (minStayNights ?? 2)));
|
const [endDate, setEndDate] = useState<string | null>(null);
|
||||||
const [guestCount, setGuestCount] = useState(Math.min(2, capacity));
|
const [guestCount, setGuestCount] = useState(Math.min(2, capacity));
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -64,34 +68,18 @@ export function BookingForm({
|
||||||
return () => ctrl.abort();
|
return () => ctrl.abort();
|
||||||
}, [carbetId]);
|
}, [carbetId]);
|
||||||
|
|
||||||
const nights = useMemo(() => Math.max(0, diffDays(startDate, endDate)), [startDate, endDate]);
|
const nights = useMemo(
|
||||||
|
() => (startDate && endDate ? Math.max(0, diffDays(startDate, endDate)) : 0),
|
||||||
|
[startDate, endDate],
|
||||||
|
);
|
||||||
const total = nights * nightlyPrice;
|
const total = nights * nightlyPrice;
|
||||||
const minN = minStayNights ?? 1;
|
const minN = minStayNights ?? 1;
|
||||||
const maxN = maxStayNights ?? 365;
|
const maxN = maxStayNights ?? 365;
|
||||||
const nightsOk = nights >= minN && nights <= maxN;
|
const datesSelected = Boolean(startDate && endDate);
|
||||||
|
const nightsOk = datesSelected && nights >= minN && nights <= maxN;
|
||||||
const guestOk = guestCount >= 1 && guestCount <= capacity;
|
const guestOk = guestCount >= 1 && guestCount <= capacity;
|
||||||
|
|
||||||
// Vérifie qu'aucun jour de la plage sélectionnée n'est bloqué.
|
const canSubmit = nightsOk && guestOk && !busy;
|
||||||
const conflictDates = useMemo(() => {
|
|
||||||
if (blockedDates.size === 0 || nights === 0) return [];
|
|
||||||
const out: string[] = [];
|
|
||||||
const startMs = new Date(startDate + "T00:00:00Z").getTime();
|
|
||||||
for (let i = 0; i < nights; i++) {
|
|
||||||
const d = new Date(startMs + i * 86400000).toISOString().slice(0, 10);
|
|
||||||
if (blockedDates.has(d)) out.push(d);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}, [blockedDates, startDate, nights]);
|
|
||||||
const hasConflict = conflictDates.length > 0;
|
|
||||||
|
|
||||||
const canSubmit = nightsOk && guestOk && !busy && !hasConflict;
|
|
||||||
|
|
||||||
// Prochaines dates bloquées (max 6) pour affichage informatif.
|
|
||||||
const upcomingBlocked = useMemo(() => {
|
|
||||||
return Array.from(blockedDates)
|
|
||||||
.sort()
|
|
||||||
.slice(0, 6);
|
|
||||||
}, [blockedDates]);
|
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
|
@ -102,6 +90,34 @@ export function BookingForm({
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
if (stripeEnabled) {
|
||||||
|
// Checkout Stripe : crée la résa + une session Checkout, redirige le user.
|
||||||
|
const res = await fetch("/api/stripe/checkout/booking", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
carbetId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
guestCount,
|
||||||
|
amount: nights * nightlyPrice,
|
||||||
|
currency: "EUR",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(json?.error || `Erreur ${res.status}`);
|
||||||
|
}
|
||||||
|
if (json.checkoutUrl) {
|
||||||
|
window.location.assign(json.checkoutUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback si pas d'URL retournée → page de la résa créée.
|
||||||
|
router.push(`/reservations/${json.bookingId ?? ""}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pas de Stripe configuré → flux direct, résa en PENDING manuel.
|
||||||
const res = await fetch("/api/bookings", {
|
const res = await fetch("/api/bookings", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -129,28 +145,34 @@ export function BookingForm({
|
||||||
<span className="text-xs text-zinc-500">jusqu'à {capacity} voyageurs</span>
|
<span className="text-xs text-zinc-500">jusqu'à {capacity} voyageurs</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
<MiniCalendar
|
||||||
<label className="block">
|
startDate={startDate}
|
||||||
<span className="text-xs text-zinc-500">Arrivée</span>
|
endDate={endDate}
|
||||||
<input
|
blockedDates={blockedDates}
|
||||||
type="date"
|
onChange={(s, e) => {
|
||||||
value={startDate}
|
setStartDate(s);
|
||||||
min={todayPlus(0)}
|
setEndDate(e);
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
setError(null);
|
||||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
<label className="block">
|
{datesSelected ? (
|
||||||
<span className="text-xs text-zinc-500">Départ</span>
|
<div className="flex items-center justify-between rounded-md bg-zinc-50 px-3 py-1.5 text-xs text-zinc-700">
|
||||||
<input
|
<span>
|
||||||
type="date"
|
<strong>{startDate}</strong> → <strong>{endDate}</strong>
|
||||||
value={endDate}
|
</span>
|
||||||
min={startDate || todayPlus(1)}
|
<button
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
type="button"
|
||||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
|
onClick={() => {
|
||||||
/>
|
setStartDate(null);
|
||||||
</label>
|
setEndDate(null);
|
||||||
</div>
|
}}
|
||||||
|
className="text-zinc-500 hover:text-zinc-900"
|
||||||
|
>
|
||||||
|
Réinitialiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
<span className="text-xs text-zinc-500">Voyageurs</span>
|
<span className="text-xs text-zinc-500">Voyageurs</span>
|
||||||
|
|
@ -160,54 +182,32 @@ export function BookingForm({
|
||||||
max={capacity}
|
max={capacity}
|
||||||
value={guestCount}
|
value={guestCount}
|
||||||
onChange={(e) => setGuestCount(Math.max(1, Math.min(capacity, Number(e.target.value) || 1)))}
|
onChange={(e) => setGuestCount(Math.max(1, Math.min(capacity, Number(e.target.value) || 1)))}
|
||||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
|
inputMode="numeric"
|
||||||
|
className="mt-0.5 block w-full min-h-[44px] rounded-md border border-zinc-300 px-3 py-2 text-base"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="space-y-1 border-t border-zinc-100 pt-3 text-sm text-zinc-700">
|
{datesSelected ? (
|
||||||
<div className="flex justify-between">
|
<div className="space-y-1 border-t border-zinc-100 pt-3 text-sm text-zinc-700">
|
||||||
<span>
|
<div className="flex justify-between">
|
||||||
{nightlyPrice.toFixed(0)} € × {nights} nuit{nights > 1 ? "s" : ""}
|
<span>
|
||||||
</span>
|
{nightlyPrice.toFixed(0)} € × {nights} nuit{nights > 1 ? "s" : ""}
|
||||||
<span className="font-mono">{(nightlyPrice * nights).toFixed(2)} €</span>
|
</span>
|
||||||
|
<span className="font-mono">{(nightlyPrice * nights).toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-base font-semibold text-zinc-900">
|
||||||
|
<span>Total</span>
|
||||||
|
<span className="font-mono">{total.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-base font-semibold text-zinc-900">
|
) : null}
|
||||||
<span>Total</span>
|
|
||||||
<span className="font-mono">{total.toFixed(2)} €</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!nightsOk && nights > 0 ? (
|
{datesSelected && !nightsOk ? (
|
||||||
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800">
|
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800">
|
||||||
Séjour entre {minN} et {maxN} nuits requis.
|
Séjour entre {minN} et {maxN} nuits requis.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{hasConflict ? (
|
|
||||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">
|
|
||||||
Cette plage chevauche {conflictDates.length} jour{conflictDates.length > 1 ? "s" : ""} déjà
|
|
||||||
pris ou bloqué{conflictDates.length > 1 ? "s" : ""} (
|
|
||||||
{conflictDates.slice(0, 3).join(", ")}
|
|
||||||
{conflictDates.length > 3 ? "…" : ""}). Changez les dates.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{upcomingBlocked.length > 0 && !hasConflict ? (
|
|
||||||
<details className="rounded border border-zinc-100 bg-zinc-50 px-3 py-1.5 text-xs text-zinc-600">
|
|
||||||
<summary className="cursor-pointer">Voir les prochaines dates indisponibles</summary>
|
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
|
||||||
{upcomingBlocked.map((d) => (
|
|
||||||
<code key={d} className="rounded bg-white px-1.5 py-0.5 text-[10px] text-zinc-700">
|
|
||||||
{d}
|
|
||||||
</code>
|
|
||||||
))}
|
|
||||||
{blockedDates.size > upcomingBlocked.length ? (
|
|
||||||
<span className="text-[10px] text-zinc-500">+ {blockedDates.size - upcomingBlocked.length} autres</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">{error}</div>
|
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">{error}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -216,9 +216,15 @@ export function BookingForm({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
className="w-full rounded-md bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
className="w-full min-h-[44px] rounded-md bg-emerald-600 px-4 py-3 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy ? "Envoi…" : isAuthenticated ? "Réserver" : "Se connecter pour réserver"}
|
{busy
|
||||||
|
? "Envoi…"
|
||||||
|
: !isAuthenticated
|
||||||
|
? "Se connecter pour réserver"
|
||||||
|
: stripeEnabled
|
||||||
|
? "Payer et réserver"
|
||||||
|
: "Réserver"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!isAuthenticated ? (
|
{!isAuthenticated ? (
|
||||||
|
|
@ -231,7 +237,9 @@ export function BookingForm({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<p className="text-center text-[11px] text-zinc-500">
|
<p className="text-center text-[11px] text-zinc-500">
|
||||||
Le créneau est bloqué dès l'envoi. Statut « En attente » jusqu'à confirmation du paiement.
|
{stripeEnabled
|
||||||
|
? "Vous serez redirigé vers Stripe pour le paiement sécurisé."
|
||||||
|
: "Le créneau est bloqué dès l'envoi. Statut « En attente » jusqu'à confirmation."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import Link from "next/link";
|
||||||
import type { CarbetSearchResult } from "@/lib/carbet-search";
|
import type { CarbetSearchResult } from "@/lib/carbet-search";
|
||||||
import { formatPirogueDuration, truncate } from "@/lib/format";
|
import { formatPirogueDuration, truncate } from "@/lib/format";
|
||||||
import { formatAverageRating } from "@/lib/reviews";
|
import { formatAverageRating } from "@/lib/reviews";
|
||||||
|
import { buildSrcSet } from "@/lib/image-variants";
|
||||||
import { AccessTypeBadge } from "@/components/AccessTypeBadge";
|
import { AccessTypeBadge } from "@/components/AccessTypeBadge";
|
||||||
|
import { OperationalBadges } from "@/components/OperationalBadges";
|
||||||
import { StayConstraints } from "@/components/StayConstraints";
|
import { StayConstraints } from "@/components/StayConstraints";
|
||||||
|
|
||||||
import { StarRating } from "./star-rating";
|
import { StarRating } from "./star-rating";
|
||||||
|
|
@ -14,13 +16,14 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
|
||||||
<article className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm transition hover:border-emerald-300 hover:shadow-md">
|
<article className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm transition hover:border-emerald-300 hover:shadow-md">
|
||||||
<Link href={href} className="relative block aspect-[4/3] bg-zinc-100">
|
<Link href={href} className="relative block aspect-[4/3] bg-zinc-100">
|
||||||
{carbet.coverUrl ? (
|
{carbet.coverUrl ? (
|
||||||
// Use a plain <img> here — uploaded media URLs come from MinIO/S3 and
|
|
||||||
// don't go through next/image's optimizer in this environment.
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={carbet.coverUrl}
|
src={carbet.coverUrl}
|
||||||
|
srcSet={buildSrcSet(carbet.coverUrl)}
|
||||||
|
sizes="(min-width: 1024px) 320px, (min-width: 640px) 50vw, 100vw"
|
||||||
alt={`Photo de ${carbet.title}`}
|
alt={`Photo de ${carbet.title}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className="h-full w-full object-cover transition group-hover:scale-[1.02]"
|
className="h-full w-full object-cover transition group-hover:scale-[1.02]"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -39,9 +42,18 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
|
||||||
<AccessTypeBadge accessType={carbet.accessType} />
|
<AccessTypeBadge accessType={carbet.accessType} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-zinc-600">
|
<p className="mt-1 text-sm text-zinc-600">
|
||||||
Fleuve {carbet.river} · {carbet.capacity} voyageur
|
Fleuve {carbet.river}
|
||||||
{carbet.capacity > 1 ? "s" : ""}
|
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<OperationalBadges
|
||||||
|
roadAccess={carbet.roadAccess}
|
||||||
|
capacity={carbet.capacity}
|
||||||
|
electricity={carbet.electricity}
|
||||||
|
gsmAtCarbet={carbet.gsmAtCarbet}
|
||||||
|
gsmExitDistanceKm={carbet.gsmExitDistanceKm}
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
<StayConstraints
|
<StayConstraints
|
||||||
minNights={carbet.minStayNights}
|
minNights={carbet.minStayNights}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,46 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { PublicCarbetMedia } from "@/lib/carbet-public";
|
import type { PublicCarbetMedia } from "@/lib/carbet-public";
|
||||||
import { MediaType } from "@/generated/prisma/enums";
|
import { MediaType } from "@/generated/prisma/enums";
|
||||||
|
import { buildSrcSet } from "@/lib/image-variants";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
media: PublicCarbetMedia[];
|
media: PublicCarbetMedia[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// SSR-friendly gallery: shows a cover (photo or video) plus a strip of
|
/**
|
||||||
// secondary media. No client component — all native HTML controls.
|
* Galerie publique : grille de vignettes ; clic = lightbox plein écran avec
|
||||||
|
* navigation prev/next, fermeture par Esc ou clic backdrop. Pas de dep externe.
|
||||||
|
*/
|
||||||
export function CarbetGallery({ title, media }: Props) {
|
export function CarbetGallery({ title, media }: Props) {
|
||||||
|
const [active, setActive] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const close = useCallback(() => setActive(null), []);
|
||||||
|
const next = useCallback(() => {
|
||||||
|
setActive((i) => (i === null ? null : (i + 1) % media.length));
|
||||||
|
}, [media.length]);
|
||||||
|
const prev = useCallback(() => {
|
||||||
|
setActive((i) => (i === null ? null : (i - 1 + media.length) % media.length));
|
||||||
|
}, [media.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active === null) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") close();
|
||||||
|
else if (e.key === "ArrowRight") next();
|
||||||
|
else if (e.key === "ArrowLeft") prev();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [active, close, next, prev]);
|
||||||
|
|
||||||
if (media.length === 0) {
|
if (media.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex aspect-[16/9] w-full items-center justify-center rounded-lg bg-zinc-100 text-sm text-zinc-400">
|
<div className="flex aspect-[16/9] w-full items-center justify-center rounded-lg bg-zinc-100 text-sm text-zinc-400">
|
||||||
|
|
@ -17,57 +49,159 @@ export function CarbetGallery({ title, media }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [cover, ...rest] = media;
|
const cover = media[0];
|
||||||
|
const rest = media.slice(1);
|
||||||
|
const current = active === null ? null : media[active];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<>
|
||||||
<figure className="overflow-hidden rounded-lg bg-zinc-100">
|
<div className="space-y-3">
|
||||||
{cover.type === MediaType.VIDEO ? (
|
<button
|
||||||
<video
|
type="button"
|
||||||
src={cover.url}
|
onClick={() => setActive(0)}
|
||||||
controls
|
className="block w-full overflow-hidden rounded-lg bg-zinc-100 transition hover:opacity-95"
|
||||||
playsInline
|
aria-label="Ouvrir la photo principale en grand"
|
||||||
preload="metadata"
|
>
|
||||||
className="aspect-[16/9] w-full bg-black object-contain"
|
{cover.type === MediaType.VIDEO ? (
|
||||||
/>
|
<video
|
||||||
) : (
|
src={cover.url}
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
controls
|
||||||
<img
|
playsInline
|
||||||
src={cover.url}
|
preload="metadata"
|
||||||
alt={`Photo principale de ${title}`}
|
className="aspect-[16/9] w-full bg-black object-contain"
|
||||||
className="aspect-[16/9] w-full object-cover"
|
/>
|
||||||
/>
|
) : (
|
||||||
)}
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
</figure>
|
<img
|
||||||
|
src={cover.url}
|
||||||
|
srcSet={buildSrcSet(cover.url)}
|
||||||
|
sizes="(min-width: 768px) 800px, 100vw"
|
||||||
|
alt={`Photo principale de ${title}`}
|
||||||
|
fetchPriority="high"
|
||||||
|
decoding="async"
|
||||||
|
className="aspect-[16/9] w-full cursor-zoom-in object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{rest.length > 0 ? (
|
{rest.length > 0 ? (
|
||||||
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
{rest.map((item) => (
|
{rest.map((item, idx) => (
|
||||||
<li
|
<li key={item.id} className="overflow-hidden rounded-md bg-zinc-100">
|
||||||
key={item.id}
|
<button
|
||||||
className="overflow-hidden rounded-md bg-zinc-100"
|
type="button"
|
||||||
>
|
onClick={() => setActive(idx + 1)}
|
||||||
{item.type === MediaType.VIDEO ? (
|
className="block w-full"
|
||||||
<video
|
aria-label="Ouvrir en grand"
|
||||||
src={item.url}
|
>
|
||||||
preload="metadata"
|
{item.type === MediaType.VIDEO ? (
|
||||||
controls
|
<video
|
||||||
playsInline
|
src={item.url}
|
||||||
className="aspect-square w-full bg-black object-contain"
|
preload="metadata"
|
||||||
/>
|
controls
|
||||||
) : (
|
playsInline
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
className="aspect-square w-full bg-black object-contain"
|
||||||
<img
|
/>
|
||||||
src={item.url}
|
) : (
|
||||||
alt={`Média de ${title}`}
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
loading="lazy"
|
<img
|
||||||
className="aspect-square w-full object-cover"
|
src={item.url}
|
||||||
/>
|
srcSet={buildSrcSet(item.url)}
|
||||||
)}
|
sizes="(min-width: 640px) 200px, 50vw"
|
||||||
</li>
|
alt={`Média de ${title}`}
|
||||||
))}
|
loading="lazy"
|
||||||
</ul>
|
decoding="async"
|
||||||
|
className="aspect-square w-full cursor-zoom-in object-cover transition hover:scale-105"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{current ? (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm"
|
||||||
|
onClick={close}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Galerie photo"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={close}
|
||||||
|
className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20"
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M6 6 L18 18 M6 18 L18 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{media.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
prev();
|
||||||
|
}}
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
|
||||||
|
aria-label="Précédent"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M15 6 L9 12 L15 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
next();
|
||||||
|
}}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
|
||||||
|
aria-label="Suivant"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M9 6 L15 12 L9 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="max-h-[88vh] max-w-[92vw]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{current.type === MediaType.VIDEO ? (
|
||||||
|
<video
|
||||||
|
src={current.url}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
className="max-h-[88vh] max-w-[92vw] object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={current.url}
|
||||||
|
srcSet={buildSrcSet(current.url)}
|
||||||
|
sizes="(min-width: 1200px) 1600px, 92vw"
|
||||||
|
alt={`Photo ${active! + 1} sur ${media.length} de ${title}`}
|
||||||
|
fetchPriority="high"
|
||||||
|
decoding="async"
|
||||||
|
className="max-h-[88vh] max-w-[92vw] object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white">
|
||||||
|
{active! + 1} / {media.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
src/app/carbets/_components/carbet-map-inner.tsx
Normal file
74
src/app/carbets/_components/carbet-map-inner.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
// Fix icône Leaflet (les paths par défaut pointent vers un CDN qui n'existe plus).
|
||||||
|
// On utilise un SVG inline en data URL.
|
||||||
|
const ICON = L.divIcon({
|
||||||
|
className: "karbe-leaflet-marker",
|
||||||
|
html: `
|
||||||
|
<div style="
|
||||||
|
width:32px;height:32px;
|
||||||
|
transform:translate(-50%,-100%);
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
">
|
||||||
|
<svg viewBox="0 0 32 40" width="32" height="40" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16 0 C7 0 0 7 0 16 C0 26 16 40 16 40 C16 40 32 26 32 16 C32 7 25 0 16 0 Z"
|
||||||
|
fill="#059669" stroke="#064e3b" stroke-width="1.5"/>
|
||||||
|
<circle cx="16" cy="15" r="5" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
iconSize: [32, 40],
|
||||||
|
iconAnchor: [16, 40],
|
||||||
|
popupAnchor: [0, -36],
|
||||||
|
});
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
title: string;
|
||||||
|
river: string;
|
||||||
|
embarkPoint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CarbetMapInner({ latitude, longitude, title, river, embarkPoint }: Props) {
|
||||||
|
const position: [number, number] = [latitude, longitude];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-zinc-200">
|
||||||
|
<MapContainer
|
||||||
|
center={position}
|
||||||
|
zoom={11}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
style={{ height: 280, width: "100%" }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<Marker position={position} icon={ICON}>
|
||||||
|
<Popup>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<br />
|
||||||
|
<span className="text-xs">Fleuve {river}</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-xs text-zinc-600">Embarquement : {embarkPoint}</span>
|
||||||
|
<br />
|
||||||
|
<a
|
||||||
|
href={`https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=14/${latitude}/${longitude}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xs text-emerald-700 underline"
|
||||||
|
>
|
||||||
|
Ouvrir dans OpenStreetMap ↗
|
||||||
|
</a>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/app/carbets/_components/carbet-map.tsx
Normal file
31
src/app/carbets/_components/carbet-map.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte interactive sur la fiche carbet — Leaflet + OpenStreetMap.
|
||||||
|
*
|
||||||
|
* Chargée dynamiquement (ssr:false) car Leaflet manipule window.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const CarbetMapInner = dynamic(
|
||||||
|
() => import("./carbet-map-inner").then((m) => m.CarbetMapInner),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="h-[280px] w-full animate-pulse rounded-lg bg-zinc-100" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
title: string;
|
||||||
|
river: string;
|
||||||
|
embarkPoint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CarbetMap(props: Props) {
|
||||||
|
return <CarbetMapInner {...props} />;
|
||||||
|
}
|
||||||
113
src/app/carbets/_components/catalog-map-inner.tsx
Normal file
113
src/app/carbets/_components/catalog-map-inner.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
|
||||||
|
import L, { LatLngBoundsExpression } from "leaflet";
|
||||||
|
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
import type { CatalogMapPoint } from "./catalog-map";
|
||||||
|
|
||||||
|
const ICON = L.divIcon({
|
||||||
|
className: "karbe-catalog-marker",
|
||||||
|
html: `
|
||||||
|
<div style="
|
||||||
|
width:28px;height:36px;
|
||||||
|
transform:translate(-50%,-100%);
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
">
|
||||||
|
<svg viewBox="0 0 32 40" width="28" height="36" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16 0 C7 0 0 7 0 16 C0 26 16 40 16 40 C16 40 32 26 32 16 C32 7 25 0 16 0 Z"
|
||||||
|
fill="#059669" stroke="#064e3b" stroke-width="1.5"/>
|
||||||
|
<circle cx="16" cy="15" r="5" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
iconSize: [28, 36],
|
||||||
|
iconAnchor: [14, 36],
|
||||||
|
popupAnchor: [0, -32],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function CatalogMapInner({ points }: { points: CatalogMapPoint[] }) {
|
||||||
|
const bounds = useMemo<LatLngBoundsExpression>(() => {
|
||||||
|
if (points.length === 0) {
|
||||||
|
// Centre par défaut sur la Guyane (Cayenne).
|
||||||
|
return [
|
||||||
|
[3.5, -54.5],
|
||||||
|
[5.5, -52.0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const lats = points.map((p) => p.latitude);
|
||||||
|
const lngs = points.map((p) => p.longitude);
|
||||||
|
const minLat = Math.min(...lats);
|
||||||
|
const maxLat = Math.max(...lats);
|
||||||
|
const minLng = Math.min(...lngs);
|
||||||
|
const maxLng = Math.max(...lngs);
|
||||||
|
// Padding 0.1°
|
||||||
|
return [
|
||||||
|
[minLat - 0.1, minLng - 0.1],
|
||||||
|
[maxLat + 0.1, maxLng + 0.1],
|
||||||
|
];
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-zinc-200">
|
||||||
|
<MapContainer
|
||||||
|
bounds={bounds}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
style={{ height: 360, width: "100%" }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
{points.map((p) => (
|
||||||
|
<Marker key={p.id} position={[p.latitude, p.longitude]} icon={ICON}>
|
||||||
|
<Popup>
|
||||||
|
<div style={{ minWidth: 180 }}>
|
||||||
|
{p.coverUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={p.coverUrl}
|
||||||
|
alt={p.title}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 110,
|
||||||
|
objectFit: "cover",
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<strong>{p.title}</strong>
|
||||||
|
<br />
|
||||||
|
<span style={{ fontSize: 11, color: "#71717a" }}>
|
||||||
|
Fleuve {p.river}
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{Number(p.nightlyPrice).toFixed(0)} €
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: "#71717a" }}> / nuit</span>
|
||||||
|
<br />
|
||||||
|
<Link
|
||||||
|
href={`/carbets/${p.slug}`}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
marginTop: 6,
|
||||||
|
color: "#059669",
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Voir la fiche →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/app/carbets/_components/catalog-map.tsx
Normal file
29
src/app/carbets/_components/catalog-map.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const CatalogMapInner = dynamic(
|
||||||
|
() => import("./catalog-map-inner").then((m) => m.CatalogMapInner),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="h-[360px] w-full animate-pulse rounded-lg bg-zinc-100" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CatalogMapPoint = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
river: string;
|
||||||
|
nightlyPrice: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
coverUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CatalogMap({ points }: { points: CatalogMapPoint[] }) {
|
||||||
|
if (points.length === 0) return null;
|
||||||
|
return <CatalogMapInner points={points} />;
|
||||||
|
}
|
||||||
186
src/app/carbets/_components/mini-calendar.tsx
Normal file
186
src/app/carbets/_components/mini-calendar.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
blockedDates: Set<string>;
|
||||||
|
onChange: (start: string | null, end: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MONTH_LABEL = [
|
||||||
|
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
|
||||||
|
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
|
||||||
|
];
|
||||||
|
const DOW_LABEL = ["L", "M", "M", "J", "V", "S", "D"];
|
||||||
|
|
||||||
|
function isoDay(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfMonth(d: Date): Date {
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(d: Date, n: number): Date {
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Génère la grille du mois : 6 semaines × 7 jours, en commençant un lundi. */
|
||||||
|
function monthGrid(monthStart: Date): (Date | null)[] {
|
||||||
|
const year = monthStart.getUTCFullYear();
|
||||||
|
const month = monthStart.getUTCMonth();
|
||||||
|
// Premier jour du mois — décale pour que la semaine commence un lundi (0=L, 6=D)
|
||||||
|
const firstDay = new Date(Date.UTC(year, month, 1));
|
||||||
|
const firstDow = (firstDay.getUTCDay() + 6) % 7;
|
||||||
|
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||||||
|
const cells: (Date | null)[] = [];
|
||||||
|
for (let i = 0; i < firstDow; i++) cells.push(null);
|
||||||
|
for (let d = 1; d <= lastDay; d++) {
|
||||||
|
cells.push(new Date(Date.UTC(year, month, d)));
|
||||||
|
}
|
||||||
|
while (cells.length % 7 !== 0) cells.push(null);
|
||||||
|
// Toujours 6 lignes pour éviter le saut de hauteur
|
||||||
|
while (cells.length < 42) cells.push(null);
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MiniCalendar({ startDate, endDate, blockedDates, onChange }: Props) {
|
||||||
|
const today = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [viewMonth, setViewMonth] = useState<Date>(() => {
|
||||||
|
const ref = startDate ? new Date(startDate + "T00:00:00Z") : today;
|
||||||
|
return startOfMonth(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
const cells = useMemo(() => monthGrid(viewMonth), [viewMonth]);
|
||||||
|
|
||||||
|
const startISO = startDate;
|
||||||
|
const endISO = endDate;
|
||||||
|
|
||||||
|
function onClick(day: Date) {
|
||||||
|
const iso = isoDay(day);
|
||||||
|
if (day.getTime() < today.getTime()) return;
|
||||||
|
if (blockedDates.has(iso)) return;
|
||||||
|
|
||||||
|
// Aucune sélection ou les deux déjà posées → reset + nouvelle start
|
||||||
|
if (!startISO || (startISO && endISO)) {
|
||||||
|
onChange(iso, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Une seule (start) déjà sélectionnée
|
||||||
|
if (iso === startISO) {
|
||||||
|
onChange(null, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (iso < startISO) {
|
||||||
|
onChange(iso, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Vérifie qu'aucun jour intermédiaire n'est bloqué
|
||||||
|
const startMs = new Date(startISO + "T00:00:00Z").getTime();
|
||||||
|
const endMs = day.getTime();
|
||||||
|
for (let t = startMs; t < endMs; t += 86_400_000) {
|
||||||
|
const d = new Date(t).toISOString().slice(0, 10);
|
||||||
|
if (blockedDates.has(d)) {
|
||||||
|
// Tombe sur un jour bloqué → on resélectionne start
|
||||||
|
onChange(iso, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange(startISO, iso);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canGoBack = viewMonth > startOfMonth(today);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-zinc-200 bg-white p-2">
|
||||||
|
<header className="mb-1 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canGoBack}
|
||||||
|
onClick={() => setViewMonth(addMonths(viewMonth, -1))}
|
||||||
|
className="rounded p-1 text-zinc-600 hover:bg-zinc-100 disabled:opacity-30"
|
||||||
|
aria-label="Mois précédent"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M15 6 L9 12 L15 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-semibold text-zinc-900">
|
||||||
|
{MONTH_LABEL[viewMonth.getUTCMonth()]} {viewMonth.getUTCFullYear()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMonth(addMonths(viewMonth, 1))}
|
||||||
|
className="rounded p-1 text-zinc-600 hover:bg-zinc-100"
|
||||||
|
aria-label="Mois suivant"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M9 6 L15 12 L9 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-0.5 text-center text-[10px] uppercase tracking-wider text-zinc-400">
|
||||||
|
{DOW_LABEL.map((d, i) => (
|
||||||
|
<div key={i} className="py-0.5">
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-0.5">
|
||||||
|
{cells.map((cell, i) => {
|
||||||
|
if (!cell) return <div key={i} className="h-7" />;
|
||||||
|
const iso = isoDay(cell);
|
||||||
|
const isPast = cell.getTime() < today.getTime();
|
||||||
|
const isBlocked = blockedDates.has(iso);
|
||||||
|
const isStart = iso === startISO;
|
||||||
|
const isEnd = iso === endISO;
|
||||||
|
const inRange = startISO && endISO && iso > startISO && iso < endISO;
|
||||||
|
const isToday = iso === isoDay(today);
|
||||||
|
const disabled = isPast || isBlocked;
|
||||||
|
|
||||||
|
let cls =
|
||||||
|
"relative h-7 rounded text-xs flex items-center justify-center transition";
|
||||||
|
if (disabled) {
|
||||||
|
cls += " text-zinc-300 cursor-not-allowed";
|
||||||
|
if (isBlocked && !isPast) cls += " line-through";
|
||||||
|
} else if (isStart || isEnd) {
|
||||||
|
cls += " bg-emerald-600 text-white font-semibold cursor-pointer";
|
||||||
|
} else if (inRange) {
|
||||||
|
cls += " bg-emerald-100 text-emerald-900 cursor-pointer";
|
||||||
|
} else {
|
||||||
|
cls += " text-zinc-800 hover:bg-zinc-100 cursor-pointer";
|
||||||
|
if (isToday) cls += " ring-1 ring-zinc-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onClick(cell)}
|
||||||
|
className={cls}
|
||||||
|
>
|
||||||
|
{cell.getUTCDate()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-[11px] text-zinc-500">
|
||||||
|
{!startISO
|
||||||
|
? "Choisissez votre date d'arrivée."
|
||||||
|
: !endISO
|
||||||
|
? "Choisissez votre date de départ."
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import type { CarbetSearchFilters } from "@/lib/carbet-search";
|
import type { CarbetSearchFilters } from "@/lib/carbet-search";
|
||||||
|
import { AMENITY_CATALOG } from "@/lib/amenities";
|
||||||
|
import { Electricity, RoadAccess } from "@/generated/prisma/enums";
|
||||||
|
|
||||||
type SearchFiltersProps = {
|
type SearchFiltersProps = {
|
||||||
filters: CarbetSearchFilters;
|
filters: CarbetSearchFilters;
|
||||||
|
|
@ -61,18 +63,165 @@ export function SearchFilters({ filters, rivers }: SearchFiltersProps) {
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-zinc-700">Voyageurs</span>
|
<span className="font-medium text-zinc-700">Voyageurs min</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
name="capacity"
|
name="capacity"
|
||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
defaultValue={filters.capacity ?? ""}
|
defaultValue={filters.capacity ?? ""}
|
||||||
placeholder="Nombre min."
|
placeholder="Au moins"
|
||||||
className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-900 focus:border-emerald-500 focus:outline-none"
|
className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-900 focus:border-emerald-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium text-zinc-700">Voyageurs max</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="capacityMax"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
defaultValue={filters.capacityMax ?? ""}
|
||||||
|
placeholder="Au plus"
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-900 focus:border-emerald-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium text-zinc-700">Budget max (€/nuit)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="priceMax"
|
||||||
|
min={1}
|
||||||
|
step="10"
|
||||||
|
defaultValue={filters.priceMax ?? ""}
|
||||||
|
placeholder="ex. 100"
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-900 focus:border-emerald-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col gap-1 text-sm sm:col-span-2 lg:col-span-5">
|
||||||
|
<legend className="font-medium text-zinc-700">Accès route</legend>
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
{[
|
||||||
|
{ value: RoadAccess.ALL_YEAR, label: "🛣️ Route toute saison" },
|
||||||
|
{ value: RoadAccess.DRY_SEASON_ONLY, label: "🟠 Route saison sèche" },
|
||||||
|
{ value: RoadAccess.NONE, label: "🛶 Pirogue uniquement" },
|
||||||
|
].map((opt) => {
|
||||||
|
const checked = (filters.roadAccess ?? []).includes(opt.value);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className={
|
||||||
|
"flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs " +
|
||||||
|
(checked
|
||||||
|
? "border-emerald-600 bg-emerald-50 text-emerald-900"
|
||||||
|
: "border-zinc-300 bg-white text-zinc-700 hover:border-zinc-400")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="roadAccess"
|
||||||
|
value={opt.value}
|
||||||
|
defaultChecked={checked}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col gap-1 text-sm sm:col-span-2 lg:col-span-5">
|
||||||
|
<legend className="font-medium text-zinc-700">Électricité</legend>
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
{[
|
||||||
|
{ value: Electricity.EDF, label: "⚡ EDF / raccordé" },
|
||||||
|
{ value: Electricity.GENERATOR_READY, label: "🔌 Préinstall groupe" },
|
||||||
|
{ value: Electricity.SOLAR, label: "☀️ Solaire" },
|
||||||
|
{ value: Electricity.NONE, label: "🕯️ Aucune" },
|
||||||
|
].map((opt) => {
|
||||||
|
const checked = (filters.electricity ?? []).includes(opt.value);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className={
|
||||||
|
"flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs " +
|
||||||
|
(checked
|
||||||
|
? "border-emerald-600 bg-emerald-50 text-emerald-900"
|
||||||
|
: "border-zinc-300 bg-white text-zinc-700 hover:border-zinc-400")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="electricity"
|
||||||
|
value={opt.value}
|
||||||
|
defaultChecked={checked}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1 text-sm sm:col-span-2 lg:col-span-5">
|
||||||
|
<span className="font-medium text-zinc-700">
|
||||||
|
📶 Réseau GSM accessible — distance max{" "}
|
||||||
|
<span className="font-mono text-emerald-700">
|
||||||
|
{filters.gsmMaxKm === 0 ? "(au carbet)" : filters.gsmMaxKm ? `≤ ${filters.gsmMaxKm} km` : "(non filtré)"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-zinc-500">Au carbet</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
name="gsmMaxKm"
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
step={0.5}
|
||||||
|
defaultValue={filters.gsmMaxKm ?? ""}
|
||||||
|
className="flex-1 accent-emerald-600"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-zinc-500">10 km</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-zinc-500">
|
||||||
|
0 km = exige le réseau directement au carbet · 10 km = peu importe.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col gap-1 text-sm sm:col-span-2 lg:col-span-5">
|
||||||
|
<legend className="font-medium text-zinc-700">Équipements souhaités</legend>
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
{AMENITY_CATALOG.map((a) => {
|
||||||
|
const checked = (filters.amenities ?? []).includes(a.key);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={a.key}
|
||||||
|
className={
|
||||||
|
"flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs " +
|
||||||
|
(checked
|
||||||
|
? "border-emerald-600 bg-emerald-50 text-emerald-900"
|
||||||
|
: "border-zinc-300 bg-white text-zinc-700 hover:border-zinc-400")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="amenities"
|
||||||
|
value={a.key}
|
||||||
|
defaultChecked={checked}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
{a.label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5 lg:justify-end">
|
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5 lg:justify-end">
|
||||||
<Link
|
<Link
|
||||||
href="/carbets"
|
href="/carbets"
|
||||||
|
|
|
||||||
29
src/app/carbets/_components/search-profiles.tsx
Normal file
29
src/app/carbets/_components/search-profiles.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { SEARCH_PROFILES, buildProfileUrl } from "@/lib/search-profiles";
|
||||||
|
|
||||||
|
export function SearchProfiles() {
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="mb-2 text-xs uppercase tracking-wider text-zinc-500">
|
||||||
|
Profils de séjour
|
||||||
|
</div>
|
||||||
|
<ul className="-mx-1 flex flex-wrap gap-1.5 px-1">
|
||||||
|
{SEARCH_PROFILES.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<Link
|
||||||
|
href={buildProfileUrl(p.id)}
|
||||||
|
title={p.description}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-zinc-200 bg-white px-3 py-1.5 text-sm text-zinc-800 transition hover:border-emerald-400 hover:bg-emerald-50 hover:text-emerald-900"
|
||||||
|
>
|
||||||
|
<span aria-hidden>{p.emoji}</span>
|
||||||
|
<span className="font-medium">{p.label}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,9 @@ import {
|
||||||
} from "@/lib/carbet-search";
|
} from "@/lib/carbet-search";
|
||||||
|
|
||||||
import { CarbetCard } from "./_components/carbet-card";
|
import { CarbetCard } from "./_components/carbet-card";
|
||||||
|
import { CatalogMap } from "./_components/catalog-map";
|
||||||
import { SearchFilters } from "./_components/search-filters";
|
import { SearchFilters } from "./_components/search-filters";
|
||||||
|
import { SearchProfiles } from "./_components/search-profiles";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Rechercher un carbet",
|
title: "Rechercher un carbet",
|
||||||
|
|
@ -56,6 +58,7 @@ export default async function CarbetsSearchPage({
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<SearchProfiles />
|
||||||
<SearchFilters filters={filters} rivers={rivers} />
|
<SearchFilters filters={filters} rivers={rivers} />
|
||||||
|
|
||||||
<section className="mt-8" aria-live="polite">
|
<section className="mt-8" aria-live="polite">
|
||||||
|
|
@ -72,6 +75,20 @@ export default async function CarbetsSearchPage({
|
||||||
{results.length} carbet{results.length > 1 ? "s" : ""} trouvé
|
{results.length} carbet{results.length > 1 ? "s" : ""} trouvé
|
||||||
{results.length > 1 ? "s" : ""}.
|
{results.length > 1 ? "s" : ""}.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mb-6">
|
||||||
|
<CatalogMap
|
||||||
|
points={results.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
slug: c.slug,
|
||||||
|
title: c.title,
|
||||||
|
river: c.river,
|
||||||
|
nightlyPrice: c.nightlyPrice,
|
||||||
|
latitude: c.latitude,
|
||||||
|
longitude: c.longitude,
|
||||||
|
coverUrl: c.coverUrl,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{results.map((carbet) => (
|
{results.map((carbet) => (
|
||||||
<li key={carbet.id}>
|
<li key={carbet.id}>
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,11 @@ export default async function SignInPage({ searchParams }: Props) {
|
||||||
>
|
>
|
||||||
Se connecter
|
Se connecter
|
||||||
</button>
|
</button>
|
||||||
|
<p className="text-center text-xs text-zinc-500">
|
||||||
|
<Link href="/mot-de-passe-oublie" className="hover:text-zinc-900 underline">
|
||||||
|
Mot de passe oublié ?
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
<p className="border-t border-zinc-100 pt-3 text-center text-sm text-zinc-500">
|
<p className="border-t border-zinc-100 pt-3 text-center text-sm text-zinc-500">
|
||||||
Pas encore de compte ?{" "}
|
Pas encore de compte ?{" "}
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
403
src/app/decouvrir/_components/ReelSlide.tsx
Normal file
403
src/app/decouvrir/_components/ReelSlide.tsx
Normal file
|
|
@ -0,0 +1,403 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import type { ReelCarbet } from "@/lib/reels";
|
||||||
|
import { buildSrcSet } from "@/lib/image-variants";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
carbet: ReelCarbet;
|
||||||
|
isActive: boolean;
|
||||||
|
shouldPreload: boolean;
|
||||||
|
isFavorite: boolean;
|
||||||
|
onToggleFavorite: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SWIPE_THRESHOLD_RATIO = 0.18; // % de la largeur pour valider le swipe
|
||||||
|
const VELOCITY_THRESHOLD = 0.4; // px/ms — un flick rapide même court valide
|
||||||
|
|
||||||
|
export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggleFavorite }: Props) {
|
||||||
|
const [mediaIndex, setMediaIndex] = useState(0);
|
||||||
|
const [muted, setMuted] = useState(true);
|
||||||
|
const [dragX, setDragX] = useState(0);
|
||||||
|
const [transitioning, setTransitioning] = useState(false);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const videoRefs = useRef<Map<number, HTMLVideoElement>>(new Map());
|
||||||
|
const drag = useRef<{
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
startTime: number;
|
||||||
|
locked: "horizontal" | "vertical" | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const total = carbet.media.length;
|
||||||
|
const current = carbet.media[mediaIndex];
|
||||||
|
|
||||||
|
const goTo = useCallback(
|
||||||
|
(next: number, animated = true) => {
|
||||||
|
const clamped = ((next % total) + total) % total;
|
||||||
|
setTransitioning(animated);
|
||||||
|
setMediaIndex(clamped);
|
||||||
|
setDragX(0);
|
||||||
|
},
|
||||||
|
[total],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextMedia = useCallback(() => goTo(mediaIndex + 1), [goTo, mediaIndex]);
|
||||||
|
const prevMedia = useCallback(() => goTo(mediaIndex - 1), [goTo, mediaIndex]);
|
||||||
|
|
||||||
|
// Suit la largeur du container pour les calculs de seuils / progress
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const update = () => setContainerWidth(el.offsetWidth || window.innerWidth);
|
||||||
|
update();
|
||||||
|
const ro = new ResizeObserver(update);
|
||||||
|
ro.observe(el);
|
||||||
|
window.addEventListener("resize", update);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener("resize", update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-play/pause vidéos selon média actif
|
||||||
|
useEffect(() => {
|
||||||
|
videoRefs.current.forEach((video, idx) => {
|
||||||
|
if (idx === mediaIndex && isActive && carbet.media[idx]?.type === "VIDEO") {
|
||||||
|
video.play().catch(() => {});
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [isActive, mediaIndex, carbet.media]);
|
||||||
|
|
||||||
|
// Reset au changement de slide carbet (différé pour éviter cascading renders)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) return;
|
||||||
|
queueMicrotask(() => goTo(0, false));
|
||||||
|
}, [isActive, goTo]);
|
||||||
|
|
||||||
|
// Navigation clavier ← →
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||||
|
if (tag === "input" || tag === "textarea") return;
|
||||||
|
if (e.key === "ArrowRight" || e.key === "l") {
|
||||||
|
e.preventDefault();
|
||||||
|
nextMedia();
|
||||||
|
} else if (e.key === "ArrowLeft" || e.key === "h") {
|
||||||
|
e.preventDefault();
|
||||||
|
prevMedia();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [isActive, nextMedia, prevMedia]);
|
||||||
|
|
||||||
|
function onTouchStart(e: React.TouchEvent) {
|
||||||
|
const t = e.touches[0];
|
||||||
|
drag.current = {
|
||||||
|
startX: t.clientX,
|
||||||
|
startY: t.clientY,
|
||||||
|
startTime: Date.now(),
|
||||||
|
locked: null,
|
||||||
|
};
|
||||||
|
setTransitioning(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: React.TouchEvent) {
|
||||||
|
if (!drag.current) return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
const dx = t.clientX - drag.current.startX;
|
||||||
|
const dy = t.clientY - drag.current.startY;
|
||||||
|
|
||||||
|
// Première détection : verrouille l'axe (horizontal ou vertical)
|
||||||
|
if (drag.current.locked === null) {
|
||||||
|
if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return; // trop petit, attend
|
||||||
|
drag.current.locked = Math.abs(dx) > Math.abs(dy) ? "horizontal" : "vertical";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drag.current.locked !== "horizontal") return;
|
||||||
|
// Empêche le scroll vertical pendant un swipe horizontal
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
|
||||||
|
// Résistance aux bords : si on swipe gauche sur le 1er ou droite sur le dernier,
|
||||||
|
// on glisse moins (effet rubber-band)
|
||||||
|
let effective = dx;
|
||||||
|
if (total <= 1) {
|
||||||
|
effective = dx * 0.2;
|
||||||
|
} else if (mediaIndex === 0 && dx > 0) {
|
||||||
|
effective = dx * 0.35;
|
||||||
|
} else if (mediaIndex === total - 1 && dx < 0) {
|
||||||
|
effective = dx * 0.35;
|
||||||
|
}
|
||||||
|
setDragX(effective);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (!drag.current) return;
|
||||||
|
const wasHorizontal = drag.current.locked === "horizontal";
|
||||||
|
const elapsed = Date.now() - drag.current.startTime;
|
||||||
|
const width = containerWidth || window.innerWidth;
|
||||||
|
const velocity = Math.abs(dragX) / Math.max(1, elapsed); // px/ms
|
||||||
|
drag.current = null;
|
||||||
|
|
||||||
|
if (!wasHorizontal) {
|
||||||
|
setDragX(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = Math.abs(dragX);
|
||||||
|
const isFlick = velocity > VELOCITY_THRESHOLD && distance > 20;
|
||||||
|
const isSlow = distance > width * SWIPE_THRESHOLD_RATIO;
|
||||||
|
const shouldChange = (isFlick || isSlow) && total > 1;
|
||||||
|
|
||||||
|
if (shouldChange) {
|
||||||
|
if (dragX < 0 && mediaIndex < total - 1) {
|
||||||
|
goTo(mediaIndex + 1);
|
||||||
|
} else if (dragX > 0 && mediaIndex > 0) {
|
||||||
|
goTo(mediaIndex - 1);
|
||||||
|
} else {
|
||||||
|
// Bord : retour à 0
|
||||||
|
setTransitioning(true);
|
||||||
|
setDragX(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTransitioning(true);
|
||||||
|
setDragX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Préchargement intelligent : current, current ± 1
|
||||||
|
const preloadIndexes = useMemo(() => {
|
||||||
|
const s = new Set<number>();
|
||||||
|
s.add(mediaIndex);
|
||||||
|
if (mediaIndex > 0) s.add(mediaIndex - 1);
|
||||||
|
if (mediaIndex < total - 1) s.add(mediaIndex + 1);
|
||||||
|
return s;
|
||||||
|
}, [mediaIndex, total]);
|
||||||
|
|
||||||
|
const share = useCallback(async () => {
|
||||||
|
const url = `${window.location.origin}/carbets/${carbet.slug}`;
|
||||||
|
const title = carbet.title;
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({ title, url }).catch(() => {});
|
||||||
|
} else {
|
||||||
|
navigator.clipboard?.writeText(url).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [carbet.slug, carbet.title]);
|
||||||
|
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
const offsetPct = -mediaIndex * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full overflow-hidden bg-black">
|
||||||
|
{/* Track : tous les médias en ligne, transformX selon index + drag */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="absolute inset-0 flex"
|
||||||
|
style={{
|
||||||
|
width: `${total * 100}%`,
|
||||||
|
transform: `translateX(calc(${offsetPct / total}% + ${dragX}px))`,
|
||||||
|
transition: transitioning ? "transform 320ms cubic-bezier(0.22, 0.61, 0.36, 1)" : "none",
|
||||||
|
touchAction: "pan-y",
|
||||||
|
}}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
onTransitionEnd={() => setTransitioning(false)}
|
||||||
|
>
|
||||||
|
{carbet.media.map((m, idx) => {
|
||||||
|
const visible = preloadIndexes.has(idx) || shouldPreload;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="relative flex h-full shrink-0 items-center justify-center"
|
||||||
|
style={{ width: `${100 / total}%` }}
|
||||||
|
aria-hidden={idx !== mediaIndex}
|
||||||
|
>
|
||||||
|
{m.type === "VIDEO" ? (
|
||||||
|
<video
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) videoRefs.current.set(idx, el);
|
||||||
|
else videoRefs.current.delete(idx);
|
||||||
|
}}
|
||||||
|
src={visible ? m.url : undefined}
|
||||||
|
muted={muted}
|
||||||
|
playsInline
|
||||||
|
loop
|
||||||
|
preload={visible ? "auto" : "none"}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={visible ? m.url : undefined}
|
||||||
|
srcSet={visible ? buildSrcSet(m.url) : undefined}
|
||||||
|
sizes="(min-width: 768px) 800px, 100vw"
|
||||||
|
alt={`${carbet.title} — média ${idx + 1}`}
|
||||||
|
loading={idx === mediaIndex ? "eager" : "lazy"}
|
||||||
|
fetchPriority={idx === mediaIndex ? "high" : "auto"}
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
className="h-full w-full select-none object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voile dégradé en bas pour lisibilité */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
|
||||||
|
|
||||||
|
{/* Indicateurs progression médias (sticks en haut) */}
|
||||||
|
{total > 1 ? (
|
||||||
|
<div className="pointer-events-none absolute left-3 right-3 top-12 flex gap-1">
|
||||||
|
{carbet.media.map((_, i) => {
|
||||||
|
const isActiveStick = i === mediaIndex;
|
||||||
|
const wasSeen = i < mediaIndex;
|
||||||
|
// Progression visuelle pendant le drag (preview du swipe)
|
||||||
|
const progress = isActiveStick && Math.abs(dragX) > 0 && containerWidth > 0
|
||||||
|
? Math.min(1, Math.abs(dragX) / containerWidth)
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={
|
||||||
|
"relative h-0.5 flex-1 overflow-hidden rounded-full " +
|
||||||
|
(isActiveStick ? "bg-white/30" : wasSeen ? "bg-white/60" : "bg-white/30")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"absolute inset-y-0 left-0 bg-white " +
|
||||||
|
(isActiveStick ? "w-full" : wasSeen ? "w-full" : "w-0")
|
||||||
|
}
|
||||||
|
style={progress > 0 ? { width: `${progress * 100}%` } : undefined}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Zones tap horizontales (50/50) sur desktop */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={prevMedia}
|
||||||
|
className="absolute inset-y-0 left-0 z-10 hidden w-1/3 cursor-default md:block"
|
||||||
|
aria-label="Média précédent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={nextMedia}
|
||||||
|
className="absolute inset-y-0 right-0 z-10 hidden w-1/3 cursor-default md:block"
|
||||||
|
aria-label="Média suivant"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sidebar boutons droite (mobile) */}
|
||||||
|
<div className="absolute bottom-32 right-3 z-20 flex flex-col items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleFavorite}
|
||||||
|
className="flex flex-col items-center text-white"
|
||||||
|
aria-label={isFavorite ? "Retirer des favoris" : "Ajouter aux favoris"}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"flex h-12 w-12 items-center justify-center rounded-full backdrop-blur transition " +
|
||||||
|
(isFavorite ? "bg-rose-500/90" : "bg-white/10 hover:bg-white/20")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill={isFavorite ? "white" : "none"} stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 text-[10px] font-semibold">Favori</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={share}
|
||||||
|
className="flex flex-col items-center text-white"
|
||||||
|
aria-label="Partager"
|
||||||
|
>
|
||||||
|
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/10 backdrop-blur hover:bg-white/20">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="18" cy="5" r="3" />
|
||||||
|
<circle cx="6" cy="12" r="3" />
|
||||||
|
<circle cx="18" cy="19" r="3" />
|
||||||
|
<path d="M8.59 13.51 L15.42 17.49" />
|
||||||
|
<path d="M15.41 6.51 L8.59 10.49" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 text-[10px] font-semibold">Partager</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{current.type === "VIDEO" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMuted((m) => !m)}
|
||||||
|
className="flex flex-col items-center text-white"
|
||||||
|
aria-label={muted ? "Activer le son" : "Couper le son"}
|
||||||
|
>
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 backdrop-blur hover:bg-white/20">
|
||||||
|
{muted ? (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
||||||
|
<line x1="23" y1="9" x2="17" y2="15" />
|
||||||
|
<line x1="17" y1="9" x2="23" y2="15" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
||||||
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bloc info bas + CTAs */}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 z-10 p-4 pb-6 text-white">
|
||||||
|
<div className="mb-2 flex items-baseline gap-2">
|
||||||
|
<h2 className="text-lg font-semibold">{carbet.title}</h2>
|
||||||
|
{carbet.averageRating !== null ? (
|
||||||
|
<span className="text-xs text-white/80">
|
||||||
|
★ {carbet.averageRating.toFixed(1)} ({carbet.reviewCount})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/80">
|
||||||
|
<span>📍 {carbet.river}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>👥 jusqu'à {carbet.capacity}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="font-mono font-semibold text-white">{Number(carbet.nightlyPrice).toFixed(0)} € / nuit</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/carbets/${carbet.slug}`}
|
||||||
|
className="rounded-full bg-white/10 px-4 py-2 text-xs font-semibold backdrop-blur hover:bg-white/20"
|
||||||
|
>
|
||||||
|
Voir la fiche
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/carbets/${carbet.slug}#reserver`}
|
||||||
|
className="rounded-full bg-emerald-500 px-4 py-2 text-xs font-semibold hover:bg-emerald-400"
|
||||||
|
>
|
||||||
|
Réserver
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/app/decouvrir/_components/ReelsViewer.tsx
Normal file
158
src/app/decouvrir/_components/ReelsViewer.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import type { ReelCarbet } from "@/lib/reels";
|
||||||
|
|
||||||
|
import { ReelSlide } from "./ReelSlide";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
carbets: ReelCarbet[];
|
||||||
|
initialFavoriteIds: string[];
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReelsViewer({ carbets, initialFavoriteIds, isAuthenticated }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const slideRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const [favorites, setFavorites] = useState<Set<string>>(new Set(initialFavoriteIds));
|
||||||
|
|
||||||
|
// Détection du carbet actif via IntersectionObserver
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries.filter((e) => e.isIntersecting);
|
||||||
|
if (visible.length === 0) return;
|
||||||
|
const best = visible.reduce((a, b) => (a.intersectionRatio > b.intersectionRatio ? a : b));
|
||||||
|
const idx = slideRefs.current.findIndex((el) => el === best.target);
|
||||||
|
if (idx !== -1) setActiveIndex(idx);
|
||||||
|
},
|
||||||
|
{ root: containerRef.current, threshold: [0.55, 0.85] },
|
||||||
|
);
|
||||||
|
slideRefs.current.forEach((el) => el && observer.observe(el));
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [carbets.length]);
|
||||||
|
|
||||||
|
// Navigation clavier ↑↓
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||||
|
if (tag === "input" || tag === "textarea") return;
|
||||||
|
if (e.key === "ArrowDown" || e.key === "j") {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = Math.min(activeIndex + 1, carbets.length - 1);
|
||||||
|
slideRefs.current[next]?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
} else if (e.key === "ArrowUp" || e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
const prev = Math.max(activeIndex - 1, 0);
|
||||||
|
slideRefs.current[prev]?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [activeIndex, carbets.length]);
|
||||||
|
|
||||||
|
const toggleFavorite = useCallback(
|
||||||
|
async (carbetId: string) => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
router.push(`/connexion?next=${encodeURIComponent("/decouvrir")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isFav = favorites.has(carbetId);
|
||||||
|
// Optimistic update
|
||||||
|
setFavorites((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (isFav) next.delete(carbetId);
|
||||||
|
else next.add(carbetId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const method = isFav ? "DELETE" : "POST";
|
||||||
|
const res = await fetch("/api/favorites", {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ carbetId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
// Rollback
|
||||||
|
setFavorites((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (isFav) next.add(carbetId);
|
||||||
|
else next.delete(carbetId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[favorites, isAuthenticated, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Préchargement N+1 et N-1 médias (un peu d'AGGRESSIVE prefetch)
|
||||||
|
const preloadIndexes = useMemo(
|
||||||
|
() => [activeIndex - 1, activeIndex, activeIndex + 1].filter((i) => i >= 0 && i < carbets.length),
|
||||||
|
[activeIndex, carbets.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10 bg-black"
|
||||||
|
style={{
|
||||||
|
// 100dvh sur navigateurs récents pour éviter le saut quand la barre d'URL mobile se masque
|
||||||
|
height: "100dvh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Bouton retour catalogue */}
|
||||||
|
<Link
|
||||||
|
href="/carbets"
|
||||||
|
className="absolute right-3 z-20 rounded-full bg-white/10 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur hover:bg-white/20"
|
||||||
|
style={{ top: "max(0.75rem, env(safe-area-inset-top, 0px))" }}
|
||||||
|
>
|
||||||
|
← Catalogue
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Compteur */}
|
||||||
|
<div
|
||||||
|
className="absolute left-3 z-20 rounded-full bg-white/10 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur"
|
||||||
|
style={{ top: "max(0.75rem, env(safe-area-inset-top, 0px))" }}
|
||||||
|
>
|
||||||
|
{activeIndex + 1} / {carbets.length}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo Karbé en surimpression haut centre */}
|
||||||
|
<Link
|
||||||
|
href="/accueil"
|
||||||
|
className="absolute left-1/2 z-20 -translate-x-1/2 text-sm font-semibold text-white/90 hover:text-white"
|
||||||
|
style={{ top: "max(1rem, env(safe-area-inset-top, 0px))" }}
|
||||||
|
>
|
||||||
|
Karbé
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="h-full snap-y snap-mandatory overflow-y-scroll overscroll-contain"
|
||||||
|
style={{ scrollSnapType: "y mandatory" }}
|
||||||
|
>
|
||||||
|
{carbets.map((c, idx) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
ref={(el) => {
|
||||||
|
slideRefs.current[idx] = el;
|
||||||
|
}}
|
||||||
|
className="h-full snap-start snap-always"
|
||||||
|
style={{ scrollSnapAlign: "start" }}
|
||||||
|
>
|
||||||
|
<ReelSlide
|
||||||
|
carbet={c}
|
||||||
|
isActive={idx === activeIndex}
|
||||||
|
shouldPreload={preloadIndexes.includes(idx)}
|
||||||
|
isFavorite={favorites.has(c.id)}
|
||||||
|
onToggleFavorite={() => toggleFavorite(c.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/app/decouvrir/page.tsx
Normal file
50
src/app/decouvrir/page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { listReelCarbets } from "@/lib/reels";
|
||||||
|
|
||||||
|
import { ReelsViewer } from "./_components/ReelsViewer";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Au fil de l'eau",
|
||||||
|
description: "Découvrez les carbets de Guyane façon Reels — swipez pour explorer.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DecouvrirPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user?.id ?? null;
|
||||||
|
const [carbets, favoriteIds] = await Promise.all([
|
||||||
|
listReelCarbets({ take: 30 }),
|
||||||
|
userId
|
||||||
|
? prisma.favorite.findMany({ where: { userId }, select: { carbetId: true } }).then((r) => r.map((x) => x.carbetId))
|
||||||
|
: Promise.resolve([] as string[]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (carbets.length === 0) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-2xl px-6 py-20 text-center">
|
||||||
|
<h1 className="text-3xl font-semibold text-zinc-900">Au fil de l'eau</h1>
|
||||||
|
<p className="mt-3 text-sm text-zinc-600">
|
||||||
|
Pas encore assez de carbets avec des photos pour démarrer le mode immersif.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/carbets"
|
||||||
|
className="mt-6 inline-block rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Voir le catalogue
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReelsViewer
|
||||||
|
carbets={carbets}
|
||||||
|
initialFavoriteIds={favoriteIds}
|
||||||
|
isAuthenticated={Boolean(userId)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/app/espace-ce/analytics/page.tsx
Normal file
95
src/app/espace-ce/analytics/page.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { MonthlyRevenueChart } from "@/components/analytics/MonthlyRevenueChart";
|
||||||
|
import { getCarbetsOccupancy, getMonthlyRevenueSeries } from "@/lib/analytics";
|
||||||
|
import { getCurrentCeOrganization } from "@/lib/ce-access";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "Analytics CE — Karbé" };
|
||||||
|
|
||||||
|
function fmtEur(n: number): string {
|
||||||
|
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CeAnalyticsPage() {
|
||||||
|
const org = await getCurrentCeOrganization();
|
||||||
|
if (!org) redirect("/admin/organizations");
|
||||||
|
|
||||||
|
const [series, occupancy] = await Promise.all([
|
||||||
|
getMonthlyRevenueSeries({ organizationId: org.id, monthsBack: 12 }),
|
||||||
|
getCarbetsOccupancy({ organizationId: org.id, monthsBack: 3 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total12m = series.reduce((s, p) => s + p.total, 0);
|
||||||
|
const totalCarbet12m = series.reduce((s, p) => s + p.carbetRevenue, 0);
|
||||||
|
const totalRental12m = series.reduce((s, p) => s + p.rentalRevenue, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-5xl px-6 py-10 space-y-6">
|
||||||
|
<header>
|
||||||
|
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
|
← Tableau de bord CE
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">
|
||||||
|
Analytics — {org.name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600">
|
||||||
|
Chiffre d'affaires des 12 derniers mois et taux d'occupation des carbets co-gérés.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<KpiCard label="CA 12 mois" value={fmtEur(total12m)} />
|
||||||
|
<KpiCard label="dont Carbet" value={fmtEur(totalCarbet12m)} />
|
||||||
|
<KpiCard label="dont Matériel" value={fmtEur(totalRental12m)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Chiffre d'affaires mensuel
|
||||||
|
</h2>
|
||||||
|
<MonthlyRevenueChart data={series} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Taux d'occupation des carbets (3 derniers mois)
|
||||||
|
</h2>
|
||||||
|
{occupancy.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">Pas encore de carbet publié.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{occupancy.map((c) => (
|
||||||
|
<li key={c.carbetId}>
|
||||||
|
<div className="flex items-baseline justify-between text-sm">
|
||||||
|
<Link href={`/carbets/${c.slug}`} className="font-medium text-zinc-900 hover:underline">
|
||||||
|
{c.title}
|
||||||
|
</Link>
|
||||||
|
<span className="font-mono text-zinc-700">
|
||||||
|
{c.occupancyPct} % ({c.bookedNights}/{c.totalNights} nuits)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-2 overflow-hidden rounded-full bg-zinc-100">
|
||||||
|
<div
|
||||||
|
className="h-full bg-emerald-500"
|
||||||
|
style={{ width: `${Math.min(100, c.occupancyPct)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold text-zinc-900 font-mono">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/app/espace-ce/carbets/[carbetId]/page.tsx
Normal file
126
src/app/espace-ce/carbets/[carbetId]/page.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { MediaUploader } from "@/components/MediaUploader";
|
||||||
|
import { canManageCarbet, requireOwnerSession } from "@/lib/carbet-access";
|
||||||
|
import { getCurrentCeOrganization } from "@/lib/ce-access";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
import { updateCarbet } from "../../../espace-hote/carbets/actions";
|
||||||
|
import { CarbetForm } from "../../../espace-hote/carbets/_components/carbet-form";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function EditCeCarbetPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ carbetId: string }>;
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}) {
|
||||||
|
const session = await requireOwnerSession();
|
||||||
|
const org = await getCurrentCeOrganization();
|
||||||
|
if (!org) redirect("/admin/organizations");
|
||||||
|
const { carbetId } = await params;
|
||||||
|
const { publishError } = await searchParams;
|
||||||
|
|
||||||
|
const carbet = await prisma.carbet.findUnique({
|
||||||
|
where: { id: carbetId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
ownerId: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
river: true,
|
||||||
|
latitude: true,
|
||||||
|
longitude: true,
|
||||||
|
embarkPoint: true,
|
||||||
|
pirogueDurationMin: true,
|
||||||
|
capacity: true,
|
||||||
|
roadAccess: true,
|
||||||
|
electricity: true,
|
||||||
|
gsmAtCarbet: true,
|
||||||
|
gsmExitDistanceKm: true,
|
||||||
|
status: true,
|
||||||
|
media: {
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
|
||||||
|
},
|
||||||
|
amenities: { select: { amenity: { select: { key: true } } } },
|
||||||
|
organizations: { select: { organizationId: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!carbet ||
|
||||||
|
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
|
||||||
|
) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sécurité supplémentaire : assure que le carbet est bien lié à l'org du user.
|
||||||
|
// (Un ADMIN peut éditer n'importe quel carbet via /admin, pas via /espace-ce.)
|
||||||
|
const isLinked = carbet.organizations.some((o) => o.organizationId === org.id);
|
||||||
|
if (!isLinked && session.user.role !== "ADMIN") {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
title: carbet.title,
|
||||||
|
description: carbet.description,
|
||||||
|
river: carbet.river,
|
||||||
|
latitude: carbet.latitude.toString(),
|
||||||
|
longitude: carbet.longitude.toString(),
|
||||||
|
embarkPoint: carbet.embarkPoint,
|
||||||
|
pirogueDurationMin: String(carbet.pirogueDurationMin),
|
||||||
|
capacity: String(carbet.capacity),
|
||||||
|
roadAccess: carbet.roadAccess ?? "",
|
||||||
|
electricity: carbet.electricity ?? "",
|
||||||
|
gsmAtCarbet: carbet.gsmAtCarbet,
|
||||||
|
gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : "",
|
||||||
|
status: carbet.status,
|
||||||
|
amenityKeys: carbet.amenities.map((entry) => entry.amenity.key),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||||
|
<Link
|
||||||
|
href="/espace-ce/carbets"
|
||||||
|
className="text-sm text-zinc-600 hover:text-zinc-900"
|
||||||
|
>
|
||||||
|
← Carbets de {org.name}
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold text-zinc-900">
|
||||||
|
{carbet.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
|
Co-géré par <strong>{org.name}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{publishError ? (
|
||||||
|
<p className="mt-4 rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
Ajoutez au moins un média avant de publier ce carbet.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900">Médias</h2>
|
||||||
|
<p className="mb-4 mt-1 text-sm text-zinc-600">
|
||||||
|
Déposez photos et vidéos courtes, réorganisez par glisser-déposer.
|
||||||
|
Le premier média sert de cover sur le catalogue.
|
||||||
|
</p>
|
||||||
|
<MediaUploader carbetId={carbet.id} initialMedia={carbet.media} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-10 border-t border-zinc-200 pt-8">
|
||||||
|
<CarbetForm
|
||||||
|
action={updateCarbet}
|
||||||
|
mode="edit"
|
||||||
|
carbetId={carbet.id}
|
||||||
|
defaults={defaults}
|
||||||
|
submitLabel="Enregistrer les modifications"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/app/espace-ce/carbets/nouveau/page.tsx
Normal file
42
src/app/espace-ce/carbets/nouveau/page.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { requireApprovedOrg } from "@/lib/ce-access";
|
||||||
|
|
||||||
|
import { createCarbet } from "../../../espace-hote/carbets/actions";
|
||||||
|
import { CarbetForm } from "../../../espace-hote/carbets/_components/carbet-form";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function NewCeCarbetPage() {
|
||||||
|
// Bloque la création si l'org n'est pas validée — redirect vers dashboard
|
||||||
|
// avec bannière « En attente de validation ».
|
||||||
|
const org = await requireApprovedOrg();
|
||||||
|
if (!org) redirect("/espace-ce");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||||
|
<Link
|
||||||
|
href="/espace-ce/carbets"
|
||||||
|
className="text-sm text-zinc-600 hover:text-zinc-900"
|
||||||
|
>
|
||||||
|
← Carbets de {org.name}
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold text-zinc-900">
|
||||||
|
Nouveau carbet CE
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600">
|
||||||
|
Ce carbet sera automatiquement lié à <strong>{org.name}</strong> et co-géré
|
||||||
|
par tous ses CE_MANAGERs. Vous ajouterez les médias après la création.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<CarbetForm
|
||||||
|
action={createCarbet}
|
||||||
|
mode="create"
|
||||||
|
submitLabel="Créer le carbet"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
src/app/espace-ce/carbets/page.tsx
Normal file
163
src/app/espace-ce/carbets/page.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { CarbetStatus } from "@/generated/prisma/enums";
|
||||||
|
import { getCurrentCeOrganization } from "@/lib/ce-access";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteCarbet,
|
||||||
|
setCarbetStatus,
|
||||||
|
} from "../../espace-hote/carbets/actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "Mes carbets CE — Karbé" };
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<CarbetStatus, string> = {
|
||||||
|
[CarbetStatus.DRAFT]: "Brouillon",
|
||||||
|
[CarbetStatus.PUBLISHED]: "Publié",
|
||||||
|
[CarbetStatus.ARCHIVED]: "Archivé",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<CarbetStatus, string> = {
|
||||||
|
[CarbetStatus.DRAFT]: "bg-zinc-100 text-zinc-700",
|
||||||
|
[CarbetStatus.PUBLISHED]: "bg-emerald-100 text-emerald-800",
|
||||||
|
[CarbetStatus.ARCHIVED]: "bg-amber-100 text-amber-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CeCarbetsListPage() {
|
||||||
|
const org = await getCurrentCeOrganization();
|
||||||
|
if (!org) redirect("/admin/organizations");
|
||||||
|
|
||||||
|
const memberships = await prisma.organizationCarbetMembership.findMany({
|
||||||
|
where: { organizationId: org.id },
|
||||||
|
orderBy: { addedAt: "desc" },
|
||||||
|
select: {
|
||||||
|
carbet: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
river: true,
|
||||||
|
status: true,
|
||||||
|
updatedAt: true,
|
||||||
|
ownerId: true,
|
||||||
|
owner: { select: { firstName: true, lastName: true } },
|
||||||
|
_count: { select: { media: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const carbets = memberships.map((m) => m.carbet);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-4xl px-6 py-12">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
|
← Tableau de bord CE
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">
|
||||||
|
Carbets co-gérés par {org.name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600">
|
||||||
|
Les carbets visibles ici peuvent être édités par tous les CE_MANAGERs de votre
|
||||||
|
organisation. La propriété nominale reste sur leur créateur initial.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{org.approved ? (
|
||||||
|
<Link
|
||||||
|
href="/espace-ce/carbets/nouveau"
|
||||||
|
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Nouveau carbet
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-md bg-zinc-100 px-3 py-2 text-xs text-zinc-500">
|
||||||
|
Publication bloquée : organisation en attente de validation
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{carbets.length === 0 ? (
|
||||||
|
<p className="mt-10 rounded-md border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
|
||||||
|
Votre CE n'a pas encore de carbet.{" "}
|
||||||
|
{org.approved ? "Créez votre premier carbet pour démarrer." : "Vous pourrez en publier dès que votre organisation sera validée."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-8 space-y-4">
|
||||||
|
{carbets.map((carbet) => (
|
||||||
|
<li
|
||||||
|
key={carbet.id}
|
||||||
|
className="flex flex-col gap-4 rounded-lg border border-zinc-200 bg-white p-5 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/espace-ce/carbets/${carbet.id}`}
|
||||||
|
className="truncate text-lg font-medium text-zinc-900 hover:text-emerald-700"
|
||||||
|
>
|
||||||
|
{carbet.title}
|
||||||
|
</Link>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_STYLES[carbet.status]}`}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[carbet.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
{carbet.river} · {carbet._count.media} média{carbet._count.media > 1 ? "s" : ""}
|
||||||
|
{" · "}créé par {carbet.owner.firstName} {carbet.owner.lastName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/espace-ce/carbets/${carbet.id}`}
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
Éditer
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{org.approved && carbet.status !== CarbetStatus.PUBLISHED ? (
|
||||||
|
<form action={setCarbetStatus}>
|
||||||
|
<input type="hidden" name="carbetId" value={carbet.id} />
|
||||||
|
<input type="hidden" name="status" value={CarbetStatus.PUBLISHED} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Publier
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{carbet.status === CarbetStatus.PUBLISHED ? (
|
||||||
|
<form action={setCarbetStatus}>
|
||||||
|
<input type="hidden" name="carbetId" value={carbet.id} />
|
||||||
|
<input type="hidden" name="status" value={CarbetStatus.DRAFT} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
Dépublier
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form action={deleteCarbet}>
|
||||||
|
<input type="hidden" name="carbetId" value={carbet.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md border border-red-200 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/app/espace-ce/layout.tsx
Normal file
8
src/app/espace-ce/layout.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { requirePluginOr404 } from "@/lib/plugins/guard";
|
||||||
|
import { requireCeManagerSession } from "@/lib/ce-access";
|
||||||
|
|
||||||
|
export default async function CeLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
await requirePluginOr404("ce-management");
|
||||||
|
await requireCeManagerSession();
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
66
src/app/espace-ce/materiel/actions.ts
Normal file
66
src/app/espace-ce/materiel/actions.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
import { getCurrentCeOrganization } from "@/lib/ce-access";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active la location matériel pour un CE : crée le RentalProvider lié à son
|
||||||
|
* organizationId. Approuvé automatiquement si l'org elle-même est approuvée.
|
||||||
|
* - Si un provider existe déjà pour cette org : redirige sans rien créer.
|
||||||
|
* - Bloque si l'org n'est pas validée (la création doit attendre l'approval).
|
||||||
|
*/
|
||||||
|
export async function activateRentalProviderForCeAction(): Promise<void> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) redirect("/connexion?next=/espace-ce/materiel");
|
||||||
|
if (session.user.role !== UserRole.CE_MANAGER && session.user.role !== UserRole.ADMIN) {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
const org = await getCurrentCeOrganization();
|
||||||
|
if (!org) redirect("/espace-ce");
|
||||||
|
if (!org.approved) {
|
||||||
|
// L'org doit être validée avant activation. La page affichera la bannière.
|
||||||
|
redirect("/espace-ce/materiel?activateError=pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.rentalProvider.findFirst({
|
||||||
|
where: { organizationId: org.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
redirect("/espace-ce/materiel");
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await prisma.rentalProvider.create({
|
||||||
|
data: {
|
||||||
|
name: `Matériel — ${org.name}`,
|
||||||
|
isSystemD: false,
|
||||||
|
managedByUserId: session.user.id,
|
||||||
|
organizationId: org.id,
|
||||||
|
contactEmail: org.contactEmail,
|
||||||
|
rivers: [],
|
||||||
|
commissionPct: 10,
|
||||||
|
active: true,
|
||||||
|
approved: true,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
approvedBy: session.user.email ?? "system",
|
||||||
|
},
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
scope: "ce",
|
||||||
|
event: "ce.rental_provider.activate",
|
||||||
|
target: created.id,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: { organizationId: org.id, name: created.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/espace-ce/materiel");
|
||||||
|
redirect("/espace-ce/materiel");
|
||||||
|
}
|
||||||
122
src/app/espace-ce/materiel/items/[itemId]/page.tsx
Normal file
122
src/app/espace-ce/materiel/items/[itemId]/page.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { MediaUploader } from "@/components/MediaUploader";
|
||||||
|
import {
|
||||||
|
getCurrentRentalProvider,
|
||||||
|
requireRentalProviderSession,
|
||||||
|
} from "@/lib/rental-access";
|
||||||
|
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
|
||||||
|
import { getHostItem } from "@/lib/rental-host";
|
||||||
|
|
||||||
|
import {
|
||||||
|
addItemBlockAction,
|
||||||
|
deleteHostItemAction,
|
||||||
|
removeItemBlockAction,
|
||||||
|
updateHostItemAction,
|
||||||
|
} from "../../../../espace-prestataire/actions";
|
||||||
|
import { HostItemForm } from "../../../../espace-prestataire/items/_components/ItemForm";
|
||||||
|
import { ItemBlocksManager } from "../../../../espace-prestataire/items/[itemId]/_components/ItemBlocksManager";
|
||||||
|
import { ItemInlineDelete } from "../../../../espace-prestataire/items/[itemId]/_components/ItemInlineDelete";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type PageProps = { params: Promise<{ itemId: string }> };
|
||||||
|
|
||||||
|
export default async function EditCeItemPage({ params }: PageProps) {
|
||||||
|
await requireRentalProviderSession();
|
||||||
|
const provider = await getCurrentRentalProvider();
|
||||||
|
if (!provider) redirect("/espace-ce/materiel");
|
||||||
|
const { itemId } = await params;
|
||||||
|
const item = await getHostItem(provider.id, itemId);
|
||||||
|
if (!item) notFound();
|
||||||
|
|
||||||
|
const updateThis = async (fd: FormData) => {
|
||||||
|
"use server";
|
||||||
|
return await updateHostItemAction(itemId, fd);
|
||||||
|
};
|
||||||
|
const deleteThis = async () => {
|
||||||
|
"use server";
|
||||||
|
return await deleteHostItemAction(itemId);
|
||||||
|
};
|
||||||
|
const addBlockThis = async (fd: FormData) => {
|
||||||
|
"use server";
|
||||||
|
return await addItemBlockAction(itemId, fd);
|
||||||
|
};
|
||||||
|
const removeBlockThis = async (blockId: string) => {
|
||||||
|
"use server";
|
||||||
|
return await removeItemBlockAction(blockId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
||||||
|
<header className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<Link href="/espace-ce/materiel/items" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
|
← Mes items
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">{item.name}</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
{RENTAL_CATEGORY_LABEL[item.category]} · Stock : {item.totalQty} · {item._count.lines}{" "}
|
||||||
|
location(s) historique
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ItemInlineDelete deleteAction={deleteThis} canDelete={item._count.lines === 0} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Photos & vidéos
|
||||||
|
</h2>
|
||||||
|
<MediaUploader
|
||||||
|
scope={{ kind: "rental-item", itemId: item.id }}
|
||||||
|
initialMedia={item.media}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<HostItemForm
|
||||||
|
action={updateThis}
|
||||||
|
submitLabel="Enregistrer les modifications"
|
||||||
|
initial={{
|
||||||
|
category: item.category,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
imageUrl: item.imageUrl,
|
||||||
|
pricePerDay: item.pricePerDay.toString(),
|
||||||
|
pricePerWeek: item.pricePerWeek?.toString() ?? null,
|
||||||
|
deposit: item.deposit.toString(),
|
||||||
|
totalQty: item.totalQty,
|
||||||
|
withMotor: item.withMotor,
|
||||||
|
fuelIncluded: item.fuelIncluded,
|
||||||
|
requiresLicense: item.requiresLicense,
|
||||||
|
active: item.active,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Calendrier de disponibilité
|
||||||
|
</h2>
|
||||||
|
<p className="mb-3 text-xs text-zinc-600">
|
||||||
|
Bloquez ici des dates pour maintenance, indisponibilité personnelle, etc. Les réservations
|
||||||
|
confirmées sont gérées automatiquement.
|
||||||
|
</p>
|
||||||
|
<ItemBlocksManager
|
||||||
|
blocks={item.availabilities.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
startDate: a.startDate.toISOString().slice(0, 10),
|
||||||
|
endDate: a.endDate.toISOString().slice(0, 10),
|
||||||
|
qty: a.qty,
|
||||||
|
reason: a.reason,
|
||||||
|
isBooking: Boolean(a.rentalBookingId),
|
||||||
|
}))}
|
||||||
|
addAction={addBlockThis}
|
||||||
|
removeAction={removeBlockThis}
|
||||||
|
totalQty={item.totalQty}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/app/espace-ce/materiel/items/new/page.tsx
Normal file
27
src/app/espace-ce/materiel/items/new/page.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { requireRentalProviderSession } from "@/lib/rental-access";
|
||||||
|
|
||||||
|
import { createHostItemAction } from "../../../../espace-prestataire/actions";
|
||||||
|
import { HostItemForm } from "../../../../espace-prestataire/items/_components/ItemForm";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function NewCeItemPage() {
|
||||||
|
await requireRentalProviderSession();
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-3xl px-6 py-10">
|
||||||
|
<Link href="/espace-ce/materiel/items" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
|
← Mes items
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvel item</h1>
|
||||||
|
<section className="mt-5 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<HostItemForm
|
||||||
|
action={createHostItemAction}
|
||||||
|
submitLabel="Créer l'item"
|
||||||
|
initial={{ active: true, totalQty: 1 }}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/app/espace-ce/materiel/items/page.tsx
Normal file
109
src/app/espace-ce/materiel/items/page.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
|
||||||
|
import {
|
||||||
|
getCurrentRentalProvider,
|
||||||
|
requireRentalProviderSession,
|
||||||
|
} from "@/lib/rental-access";
|
||||||
|
import { listHostItems } from "@/lib/rental-host";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "Items rental CE — Karbé" };
|
||||||
|
|
||||||
|
export default async function CeMaterielItemsPage() {
|
||||||
|
await requireRentalProviderSession();
|
||||||
|
const provider = await getCurrentRentalProvider();
|
||||||
|
// Sans provider activé → renvoie sur l'onboarding /espace-ce/materiel
|
||||||
|
if (!provider) redirect("/espace-ce/materiel");
|
||||||
|
|
||||||
|
const items = await listHostItems(provider.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-6 py-10">
|
||||||
|
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<Link href="/espace-ce/materiel" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
|
← Dashboard matériel CE
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">
|
||||||
|
Items locables — {provider.name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
{items.length} item{items.length > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/espace-ce/materiel/items/new"
|
||||||
|
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
+ Nouvel item
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
|
||||||
|
Pas encore d'item.{" "}
|
||||||
|
<Link href="/espace-ce/materiel/items/new" className="text-emerald-700 underline">
|
||||||
|
Créer mon premier item
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Nom</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">Catégorie</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">€/j</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">Stock</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">Caution</th>
|
||||||
|
<th className="px-4 py-2 text-right font-semibold">Résa</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold">État</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100">
|
||||||
|
{items.map((i) => (
|
||||||
|
<tr key={i.id} className="hover:bg-zinc-50">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Link
|
||||||
|
href={`/espace-ce/materiel/items/${i.id}`}
|
||||||
|
className="font-medium text-zinc-900 hover:underline"
|
||||||
|
>
|
||||||
|
{i.name}
|
||||||
|
</Link>
|
||||||
|
<div className="text-[11px] text-zinc-500">
|
||||||
|
{i.withMotor ? "⚙️ moteur · " : ""}
|
||||||
|
{i.requiresLicense ? "🪪 permis · " : ""}
|
||||||
|
{i.fuelIncluded ? "⛽ essence " : ""}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-zinc-700">{RENTAL_CATEGORY_LABEL[i.category]}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono text-zinc-700">
|
||||||
|
{Number(i.pricePerDay).toFixed(0)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i.totalQty}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono text-zinc-700">
|
||||||
|
{Number(i.deposit).toFixed(0)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i._count.lines}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{i.active ? (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||||
|
Actif
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-500 ring-1 ring-inset ring-zinc-300">
|
||||||
|
Inactif
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/app/espace-ce/materiel/page.tsx
Normal file
152
src/app/espace-ce/materiel/page.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getCurrentCeOrganization } from "@/lib/ce-access";
|
||||||
|
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||||
|
import {
|
||||||
|
getCurrentRentalProviderForCe,
|
||||||
|
} from "@/lib/rental-access";
|
||||||
|
import { getHostRentalKpis } from "@/lib/rental-host";
|
||||||
|
|
||||||
|
import { activateRentalProviderForCeAction } from "./actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "Matériel CE — Karbé" };
|
||||||
|
|
||||||
|
function fmtEur(amount: string | number): string {
|
||||||
|
return Number(amount).toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CeMaterielPage() {
|
||||||
|
// Soft dependency : si le plugin gear-rental est off, on masque /espace-ce/materiel
|
||||||
|
// (le bouton du dashboard a déjà été désactivé côté UX).
|
||||||
|
if (!(await isPluginEnabled("gear-rental"))) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||||
|
<h1 className="text-3xl font-semibold text-zinc-900">Matériel rental</h1>
|
||||||
|
<p className="mt-4 rounded-md border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
|
||||||
|
La marketplace location matériel n'est pas activée. Activez le plugin
|
||||||
|
<code className="ml-1 rounded bg-zinc-200 px-1.5 py-0.5 text-xs">gear-rental</code>{" "}
|
||||||
|
dans <Link href="/admin/plugins" className="underline">/admin/plugins</Link>.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const org = await getCurrentCeOrganization();
|
||||||
|
if (!org) redirect("/admin/organizations");
|
||||||
|
|
||||||
|
const provider = await getCurrentRentalProviderForCe(org.id);
|
||||||
|
|
||||||
|
// Onboarding : pas encore de provider activé
|
||||||
|
if (!provider) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||||
|
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
|
← Tableau de bord CE
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">Matériel rental</h1>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600">
|
||||||
|
Activez la location matériel pour proposer hamacs, kayaks, pirogues, etc. à vos
|
||||||
|
membres et au public touriste. Le provider sera créé au nom de votre CE.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!org.approved ? (
|
||||||
|
<p className="mt-6 rounded-md border border-amber-200 bg-amber-50/60 px-4 py-3 text-sm text-amber-900">
|
||||||
|
🕒 Votre organisation est en attente de validation. La location matériel sera
|
||||||
|
activable dès qu'un admin Karbé aura validé votre CE.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form action={activateRentalProviderForCeAction} className="mt-8">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Activer la location matériel pour {org.name}
|
||||||
|
</button>
|
||||||
|
<p className="mt-2 text-xs text-zinc-500">
|
||||||
|
Vous pourrez ensuite ajouter vos items (hamac, pirogue, kayak…). Commission
|
||||||
|
par défaut : 10 % (ajustable par un admin Karbé).
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider existant : dashboard + KPIs
|
||||||
|
const kpis = await getHostRentalKpis(provider.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-5xl px-6 py-10 space-y-6">
|
||||||
|
<header>
|
||||||
|
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
|
← Tableau de bord CE
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">
|
||||||
|
Matériel rental — {provider.name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
Commission Karbé : {Number(provider.commissionPct).toFixed(1)} % · Géré par {org.name}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<KpiCard label="Items actifs" value={kpis.itemsActive} />
|
||||||
|
<KpiCard label="Réservations en attente" value={kpis.bookingsPending} />
|
||||||
|
<KpiCard label="Confirmées à venir" value={kpis.bookingsConfirmed} />
|
||||||
|
<KpiCard label="Revenu 30j" value={fmtEur(kpis.revenue30d)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<ActionCard
|
||||||
|
href="/espace-ce/materiel/items"
|
||||||
|
title="Mes items"
|
||||||
|
description={
|
||||||
|
kpis.itemsActive > 0
|
||||||
|
? `${kpis.itemsActive} item${kpis.itemsActive > 1 ? "s" : ""} en location.`
|
||||||
|
: "Ajoutez votre premier item (hamac, kayak, pirogue…)."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ActionCard
|
||||||
|
href="/espace-ce/materiel/reservations"
|
||||||
|
title="Réservations"
|
||||||
|
description={
|
||||||
|
kpis.bookingsPending > 0
|
||||||
|
? `${kpis.bookingsPending} demande${kpis.bookingsPending > 1 ? "s" : ""} à préparer.`
|
||||||
|
: "Suivez vos réservations en cours, à préparer et terminées."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold text-zinc-900 font-mono">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionCard({
|
||||||
|
href,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="rounded-lg border border-zinc-200 bg-white px-5 py-4 shadow-sm transition hover:border-zinc-400 hover:shadow"
|
||||||
|
>
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">{title}</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600">{description}</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/app/espace-ce/materiel/reservations/page.tsx
Normal file
150
src/app/espace-ce/materiel/reservations/page.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { RentalBookingStatus } from "@/generated/prisma/enums";
|
||||||
|
import { RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
|
||||||
|
import {
|
||||||
|
getCurrentRentalProvider,
|
||||||
|
requireRentalProviderSession,
|
||||||
|
} from "@/lib/rental-access";
|
||||||
|
import { listHostBookings } from "@/lib/rental-host";
|
||||||
|
|
||||||
|
import { BookingDecision } from "../../../espace-prestataire/reservations/_components/BookingDecision";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "Réservations matériel CE — Karbé" };
|
||||||
|
|
||||||
|
const STATUS_VALUES = new Set<string>([
|
||||||
|
RentalBookingStatus.PENDING,
|
||||||
|
RentalBookingStatus.CONFIRMED,
|
||||||
|
RentalBookingStatus.HANDED_OVER,
|
||||||
|
RentalBookingStatus.RETURNED,
|
||||||
|
RentalBookingStatus.CANCELLED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
searchParams: Promise<{ status?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function CeReservationsPage({ searchParams }: PageProps) {
|
||||||
|
await requireRentalProviderSession();
|
||||||
|
const provider = await getCurrentRentalProvider();
|
||||||
|
if (!provider) redirect("/espace-ce/materiel");
|
||||||
|
const sp = await searchParams;
|
||||||
|
const status = STATUS_VALUES.has(sp.status ?? "")
|
||||||
|
? (sp.status as RentalBookingStatus)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const bookings = await listHostBookings(provider.id, { status });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-6 py-10">
|
||||||
|
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<Link href="/espace-ce/materiel" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
|
← Dashboard matériel CE
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Réservations</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
{bookings.length} résultat{bookings.length > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form method="get" className="flex items-center gap-2 text-sm">
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={status ?? ""}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-emerald-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Tous statuts</option>
|
||||||
|
{Object.values(RentalBookingStatus).map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{RENTAL_STATUS_LABEL[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Filtrer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{bookings.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
|
||||||
|
Aucune réservation matériel.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{bookings.map((b) => (
|
||||||
|
<li key={b.id} className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-zinc-900">
|
||||||
|
{b.tenant.firstName} {b.tenant.lastName}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{b.tenant.email}
|
||||||
|
{b.tenant.phone ? ` · ${b.tenant.phone}` : ""}
|
||||||
|
</p>
|
||||||
|
{b.booking ? (
|
||||||
|
<p className="mt-0.5 text-xs text-emerald-700">
|
||||||
|
🏠 Lié à la résa carbet :{" "}
|
||||||
|
<Link href={`/reservations/${b.booking.id}`} className="underline">
|
||||||
|
{b.booking.carbet.title}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-0.5 text-xs text-zinc-500">
|
||||||
|
Location standalone (sans carbet)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
{dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-base font-semibold text-zinc-900">
|
||||||
|
{Number(b.amount).toFixed(2)} {b.currency}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-2 space-y-1 border-t border-zinc-100 pt-2 text-sm text-zinc-700">
|
||||||
|
{b.lines.map((l) => (
|
||||||
|
<li key={l.id} className="flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
{l.qty}× <strong>{l.item.name}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-zinc-600">
|
||||||
|
{Number(l.lineTotal).toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-zinc-100 pt-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-zinc-700 ring-1 ring-inset ring-zinc-300">
|
||||||
|
{RENTAL_STATUS_LABEL[b.status]}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-zinc-700 ring-1 ring-inset ring-zinc-300">
|
||||||
|
{b.paymentStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<BookingDecision bookingId={b.id} status={b.status} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/app/espace-ce/membres/_components/InviteForm.tsx
Normal file
90
src/app/espace-ce/membres/_components/InviteForm.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
|
||||||
|
import type { CreateInviteResult } from "../actions";
|
||||||
|
|
||||||
|
export function InviteForm({
|
||||||
|
action,
|
||||||
|
siteUrl,
|
||||||
|
}: {
|
||||||
|
action: (fd: FormData) => Promise<CreateInviteResult>;
|
||||||
|
siteUrl: string;
|
||||||
|
}) {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [link, setLink] = useState<string | null>(null);
|
||||||
|
const [emailSent, setEmailSent] = useState(false);
|
||||||
|
|
||||||
|
function onSubmit(fd: FormData) {
|
||||||
|
setError(null);
|
||||||
|
setLink(null);
|
||||||
|
setEmailSent(false);
|
||||||
|
const emailValue = ((fd.get("email") as string | null) ?? "").trim();
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await action(fd);
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(res.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLink(`${siteUrl}/inscription?invite=${res.token}`);
|
||||||
|
setEmailSent(Boolean(emailValue));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<form action={onSubmit} className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="block grow">
|
||||||
|
<span className="text-xs text-zinc-600">Email du futur CE_MEMBER (optionnel)</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="prenom.nom@entreprise.gf"
|
||||||
|
maxLength={200}
|
||||||
|
className="mt-0.5 block w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{pending ? "Génération…" : "Générer un lien"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{link ? (
|
||||||
|
<div className="rounded-md border border-emerald-200 bg-emerald-50/60 p-3 text-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-800">
|
||||||
|
✓ Lien d'invitation généré (valable 14 jours)
|
||||||
|
{emailSent ? " · email envoyé" : ""}
|
||||||
|
</p>
|
||||||
|
<code className="mt-1 block break-all rounded bg-white px-2 py-1.5 font-mono text-xs text-zinc-700">
|
||||||
|
{link}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(link).catch(() => {});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mt-2 rounded border border-emerald-300 bg-white px-2 py-1 text-[11px] text-emerald-800 hover:bg-emerald-100"
|
||||||
|
>
|
||||||
|
Copier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p className="text-[11px] text-zinc-500">
|
||||||
|
Si vous indiquez un email, l'invitation sera envoyée automatiquement et le lien
|
||||||
|
sera bloqué pour toute autre adresse à la connexion. Sans email, n'importe qui
|
||||||
|
ayant le lien peut rejoindre votre CE.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/app/espace-ce/membres/actions.ts
Normal file
82
src/app/espace-ce/membres/actions.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { recordAudit } from "@/lib/admin/audit";
|
||||||
|
import {
|
||||||
|
createOrgInviteToken,
|
||||||
|
revokeOrgInviteToken,
|
||||||
|
} from "@/lib/ce-invites";
|
||||||
|
import { getCurrentCeOrganization } from "@/lib/ce-access";
|
||||||
|
import { sendCeInviteEmail } from "@/lib/email";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
|
||||||
|
|
||||||
|
export type CreateInviteResult =
|
||||||
|
| { ok: true; token: string }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
export async function createInviteAction(fd: FormData): Promise<CreateInviteResult> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return { ok: false, error: "Non authentifié." };
|
||||||
|
if (session.user.role !== UserRole.CE_MANAGER && session.user.role !== UserRole.ADMIN) {
|
||||||
|
return { ok: false, error: "Réservé aux CE_MANAGER." };
|
||||||
|
}
|
||||||
|
const org = await getCurrentCeOrganization();
|
||||||
|
if (!org) return { ok: false, error: "Aucune organisation détectée." };
|
||||||
|
if (!org.approved) return { ok: false, error: "Votre organisation doit être validée." };
|
||||||
|
|
||||||
|
const email = ((fd.get("email") as string | null) ?? "").trim().toLowerCase() || null;
|
||||||
|
if (email && !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) {
|
||||||
|
return { ok: false, error: "Email invalide." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await createOrgInviteToken({
|
||||||
|
organizationId: org.id,
|
||||||
|
createdByUserId: session.user.id,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
await recordAudit({
|
||||||
|
scope: "ce.invite",
|
||||||
|
event: "invite.create",
|
||||||
|
target: org.id,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: { email, emailedAutomatically: Boolean(email) },
|
||||||
|
});
|
||||||
|
// Envoi automatique si email destinataire fourni (best-effort, dry-run sans Resend).
|
||||||
|
if (email) {
|
||||||
|
const inviteUrl = `${SITE_URL}/inscription?invite=${token}`;
|
||||||
|
try {
|
||||||
|
await sendCeInviteEmail(email, org.name, inviteUrl, session.user.name);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[ce.invite] email send failed:", e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
revalidatePath("/espace-ce/membres");
|
||||||
|
return { ok: true, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeInviteAction(tokenHash: string): Promise<void> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return;
|
||||||
|
if (session.user.role !== UserRole.CE_MANAGER && session.user.role !== UserRole.ADMIN) return;
|
||||||
|
const org = await getCurrentCeOrganization();
|
||||||
|
if (!org) return;
|
||||||
|
const invite = await prisma.orgInviteToken.findUnique({
|
||||||
|
where: { tokenHash },
|
||||||
|
select: { organizationId: true },
|
||||||
|
});
|
||||||
|
if (!invite || invite.organizationId !== org.id) return;
|
||||||
|
await revokeOrgInviteToken(tokenHash);
|
||||||
|
await recordAudit({
|
||||||
|
scope: "ce.invite",
|
||||||
|
event: "invite.revoke",
|
||||||
|
target: org.id,
|
||||||
|
actorEmail: session.user.email ?? null,
|
||||||
|
details: {},
|
||||||
|
});
|
||||||
|
revalidatePath("/espace-ce/membres");
|
||||||
|
}
|
||||||
173
src/app/espace-ce/membres/page.tsx
Normal file
173
src/app/espace-ce/membres/page.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { UserRole } from "@/generated/prisma/enums";
|
||||||
|
import { getCurrentCeOrganization } from "@/lib/ce-access";
|
||||||
|
import { listOrgInviteTokens } from "@/lib/ce-invites";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
import { createInviteAction, revokeInviteAction } from "./actions";
|
||||||
|
import { InviteForm } from "./_components/InviteForm";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "Membres CE — Karbé" };
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<string, string> = {
|
||||||
|
CE_MANAGER: "Manager",
|
||||||
|
CE_MEMBER: "Membre",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CeMembresPage() {
|
||||||
|
const org = await getCurrentCeOrganization();
|
||||||
|
if (!org) redirect("/admin/organizations");
|
||||||
|
|
||||||
|
const [members, invites] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: org.id,
|
||||||
|
role: { in: [UserRole.CE_MANAGER, UserRole.CE_MEMBER] },
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ role: "asc" }, { lastName: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
listOrgInviteTokens(org.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
|
||||||
|
|
||||||
|
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
||||||
|
<header>
|
||||||
|
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||||
|
← Tableau de bord CE
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">
|
||||||
|
Membres — {org.name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600">
|
||||||
|
{members.length} membre{members.length > 1 ? "s" : ""} actif{members.length > 1 ? "s" : ""}.
|
||||||
|
Générez un lien d'invitation pour qu'un nouveau CE_MEMBER s'inscrive et
|
||||||
|
rejoigne automatiquement votre organisation.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Inviter un membre
|
||||||
|
</h2>
|
||||||
|
{!org.approved ? (
|
||||||
|
<p className="mt-3 text-sm text-amber-900">
|
||||||
|
🕒 La génération d'invitations est bloquée tant que votre organisation n'est
|
||||||
|
pas validée.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3">
|
||||||
|
<InviteForm action={createInviteAction} siteUrl={siteUrl} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Membres ({members.length})
|
||||||
|
</h2>
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">Aucun membre actif pour l'instant.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-zinc-100">
|
||||||
|
{members.map((m) => (
|
||||||
|
<li key={m.id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-zinc-900">
|
||||||
|
{m.firstName} {m.lastName}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500">{m.email}</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider " +
|
||||||
|
(m.role === "CE_MANAGER"
|
||||||
|
? "bg-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-300"
|
||||||
|
: "bg-zinc-100 text-zinc-700 ring-1 ring-inset ring-zinc-300")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ROLE_LABEL[m.role] ?? m.role}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Invitations en cours ({invites.filter((i) => !i.usedAt && i.expiresAt > new Date()).length})
|
||||||
|
</h2>
|
||||||
|
{invites.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">Aucune invitation envoyée pour l'instant.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-zinc-100">
|
||||||
|
{invites.map((inv) => {
|
||||||
|
const expired = inv.expiresAt < new Date();
|
||||||
|
const used = inv.usedAt !== null;
|
||||||
|
const status = used ? "consommé" : expired ? "expiré" : "actif";
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={inv.tokenHash}
|
||||||
|
className="flex items-center justify-between gap-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-xs text-zinc-700">
|
||||||
|
{inv.email ?? "(lien partagé)"}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-zinc-500">
|
||||||
|
Créé {dateFmt.format(inv.createdAt)} · Expire {dateFmt.format(inv.expiresAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider " +
|
||||||
|
(status === "actif"
|
||||||
|
? "bg-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-300"
|
||||||
|
: status === "consommé"
|
||||||
|
? "bg-zinc-100 text-zinc-600 ring-1 ring-inset ring-zinc-300"
|
||||||
|
: "bg-amber-100 text-amber-800 ring-1 ring-inset ring-amber-300")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
{!used && !expired ? (
|
||||||
|
<form action={revokeInviteAction.bind(null, inv.tokenHash)}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded border border-rose-200 bg-white px-2 py-0.5 text-[11px] text-rose-700 hover:bg-rose-50"
|
||||||
|
>
|
||||||
|
Révoquer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/app/espace-ce/page.tsx
Normal file
150
src/app/espace-ce/page.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getCurrentCeOrganization } from "@/lib/ce-access";
|
||||||
|
import { getCeOrgKpis } from "@/lib/ce-dashboard";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "Espace CE — Karbé" };
|
||||||
|
|
||||||
|
function fmtEur(n: number): string {
|
||||||
|
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CeDashboardPage() {
|
||||||
|
const org = await getCurrentCeOrganization();
|
||||||
|
if (!org) {
|
||||||
|
// ADMIN sans organizationId ciblé : pour l'instant, renvoyer vers la liste admin.
|
||||||
|
redirect("/admin/organizations");
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpis = await getCeOrgKpis(org.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-5xl px-6 py-10 space-y-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-3xl font-semibold text-zinc-900">
|
||||||
|
Espace CE — {org.name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
Dashboard de votre comité d'entreprise. Co-gérez vos carbets et activez la location
|
||||||
|
de matériel pour vos membres et le public touriste.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!org.approved ? (
|
||||||
|
<section className="rounded-lg border border-amber-200 bg-amber-50/60 px-5 py-4">
|
||||||
|
<h2 className="text-base font-semibold text-amber-900">
|
||||||
|
🕒 Votre organisation est en attente de validation
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-amber-900">
|
||||||
|
L'équipe Karbé vérifie votre demande. Vous pouvez préparer vos carbets et items
|
||||||
|
en brouillon, mais rien ne sera publié tant que votre organisation n'est pas
|
||||||
|
validée. Cela prend généralement moins de 48h. Si vous n'avez pas de retour
|
||||||
|
sous 72h, contactez{" "}
|
||||||
|
<a href="mailto:contact@karbe.cosmolan.fr" className="underline">
|
||||||
|
contact@karbe.cosmolan.fr
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<KpiCard label="Carbets co-gérés" value={kpis.carbetsCount} />
|
||||||
|
<KpiCard label="Items matériel" value={kpis.rentalItemsCount} />
|
||||||
|
<KpiCard label="Réservations 30j" value={kpis.bookings30dCount + kpis.rentalBookings30dCount} />
|
||||||
|
<KpiCard label="Revenu 30j" value={fmtEur(kpis.revenue30d)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<ActionCard
|
||||||
|
href="/espace-ce/carbets"
|
||||||
|
title="Mes carbets"
|
||||||
|
description={
|
||||||
|
kpis.carbetsCount > 0
|
||||||
|
? `${kpis.carbetsCount} carbet${kpis.carbetsCount > 1 ? "s" : ""} co-géré${kpis.carbetsCount > 1 ? "s" : ""} par votre CE.`
|
||||||
|
: org.approved
|
||||||
|
? "Ajoutez votre premier carbet et ouvrez-le à vos membres + au public."
|
||||||
|
: "Vous pouvez préparer vos carbets en brouillon, ils seront publiables après validation."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ActionCard
|
||||||
|
href="/espace-ce/materiel"
|
||||||
|
title="Matériel rental"
|
||||||
|
description={
|
||||||
|
kpis.rentalItemsCount > 0
|
||||||
|
? `${kpis.rentalItemsCount} item${kpis.rentalItemsCount > 1 ? "s" : ""} en location.`
|
||||||
|
: org.approved
|
||||||
|
? "Proposez hamacs, kayaks, pirogue… à vos membres et au public."
|
||||||
|
: "Disponible après validation de votre organisation."
|
||||||
|
}
|
||||||
|
disabled={!org.approved}
|
||||||
|
comingSoon
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Voir aussi vos{" "}
|
||||||
|
<Link href="/espace-ce/analytics" className="text-zinc-700 underline hover:text-zinc-900">
|
||||||
|
analytics CA & occupation
|
||||||
|
</Link>{" "}
|
||||||
|
et gérez vos{" "}
|
||||||
|
<Link href="/espace-ce/membres" className="text-zinc-700 underline hover:text-zinc-900">
|
||||||
|
membres et invitations CE
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold text-zinc-900 font-mono">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionCard({
|
||||||
|
href,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
disabled,
|
||||||
|
comingSoon,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
comingSoon?: boolean;
|
||||||
|
}) {
|
||||||
|
const baseCls =
|
||||||
|
"rounded-lg border bg-white px-5 py-4 shadow-sm transition " +
|
||||||
|
(disabled
|
||||||
|
? "border-zinc-200 opacity-60"
|
||||||
|
: "border-zinc-200 hover:border-zinc-400 hover:shadow");
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">
|
||||||
|
{title}
|
||||||
|
{comingSoon ? (
|
||||||
|
<span className="ml-2 rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-600">
|
||||||
|
Bientôt
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600">{description}</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if (disabled || comingSoon) {
|
||||||
|
return <div className={baseCls}>{inner}</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link href={href} className={baseCls}>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/app/espace-hote/_components/BookingDecision.tsx
Normal file
77
src/app/espace-hote/_components/BookingDecision.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { confirmBookingAsHost, rejectBookingAsHost } from "../actions";
|
||||||
|
|
||||||
|
export function BookingDecision({ bookingId }: { bookingId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [confirmReject, setConfirmReject] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function accept() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await confirmBookingAsHost(bookingId);
|
||||||
|
if (res && res.ok === false) setError(res.error);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function reject() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await rejectBookingAsHost(bookingId);
|
||||||
|
if (res && res.ok === false) setError(res.error);
|
||||||
|
setConfirmReject(false);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{confirmReject ? (
|
||||||
|
<div className="flex items-center gap-1.5 rounded border border-rose-300 bg-rose-50 px-2 py-1">
|
||||||
|
<span className="text-xs text-rose-900">Refuser ?</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reject}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Oui
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmReject(false)}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={accept}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded bg-emerald-600 px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Confirmer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmReject(true)}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded border border-rose-300 bg-white px-2.5 py-1 text-[11px] font-semibold text-rose-700 hover:bg-rose-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Refuser
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error ? <span className="text-[11px] text-rose-700">{error}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue