From b9bfc5ee32d5b6e23e621f5b8bddc38f1190c99c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Karb=C3=A9=20Architect?=
Date: Fri, 29 May 2026 04:58:12 +0000
Subject: [PATCH 01/83] =?UTF-8?q?feat(espace-hote):=20CRUD=20carbet=20prop?=
=?UTF-8?q?ri=C3=A9taire=20+=20upload=20m=C3=A9dias=20S3/MinIO?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Interface propriétaire sous /espace-hote/carbets :
- Liste, création, édition et suppression de carbets (formulaire complet :
présentation, localisation, accès pirogue, commodités).
- Upload photos/vidéos vers S3/MinIO (route handler multipart), réordonnancement
et suppression des médias, photo de couverture.
- Statut de publication (brouillon / publié / archivé) avec garde
« au moins un média avant publication ».
Réutilise le schéma Prisma (SYS-2) et l'authentification NextAuth (SYS-3) :
gating via requireRole([OWNER, ADMIN]) et contrôle de propriété sur chaque
mutation. Stockage objet configurable par variables S3_* (compatible MinIO).
Co-Authored-By: Paperclip
---
.env.example | 14 +
package-lock.json | 1029 ++++++++++++++++-
package.json | 17 +-
src/app/api/carbets/[carbetId]/media/route.ts | 128 ++
.../espace-hote/carbets/[carbetId]/page.tsx | 104 ++
.../carbets/_components/carbet-form.tsx | 286 +++++
.../carbets/_components/media-manager.tsx | 199 ++++
src/app/espace-hote/carbets/actions.ts | 360 ++++++
src/app/espace-hote/carbets/form-types.ts | 7 +
src/app/espace-hote/carbets/nouveau/page.tsx | 36 +
src/app/espace-hote/carbets/page.tsx | 142 +++
src/lib/amenities.ts | 37 +
src/lib/carbet-access.ts | 21 +
src/lib/media.ts | 54 +
src/lib/slug.ts | 38 +
src/lib/storage.ts | 120 ++
16 files changed, 2577 insertions(+), 15 deletions(-)
create mode 100644 src/app/api/carbets/[carbetId]/media/route.ts
create mode 100644 src/app/espace-hote/carbets/[carbetId]/page.tsx
create mode 100644 src/app/espace-hote/carbets/_components/carbet-form.tsx
create mode 100644 src/app/espace-hote/carbets/_components/media-manager.tsx
create mode 100644 src/app/espace-hote/carbets/actions.ts
create mode 100644 src/app/espace-hote/carbets/form-types.ts
create mode 100644 src/app/espace-hote/carbets/nouveau/page.tsx
create mode 100644 src/app/espace-hote/carbets/page.tsx
create mode 100644 src/lib/amenities.ts
create mode 100644 src/lib/carbet-access.ts
create mode 100644 src/lib/media.ts
create mode 100644 src/lib/slug.ts
create mode 100644 src/lib/storage.ts
diff --git a/.env.example b/.env.example
index bd44558..5e08691 100644
--- a/.env.example
+++ b/.env.example
@@ -5,3 +5,17 @@ DATABASE_URL="postgresql://user:password@localhost:5432/karbe?schema=public"
# Secret pour NextAuth (à générer, ex: `openssl rand -base64 32`).
NEXTAUTH_SECRET="changeme"
+AUTH_SECRET="changeme"
+
+# Stockage objet des médias (S3 ou MinIO). Compatible AWS S3 et MinIO.
+# Pour MinIO en local : renseignez S3_ENDPOINT (ex: http://localhost:9000)
+# et laissez S3_FORCE_PATH_STYLE à "true".
+S3_ENDPOINT="http://localhost:9000"
+S3_REGION="us-east-1"
+S3_BUCKET="karbe-medias"
+S3_ACCESS_KEY_ID="changeme"
+S3_SECRET_ACCESS_KEY="changeme"
+# URL publique de base servant les objets (CDN ou bucket public).
+# Laissez vide pour dériver l'URL depuis S3_ENDPOINT + bucket.
+S3_PUBLIC_URL=""
+S3_FORCE_PATH_STYLE="true"
diff --git a/package-lock.json b/package-lock.json
index 47fe47f..3d3a79a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,22 +9,27 @@
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
+ "@aws-sdk/client-s3": "^3.1056.0",
+ "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
+ "bcryptjs": "^3.0.3",
"next": "16.2.6",
+ "next-auth": "^5.0.0-beta.31",
+ "pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
- "@types/node": "^20",
- "@types/react": "^19",
- "@types/react-dom": "^19",
+ "@types/node": "^20.19.41",
+ "@types/react": "^19.2.15",
+ "@types/react-dom": "^19.2.3",
"dotenv": "^17.4.2",
- "eslint": "^9",
- "eslint-config-next": "16.2.6",
+ "eslint": "^9.39.4",
+ "eslint-config-next": "^16.2.6",
"prisma": "^7.8.0",
"tailwindcss": "^4",
- "typescript": "^5"
+ "typescript": "^5.9.3"
}
},
"node_modules/@alloc/quick-lru": {
@@ -40,6 +45,529 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@auth/core": {
+ "version": "0.41.2",
+ "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz",
+ "integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==",
+ "license": "ISC",
+ "dependencies": {
+ "@panva/hkdf": "^1.2.1",
+ "jose": "^6.0.6",
+ "oauth4webapi": "^3.3.0",
+ "preact": "10.24.3",
+ "preact-render-to-string": "6.5.11"
+ },
+ "peerDependencies": {
+ "@simplewebauthn/browser": "^9.0.1",
+ "@simplewebauthn/server": "^9.0.2",
+ "nodemailer": "^7.0.7"
+ },
+ "peerDependenciesMeta": {
+ "@simplewebauthn/browser": {
+ "optional": true
+ },
+ "@simplewebauthn/server": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@aws-crypto/crc32": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
+ "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/crc32c": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz",
+ "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz",
+ "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/supports-web-crypto": "^5.2.0",
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "@aws-sdk/util-locate-window": "^3.0.0",
+ "@smithy/util-utf8": "^2.0.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-browser": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
+ "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-js": "^5.2.0",
+ "@aws-crypto/supports-web-crypto": "^5.2.0",
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "@aws-sdk/util-locate-window": "^3.0.0",
+ "@smithy/util-utf8": "^2.0.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-js": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
+ "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/supports-web-crypto": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
+ "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/util": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
+ "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.222.0",
+ "@smithy/util-utf8": "^2.0.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-sdk/client-s3": {
+ "version": "3.1056.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1056.0.tgz",
+ "integrity": "sha512-WfMZEM2eC96anIT0RzR/QmhaefWKsNjOHNv09ORBkcA++ULBnm1fRau+KKGEIbr5nDnf9BTG7E7U3oOVklQS8g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha1-browser": "5.2.0",
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.974.15",
+ "@aws-sdk/credential-provider-node": "^3.972.46",
+ "@aws-sdk/middleware-bucket-endpoint": "^3.972.17",
+ "@aws-sdk/middleware-expect-continue": "^3.972.14",
+ "@aws-sdk/middleware-flexible-checksums": "^3.974.23",
+ "@aws-sdk/middleware-location-constraint": "^3.972.11",
+ "@aws-sdk/middleware-sdk-s3": "^3.972.44",
+ "@aws-sdk/middleware-ssec": "^3.972.11",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.30",
+ "@aws-sdk/types": "^3.973.9",
+ "@smithy/core": "^3.24.5",
+ "@smithy/fetch-http-handler": "^5.4.5",
+ "@smithy/node-http-handler": "^4.7.5",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/core": {
+ "version": "3.974.15",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.15.tgz",
+ "integrity": "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.9",
+ "@aws-sdk/xml-builder": "^3.972.26",
+ "@aws/lambda-invoke-store": "^0.2.2",
+ "@smithy/core": "^3.24.5",
+ "@smithy/signature-v4": "^5.4.5",
+ "@smithy/types": "^4.14.2",
+ "bowser": "^2.11.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/crc64-nvme": {
+ "version": "3.972.9",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.9.tgz",
+ "integrity": "sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-env": {
+ "version": "3.972.41",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.41.tgz",
+ "integrity": "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.974.15",
+ "@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/credential-provider-http": {
+ "version": "3.972.43",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.43.tgz",
+ "integrity": "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.974.15",
+ "@aws-sdk/types": "^3.973.9",
+ "@smithy/core": "^3.24.5",
+ "@smithy/fetch-http-handler": "^5.4.5",
+ "@smithy/node-http-handler": "^4.7.5",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-ini": {
+ "version": "3.972.45",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.45.tgz",
+ "integrity": "sha512-sJe5ZWibO4s7RWjFQ8Zol76KxoJcIYyEZH1/wxQSBMSIAAxzaJ8cS/ITAaIHWUQvDKQdt18+cJAHKWB7n1Jmrg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.974.15",
+ "@aws-sdk/credential-provider-env": "^3.972.41",
+ "@aws-sdk/credential-provider-http": "^3.972.43",
+ "@aws-sdk/credential-provider-login": "^3.972.45",
+ "@aws-sdk/credential-provider-process": "^3.972.41",
+ "@aws-sdk/credential-provider-sso": "^3.972.45",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.45",
+ "@aws-sdk/nested-clients": "^3.997.13",
+ "@aws-sdk/types": "^3.973.9",
+ "@smithy/core": "^3.24.5",
+ "@smithy/credential-provider-imds": "^4.3.5",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-login": {
+ "version": "3.972.45",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.45.tgz",
+ "integrity": "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.974.15",
+ "@aws-sdk/nested-clients": "^3.997.13",
+ "@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/credential-provider-node": {
+ "version": "3.972.46",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.46.tgz",
+ "integrity": "sha512-cS4w0jzDRb1jOlkiJS3y80OxddHzkky/MN9k3NYs5jganNKVLjF0lpvjlwS118oGMr3cdAfOlVdo8gLurTSE7w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/credential-provider-env": "^3.972.41",
+ "@aws-sdk/credential-provider-http": "^3.972.43",
+ "@aws-sdk/credential-provider-ini": "^3.972.45",
+ "@aws-sdk/credential-provider-process": "^3.972.41",
+ "@aws-sdk/credential-provider-sso": "^3.972.45",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.45",
+ "@aws-sdk/types": "^3.973.9",
+ "@smithy/core": "^3.24.5",
+ "@smithy/credential-provider-imds": "^4.3.5",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-process": {
+ "version": "3.972.41",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.41.tgz",
+ "integrity": "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.974.15",
+ "@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/credential-provider-sso": {
+ "version": "3.972.45",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.45.tgz",
+ "integrity": "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.974.15",
+ "@aws-sdk/nested-clients": "^3.997.13",
+ "@aws-sdk/token-providers": "3.1056.0",
+ "@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/credential-provider-web-identity": {
+ "version": "3.972.45",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.45.tgz",
+ "integrity": "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.974.15",
+ "@aws-sdk/nested-clients": "^3.997.13",
+ "@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/middleware-bucket-endpoint": {
+ "version": "3.972.17",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.17.tgz",
+ "integrity": "sha512-lbDmWuHenc+kiwCNrxz4MyN6nkxCWyTXPIWuspJN0ibziu+8CXci7vI1bK9MAkwy8cwJOEXNu0gBM5S0uTGRIg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.974.15",
+ "@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/middleware-expect-continue": {
+ "version": "3.972.14",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.14.tgz",
+ "integrity": "sha512-3TNFEVGO4sWZj9TEXOCZLzGEctXHnaO4fk2EQ8KVaboTbwHmEPEQrm17Xb9koImUIXEw0sgi2xtHjg7LuTS3rA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@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/middleware-flexible-checksums": {
+ "version": "3.974.23",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.23.tgz",
+ "integrity": "sha512-4nPKARo2lfKvQGUt2fPA5NlS/mEohckdxpuC9ecbjVfj7B7NFFYHeTg+Bf5BEQwdn3yRfUIzFiEkPp8Yuaw3wA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/crc32": "5.2.0",
+ "@aws-crypto/crc32c": "5.2.0",
+ "@aws-crypto/util": "5.2.0",
+ "@aws-sdk/core": "^3.974.15",
+ "@aws-sdk/crc64-nvme": "^3.972.9",
+ "@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/middleware-location-constraint": {
+ "version": "3.972.11",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.11.tgz",
+ "integrity": "sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.9",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-sdk-s3": {
+ "version": "3.972.44",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.44.tgz",
+ "integrity": "sha512-8HQsRg1NpX8vR4vNl1E8pyLnqZroq9VSL2vZQVSgBqp6wv6365LzYD08/c9FFh/9FTg7YRc7aTtEmXF0ir/pqg==",
+ "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/middleware-ssec": {
+ "version": "3.972.11",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.11.tgz",
+ "integrity": "sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.9",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/nested-clients": {
+ "version": "3.997.13",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.13.tgz",
+ "integrity": "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@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/fetch-http-handler": "^5.4.5",
+ "@smithy/node-http-handler": "^4.7.5",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region": {
+ "version": "3.996.30",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz",
+ "integrity": "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.9",
+ "@smithy/signature-v4": "^5.4.5",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/token-providers": {
+ "version": "3.1056.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1056.0.tgz",
+ "integrity": "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.974.15",
+ "@aws-sdk/nested-clients": "^3.997.13",
+ "@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/types": {
+ "version": "3.973.9",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz",
+ "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-locate-window": {
+ "version": "3.965.5",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz",
+ "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/xml-builder": {
+ "version": "3.972.26",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.26.tgz",
+ "integrity": "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.14.2",
+ "fast-xml-parser": "5.7.3",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws/lambda-invoke-store": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz",
+ "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1312,6 +1840,18 @@
"node": ">= 10"
}
},
+ "node_modules/@nodable/entities": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz",
+ "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodable"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1360,6 +1900,27 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@panva/hkdf": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
+ "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/@prisma/adapter-pg": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz",
+ "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/driver-adapter-utils": "7.8.0",
+ "@types/pg": "^8.16.0",
+ "pg": "^8.16.3",
+ "postgres-array": "3.0.4"
+ }
+ },
"node_modules/@prisma/client": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz",
@@ -1407,7 +1968,6 @@
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz",
"integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==",
- "devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/dev": {
@@ -1436,6 +1996,15 @@
"zeptomatch": "2.1.0"
}
},
+ "node_modules/@prisma/driver-adapter-utils": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz",
+ "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "7.8.0"
+ }
+ },
"node_modules/@prisma/engines": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz",
@@ -1728,6 +2297,126 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@smithy/core": {
+ "version": "3.24.5",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz",
+ "integrity": "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/crc32": "5.2.0",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/credential-provider-imds": {
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.5.tgz",
+ "integrity": "sha512-yiF8xHpdkaTfzLVqFzsP6WvNghEK+qZzLYWFD13L2SsFhbXwBGlxdocKF95qjr7s5lE5NRage+EJFK4mAsx88Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.24.5",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/fetch-http-handler": {
+ "version": "5.4.5",
+ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.5.tgz",
+ "integrity": "sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.24.5",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/is-array-buffer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
+ "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@smithy/node-http-handler": {
+ "version": "4.7.5",
+ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz",
+ "integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.24.5",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/signature-v4": {
+ "version": "5.4.5",
+ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.5.tgz",
+ "integrity": "sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.24.5",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/types": {
+ "version": "4.14.2",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz",
+ "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-buffer-from": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
+ "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@smithy/util-utf8": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
+ "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2063,12 +2752,22 @@
"version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/pg": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
+ "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
@@ -3042,6 +3741,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bcryptjs": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+ "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
"node_modules/better-result": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz",
@@ -3049,6 +3757,12 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/bowser": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
+ "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
+ "license": "MIT"
+ },
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
@@ -4304,6 +5018,43 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/fast-xml-builder": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
+ "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "path-expression-matcher": "^1.5.0",
+ "xml-naming": "^0.1.0"
+ }
+ },
+ "node_modules/fast-xml-parser": {
+ "version": "5.7.3",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz",
+ "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@nodable/entities": "^2.1.0",
+ "fast-xml-builder": "^1.1.7",
+ "path-expression-matcher": "^1.5.0",
+ "strnum": "^2.2.3"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -5307,6 +6058,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
+ "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5978,6 +6738,33 @@
}
}
},
+ "node_modules/next-auth": {
+ "version": "5.0.0-beta.31",
+ "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.31.tgz",
+ "integrity": "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==",
+ "license": "ISC",
+ "dependencies": {
+ "@auth/core": "0.41.2"
+ },
+ "peerDependencies": {
+ "@simplewebauthn/browser": "^9.0.1",
+ "@simplewebauthn/server": "^9.0.2",
+ "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
+ "nodemailer": "^7.0.7",
+ "react": "^18.2.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@simplewebauthn/browser": {
+ "optional": true
+ },
+ "@simplewebauthn/server": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -6035,6 +6822,15 @@
"node": ">=18"
}
},
+ "node_modules/oauth4webapi": {
+ "version": "3.8.6",
+ "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
+ "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -6256,6 +7052,21 @@
"node": ">=8"
}
},
+ "node_modules/path-expression-matcher": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
+ "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -6287,6 +7098,104 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/pg": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
+ "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.13.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.14.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.13.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
+ "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
+ "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pg-types/node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6371,6 +7280,64 @@
"url": "https://github.com/sponsors/porsager"
}
},
+ "node_modules/postgres-array": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
+ "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/preact": {
+ "version": "10.24.3",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
+ "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/preact-render-to-string": {
+ "version": "6.5.11",
+ "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
+ "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "preact": ">=10"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -7012,6 +7979,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
@@ -7186,6 +8162,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/strnum": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
+ "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -7514,7 +8502,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -7726,6 +8713,30 @@
"node": ">=0.10.0"
}
},
+ "node_modules/xml-naming": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
+ "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/package.json b/package.json
index 51eb93c..f6e1dc5 100644
--- a/package.json
+++ b/package.json
@@ -10,21 +10,26 @@
"postinstall": "prisma generate"
},
"dependencies": {
+ "@aws-sdk/client-s3": "^3.1056.0",
+ "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
+ "bcryptjs": "^3.0.3",
"next": "16.2.6",
+ "next-auth": "^5.0.0-beta.31",
+ "pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
- "@types/node": "^20",
- "@types/react": "^19",
- "@types/react-dom": "^19",
+ "@types/node": "^20.19.41",
+ "@types/react": "^19.2.15",
+ "@types/react-dom": "^19.2.3",
"dotenv": "^17.4.2",
- "eslint": "^9",
- "eslint-config-next": "16.2.6",
+ "eslint": "^9.39.4",
+ "eslint-config-next": "^16.2.6",
"prisma": "^7.8.0",
"tailwindcss": "^4",
- "typescript": "^5"
+ "typescript": "^5.9.3"
}
}
diff --git a/src/app/api/carbets/[carbetId]/media/route.ts b/src/app/api/carbets/[carbetId]/media/route.ts
new file mode 100644
index 0000000..9661048
--- /dev/null
+++ b/src/app/api/carbets/[carbetId]/media/route.ts
@@ -0,0 +1,128 @@
+import { revalidatePath } from "next/cache";
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+import { auth } from "@/auth";
+import { canManageCarbet } from "@/lib/carbet-access";
+import {
+ buildMediaKey,
+ humanFileSize,
+ maxBytesForType,
+ mediaTypeForMime,
+} from "@/lib/media";
+import { prisma } from "@/lib/prisma";
+import { isStorageConfigured, putObject } from "@/lib/storage";
+import { UserRole } from "@/generated/prisma/enums";
+
+export const runtime = "nodejs";
+
+type UploadError = { name: string; error: string };
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ carbetId: string }> },
+) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
+ }
+ if (session.user.role !== UserRole.OWNER && session.user.role !== UserRole.ADMIN) {
+ return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
+ }
+
+ const { carbetId } = await params;
+
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: carbetId },
+ select: { ownerId: true },
+ });
+ if (!carbet) {
+ return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
+ }
+ if (!canManageCarbet(session, carbet.ownerId)) {
+ return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
+ }
+
+ if (!isStorageConfigured()) {
+ return NextResponse.json(
+ {
+ error:
+ "Le stockage des médias (S3/MinIO) n'est pas configuré sur le serveur.",
+ },
+ { status: 503 },
+ );
+ }
+
+ const formData = await request.formData();
+ const files = formData
+ .getAll("files")
+ .filter((entry): entry is File => entry instanceof File);
+
+ if (files.length === 0) {
+ return NextResponse.json(
+ { error: "Aucun fichier reçu." },
+ { status: 400 },
+ );
+ }
+
+ const lastMedia = await prisma.media.findFirst({
+ where: { carbetId },
+ orderBy: { sortOrder: "desc" },
+ select: { sortOrder: true },
+ });
+ let nextOrder = (lastMedia?.sortOrder ?? -1) + 1;
+
+ const created: Array<{
+ id: string;
+ type: string;
+ s3Url: string;
+ sortOrder: number;
+ }> = [];
+ const errors: UploadError[] = [];
+
+ for (const file of files) {
+ const type = mediaTypeForMime(file.type);
+ if (!type) {
+ errors.push({ name: file.name, error: `Type non supporté (${file.type}).` });
+ continue;
+ }
+ const maxBytes = maxBytesForType(type);
+ if (file.size > maxBytes) {
+ errors.push({
+ name: file.name,
+ error: `Fichier trop volumineux (max ${humanFileSize(maxBytes)}).`,
+ });
+ continue;
+ }
+
+ try {
+ const key = buildMediaKey(carbetId, file.type);
+ const buffer = Buffer.from(await file.arrayBuffer());
+ const url = await putObject(key, buffer, file.type);
+ const media = await prisma.media.create({
+ data: {
+ carbetId,
+ type,
+ s3Key: key,
+ s3Url: url,
+ sortOrder: nextOrder,
+ },
+ select: { id: true, type: true, s3Url: true, sortOrder: true },
+ });
+ nextOrder += 1;
+ created.push(media);
+ } catch (error) {
+ console.error("Échec de l'upload média", error);
+ errors.push({ name: file.name, error: "Échec de l'envoi vers le stockage." });
+ }
+ }
+
+ if (created.length > 0) {
+ revalidatePath(`/espace-hote/carbets/${carbetId}`);
+ }
+
+ return NextResponse.json(
+ { created, errors },
+ { status: created.length > 0 ? 201 : 400 },
+ );
+}
diff --git a/src/app/espace-hote/carbets/[carbetId]/page.tsx b/src/app/espace-hote/carbets/[carbetId]/page.tsx
new file mode 100644
index 0000000..93768b1
--- /dev/null
+++ b/src/app/espace-hote/carbets/[carbetId]/page.tsx
@@ -0,0 +1,104 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+
+import { canManageCarbet, requireOwnerSession } from "@/lib/carbet-access";
+import { prisma } from "@/lib/prisma";
+import { isStorageConfigured } from "@/lib/storage";
+
+import { updateCarbet } from "../actions";
+import { CarbetForm } from "../_components/carbet-form";
+import { MediaManager } from "../_components/media-manager";
+
+export default async function EditCarbetPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ carbetId: string }>;
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+}) {
+ const session = await requireOwnerSession();
+ 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,
+ status: true,
+ media: {
+ orderBy: { sortOrder: "asc" },
+ select: { id: true, type: true, s3Url: true, sortOrder: true },
+ },
+ amenities: { select: { amenity: { select: { key: true } } } },
+ },
+ });
+
+ if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
+ 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),
+ status: carbet.status,
+ amenityKeys: carbet.amenities.map((entry) => entry.amenity.key),
+ };
+
+ return (
+
+
+ ← Mes carbets
+
+
+ {carbet.title}
+
+
+ {publishError ? (
+
+ Ajoutez au moins un média avant de publier ce carbet.
+
+ ) : null}
+
+
+ Médias
+
+ Le premier média sert de photo de couverture. Réordonnez avec les
+ flèches.
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/espace-hote/carbets/_components/carbet-form.tsx b/src/app/espace-hote/carbets/_components/carbet-form.tsx
new file mode 100644
index 0000000..ac2d234
--- /dev/null
+++ b/src/app/espace-hote/carbets/_components/carbet-form.tsx
@@ -0,0 +1,286 @@
+"use client";
+
+import Link from "next/link";
+import { useActionState } from "react";
+
+import { AMENITY_CATALOG } from "@/lib/amenities";
+import { CarbetStatus } from "@/generated/prisma/enums";
+
+import { EMPTY_FORM_STATE, type CarbetFormState } from "../form-types";
+
+export type CarbetFormDefaults = {
+ title: string;
+ description: string;
+ river: string;
+ latitude: string;
+ longitude: string;
+ embarkPoint: string;
+ pirogueDurationMin: string;
+ capacity: string;
+ status: CarbetStatus;
+ amenityKeys: string[];
+};
+
+type CarbetFormProps = {
+ action: (
+ state: CarbetFormState,
+ formData: FormData,
+ ) => Promise;
+ mode: "create" | "edit";
+ carbetId?: string;
+ defaults?: Partial;
+ submitLabel: string;
+};
+
+const inputClass =
+ "mt-1 block w-full rounded-md border border-zinc-300 px-3 py-2 text-sm shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500";
+const labelClass = "block text-sm font-medium text-zinc-800";
+const errorClass = "mt-1 text-xs text-red-600";
+
+function FieldError({ message }: { message?: string }) {
+ if (!message) return null;
+ return {message}
;
+}
+
+export function CarbetForm({
+ action,
+ mode,
+ carbetId,
+ defaults = {},
+ submitLabel,
+}: CarbetFormProps) {
+ const [state, formAction, pending] = useActionState(
+ action,
+ EMPTY_FORM_STATE,
+ );
+ const selectedAmenities = new Set(defaults.amenityKeys ?? []);
+
+ return (
+
+ );
+}
diff --git a/src/app/espace-hote/carbets/_components/media-manager.tsx b/src/app/espace-hote/carbets/_components/media-manager.tsx
new file mode 100644
index 0000000..bc96e00
--- /dev/null
+++ b/src/app/espace-hote/carbets/_components/media-manager.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import { useRef, useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+import { ACCEPTED_MIME_TYPES } from "@/lib/media";
+
+import { deleteMedia, reorderMedia } from "../actions";
+
+export type MediaItem = {
+ id: string;
+ type: string;
+ s3Url: string;
+ sortOrder: number;
+};
+
+type UploadError = { name: string; error: string };
+
+export function MediaManager({
+ carbetId,
+ media,
+ storageConfigured,
+}: {
+ carbetId: string;
+ media: MediaItem[];
+ storageConfigured: boolean;
+}) {
+ const router = useRouter();
+ const fileInputRef = useRef(null);
+ const [uploading, setUploading] = useState(false);
+ const [errors, setErrors] = useState([]);
+ const [isPending, startTransition] = useTransition();
+
+ const busy = uploading || isPending;
+
+ async function handleUpload(event: React.ChangeEvent) {
+ const input = event.currentTarget;
+ const files = input.files;
+ if (!files || files.length === 0) return;
+
+ const formData = new FormData();
+ Array.from(files).forEach((file) => formData.append("files", file));
+
+ setUploading(true);
+ setErrors([]);
+ try {
+ const response = await fetch(`/api/carbets/${carbetId}/media`, {
+ method: "POST",
+ body: formData,
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok && !(data.created?.length > 0)) {
+ setErrors(
+ data.errors?.length
+ ? data.errors
+ : [{ name: "", error: data.error ?? "Échec de l'envoi." }],
+ );
+ } else if (data.errors?.length) {
+ setErrors(data.errors);
+ }
+ router.refresh();
+ } catch {
+ setErrors([{ name: "", error: "Erreur réseau lors de l'envoi." }]);
+ } finally {
+ setUploading(false);
+ input.value = "";
+ }
+ }
+
+ function handleMove(index: number, direction: -1 | 1) {
+ const target = index + direction;
+ if (target < 0 || target >= media.length) return;
+ const ids = media.map((m) => m.id);
+ [ids[index], ids[target]] = [ids[target], ids[index]];
+ startTransition(async () => {
+ await reorderMedia(carbetId, ids);
+ router.refresh();
+ });
+ }
+
+ function handleDelete(id: string) {
+ startTransition(async () => {
+ await deleteMedia(id);
+ router.refresh();
+ });
+ }
+
+ return (
+
+
+
+
+
+ Images (JPEG, PNG, WebP, AVIF) et vidéos (MP4, WebM, MOV).
+
+
+
+ {!storageConfigured ? (
+
+ Le stockage des médias n'est pas configuré sur le serveur
+ (variables S3_*). L'envoi est désactivé.
+
+ ) : null}
+
+ {errors.length > 0 ? (
+
+ {errors.map((err, index) => (
+ -
+ {err.name ? `${err.name} : ` : ""}
+ {err.error}
+
+ ))}
+
+ ) : null}
+
+ {media.length === 0 ? (
+
+ Aucun média pour le moment. Ajoutez au moins une photo pour pouvoir
+ publier ce carbet.
+
+ ) : (
+
+ {media.map((item, index) => (
+ -
+
+ {item.type === "VIDEO" ? (
+
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ )}
+ {index === 0 ? (
+
+ Couverture
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/espace-hote/carbets/actions.ts b/src/app/espace-hote/carbets/actions.ts
new file mode 100644
index 0000000..8964376
--- /dev/null
+++ b/src/app/espace-hote/carbets/actions.ts
@@ -0,0 +1,360 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { notFound, redirect } from "next/navigation";
+
+import { amenityLabel, isKnownAmenityKey } from "@/lib/amenities";
+import { canManageCarbet, requireOwnerSession } from "@/lib/carbet-access";
+import { prisma } from "@/lib/prisma";
+import { ensureUniqueCarbetSlug } from "@/lib/slug";
+import { deleteObject } from "@/lib/storage";
+import { Prisma } from "@/generated/prisma/client";
+import { CarbetStatus } from "@/generated/prisma/enums";
+
+import type { CarbetFormState } from "./form-types";
+
+type ParsedCarbet = {
+ title: string;
+ description: string;
+ river: string;
+ latitude: string;
+ longitude: string;
+ embarkPoint: string;
+ pirogueDurationMin: number;
+ capacity: number;
+ status: CarbetStatus;
+ amenities: string[];
+};
+
+function isCarbetStatus(value: string): value is CarbetStatus {
+ return (Object.values(CarbetStatus) as string[]).includes(value);
+}
+
+function parseCarbetForm(formData: FormData): {
+ data: ParsedCarbet;
+ errors: Record;
+} {
+ const errors: Record = {};
+
+ const title = String(formData.get("title") ?? "").trim();
+ const description = String(formData.get("description") ?? "").trim();
+ const river = String(formData.get("river") ?? "").trim();
+ const latitudeRaw = String(formData.get("latitude") ?? "").trim();
+ const longitudeRaw = String(formData.get("longitude") ?? "").trim();
+ const embarkPoint = String(formData.get("embarkPoint") ?? "").trim();
+ const pirogueRaw = String(formData.get("pirogueDurationMin") ?? "").trim();
+ const capacityRaw = String(formData.get("capacity") ?? "").trim();
+ const statusRaw = String(formData.get("status") ?? CarbetStatus.DRAFT).trim();
+ const amenities = formData
+ .getAll("amenities")
+ .map((value) => String(value))
+ .filter(isKnownAmenityKey);
+
+ if (title.length < 3) {
+ errors.title = "Le titre doit contenir au moins 3 caractères.";
+ } else if (title.length > 120) {
+ errors.title = "Le titre ne peut pas dépasser 120 caractères.";
+ }
+
+ if (description.length < 20) {
+ errors.description =
+ "La description doit contenir au moins 20 caractères.";
+ }
+
+ if (!river) {
+ errors.river = "Indiquez la rivière ou le fleuve.";
+ }
+
+ const latitude = Number(latitudeRaw);
+ if (!latitudeRaw || Number.isNaN(latitude) || latitude < -90 || latitude > 90) {
+ errors.latitude = "Latitude invalide (entre -90 et 90).";
+ }
+
+ const longitude = Number(longitudeRaw);
+ if (
+ !longitudeRaw ||
+ Number.isNaN(longitude) ||
+ longitude < -180 ||
+ longitude > 180
+ ) {
+ errors.longitude = "Longitude invalide (entre -180 et 180).";
+ }
+
+ if (!embarkPoint) {
+ errors.embarkPoint = "Indiquez le point d'embarquement en pirogue.";
+ }
+
+ const pirogueDurationMin = Number(pirogueRaw);
+ if (
+ !pirogueRaw ||
+ !Number.isInteger(pirogueDurationMin) ||
+ pirogueDurationMin < 0 ||
+ pirogueDurationMin > 1440
+ ) {
+ errors.pirogueDurationMin =
+ "Durée du trajet pirogue invalide (0 à 1440 minutes).";
+ }
+
+ const capacity = Number(capacityRaw);
+ if (
+ !capacityRaw ||
+ !Number.isInteger(capacity) ||
+ capacity < 1 ||
+ capacity > 100
+ ) {
+ errors.capacity = "Capacité invalide (1 à 100 personnes).";
+ }
+
+ const status = isCarbetStatus(statusRaw) ? statusRaw : CarbetStatus.DRAFT;
+
+ return {
+ data: {
+ title,
+ description,
+ river,
+ latitude: latitudeRaw,
+ longitude: longitudeRaw,
+ embarkPoint,
+ pirogueDurationMin,
+ capacity,
+ status,
+ amenities,
+ },
+ errors,
+ };
+}
+
+async function syncAmenities(
+ tx: Prisma.TransactionClient,
+ carbetId: string,
+ keys: string[],
+): Promise {
+ const uniqueKeys = Array.from(new Set(keys));
+ const amenityIds: string[] = [];
+
+ for (const key of uniqueKeys) {
+ const amenity = await tx.amenity.upsert({
+ where: { key },
+ update: {},
+ create: { key, label: amenityLabel(key) },
+ select: { id: true },
+ });
+ amenityIds.push(amenity.id);
+ }
+
+ await tx.carbetAmenity.deleteMany({ where: { carbetId } });
+ if (amenityIds.length > 0) {
+ await tx.carbetAmenity.createMany({
+ data: amenityIds.map((amenityId) => ({ carbetId, amenityId })),
+ skipDuplicates: true,
+ });
+ }
+}
+
+export async function createCarbet(
+ _prevState: CarbetFormState,
+ formData: FormData,
+): Promise {
+ const session = await requireOwnerSession();
+ const { data, errors } = parseCarbetForm(formData);
+
+ if (data.status === CarbetStatus.PUBLISHED) {
+ // A brand-new carbet has no media yet, so it cannot be published directly.
+ errors.status =
+ "Enregistrez d'abord le carbet, ajoutez des médias, puis publiez-le.";
+ }
+
+ if (Object.keys(errors).length > 0) {
+ return { ok: false, errors };
+ }
+
+ const slug = await ensureUniqueCarbetSlug(data.title);
+
+ const carbet = await prisma.$transaction(async (tx) => {
+ const created = await tx.carbet.create({
+ data: {
+ ownerId: session.user.id,
+ title: data.title,
+ slug,
+ description: data.description,
+ river: data.river,
+ latitude: data.latitude,
+ longitude: data.longitude,
+ embarkPoint: data.embarkPoint,
+ pirogueDurationMin: data.pirogueDurationMin,
+ capacity: data.capacity,
+ status: CarbetStatus.DRAFT,
+ },
+ select: { id: true },
+ });
+ await syncAmenities(tx, created.id, data.amenities);
+ return created;
+ });
+
+ revalidatePath("/espace-hote/carbets");
+ redirect(`/espace-hote/carbets/${carbet.id}`);
+}
+
+export async function updateCarbet(
+ _prevState: CarbetFormState,
+ formData: FormData,
+): Promise {
+ const session = await requireOwnerSession();
+ const carbetId = String(formData.get("carbetId") ?? "");
+
+ const existing = await prisma.carbet.findUnique({
+ where: { id: carbetId },
+ select: { ownerId: true, _count: { select: { media: true } } },
+ });
+
+ if (!existing || !canManageCarbet(session, existing.ownerId)) {
+ return {
+ ok: false,
+ errors: { _global: "Carbet introuvable ou accès refusé." },
+ };
+ }
+
+ const { data, errors } = parseCarbetForm(formData);
+
+ if (
+ data.status === CarbetStatus.PUBLISHED &&
+ existing._count.media === 0
+ ) {
+ errors.status = "Ajoutez au moins un média avant de publier ce carbet.";
+ }
+
+ if (Object.keys(errors).length > 0) {
+ return { ok: false, errors };
+ }
+
+ await prisma.$transaction(async (tx) => {
+ await tx.carbet.update({
+ where: { id: carbetId },
+ data: {
+ title: data.title,
+ description: data.description,
+ river: data.river,
+ latitude: data.latitude,
+ longitude: data.longitude,
+ embarkPoint: data.embarkPoint,
+ pirogueDurationMin: data.pirogueDurationMin,
+ capacity: data.capacity,
+ status: data.status,
+ },
+ });
+ await syncAmenities(tx, carbetId, data.amenities);
+ });
+
+ revalidatePath("/espace-hote/carbets");
+ revalidatePath(`/espace-hote/carbets/${carbetId}`);
+
+ return { ok: true, errors: {}, message: "Carbet enregistré." };
+}
+
+export async function setCarbetStatus(formData: FormData): Promise {
+ const session = await requireOwnerSession();
+ const carbetId = String(formData.get("carbetId") ?? "");
+ const statusRaw = String(formData.get("status") ?? "");
+
+ if (!isCarbetStatus(statusRaw)) {
+ notFound();
+ }
+
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: carbetId },
+ select: { ownerId: true, _count: { select: { media: true } } },
+ });
+
+ if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
+ notFound();
+ }
+
+ if (statusRaw === CarbetStatus.PUBLISHED && carbet._count.media === 0) {
+ redirect(`/espace-hote/carbets/${carbetId}?publishError=1`);
+ }
+
+ await prisma.carbet.update({
+ where: { id: carbetId },
+ data: { status: statusRaw },
+ });
+
+ revalidatePath("/espace-hote/carbets");
+ revalidatePath(`/espace-hote/carbets/${carbetId}`);
+ redirect("/espace-hote/carbets");
+}
+
+export async function deleteCarbet(formData: FormData): Promise {
+ const session = await requireOwnerSession();
+ const carbetId = String(formData.get("carbetId") ?? "");
+
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: carbetId },
+ select: { ownerId: true, media: { select: { s3Key: true } } },
+ });
+
+ if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
+ notFound();
+ }
+
+ await Promise.allSettled(
+ carbet.media.map((media) => deleteObject(media.s3Key)),
+ );
+
+ // Media and amenity joins cascade-delete via the schema relations.
+ await prisma.carbet.delete({ where: { id: carbetId } });
+
+ revalidatePath("/espace-hote/carbets");
+ redirect("/espace-hote/carbets");
+}
+
+export async function reorderMedia(
+ carbetId: string,
+ orderedMediaIds: string[],
+): Promise<{ ok: boolean }> {
+ const session = await requireOwnerSession();
+
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: carbetId },
+ select: { ownerId: true, media: { select: { id: true } } },
+ });
+
+ if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
+ return { ok: false };
+ }
+
+ const validIds = new Set(carbet.media.map((m) => m.id));
+ const sanitized = orderedMediaIds.filter((id) => validIds.has(id));
+
+ await prisma.$transaction(
+ sanitized.map((id, index) =>
+ prisma.media.update({
+ where: { id },
+ data: { sortOrder: index },
+ }),
+ ),
+ );
+
+ revalidatePath(`/espace-hote/carbets/${carbetId}`);
+ return { ok: true };
+}
+
+export async function deleteMedia(
+ mediaId: string,
+): Promise<{ ok: boolean }> {
+ const session = await requireOwnerSession();
+
+ const media = await prisma.media.findUnique({
+ where: { id: mediaId },
+ select: { s3Key: true, carbetId: true, carbet: { select: { ownerId: true } } },
+ });
+
+ if (!media || !canManageCarbet(session, media.carbet.ownerId)) {
+ return { ok: false };
+ }
+
+ await deleteObject(media.s3Key);
+ await prisma.media.delete({ where: { id: mediaId } });
+
+ revalidatePath(`/espace-hote/carbets/${media.carbetId}`);
+ return { ok: true };
+}
diff --git a/src/app/espace-hote/carbets/form-types.ts b/src/app/espace-hote/carbets/form-types.ts
new file mode 100644
index 0000000..2710c42
--- /dev/null
+++ b/src/app/espace-hote/carbets/form-types.ts
@@ -0,0 +1,7 @@
+export type CarbetFormState = {
+ ok: boolean;
+ errors: Record;
+ message?: string;
+};
+
+export const EMPTY_FORM_STATE: CarbetFormState = { ok: false, errors: {} };
diff --git a/src/app/espace-hote/carbets/nouveau/page.tsx b/src/app/espace-hote/carbets/nouveau/page.tsx
new file mode 100644
index 0000000..b48f5af
--- /dev/null
+++ b/src/app/espace-hote/carbets/nouveau/page.tsx
@@ -0,0 +1,36 @@
+import Link from "next/link";
+
+import { requireOwnerSession } from "@/lib/carbet-access";
+
+import { createCarbet } from "../actions";
+import { CarbetForm } from "../_components/carbet-form";
+
+export default async function NewCarbetPage() {
+ await requireOwnerSession();
+
+ return (
+
+
+ ← Mes carbets
+
+
+ Nouveau carbet
+
+
+ Renseignez les informations principales. Vous ajouterez les médias à
+ l'étape suivante.
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/espace-hote/carbets/page.tsx b/src/app/espace-hote/carbets/page.tsx
new file mode 100644
index 0000000..f4fe8a1
--- /dev/null
+++ b/src/app/espace-hote/carbets/page.tsx
@@ -0,0 +1,142 @@
+import Link from "next/link";
+
+import { requireOwnerSession } from "@/lib/carbet-access";
+import { prisma } from "@/lib/prisma";
+import { CarbetStatus } from "@/generated/prisma/enums";
+
+import { deleteCarbet, setCarbetStatus } from "./actions";
+
+const STATUS_LABELS: Record = {
+ [CarbetStatus.DRAFT]: "Brouillon",
+ [CarbetStatus.PUBLISHED]: "Publié",
+ [CarbetStatus.ARCHIVED]: "Archivé",
+};
+
+const STATUS_STYLES: Record = {
+ [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 CarbetsListPage() {
+ const session = await requireOwnerSession();
+
+ const carbets = await prisma.carbet.findMany({
+ where: { ownerId: session.user.id },
+ orderBy: { updatedAt: "desc" },
+ select: {
+ id: true,
+ title: true,
+ river: true,
+ status: true,
+ updatedAt: true,
+ _count: { select: { media: true } },
+ },
+ });
+
+ return (
+
+
+
+
Mes carbets
+
+ Gérez vos annonces, leurs médias et leur statut de publication.
+
+
+
+ Nouveau carbet
+
+
+
+ {carbets.length === 0 ? (
+
+ Vous n'avez pas encore de carbet. Créez votre première annonce
+ pour commencer.
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/lib/amenities.ts b/src/lib/amenities.ts
new file mode 100644
index 0000000..3ed4f79
--- /dev/null
+++ b/src/lib/amenities.ts
@@ -0,0 +1,37 @@
+export type AmenityDefinition = {
+ key: string;
+ label: string;
+ description?: string;
+};
+
+// Catalogue de commodités adapté aux carbets fluviaux de Guyane.
+// Les lignes Amenity sont créées à la demande (upsert par `key`) lors de
+// l'enregistrement d'un carbet, ce qui évite un script de seed dédié.
+export const AMENITY_CATALOG: AmenityDefinition[] = [
+ { key: "hamac", label: "Hamacs fournis" },
+ { key: "moustiquaire", label: "Moustiquaires" },
+ { key: "eau_potable", label: "Eau potable" },
+ { key: "electricite_solaire", label: "Électricité solaire" },
+ { key: "groupe_electrogene", label: "Groupe électrogène" },
+ { key: "toilettes_seches", label: "Toilettes sèches" },
+ { key: "douche", label: "Douche" },
+ { key: "cuisine_equipee", label: "Cuisine équipée" },
+ { key: "rechaud_gaz", label: "Réchaud à gaz" },
+ { key: "glaciere", label: "Glacière / réfrigérateur" },
+ { key: "carbet_couvert", label: "Carbet couvert" },
+ { key: "baignade_riviere", label: "Baignade en rivière" },
+ { key: "materiel_peche", label: "Matériel de pêche" },
+ { key: "kayak_canoe", label: "Kayak / canoë" },
+ { key: "barbecue", label: "Barbecue / foyer" },
+ { key: "guide_local", label: "Guide local disponible" },
+];
+
+const CATALOG_BY_KEY = new Map(AMENITY_CATALOG.map((a) => [a.key, a]));
+
+export function isKnownAmenityKey(key: string): boolean {
+ return CATALOG_BY_KEY.has(key);
+}
+
+export function amenityLabel(key: string): string {
+ return CATALOG_BY_KEY.get(key)?.label ?? key;
+}
diff --git a/src/lib/carbet-access.ts b/src/lib/carbet-access.ts
new file mode 100644
index 0000000..420cd49
--- /dev/null
+++ b/src/lib/carbet-access.ts
@@ -0,0 +1,21 @@
+import type { Session } from "next-auth";
+
+import { requireRole } from "@/lib/authorization";
+import { UserRole } from "@/generated/prisma/enums";
+
+const MANAGER_ROLES: UserRole[] = [UserRole.OWNER, UserRole.ADMIN];
+
+// Owner area (espace hôte) — accessible to carbet owners and admins.
+export async function requireOwnerSession(): Promise {
+ return requireRole(MANAGER_ROLES);
+}
+
+// A user can manage a given carbet if they own it, or if they are an admin.
+export function canManageCarbet(
+ session: Session,
+ carbetOwnerId: string,
+): boolean {
+ return (
+ session.user.role === UserRole.ADMIN || session.user.id === carbetOwnerId
+ );
+}
diff --git a/src/lib/media.ts b/src/lib/media.ts
new file mode 100644
index 0000000..1108841
--- /dev/null
+++ b/src/lib/media.ts
@@ -0,0 +1,54 @@
+import { randomUUID } from "node:crypto";
+
+import { MediaType } from "@/generated/prisma/enums";
+
+export const MAX_PHOTO_BYTES = 10 * 1024 * 1024; // 10 Mo
+export const MAX_VIDEO_BYTES = 200 * 1024 * 1024; // 200 Mo
+
+const PHOTO_MIME: Record = {
+ "image/jpeg": "jpg",
+ "image/png": "png",
+ "image/webp": "webp",
+ "image/avif": "avif",
+};
+
+const VIDEO_MIME: Record = {
+ "video/mp4": "mp4",
+ "video/webm": "webm",
+ "video/quicktime": "mov",
+};
+
+export const ACCEPTED_MIME_TYPES = [
+ ...Object.keys(PHOTO_MIME),
+ ...Object.keys(VIDEO_MIME),
+];
+
+export function mediaTypeForMime(mime: string): MediaType | null {
+ if (mime in PHOTO_MIME) return MediaType.PHOTO;
+ if (mime in VIDEO_MIME) return MediaType.VIDEO;
+ return null;
+}
+
+export function extensionForMime(mime: string): string {
+ return PHOTO_MIME[mime] ?? VIDEO_MIME[mime] ?? "bin";
+}
+
+export function maxBytesForType(type: MediaType): number {
+ return type === MediaType.VIDEO ? MAX_VIDEO_BYTES : MAX_PHOTO_BYTES;
+}
+
+export function buildMediaKey(carbetId: string, mime: string): string {
+ return `carbets/${carbetId}/${randomUUID()}.${extensionForMime(mime)}`;
+}
+
+export function humanFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} o`;
+ const units = ["Ko", "Mo", "Go"];
+ let value = bytes / 1024;
+ let unitIndex = 0;
+ while (value >= 1024 && unitIndex < units.length - 1) {
+ value /= 1024;
+ unitIndex += 1;
+ }
+ return `${value.toFixed(1)} ${units[unitIndex]}`;
+}
diff --git a/src/lib/slug.ts b/src/lib/slug.ts
new file mode 100644
index 0000000..ec7bfee
--- /dev/null
+++ b/src/lib/slug.ts
@@ -0,0 +1,38 @@
+import { prisma } from "@/lib/prisma";
+
+export function slugify(input: string): string {
+ const base = input
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 80);
+ return base || "carbet";
+}
+
+export async function ensureUniqueCarbetSlug(
+ source: string,
+ excludeId?: string,
+): Promise {
+ const root = slugify(source);
+ let candidate = root;
+ let suffix = 1;
+
+ // Loop until we find a slug not used by another carbet.
+ for (;;) {
+ const existing = await prisma.carbet.findFirst({
+ where: {
+ slug: candidate,
+ ...(excludeId ? { NOT: { id: excludeId } } : {}),
+ },
+ select: { id: true },
+ });
+ if (!existing) {
+ return candidate;
+ }
+ suffix += 1;
+ candidate = `${root}-${suffix}`;
+ }
+}
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
new file mode 100644
index 0000000..353ef22
--- /dev/null
+++ b/src/lib/storage.ts
@@ -0,0 +1,120 @@
+import {
+ DeleteObjectCommand,
+ PutObjectCommand,
+ S3Client,
+} from "@aws-sdk/client-s3";
+
+type StorageConfig = {
+ endpoint?: string;
+ region: string;
+ bucket: string;
+ accessKeyId: string;
+ secretAccessKey: string;
+ publicUrl?: string;
+ forcePathStyle: boolean;
+};
+
+export class StorageNotConfiguredError extends Error {
+ constructor() {
+ super(
+ "Le stockage objet (S3/MinIO) n'est pas configuré. Renseignez les variables S3_* dans votre environnement.",
+ );
+ this.name = "StorageNotConfiguredError";
+ }
+}
+
+function readConfig(): StorageConfig | null {
+ const bucket = process.env.S3_BUCKET;
+ const accessKeyId = process.env.S3_ACCESS_KEY_ID;
+ const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
+
+ if (!bucket || !accessKeyId || !secretAccessKey) {
+ return null;
+ }
+
+ const endpoint = process.env.S3_ENDPOINT || undefined;
+ // MinIO and most S3-compatible servers require path-style addressing.
+ // Default to path-style whenever a custom endpoint is set, unless opted out.
+ const forcePathStyle = endpoint
+ ? process.env.S3_FORCE_PATH_STYLE !== "false"
+ : process.env.S3_FORCE_PATH_STYLE === "true";
+
+ return {
+ endpoint,
+ region: process.env.S3_REGION || "us-east-1",
+ bucket,
+ accessKeyId,
+ secretAccessKey,
+ publicUrl: process.env.S3_PUBLIC_URL || undefined,
+ forcePathStyle,
+ };
+}
+
+let cachedClient: S3Client | null = null;
+
+function getClient(config: StorageConfig): S3Client {
+ if (!cachedClient) {
+ cachedClient = new S3Client({
+ region: config.region,
+ endpoint: config.endpoint,
+ forcePathStyle: config.forcePathStyle,
+ credentials: {
+ accessKeyId: config.accessKeyId,
+ secretAccessKey: config.secretAccessKey,
+ },
+ });
+ }
+ return cachedClient;
+}
+
+export function isStorageConfigured(): boolean {
+ return readConfig() !== null;
+}
+
+function buildPublicUrl(config: StorageConfig, key: string): string {
+ const trim = (value: string) => value.replace(/\/+$/, "");
+ if (config.publicUrl) {
+ return `${trim(config.publicUrl)}/${key}`;
+ }
+ if (config.endpoint) {
+ return config.forcePathStyle
+ ? `${trim(config.endpoint)}/${config.bucket}/${key}`
+ : `${trim(config.endpoint).replace("://", `://${config.bucket}.`)}/${key}`;
+ }
+ return `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
+}
+
+export async function putObject(
+ key: string,
+ body: Buffer | Uint8Array,
+ contentType: string,
+): Promise {
+ const config = readConfig();
+ if (!config) {
+ throw new StorageNotConfiguredError();
+ }
+
+ await getClient(config).send(
+ new PutObjectCommand({
+ Bucket: config.bucket,
+ Key: key,
+ Body: body,
+ ContentType: contentType,
+ }),
+ );
+
+ return buildPublicUrl(config, key);
+}
+
+export async function deleteObject(key: string): Promise {
+ const config = readConfig();
+ if (!config) {
+ // Nothing we can do without storage credentials; treat as a no-op so the
+ // database row can still be removed.
+ return;
+ }
+
+ await getClient(config).send(
+ new DeleteObjectCommand({ Bucket: config.bucket, Key: key }),
+ );
+}
From c2df6722f22990591dbd141350c774792fd85085 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Karb=C3=A9=20Architect?=
Date: Fri, 29 May 2026 22:24:25 +0000
Subject: [PATCH 02/83] feat(carbets): public search + carbet detail page
(SSR/SEO)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implémente SYS-5 : la marketplace publique pour découvrir les carbets
fluviaux publiés par les hôtes.
- /carbets : page de recherche server-side avec filtres GET
(fleuve, dates de séjour, capacité min.), grille de résultats
avec photo de couverture, fleuve, capacité, durée pirogue
- /carbets/[slug] : fiche carbet SSR
- generateMetadata (title/description + OpenGraph/Twitter cards)
- galerie médias (photo couverture + vignettes vidéo/photo)
- description, équipements (catalogue), accès, coords GPS,
capacité, prénom de l'hôte
- robots.ts + sitemap.xml (incluant les carbets publiés)
- metadataBase / title.template au niveau du root layout, OG par
défaut Karbé
- Lien "Découvrir les carbets" sur la home
- Helpers partagés : lib/carbet-search.ts (parse filters + query),
lib/carbet-public.ts (fetch SSR mémoïsé via React cache),
lib/format.ts (durée pirogue, troncature, coords)
- Nouvelle variable d'env NEXT_PUBLIC_SITE_URL (canonical/OG/sitemap)
Co-Authored-By: Paperclip
---
.env.example | 4 +
src/app/carbets/[slug]/page.tsx | 171 ++++++++++++++++++
src/app/carbets/_components/carbet-card.tsx | 47 +++++
.../carbets/_components/carbet-gallery.tsx | 73 ++++++++
.../carbets/_components/search-filters.tsx | 90 +++++++++
src/app/carbets/page.tsx | 87 +++++++++
src/app/layout.tsx | 16 +-
src/app/page.tsx | 16 ++
src/app/robots.ts | 16 ++
src/app/sitemap.ts | 38 ++++
src/lib/carbet-public.ts | 85 +++++++++
src/lib/carbet-search.ts | 158 ++++++++++++++++
src/lib/format.ts | 27 +++
13 files changed, 827 insertions(+), 1 deletion(-)
create mode 100644 src/app/carbets/[slug]/page.tsx
create mode 100644 src/app/carbets/_components/carbet-card.tsx
create mode 100644 src/app/carbets/_components/carbet-gallery.tsx
create mode 100644 src/app/carbets/_components/search-filters.tsx
create mode 100644 src/app/carbets/page.tsx
create mode 100644 src/app/robots.ts
create mode 100644 src/app/sitemap.ts
create mode 100644 src/lib/carbet-public.ts
create mode 100644 src/lib/carbet-search.ts
create mode 100644 src/lib/format.ts
diff --git a/.env.example b/.env.example
index 5e08691..7190dcf 100644
--- a/.env.example
+++ b/.env.example
@@ -7,6 +7,10 @@ DATABASE_URL="postgresql://user:password@localhost:5432/karbe?schema=public"
NEXTAUTH_SECRET="changeme"
AUTH_SECRET="changeme"
+# URL publique du site, utilisée pour résoudre les URLs canoniques et
+# OpenGraph (SEO). En développement, laissez la valeur par défaut.
+NEXT_PUBLIC_SITE_URL="http://localhost:3000"
+
# Stockage objet des médias (S3 ou MinIO). Compatible AWS S3 et MinIO.
# Pour MinIO en local : renseignez S3_ENDPOINT (ex: http://localhost:9000)
# et laissez S3_FORCE_PATH_STYLE à "true".
diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx
new file mode 100644
index 0000000..cf0ed79
--- /dev/null
+++ b/src/app/carbets/[slug]/page.tsx
@@ -0,0 +1,171 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+
+import { getPublicCarbet } from "@/lib/carbet-public";
+import {
+ formatCoordinate,
+ formatPirogueDuration,
+ truncate,
+} from "@/lib/format";
+import { MediaType } from "@/generated/prisma/enums";
+
+import { CarbetGallery } from "../_components/carbet-gallery";
+
+type PageProps = {
+ params: Promise<{ slug: string }>;
+};
+
+export async function generateMetadata({
+ params,
+}: PageProps): Promise {
+ const { slug } = await params;
+ const carbet = await getPublicCarbet(slug);
+
+ if (!carbet) {
+ return {
+ title: "Carbet introuvable",
+ robots: { index: false, follow: false },
+ };
+ }
+
+ const description = truncate(carbet.description, 200);
+ const coverPhoto = carbet.media.find((m) => m.type === MediaType.PHOTO);
+ const ogImages = coverPhoto
+ ? [{ url: coverPhoto.url, alt: `Photo de ${carbet.title}` }]
+ : undefined;
+ const canonical = `/carbets/${carbet.slug}`;
+
+ return {
+ title: `${carbet.title} — Carbet sur ${carbet.river}`,
+ description,
+ alternates: { canonical },
+ openGraph: {
+ type: "website",
+ title: `${carbet.title} — Carbet sur le fleuve ${carbet.river}`,
+ description,
+ url: canonical,
+ siteName: "Karbé",
+ locale: "fr_FR",
+ images: ogImages,
+ },
+ twitter: {
+ card: ogImages ? "summary_large_image" : "summary",
+ title: carbet.title,
+ description,
+ images: ogImages?.map((img) => img.url),
+ },
+ };
+}
+
+export default async function PublicCarbetPage({ params }: PageProps) {
+ const { slug } = await params;
+ const carbet = await getPublicCarbet(slug);
+
+ if (!carbet) {
+ notFound();
+ }
+
+ return (
+
+
+ ← Tous les carbets
+
+
+
+
+ Fleuve {carbet.river}
+
+
+ {carbet.title}
+
+
+ Accueil par {carbet.ownerFirstName} · {carbet.capacity} voyageur
+ {carbet.capacity > 1 ? "s" : ""} · Pirogue{" "}
+ {formatPirogueDuration(carbet.pirogueDurationMin)} depuis{" "}
+ {carbet.embarkPoint}
+
+
+
+
+
+
+
+
+
+ À propos de ce carbet
+
+
+ {carbet.description}
+
+
+
+ {carbet.amenities.length > 0 ? (
+
+
+ Équipements
+
+
+ {carbet.amenities.map((amenity) => (
+ -
+
+ ●
+
+ {amenity.label}
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx
new file mode 100644
index 0000000..0667ccf
--- /dev/null
+++ b/src/app/carbets/_components/carbet-card.tsx
@@ -0,0 +1,47 @@
+import Link from "next/link";
+
+import type { CarbetSearchResult } from "@/lib/carbet-search";
+import { formatPirogueDuration, truncate } from "@/lib/format";
+
+export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
+ const href = `/carbets/${carbet.slug}`;
+ return (
+
+
+ {carbet.coverUrl ? (
+ // Use a plain
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
+
+ ) : (
+
+ Pas de photo
+
+ )}
+
+
+
+
+ {carbet.title}
+
+
+
+ Fleuve {carbet.river} · {carbet.capacity} voyageur
+ {carbet.capacity > 1 ? "s" : ""}
+
+
+ {truncate(carbet.description, 180)}
+
+
+ Pirogue {formatPirogueDuration(carbet.pirogueDurationMin)} depuis{" "}
+ {carbet.embarkPoint}
+
+
+
+ );
+}
diff --git a/src/app/carbets/_components/carbet-gallery.tsx b/src/app/carbets/_components/carbet-gallery.tsx
new file mode 100644
index 0000000..807adda
--- /dev/null
+++ b/src/app/carbets/_components/carbet-gallery.tsx
@@ -0,0 +1,73 @@
+import type { PublicCarbetMedia } from "@/lib/carbet-public";
+import { MediaType } from "@/generated/prisma/enums";
+
+type Props = {
+ title: string;
+ media: PublicCarbetMedia[];
+};
+
+// SSR-friendly gallery: shows a cover (photo or video) plus a strip of
+// secondary media. No client component — all native HTML controls.
+export function CarbetGallery({ title, media }: Props) {
+ if (media.length === 0) {
+ return (
+
+ Pas encore de média pour ce carbet.
+
+ );
+ }
+
+ const [cover, ...rest] = media;
+
+ return (
+
+
+ {cover.type === MediaType.VIDEO ? (
+
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+
+ {rest.length > 0 ? (
+
+ {rest.map((item) => (
+ -
+ {item.type === MediaType.VIDEO ? (
+
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/src/app/carbets/_components/search-filters.tsx b/src/app/carbets/_components/search-filters.tsx
new file mode 100644
index 0000000..3b77f08
--- /dev/null
+++ b/src/app/carbets/_components/search-filters.tsx
@@ -0,0 +1,90 @@
+import type { CarbetSearchFilters } from "@/lib/carbet-search";
+
+type SearchFiltersProps = {
+ filters: CarbetSearchFilters;
+ rivers: string[];
+};
+
+function toDateInput(date: Date | undefined): string {
+ if (!date) return "";
+ // The Date was built from a YYYY-MM-DD UTC string, so toISOString() gives us
+ // back the same calendar day regardless of the server timezone.
+ return date.toISOString().slice(0, 10);
+}
+
+export function SearchFilters({ filters, rivers }: SearchFiltersProps) {
+ return (
+
+ );
+}
diff --git a/src/app/carbets/page.tsx b/src/app/carbets/page.tsx
new file mode 100644
index 0000000..a49ed1b
--- /dev/null
+++ b/src/app/carbets/page.tsx
@@ -0,0 +1,87 @@
+import type { Metadata } from "next";
+
+import {
+ listPublishedRivers,
+ parseSearchFilters,
+ searchCarbets,
+ type RawSearchParams,
+} from "@/lib/carbet-search";
+
+import { CarbetCard } from "./_components/carbet-card";
+import { SearchFilters } from "./_components/search-filters";
+
+export const metadata: Metadata = {
+ title: "Rechercher un carbet",
+ description:
+ "Explorez les carbets fluviaux de Guyane disponibles sur Karbé : filtrez par fleuve, dates de séjour et capacité d'accueil.",
+ alternates: { canonical: "/carbets" },
+ openGraph: {
+ title: "Rechercher un carbet — Karbé",
+ description:
+ "Trouvez un carbet authentique le long des fleuves de Guyane. Filtrez par fleuve, dates et capacité.",
+ type: "website",
+ },
+};
+
+export default async function CarbetsSearchPage({
+ searchParams,
+}: {
+ searchParams: Promise;
+}) {
+ const raw = await searchParams;
+ const filters = parseSearchFilters(raw);
+
+ const [results, rivers] = await Promise.all([
+ searchCarbets(filters),
+ listPublishedRivers(),
+ ]);
+
+ const hasActiveFilters = Boolean(
+ filters.river ||
+ filters.startDate ||
+ filters.endDate ||
+ filters.capacity,
+ );
+
+ return (
+
+
+
+ Carbets fluviaux de Guyane
+
+
+ Sélectionnez votre fleuve, vos dates et le nombre de voyageurs pour
+ découvrir les carbets disponibles, depuis le Maroni jusqu'à
+ l'Oyapock.
+
+
+
+
+
+
+ Résultats de la recherche
+ {results.length === 0 ? (
+
+ {hasActiveFilters
+ ? "Aucun carbet ne correspond à votre recherche. Essayez d'élargir les filtres."
+ : "Aucun carbet publié pour le moment. Revenez bientôt !"}
+
+ ) : (
+ <>
+
+ {results.length} carbet{results.length > 1 ? "s" : ""} trouvé
+ {results.length > 1 ? "s" : ""}.
+
+
+ {results.map((carbet) => (
+ -
+
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index fb56576..a671161 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -12,10 +12,24 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
+const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
+
export const metadata: Metadata = {
- title: "Karbé — carbets fluviaux de Guyane",
+ metadataBase: new URL(siteUrl),
+ title: {
+ default: "Karbé — carbets fluviaux de Guyane",
+ template: "%s | Karbé",
+ },
description:
"Karbé, la marketplace de location de carbets fluviaux de Guyane.",
+ openGraph: {
+ type: "website",
+ siteName: "Karbé",
+ locale: "fr_FR",
+ title: "Karbé — carbets fluviaux de Guyane",
+ description:
+ "La marketplace pour louer des carbets fluviaux le long des fleuves de Guyane.",
+ },
};
export default function RootLayout({
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 44be437..d709fb4 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,3 +1,5 @@
+import Link from "next/link";
+
export default function Home() {
return (
@@ -10,6 +12,20 @@ export default function Home() {
Connecter voyageurs et hôtes pour des séjours authentiques au cœur de
la forêt amazonienne.
+
+
+ Découvrir les carbets
+
+
+ Espace hôte
+
+
);
diff --git a/src/app/robots.ts b/src/app/robots.ts
new file mode 100644
index 0000000..f53e553
--- /dev/null
+++ b/src/app/robots.ts
@@ -0,0 +1,16 @@
+import type { MetadataRoute } from "next";
+
+const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: [
+ {
+ userAgent: "*",
+ allow: "/",
+ disallow: ["/admin", "/espace-hote", "/api/", "/connexion"],
+ },
+ ],
+ sitemap: `${siteUrl.replace(/\/+$/, "")}/sitemap.xml`,
+ };
+}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
new file mode 100644
index 0000000..78d6edf
--- /dev/null
+++ b/src/app/sitemap.ts
@@ -0,0 +1,38 @@
+import type { MetadataRoute } from "next";
+
+import { prisma } from "@/lib/prisma";
+import { CarbetStatus } from "@/generated/prisma/enums";
+
+const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
+
+function abs(path: string): string {
+ return `${siteUrl.replace(/\/+$/, "")}${path}`;
+}
+
+export default async function sitemap(): Promise {
+ const staticRoutes: MetadataRoute.Sitemap = [
+ { url: abs("/"), changeFrequency: "weekly", priority: 1 },
+ { url: abs("/carbets"), changeFrequency: "daily", priority: 0.9 },
+ { url: abs("/cgv"), changeFrequency: "yearly", priority: 0.2 },
+ { url: abs("/mentions-legales"), changeFrequency: "yearly", priority: 0.2 },
+ {
+ url: abs("/politique-de-confidentialite"),
+ changeFrequency: "yearly",
+ priority: 0.2,
+ },
+ ];
+
+ const carbets = await prisma.carbet.findMany({
+ where: { status: CarbetStatus.PUBLISHED },
+ select: { slug: true, updatedAt: true },
+ });
+
+ const carbetRoutes: MetadataRoute.Sitemap = carbets.map((carbet) => ({
+ url: abs(`/carbets/${carbet.slug}`),
+ lastModified: carbet.updatedAt,
+ changeFrequency: "weekly",
+ priority: 0.7,
+ }));
+
+ return [...staticRoutes, ...carbetRoutes];
+}
diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts
new file mode 100644
index 0000000..5735b6c
--- /dev/null
+++ b/src/lib/carbet-public.ts
@@ -0,0 +1,85 @@
+import { cache } from "react";
+
+import { prisma } from "@/lib/prisma";
+import { amenityLabel } from "@/lib/amenities";
+import { CarbetStatus, MediaType } from "@/generated/prisma/enums";
+
+export type PublicCarbetMedia = {
+ id: string;
+ type: MediaType;
+ url: string;
+};
+
+export type PublicCarbetDetail = {
+ id: string;
+ slug: string;
+ title: string;
+ description: string;
+ river: string;
+ embarkPoint: string;
+ pirogueDurationMin: number;
+ capacity: number;
+ latitude: string;
+ longitude: string;
+ ownerFirstName: string;
+ media: PublicCarbetMedia[];
+ amenities: { key: string; label: string }[];
+};
+
+// Memoized within a single request so generateMetadata() and the page itself
+// only hit the database once per render.
+export const getPublicCarbet = cache(
+ async (slug: string): Promise => {
+ const carbet = await prisma.carbet.findFirst({
+ where: { slug, status: CarbetStatus.PUBLISHED },
+ select: {
+ id: true,
+ slug: true,
+ title: true,
+ description: true,
+ river: true,
+ embarkPoint: true,
+ pirogueDurationMin: true,
+ capacity: true,
+ latitude: true,
+ longitude: true,
+ owner: { select: { firstName: true } },
+ media: {
+ orderBy: { sortOrder: "asc" },
+ select: { id: true, type: true, s3Url: true },
+ },
+ amenities: {
+ select: { amenity: { select: { key: true, label: true } } },
+ },
+ },
+ });
+
+ if (!carbet) return null;
+
+ return {
+ id: carbet.id,
+ slug: carbet.slug,
+ title: carbet.title,
+ description: carbet.description,
+ river: carbet.river,
+ embarkPoint: carbet.embarkPoint,
+ pirogueDurationMin: carbet.pirogueDurationMin,
+ capacity: carbet.capacity,
+ latitude: carbet.latitude.toString(),
+ longitude: carbet.longitude.toString(),
+ ownerFirstName: carbet.owner.firstName,
+ media: carbet.media.map((m) => ({
+ id: m.id,
+ type: m.type,
+ url: m.s3Url,
+ })),
+ amenities: carbet.amenities
+ .map((entry) => ({
+ key: entry.amenity.key,
+ // Prefer the catalogue label so renames roll out without a backfill.
+ label: amenityLabel(entry.amenity.key) || entry.amenity.label,
+ }))
+ .sort((a, b) => a.label.localeCompare(b.label, "fr")),
+ };
+ },
+);
diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts
new file mode 100644
index 0000000..d6c2fd8
--- /dev/null
+++ b/src/lib/carbet-search.ts
@@ -0,0 +1,158 @@
+import { prisma } from "@/lib/prisma";
+import { Prisma } from "@/generated/prisma/client";
+import {
+ AvailabilityBlockReason,
+ AvailabilityScope,
+ CarbetStatus,
+} from "@/generated/prisma/enums";
+
+export type CarbetSearchFilters = {
+ river?: string;
+ startDate?: Date;
+ endDate?: Date;
+ capacity?: number;
+};
+
+export type RawSearchParams = {
+ [key: string]: string | string[] | undefined;
+};
+
+function pickString(value: string | string[] | undefined): string | undefined {
+ if (Array.isArray(value)) return value[0];
+ return value;
+}
+
+// Parse and normalize raw URLSearchParams into a typed filter set.
+// Invalid / partial inputs are dropped so the search page degrades gracefully.
+export function parseSearchFilters(
+ searchParams: RawSearchParams,
+): CarbetSearchFilters {
+ const filters: CarbetSearchFilters = {};
+
+ const river = pickString(searchParams.river)?.trim();
+ if (river) {
+ filters.river = river;
+ }
+
+ const startRaw = pickString(searchParams.startDate);
+ const endRaw = pickString(searchParams.endDate);
+ const start = startRaw ? new Date(`${startRaw}T00:00:00.000Z`) : undefined;
+ const end = endRaw ? new Date(`${endRaw}T23:59:59.999Z`) : undefined;
+ const startValid = start && !Number.isNaN(start.getTime());
+ const endValid = end && !Number.isNaN(end.getTime());
+
+ // Only honour a date range if both bounds parse and start <= end.
+ if (startValid && endValid && start! <= end!) {
+ filters.startDate = start;
+ filters.endDate = end;
+ } else if (startValid && !endRaw) {
+ filters.startDate = start;
+ } else if (endValid && !startRaw) {
+ filters.endDate = end;
+ }
+
+ const capacityRaw = pickString(searchParams.capacity);
+ if (capacityRaw) {
+ const capacity = Number(capacityRaw);
+ if (Number.isInteger(capacity) && capacity > 0 && capacity <= 100) {
+ filters.capacity = capacity;
+ }
+ }
+
+ return filters;
+}
+
+export type CarbetSearchResult = {
+ id: string;
+ slug: string;
+ title: string;
+ river: string;
+ embarkPoint: string;
+ pirogueDurationMin: number;
+ capacity: number;
+ description: string;
+ coverUrl: string | null;
+ mediaCount: number;
+};
+
+// Build the Prisma where-clause for a public carbet search. A carbet is only
+// considered if it is PUBLISHED and (when dates are given) has at least one
+// PUBLIC + available slot that covers the requested range.
+function buildWhere(filters: CarbetSearchFilters): Prisma.CarbetWhereInput {
+ const where: Prisma.CarbetWhereInput = {
+ status: CarbetStatus.PUBLISHED,
+ };
+
+ if (filters.river) {
+ where.river = { contains: filters.river, mode: "insensitive" };
+ }
+
+ if (filters.capacity) {
+ where.capacity = { gte: filters.capacity };
+ }
+
+ if (filters.startDate && filters.endDate) {
+ where.availabilities = {
+ some: {
+ scope: AvailabilityScope.PUBLIC,
+ isAvailable: true,
+ blockReason: AvailabilityBlockReason.NONE,
+ startDate: { lte: filters.startDate },
+ endDate: { gte: filters.endDate },
+ },
+ };
+ }
+
+ return where;
+}
+
+export async function searchCarbets(
+ filters: CarbetSearchFilters,
+ limit = 30,
+): Promise {
+ const carbets = await prisma.carbet.findMany({
+ where: buildWhere(filters),
+ orderBy: [{ updatedAt: "desc" }],
+ take: limit,
+ select: {
+ id: true,
+ slug: true,
+ title: true,
+ river: true,
+ embarkPoint: true,
+ pirogueDurationMin: true,
+ capacity: true,
+ description: true,
+ media: {
+ orderBy: { sortOrder: "asc" },
+ take: 1,
+ select: { s3Url: true },
+ },
+ _count: { select: { media: true } },
+ },
+ });
+
+ return carbets.map((carbet) => ({
+ id: carbet.id,
+ slug: carbet.slug,
+ title: carbet.title,
+ river: carbet.river,
+ embarkPoint: carbet.embarkPoint,
+ pirogueDurationMin: carbet.pirogueDurationMin,
+ capacity: carbet.capacity,
+ description: carbet.description,
+ coverUrl: carbet.media[0]?.s3Url ?? null,
+ mediaCount: carbet._count.media,
+ }));
+}
+
+// Distinct list of rivers across the published catalogue, for filter UI hints.
+export async function listPublishedRivers(): Promise {
+ const rows = await prisma.carbet.findMany({
+ where: { status: CarbetStatus.PUBLISHED },
+ distinct: ["river"],
+ orderBy: { river: "asc" },
+ select: { river: true },
+ });
+ return rows.map((row) => row.river);
+}
diff --git a/src/lib/format.ts b/src/lib/format.ts
new file mode 100644
index 0000000..5770802
--- /dev/null
+++ b/src/lib/format.ts
@@ -0,0 +1,27 @@
+// Format a pirogue trip duration (minutes) into a human readable French label
+// such as "45 min" or "1 h 20".
+export function formatPirogueDuration(minutes: number): string {
+ if (minutes < 60) return `${minutes} min`;
+ const hours = Math.floor(minutes / 60);
+ const rest = minutes % 60;
+ if (rest === 0) return `${hours} h`;
+ return `${hours} h ${String(rest).padStart(2, "0")}`;
+}
+
+// Trim a long description for use in cards or meta descriptions.
+export function truncate(text: string, max: number): string {
+ if (text.length <= max) return text;
+ const slice = text.slice(0, max - 1);
+ const lastSpace = slice.lastIndexOf(" ");
+ const cut = lastSpace > max * 0.6 ? slice.slice(0, lastSpace) : slice;
+ return `${cut.trim()}…`;
+}
+
+// Format a decimal coordinate (Prisma Decimal | number | string) for display.
+export function formatCoordinate(
+ value: number | string | { toString(): string },
+): string {
+ const num = typeof value === "number" ? value : Number(value.toString());
+ if (Number.isNaN(num)) return "—";
+ return num.toFixed(5);
+}
From 6985396db58f240b50945f314c859851b9880feb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Karb=C3=A9=20Architect?=
Date: Sat, 30 May 2026 04:33:07 +0000
Subject: [PATCH 03/83] fix(build): add app global-error fallback for Next 16
prerender
---
src/app/global-error.tsx | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
create mode 100644 src/app/global-error.tsx
diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx
new file mode 100644
index 0000000..b0e647d
--- /dev/null
+++ b/src/app/global-error.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import { useEffect } from 'react';
+
+type GlobalErrorProps = {
+ error: Error & { digest?: string };
+ reset: () => void;
+};
+
+export default function GlobalError({ error, reset }: GlobalErrorProps) {
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+
+ return (
+
+
+
+ Une erreur inattendue est survenue
+ Veuillez reessayer.
+
+
+
+
+ );
+}
From 0de034022a2c734cf8f3d9c2a5414cc892ecfd0b Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Sat, 30 May 2026 14:42:29 +0000
Subject: [PATCH 04/83] =?UTF-8?q?feat(booking):=20API=20r=C3=A9servation?=
=?UTF-8?q?=20+=20availability=20+=20lib=20m=C3=A9tier?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Récupéré du workspace Backend (3 fichiers, 406 lignes) :
- src/lib/booking.ts : logique métier réservation
- src/app/api/bookings/route.ts : POST/GET bookings
- src/app/api/carbets/[carbetId]/availability/route.ts : calendrier dispo
Le schéma Booking/Availability était déjà dans main.
---
src/app/api/bookings/route.ts | 211 ++++++++++++++++++
.../carbets/[carbetId]/availability/route.ts | 145 ++++++++++++
src/lib/booking.ts | 50 +++++
3 files changed, 406 insertions(+)
create mode 100644 src/app/api/bookings/route.ts
create mode 100644 src/app/api/carbets/[carbetId]/availability/route.ts
create mode 100644 src/lib/booking.ts
diff --git a/src/app/api/bookings/route.ts b/src/app/api/bookings/route.ts
new file mode 100644
index 0000000..11f94ad
--- /dev/null
+++ b/src/app/api/bookings/route.ts
@@ -0,0 +1,211 @@
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import {
+ AvailabilityScope,
+ BookingStatus,
+ CarbetStatus,
+ UserRole,
+} from "@/generated/prisma/enums";
+import {
+ enumerateUtcDays,
+ hasOverlap,
+ isPublicAllowedByDefaultPolicy,
+ isCeUserRole,
+ normalizeUtcDayStart,
+ parseIsoDate,
+} from "@/lib/booking";
+import { prisma } from "@/lib/prisma";
+
+export const runtime = "nodejs";
+
+type CreateBookingBody = {
+ carbetId?: string;
+ startDate?: string;
+ endDate?: string;
+ guestCount?: number;
+};
+
+export async function POST(request: Request) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
+ }
+
+ let body: CreateBookingBody;
+ try {
+ body = (await request.json()) as CreateBookingBody;
+ } catch {
+ return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
+ }
+
+ if (!body.carbetId) {
+ return NextResponse.json({ error: "carbetId requis." }, { status: 400 });
+ }
+
+ const startDateRaw = parseIsoDate(body.startDate);
+ const endDateRaw = parseIsoDate(body.endDate);
+ const guestCount = Number(body.guestCount);
+
+ if (!startDateRaw || !endDateRaw) {
+ return NextResponse.json(
+ { error: "startDate et endDate valides sont requis." },
+ { status: 400 },
+ );
+ }
+
+ const startDate = normalizeUtcDayStart(startDateRaw);
+ const endDate = normalizeUtcDayStart(endDateRaw);
+
+ if (endDate <= startDate) {
+ return NextResponse.json(
+ { error: "La date de fin doit être après la date de début." },
+ { status: 400 },
+ );
+ }
+
+ if (!Number.isInteger(guestCount) || guestCount <= 0) {
+ return NextResponse.json(
+ { error: "guestCount doit être un entier strictement positif." },
+ { status: 400 },
+ );
+ }
+
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: body.carbetId },
+ select: {
+ id: true,
+ ownerId: true,
+ capacity: true,
+ status: true,
+ },
+ });
+
+ if (!carbet) {
+ return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
+ }
+
+ const isManager =
+ session.user.role === UserRole.ADMIN || session.user.id === carbet.ownerId;
+
+ if (!isManager && carbet.status !== CarbetStatus.PUBLISHED) {
+ return NextResponse.json({ error: "Carbet indisponible." }, { status: 404 });
+ }
+
+ if (guestCount > carbet.capacity) {
+ return NextResponse.json(
+ { error: `Capacité max dépassée (${carbet.capacity}).` },
+ { status: 400 },
+ );
+ }
+
+ const [overlappingBookings, availabilities] = await Promise.all([
+ prisma.booking.findMany({
+ where: {
+ carbetId: carbet.id,
+ status: { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED] },
+ startDate: { lt: endDate },
+ endDate: { gt: startDate },
+ },
+ select: {
+ id: true,
+ startDate: true,
+ endDate: true,
+ },
+ }),
+ prisma.availability.findMany({
+ where: {
+ carbetId: carbet.id,
+ startDate: { lt: endDate },
+ endDate: { gt: startDate },
+ },
+ select: {
+ id: true,
+ startDate: true,
+ endDate: true,
+ isAvailable: true,
+ scope: true,
+ },
+ }),
+ ]);
+
+ if (overlappingBookings.length > 0) {
+ return NextResponse.json(
+ { error: "Ce créneau est déjà réservé." },
+ { status: 409 },
+ );
+ }
+
+ const ceAccess = isCeUserRole(session.user.role);
+ const days = enumerateUtcDays(startDate, endDate);
+
+ for (const day of days) {
+ const nextDay = new Date(day);
+ nextDay.setUTCDate(nextDay.getUTCDate() + 1);
+
+ const coveredSlots = availabilities.filter((a) =>
+ hasOverlap(day, nextDay, a.startDate, a.endDate),
+ );
+
+ if (coveredSlots.length === 0) {
+ const defaultAllowed = isManager || ceAccess || isPublicAllowedByDefaultPolicy(day);
+ if (defaultAllowed) {
+ continue;
+ }
+
+ return NextResponse.json(
+ {
+ error: `Créneau non réservable le ${day.toISOString().slice(0, 10)} (week-end réservé CE).`,
+ },
+ { status: 409 },
+ );
+ }
+
+ const allowedSlot = coveredSlots.find((slot) => {
+ if (!slot.isAvailable) {
+ return false;
+ }
+
+ if (slot.scope === AvailabilityScope.CE_ONLY && !ceAccess && !isManager) {
+ return false;
+ }
+
+ return true;
+ });
+
+ if (!allowedSlot) {
+ return NextResponse.json(
+ {
+ error: `Créneau non réservable le ${day.toISOString().slice(0, 10)} (restriction CE ou blocage).`,
+ },
+ { status: 409 },
+ );
+ }
+ }
+
+ const booking = await prisma.booking.create({
+ data: {
+ carbetId: carbet.id,
+ tenantId: session.user.id,
+ startDate,
+ endDate,
+ guestCount,
+ status: BookingStatus.PENDING,
+ amount: 0,
+ currency: "EUR",
+ },
+ select: {
+ id: true,
+ carbetId: true,
+ tenantId: true,
+ startDate: true,
+ endDate: true,
+ guestCount: true,
+ status: true,
+ paymentStatus: true,
+ createdAt: true,
+ },
+ });
+
+ return NextResponse.json({ booking }, { status: 201 });
+}
diff --git a/src/app/api/carbets/[carbetId]/availability/route.ts b/src/app/api/carbets/[carbetId]/availability/route.ts
new file mode 100644
index 0000000..1840d0a
--- /dev/null
+++ b/src/app/api/carbets/[carbetId]/availability/route.ts
@@ -0,0 +1,145 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+import { auth } from "@/auth";
+import { UserRole, BookingStatus, CarbetStatus } from "@/generated/prisma/enums";
+import {
+ enumerateUtcDays,
+ hasOverlap,
+ isPublicAllowedByDefaultPolicy,
+ isCeUserRole,
+ normalizeUtcDayStart,
+ parseIsoDate,
+} from "@/lib/booking";
+import { prisma } from "@/lib/prisma";
+
+export const runtime = "nodejs";
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ carbetId: string }> },
+) {
+ const { carbetId } = await params;
+ const session = await auth();
+
+ const from = parseIsoDate(request.nextUrl.searchParams.get("from"));
+ const to = parseIsoDate(request.nextUrl.searchParams.get("to"));
+
+ if (!from || !to) {
+ return NextResponse.json(
+ { error: "Paramètres from et to (ISO date) requis." },
+ { status: 400 },
+ );
+ }
+
+ const startDate = normalizeUtcDayStart(from);
+ const endDate = normalizeUtcDayStart(to);
+
+ if (endDate <= startDate) {
+ return NextResponse.json(
+ { error: "La date de fin doit être après la date de début." },
+ { status: 400 },
+ );
+ }
+
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: carbetId },
+ select: { id: true, ownerId: true, status: true },
+ });
+
+ if (!carbet) {
+ return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
+ }
+
+ const isManager =
+ session?.user?.id &&
+ (session.user.role === UserRole.ADMIN || session.user.id === carbet.ownerId);
+
+ if (!isManager && carbet.status !== CarbetStatus.PUBLISHED) {
+ return NextResponse.json({ error: "Carbet indisponible." }, { status: 404 });
+ }
+
+ const [availabilities, bookings] = await Promise.all([
+ prisma.availability.findMany({
+ where: {
+ carbetId,
+ startDate: { lt: endDate },
+ endDate: { gt: startDate },
+ },
+ orderBy: { startDate: "asc" },
+ select: {
+ id: true,
+ startDate: true,
+ endDate: true,
+ isAvailable: true,
+ scope: true,
+ blockReason: true,
+ },
+ }),
+ prisma.booking.findMany({
+ where: {
+ carbetId,
+ status: { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED] },
+ startDate: { lt: endDate },
+ endDate: { gt: startDate },
+ },
+ select: {
+ id: true,
+ startDate: true,
+ endDate: true,
+ },
+ }),
+ ]);
+
+ const ceAccess = isCeUserRole(session?.user?.role);
+ const days = enumerateUtcDays(startDate, endDate);
+
+ const calendar = days.map((day) => {
+ const nextDay = new Date(day);
+ nextDay.setUTCDate(nextDay.getUTCDate() + 1);
+
+ const dayAvailability = availabilities.filter((a) =>
+ hasOverlap(day, nextDay, a.startDate, a.endDate),
+ );
+
+ const isBooked = bookings.some((b) =>
+ hasOverlap(day, nextDay, b.startDate, b.endDate),
+ );
+
+ const applicable = dayAvailability.find((a) => {
+ if (!a.isAvailable) {
+ return false;
+ }
+
+ if (a.scope === "CE_ONLY" && !ceAccess && !isManager) {
+ return false;
+ }
+
+ return true;
+ });
+
+ const defaultPolicyAllows = isManager || ceAccess || isPublicAllowedByDefaultPolicy(day);
+ const hasConfiguredSlot = dayAvailability.length > 0;
+ const isAvailable = (hasConfiguredSlot ? Boolean(applicable) : defaultPolicyAllows) && !isBooked;
+
+ return {
+ date: day.toISOString().slice(0, 10),
+ isAvailable,
+ scope: applicable?.scope ?? (defaultPolicyAllows ? (ceAccess || isManager ? "CE_ONLY" : "PUBLIC") : null),
+ blockReason:
+ isBooked
+ ? "BOOKED"
+ : dayAvailability.find((a) => !a.isAvailable)?.blockReason ??
+ (hasConfiguredSlot ? null : defaultPolicyAllows ? null : "WEEKEND_BLOCKED"),
+ hasCeSlot: dayAvailability.some((a) => a.scope === "CE_ONLY"),
+ source: hasConfiguredSlot ? "CONFIGURED" : "DEFAULT_POLICY",
+ };
+ });
+
+ return NextResponse.json({
+ carbetId,
+ from: startDate.toISOString(),
+ to: endDate.toISOString(),
+ calendar,
+ });
+}
diff --git a/src/lib/booking.ts b/src/lib/booking.ts
new file mode 100644
index 0000000..961f73c
--- /dev/null
+++ b/src/lib/booking.ts
@@ -0,0 +1,50 @@
+import { UserRole } from "@/generated/prisma/enums";
+
+export const DAY_MS = 24 * 60 * 60 * 1000;
+
+export function parseIsoDate(value: unknown): Date | null {
+ if (typeof value !== "string") {
+ return null;
+ }
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return null;
+ }
+
+ return date;
+}
+
+export function normalizeUtcDayStart(date: Date): Date {
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
+}
+
+export function hasOverlap(startA: Date, endA: Date, startB: Date, endB: Date): boolean {
+ return startA < endB && endA > startB;
+}
+
+export function enumerateUtcDays(start: Date, end: Date): Date[] {
+ const days: Date[] = [];
+ const cursor = normalizeUtcDayStart(start);
+ const limit = normalizeUtcDayStart(end);
+
+ while (cursor < limit) {
+ days.push(new Date(cursor));
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
+ }
+
+ return days;
+}
+
+export function isCeUserRole(role?: UserRole): boolean {
+ return role === UserRole.CE_MANAGER || role === UserRole.CE_MEMBER;
+}
+
+export function isWeekendUtcDay(date: Date): boolean {
+ const day = date.getUTCDay();
+ return day === 0 || day === 6;
+}
+
+export function isPublicAllowedByDefaultPolicy(day: Date): boolean {
+ return !isWeekendUtcDay(day);
+}
From 74f39293cce603e920b1c404cd107d24ada2778a Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Sat, 30 May 2026 15:00:21 +0000
Subject: [PATCH 05/83] =?UTF-8?q?feat(payment):=20int=C3=A9gration=20Strip?=
=?UTF-8?q?e=20(subscription=20loueur=20+=20booking=20checkout=20+=20webho?=
=?UTF-8?q?ok)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
package.json | 3 +-
src/app/api/stripe/checkout/booking/route.ts | 252 ++++++++++++++++++
.../api/stripe/checkout/subscription/route.ts | 77 ++++++
src/app/api/stripe/webhook/route.ts | 149 +++++++++++
src/lib/stripe.ts | 33 +++
5 files changed, 513 insertions(+), 1 deletion(-)
create mode 100644 src/app/api/stripe/checkout/booking/route.ts
create mode 100644 src/app/api/stripe/checkout/subscription/route.ts
create mode 100644 src/app/api/stripe/webhook/route.ts
create mode 100644 src/lib/stripe.ts
diff --git a/package.json b/package.json
index f6e1dc5..4b2d77d 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,8 @@
"next-auth": "^5.0.0-beta.31",
"pg": "^8.21.0",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "stripe": "^18.3.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/src/app/api/stripe/checkout/booking/route.ts b/src/app/api/stripe/checkout/booking/route.ts
new file mode 100644
index 0000000..b786f61
--- /dev/null
+++ b/src/app/api/stripe/checkout/booking/route.ts
@@ -0,0 +1,252 @@
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import {
+ AvailabilityScope,
+ BookingStatus,
+ CarbetStatus,
+ PaymentStatus,
+ UserRole,
+} from "@/generated/prisma/enums";
+import {
+ enumerateUtcDays,
+ hasOverlap,
+ isCeUserRole,
+ isPublicAllowedByDefaultPolicy,
+ normalizeUtcDayStart,
+ parseIsoDate,
+} from "@/lib/booking";
+import { prisma } from "@/lib/prisma";
+import { getStripeClient, toStripeAmountCents } from "@/lib/stripe";
+
+export const runtime = "nodejs";
+
+type BookingCheckoutBody = {
+ carbetId?: string;
+ startDate?: string;
+ endDate?: string;
+ guestCount?: number;
+ amount?: number;
+ currency?: string;
+};
+
+export async function POST(request: Request) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
+ }
+
+ let body: BookingCheckoutBody;
+ try {
+ body = (await request.json()) as BookingCheckoutBody;
+ } catch {
+ return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
+ }
+
+ if (!body.carbetId) {
+ return NextResponse.json({ error: "carbetId requis." }, { status: 400 });
+ }
+
+ const startDateRaw = parseIsoDate(body.startDate);
+ const endDateRaw = parseIsoDate(body.endDate);
+ const guestCount = Number(body.guestCount);
+ const amount = Number(body.amount);
+ const currency = (body.currency ?? "EUR").toLowerCase();
+
+ if (!startDateRaw || !endDateRaw) {
+ return NextResponse.json(
+ { error: "startDate et endDate valides sont requis." },
+ { status: 400 },
+ );
+ }
+
+ const startDate = normalizeUtcDayStart(startDateRaw);
+ const endDate = normalizeUtcDayStart(endDateRaw);
+
+ if (endDate <= startDate) {
+ return NextResponse.json(
+ { error: "La date de fin doit être après la date de début." },
+ { status: 400 },
+ );
+ }
+
+ if (!Number.isInteger(guestCount) || guestCount <= 0) {
+ return NextResponse.json(
+ { error: "guestCount doit être un entier strictement positif." },
+ { status: 400 },
+ );
+ }
+
+ if (!Number.isFinite(amount) || amount <= 0) {
+ return NextResponse.json({ error: "amount doit être > 0." }, { status: 400 });
+ }
+
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: body.carbetId },
+ select: {
+ id: true,
+ ownerId: true,
+ title: true,
+ capacity: true,
+ status: true,
+ },
+ });
+
+ if (!carbet) {
+ return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
+ }
+
+ const isManager =
+ session.user.role === UserRole.ADMIN || session.user.id === carbet.ownerId;
+
+ if (!isManager && carbet.status !== CarbetStatus.PUBLISHED) {
+ return NextResponse.json({ error: "Carbet indisponible." }, { status: 404 });
+ }
+
+ if (guestCount > carbet.capacity) {
+ return NextResponse.json(
+ { error: `Capacité max dépassée (${carbet.capacity}).` },
+ { status: 400 },
+ );
+ }
+
+ const [overlappingBookings, availabilities] = await Promise.all([
+ prisma.booking.findMany({
+ where: {
+ carbetId: carbet.id,
+ status: { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED] },
+ startDate: { lt: endDate },
+ endDate: { gt: startDate },
+ },
+ select: { id: true, startDate: true, endDate: true },
+ }),
+ prisma.availability.findMany({
+ where: {
+ carbetId: carbet.id,
+ startDate: { lt: endDate },
+ endDate: { gt: startDate },
+ },
+ select: {
+ id: true,
+ startDate: true,
+ endDate: true,
+ isAvailable: true,
+ scope: true,
+ },
+ }),
+ ]);
+
+ if (overlappingBookings.length > 0) {
+ return NextResponse.json(
+ { error: "Ce créneau est déjà réservé." },
+ { status: 409 },
+ );
+ }
+
+ const ceAccess = isCeUserRole(session.user.role);
+ const days = enumerateUtcDays(startDate, endDate);
+
+ for (const day of days) {
+ const nextDay = new Date(day);
+ nextDay.setUTCDate(nextDay.getUTCDate() + 1);
+
+ const coveredSlots = availabilities.filter((a) =>
+ hasOverlap(day, nextDay, a.startDate, a.endDate),
+ );
+
+ if (coveredSlots.length === 0) {
+ const defaultAllowed = isManager || ceAccess || isPublicAllowedByDefaultPolicy(day);
+ if (defaultAllowed) {
+ continue;
+ }
+
+ return NextResponse.json(
+ {
+ error: `Créneau non réservable le ${day.toISOString().slice(0, 10)} (week-end réservé CE).`,
+ },
+ { status: 409 },
+ );
+ }
+
+ const allowedSlot = coveredSlots.find((slot) => {
+ if (!slot.isAvailable) {
+ return false;
+ }
+
+ if (slot.scope === AvailabilityScope.CE_ONLY && !ceAccess && !isManager) {
+ return false;
+ }
+
+ return true;
+ });
+
+ if (!allowedSlot) {
+ return NextResponse.json(
+ {
+ error: `Créneau non réservable le ${day.toISOString().slice(0, 10)} (restriction CE ou blocage).`,
+ },
+ { status: 409 },
+ );
+ }
+ }
+
+ const booking = await prisma.booking.create({
+ data: {
+ carbetId: carbet.id,
+ tenantId: session.user.id,
+ startDate,
+ endDate,
+ guestCount,
+ status: BookingStatus.PENDING,
+ paymentStatus: PaymentStatus.PENDING,
+ amount,
+ currency: currency.toUpperCase(),
+ },
+ select: {
+ id: true,
+ amount: true,
+ currency: true,
+ startDate: true,
+ endDate: true,
+ },
+ });
+
+ const appUrl = process.env.APP_URL;
+ if (!appUrl) {
+ return NextResponse.json({ error: "APP_URL manquante." }, { status: 500 });
+ }
+
+ const stripe = getStripeClient();
+ const checkoutSession = await stripe.checkout.sessions.create({
+ mode: "payment",
+ success_url: `${appUrl}/reservations/${booking.id}?payment=success`,
+ cancel_url: `${appUrl}/reservations/${booking.id}?payment=cancel`,
+ customer_email: session.user.email ?? undefined,
+ line_items: [
+ {
+ quantity: 1,
+ price_data: {
+ currency,
+ unit_amount: toStripeAmountCents(amount),
+ product_data: {
+ name: `Réservation carbet: ${carbet.title}`,
+ description: `${booking.startDate.toISOString().slice(0, 10)} au ${booking.endDate.toISOString().slice(0, 10)}`,
+ },
+ },
+ },
+ ],
+ metadata: {
+ bookingId: booking.id,
+ type: "booking",
+ },
+ });
+
+ return NextResponse.json(
+ {
+ bookingId: booking.id,
+ checkoutSessionId: checkoutSession.id,
+ checkoutUrl: checkoutSession.url,
+ },
+ { status: 201 },
+ );
+}
diff --git a/src/app/api/stripe/checkout/subscription/route.ts b/src/app/api/stripe/checkout/subscription/route.ts
new file mode 100644
index 0000000..617d449
--- /dev/null
+++ b/src/app/api/stripe/checkout/subscription/route.ts
@@ -0,0 +1,77 @@
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { getStripeClient } from "@/lib/stripe";
+
+export const runtime = "nodejs";
+
+type SubscriptionCheckoutBody = {
+ carbetId?: string;
+};
+
+export async function POST(request: Request) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
+ }
+
+ let body: SubscriptionCheckoutBody;
+ try {
+ body = (await request.json()) as SubscriptionCheckoutBody;
+ } catch {
+ return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
+ }
+
+ if (!body.carbetId) {
+ return NextResponse.json({ error: "carbetId requis." }, { status: 400 });
+ }
+
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: body.carbetId },
+ select: { id: true, ownerId: true, title: true },
+ });
+
+ if (!carbet) {
+ return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
+ }
+
+ const canManage =
+ session.user.role === UserRole.ADMIN || session.user.id === carbet.ownerId;
+
+ if (!canManage) {
+ return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
+ }
+
+ const priceId = process.env.STRIPE_OWNER_SUBSCRIPTION_PRICE_ID;
+ const appUrl = process.env.APP_URL;
+ if (!priceId || !appUrl) {
+ return NextResponse.json(
+ { error: "Configuration Stripe abonnement incomplète." },
+ { status: 500 },
+ );
+ }
+
+ const stripe = getStripeClient();
+ const checkoutSession = await stripe.checkout.sessions.create({
+ mode: "subscription",
+ success_url: `${appUrl}/espace-hote/carbets/${carbet.id}?subscription=success`,
+ cancel_url: `${appUrl}/espace-hote/carbets/${carbet.id}?subscription=cancel`,
+ customer_email: session.user.email ?? undefined,
+ line_items: [{ price: priceId, quantity: 1 }],
+ metadata: {
+ ownerId: carbet.ownerId,
+ carbetId: carbet.id,
+ type: "owner_subscription",
+ },
+ });
+
+ return NextResponse.json(
+ {
+ checkoutSessionId: checkoutSession.id,
+ checkoutUrl: checkoutSession.url,
+ },
+ { status: 201 },
+ );
+}
diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts
new file mode 100644
index 0000000..d3bc17a
--- /dev/null
+++ b/src/app/api/stripe/webhook/route.ts
@@ -0,0 +1,149 @@
+import { NextResponse } from "next/server";
+import Stripe from "stripe";
+
+import {
+ BookingStatus,
+ PaymentStatus,
+ SubscriptionStatus,
+} from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe";
+
+export const runtime = "nodejs";
+
+function mapStripeSubscriptionStatus(status: Stripe.Subscription.Status): SubscriptionStatus {
+ switch (status) {
+ case "trialing":
+ return SubscriptionStatus.TRIAL;
+ case "active":
+ return SubscriptionStatus.ACTIVE;
+ case "past_due":
+ case "unpaid":
+ case "paused":
+ return SubscriptionStatus.PAST_DUE;
+ case "canceled":
+ case "incomplete_expired":
+ return SubscriptionStatus.CANCELED;
+ case "incomplete":
+ return SubscriptionStatus.TRIAL;
+ default:
+ return SubscriptionStatus.TRIAL;
+ }
+}
+
+async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
+ const bookingId = session.metadata?.bookingId;
+ const type = session.metadata?.type;
+
+ if (type === "booking" && bookingId) {
+ await prisma.booking.update({
+ where: { id: bookingId },
+ data: {
+ paymentStatus: PaymentStatus.SUCCEEDED,
+ status: BookingStatus.CONFIRMED,
+ },
+ });
+ return;
+ }
+
+ if (type === "owner_subscription") {
+ const ownerId = session.metadata?.ownerId;
+ const carbetId = session.metadata?.carbetId;
+ const providerSubId = typeof session.subscription === "string" ? session.subscription : null;
+
+ if (!ownerId || !carbetId || !providerSubId) {
+ return;
+ }
+
+ await prisma.subscription.upsert({
+ where: { providerSubId },
+ update: {
+ status: SubscriptionStatus.ACTIVE,
+ renewedAt: new Date(),
+ },
+ create: {
+ ownerId,
+ carbetId,
+ provider: "stripe",
+ providerSubId,
+ status: SubscriptionStatus.ACTIVE,
+ },
+ });
+ }
+}
+
+async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) {
+ const bookingId = paymentIntent.metadata?.bookingId;
+ if (!bookingId) {
+ return;
+ }
+
+ await prisma.booking.update({
+ where: { id: bookingId },
+ data: {
+ paymentStatus: PaymentStatus.FAILED,
+ status: BookingStatus.CANCELLED,
+ },
+ });
+}
+
+async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
+ await prisma.subscription.upsert({
+ where: { providerSubId: subscription.id },
+ update: {
+ status: mapStripeSubscriptionStatus(subscription.status),
+ renewedAt: fromStripeTimestamp(subscription.current_period_end),
+ canceledAt: fromStripeTimestamp(subscription.canceled_at),
+ },
+ create: {
+ ownerId: subscription.metadata.ownerId,
+ carbetId: subscription.metadata.carbetId,
+ provider: "stripe",
+ providerSubId: subscription.id,
+ status: mapStripeSubscriptionStatus(subscription.status),
+ startedAt: fromStripeTimestamp(subscription.start_date) ?? new Date(),
+ renewedAt: fromStripeTimestamp(subscription.current_period_end),
+ canceledAt: fromStripeTimestamp(subscription.canceled_at),
+ },
+ });
+}
+
+export async function POST(request: Request) {
+ const secret = process.env.STRIPE_WEBHOOK_SECRET;
+ if (!secret) {
+ return NextResponse.json({ error: "STRIPE_WEBHOOK_SECRET manquante." }, { status: 500 });
+ }
+
+ const stripe = getStripeClient();
+ const signature = request.headers.get("stripe-signature");
+ if (!signature) {
+ return NextResponse.json({ error: "Signature Stripe absente." }, { status: 400 });
+ }
+
+ const payload = await request.text();
+
+ let event: Stripe.Event;
+ try {
+ event = stripe.webhooks.constructEvent(payload, signature, secret);
+ } catch {
+ return NextResponse.json({ error: "Signature Stripe invalide." }, { status: 400 });
+ }
+
+ switch (event.type) {
+ case "checkout.session.completed":
+ await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
+ break;
+ case "payment_intent.payment_failed":
+ await handlePaymentIntentFailed(event.data.object as Stripe.PaymentIntent);
+ break;
+ case "customer.subscription.created":
+ case "customer.subscription.updated":
+ case "customer.subscription.deleted":
+ await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
+ break;
+ default:
+ break;
+ }
+
+ return NextResponse.json({ received: true });
+}
diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts
new file mode 100644
index 0000000..adda277
--- /dev/null
+++ b/src/lib/stripe.ts
@@ -0,0 +1,33 @@
+import Stripe from "stripe";
+
+let stripeClient: Stripe | null = null;
+
+export function getStripeClient(): Stripe {
+ if (stripeClient) {
+ return stripeClient;
+ }
+
+ const secretKey = process.env.STRIPE_SECRET_KEY;
+ if (!secretKey) {
+ throw new Error("STRIPE_SECRET_KEY manquante.");
+ }
+
+ stripeClient = new Stripe(secretKey);
+ return stripeClient;
+}
+
+export function toStripeAmountCents(amount: number): number {
+ if (!Number.isFinite(amount) || amount <= 0) {
+ throw new Error("Montant invalide.");
+ }
+
+ return Math.round(amount * 100);
+}
+
+export function fromStripeTimestamp(ts: number | null | undefined): Date | null {
+ if (!ts) {
+ return null;
+ }
+
+ return new Date(ts * 1000);
+}
From 75159813363ecb9b825ab4d45f1832dc02ef13f7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Karb=C3=A9=20Frontend?=
Date: Sat, 30 May 2026 15:08:55 +0000
Subject: [PATCH 06/83] feat(reviews): avis & notes carbet (SYS-8)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Lib reviews: constants/types (client-safe) + DB helpers (server-only)
- API POST /api/bookings/[bookingId]/review : avis locataire après séjour COMPLETED
- API POST /api/reviews/[reviewId]/response : réponse loueur
- API GET /api/carbets/[carbetId]/reviews : liste + stats agrégées
- Fiche carbet : note moyenne + nombre d'avis + liste avec réponses loueur
- Carte carbet : étoiles + note moyenne + compteur
- /mes-reservations : formulaire d'avis pour les séjours terminés du locataire
---
.../api/bookings/[bookingId]/review/route.ts | 112 +++++++++++++++
.../api/carbets/[carbetId]/reviews/route.ts | 39 +++++
.../api/reviews/[reviewId]/response/route.ts | 85 +++++++++++
src/app/carbets/[slug]/page.tsx | 34 ++++-
src/app/carbets/_components/carbet-card.tsx | 12 ++
.../_components/host-response-form.tsx | 69 +++++++++
src/app/carbets/_components/review-form.tsx | 111 ++++++++++++++
.../carbets/_components/reviews-section.tsx | 104 ++++++++++++++
src/app/carbets/_components/star-rating.tsx | 36 +++++
src/app/mes-reservations/page.tsx | 135 ++++++++++++++++++
src/lib/carbet-public.ts | 17 +++
src/lib/carbet-search.ts | 37 +++--
src/lib/reviews-server.ts | 88 ++++++++++++
src/lib/reviews.ts | 38 +++++
14 files changed, 903 insertions(+), 14 deletions(-)
create mode 100644 src/app/api/bookings/[bookingId]/review/route.ts
create mode 100644 src/app/api/carbets/[carbetId]/reviews/route.ts
create mode 100644 src/app/api/reviews/[reviewId]/response/route.ts
create mode 100644 src/app/carbets/_components/host-response-form.tsx
create mode 100644 src/app/carbets/_components/review-form.tsx
create mode 100644 src/app/carbets/_components/reviews-section.tsx
create mode 100644 src/app/carbets/_components/star-rating.tsx
create mode 100644 src/app/mes-reservations/page.tsx
create mode 100644 src/lib/reviews-server.ts
create mode 100644 src/lib/reviews.ts
diff --git a/src/app/api/bookings/[bookingId]/review/route.ts b/src/app/api/bookings/[bookingId]/review/route.ts
new file mode 100644
index 0000000..62fe319
--- /dev/null
+++ b/src/app/api/bookings/[bookingId]/review/route.ts
@@ -0,0 +1,112 @@
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import { BookingStatus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import {
+ REVIEW_COMMENT_MAX,
+ REVIEW_RATING_MAX,
+ REVIEW_RATING_MIN,
+ isValidRating,
+} from "@/lib/reviews";
+
+export const runtime = "nodejs";
+
+type CreateReviewBody = {
+ rating?: number;
+ comment?: string;
+};
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ bookingId: string }> },
+) {
+ const { bookingId } = await params;
+
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
+ }
+
+ let body: CreateReviewBody;
+ try {
+ body = (await request.json()) as CreateReviewBody;
+ } catch {
+ return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
+ }
+
+ const rating = Number(body.rating);
+ if (!isValidRating(rating)) {
+ return NextResponse.json(
+ {
+ error: `La note doit être un entier entre ${REVIEW_RATING_MIN} et ${REVIEW_RATING_MAX}.`,
+ },
+ { status: 400 },
+ );
+ }
+
+ const comment =
+ typeof body.comment === "string" ? body.comment.trim() : "";
+ if (comment.length > REVIEW_COMMENT_MAX) {
+ return NextResponse.json(
+ {
+ error: `Le commentaire est limité à ${REVIEW_COMMENT_MAX} caractères.`,
+ },
+ { status: 400 },
+ );
+ }
+
+ const booking = await prisma.booking.findUnique({
+ where: { id: bookingId },
+ select: {
+ id: true,
+ carbetId: true,
+ tenantId: true,
+ status: true,
+ review: { select: { id: true } },
+ },
+ });
+
+ if (!booking) {
+ return NextResponse.json({ error: "Réservation introuvable." }, { status: 404 });
+ }
+
+ if (booking.tenantId !== session.user.id) {
+ return NextResponse.json(
+ { error: "Seul le locataire peut laisser un avis." },
+ { status: 403 },
+ );
+ }
+
+ if (booking.status !== BookingStatus.COMPLETED) {
+ return NextResponse.json(
+ { error: "Un avis ne peut être laissé qu'après un séjour terminé." },
+ { status: 409 },
+ );
+ }
+
+ if (booking.review) {
+ return NextResponse.json(
+ { error: "Un avis a déjà été déposé pour cette réservation." },
+ { status: 409 },
+ );
+ }
+
+ const review = await prisma.review.create({
+ data: {
+ bookingId: booking.id,
+ carbetId: booking.carbetId,
+ authorId: session.user.id,
+ rating,
+ comment: comment.length > 0 ? comment : null,
+ },
+ select: {
+ id: true,
+ rating: true,
+ comment: true,
+ createdAt: true,
+ },
+ });
+
+ return NextResponse.json({ review }, { status: 201 });
+}
diff --git a/src/app/api/carbets/[carbetId]/reviews/route.ts b/src/app/api/carbets/[carbetId]/reviews/route.ts
new file mode 100644
index 0000000..6146f8f
--- /dev/null
+++ b/src/app/api/carbets/[carbetId]/reviews/route.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+import { getCarbetReviewStats, listCarbetReviews } from "@/lib/reviews-server";
+import { prisma } from "@/lib/prisma";
+
+export const runtime = "nodejs";
+
+const MAX_LIMIT = 100;
+const DEFAULT_LIMIT = 20;
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ carbetId: string }> },
+) {
+ const { carbetId } = await params;
+
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: carbetId },
+ select: { id: true },
+ });
+ if (!carbet) {
+ return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
+ }
+
+ const limitRaw = request.nextUrl.searchParams.get("limit");
+ const parsedLimit = Number(limitRaw);
+ const limit =
+ Number.isInteger(parsedLimit) && parsedLimit > 0
+ ? Math.min(parsedLimit, MAX_LIMIT)
+ : DEFAULT_LIMIT;
+
+ const [stats, reviews] = await Promise.all([
+ getCarbetReviewStats(carbetId),
+ listCarbetReviews(carbetId, limit),
+ ]);
+
+ return NextResponse.json({ stats, reviews });
+}
diff --git a/src/app/api/reviews/[reviewId]/response/route.ts b/src/app/api/reviews/[reviewId]/response/route.ts
new file mode 100644
index 0000000..362e833
--- /dev/null
+++ b/src/app/api/reviews/[reviewId]/response/route.ts
@@ -0,0 +1,85 @@
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { REVIEW_HOST_RESPONSE_MAX } from "@/lib/reviews";
+
+export const runtime = "nodejs";
+
+type HostResponseBody = {
+ response?: string;
+};
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ reviewId: string }> },
+) {
+ const { reviewId } = await params;
+
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
+ }
+
+ let body: HostResponseBody;
+ try {
+ body = (await request.json()) as HostResponseBody;
+ } catch {
+ return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
+ }
+
+ const response =
+ typeof body.response === "string" ? body.response.trim() : "";
+ if (response.length === 0) {
+ return NextResponse.json(
+ { error: "La réponse est requise." },
+ { status: 400 },
+ );
+ }
+ if (response.length > REVIEW_HOST_RESPONSE_MAX) {
+ return NextResponse.json(
+ {
+ error: `La réponse est limitée à ${REVIEW_HOST_RESPONSE_MAX} caractères.`,
+ },
+ { status: 400 },
+ );
+ }
+
+ const review = await prisma.review.findUnique({
+ where: { id: reviewId },
+ select: {
+ id: true,
+ hostResponse: true,
+ carbet: { select: { ownerId: true } },
+ },
+ });
+
+ if (!review) {
+ return NextResponse.json({ error: "Avis introuvable." }, { status: 404 });
+ }
+
+ const isOwner = review.carbet.ownerId === session.user.id;
+ const isAdmin = session.user.role === UserRole.ADMIN;
+ if (!isOwner && !isAdmin) {
+ return NextResponse.json(
+ { error: "Seul le loueur peut répondre à cet avis." },
+ { status: 403 },
+ );
+ }
+
+ const updated = await prisma.review.update({
+ where: { id: reviewId },
+ data: {
+ hostResponse: response,
+ hostRespondedAt: new Date(),
+ },
+ select: {
+ id: true,
+ hostResponse: true,
+ hostRespondedAt: true,
+ },
+ });
+
+ return NextResponse.json({ review: updated }, { status: 200 });
+}
diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx
index cf0ed79..ae88cad 100644
--- a/src/app/carbets/[slug]/page.tsx
+++ b/src/app/carbets/[slug]/page.tsx
@@ -2,15 +2,19 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
+import { auth } from "@/auth";
import { getPublicCarbet } from "@/lib/carbet-public";
import {
formatCoordinate,
formatPirogueDuration,
truncate,
} from "@/lib/format";
-import { MediaType } from "@/generated/prisma/enums";
+import { MediaType, UserRole } from "@/generated/prisma/enums";
+import { formatAverageRating } from "@/lib/reviews";
import { CarbetGallery } from "../_components/carbet-gallery";
+import { ReviewsSection } from "../_components/reviews-section";
+import { StarRating } from "../_components/star-rating";
type PageProps = {
params: Promise<{ slug: string }>;
@@ -60,12 +64,20 @@ export async function generateMetadata({
export default async function PublicCarbetPage({ params }: PageProps) {
const { slug } = await params;
- const carbet = await getPublicCarbet(slug);
+ const [carbet, session] = await Promise.all([
+ getPublicCarbet(slug),
+ auth(),
+ ]);
if (!carbet) {
notFound();
}
+ const viewerId = session?.user?.id ?? null;
+ const isViewerOwner =
+ viewerId !== null &&
+ (viewerId === carbet.ownerId || session?.user?.role === UserRole.ADMIN);
+
return (
+ {carbet.reviewStats.count > 0 &&
+ carbet.reviewStats.averageRating !== null ? (
+
+
+
+ {formatAverageRating(carbet.reviewStats.averageRating)}
+
+
+ · {carbet.reviewStats.count} avis
+
+
+ ) : null}
@@ -166,6 +190,12 @@ export default async function PublicCarbetPage({ params }: PageProps) {
+
+
);
}
diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx
index 0667ccf..feecf82 100644
--- a/src/app/carbets/_components/carbet-card.tsx
+++ b/src/app/carbets/_components/carbet-card.tsx
@@ -2,6 +2,9 @@ import Link from "next/link";
import type { CarbetSearchResult } from "@/lib/carbet-search";
import { formatPirogueDuration, truncate } from "@/lib/format";
+import { formatAverageRating } from "@/lib/reviews";
+
+import { StarRating } from "./star-rating";
export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
const href = `/carbets/${carbet.slug}`;
@@ -34,6 +37,15 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
Fleuve {carbet.river} · {carbet.capacity} voyageur
{carbet.capacity > 1 ? "s" : ""}
+ {carbet.reviewCount > 0 && carbet.averageRating !== null ? (
+
+
+
+ {formatAverageRating(carbet.averageRating)}
+
+ ({carbet.reviewCount})
+
+ ) : null}
{truncate(carbet.description, 180)}
diff --git a/src/app/carbets/_components/host-response-form.tsx b/src/app/carbets/_components/host-response-form.tsx
new file mode 100644
index 0000000..2d24a0b
--- /dev/null
+++ b/src/app/carbets/_components/host-response-form.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+import { REVIEW_HOST_RESPONSE_MAX } from "@/lib/reviews";
+
+export function HostResponseForm({ reviewId }: { reviewId: string }) {
+ const router = useRouter();
+ const [response, setResponse] = useState("");
+ const [error, setError] = useState(null);
+ const [pending, setPending] = useState(false);
+
+ async function handleSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ setError(null);
+
+ const trimmed = response.trim();
+ if (trimmed.length === 0) {
+ setError("Veuillez saisir une réponse.");
+ return;
+ }
+
+ setPending(true);
+ try {
+ const res = await fetch(`/api/reviews/${reviewId}/response`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ response: trimmed }),
+ });
+
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ setError(data.error ?? "Impossible d'envoyer la réponse.");
+ return;
+ }
+
+ router.refresh();
+ } catch {
+ setError("Erreur réseau, veuillez réessayer.");
+ } finally {
+ setPending(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/carbets/_components/review-form.tsx b/src/app/carbets/_components/review-form.tsx
new file mode 100644
index 0000000..cdff7d5
--- /dev/null
+++ b/src/app/carbets/_components/review-form.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+import {
+ REVIEW_COMMENT_MAX,
+ REVIEW_RATING_MAX,
+ REVIEW_RATING_MIN,
+} from "@/lib/reviews";
+
+type ReviewFormProps = {
+ bookingId: string;
+};
+
+export function ReviewForm({ bookingId }: ReviewFormProps) {
+ const router = useRouter();
+ const [rating, setRating] = useState(0);
+ const [hover, setHover] = useState(0);
+ const [comment, setComment] = useState("");
+ const [error, setError] = useState(null);
+ const [pending, setPending] = useState(false);
+
+ async function handleSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ setError(null);
+
+ if (rating < REVIEW_RATING_MIN || rating > REVIEW_RATING_MAX) {
+ setError("Veuillez choisir une note entre 1 et 5.");
+ return;
+ }
+
+ setPending(true);
+ try {
+ const res = await fetch(`/api/bookings/${bookingId}/review`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rating, comment: comment.trim() }),
+ });
+
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ setError(data.error ?? "Impossible d'envoyer votre avis.");
+ return;
+ }
+
+ router.refresh();
+ } catch {
+ setError("Erreur réseau, veuillez réessayer.");
+ } finally {
+ setPending(false);
+ }
+ }
+
+ const display = hover || rating;
+
+ return (
+
+ );
+}
diff --git a/src/app/carbets/_components/reviews-section.tsx b/src/app/carbets/_components/reviews-section.tsx
new file mode 100644
index 0000000..119691a
--- /dev/null
+++ b/src/app/carbets/_components/reviews-section.tsx
@@ -0,0 +1,104 @@
+import type { CarbetReview, CarbetReviewStats } from "@/lib/reviews";
+import { formatAverageRating } from "@/lib/reviews";
+
+import { HostResponseForm } from "./host-response-form";
+import { StarRating } from "./star-rating";
+
+type ReviewsSectionProps = {
+ stats: CarbetReviewStats;
+ reviews: CarbetReview[];
+ isOwner: boolean;
+};
+
+const dateFormatter = new Intl.DateTimeFormat("fr-FR", {
+ year: "numeric",
+ month: "long",
+});
+
+const stayFormatter = new Intl.DateTimeFormat("fr-FR", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+});
+
+function formatStay(start: string, end: string): string {
+ const startDate = new Date(start);
+ const endDate = new Date(end);
+ return `${stayFormatter.format(startDate)} → ${stayFormatter.format(endDate)}`;
+}
+
+export function ReviewsSection({ stats, reviews, isOwner }: ReviewsSectionProps) {
+ return (
+
+
+ Avis des voyageurs
+ {stats.count > 0 && stats.averageRating !== null ? (
+
+
+
+ {formatAverageRating(stats.averageRating)}
+
+
+ · {stats.count} avis
+
+
+ ) : (
+ Aucun avis pour le moment.
+ )}
+
+
+ {reviews.length > 0 ? (
+
+ {reviews.map((review) => (
+ -
+
+
+
+ {review.authorFirstName}
+
+
+ {dateFormatter.format(new Date(review.createdAt))} ·{" "}
+ Séjour {formatStay(review.stayStartDate, review.stayEndDate)}
+
+
+
+
+
+ {review.rating}/5
+
+
+
+
+ {review.comment ? (
+
+ {review.comment}
+
+ ) : null}
+
+ {review.hostResponse ? (
+
+
+ Réponse du loueur
+ {review.hostRespondedAt ? (
+
+ · {dateFormatter.format(new Date(review.hostRespondedAt))}
+
+ ) : null}
+
+
+ {review.hostResponse}
+
+
+ ) : isOwner ? (
+
+ ) : null}
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/src/app/carbets/_components/star-rating.tsx b/src/app/carbets/_components/star-rating.tsx
new file mode 100644
index 0000000..00e631a
--- /dev/null
+++ b/src/app/carbets/_components/star-rating.tsx
@@ -0,0 +1,36 @@
+type StarRatingProps = {
+ value: number;
+ ariaLabel?: string;
+ className?: string;
+};
+
+// Static 5-star display. `value` is a 0–5 rating (decimal allowed for averages).
+// Filled portion is achieved by overlaying gold stars on top of grey stars and
+// clipping the overlay by width — works without client JS so it can render on
+// server components.
+export function StarRating({ value, ariaLabel, className }: StarRatingProps) {
+ const clamped = Math.min(5, Math.max(0, value));
+ const fillPct = (clamped / 5) * 100;
+ const label = ariaLabel ?? `Note ${clamped.toFixed(1).replace(".", ",")} sur 5`;
+
+ return (
+
+
+ ★★★★★
+
+
+ ★★★★★
+
+
+ );
+}
diff --git a/src/app/mes-reservations/page.tsx b/src/app/mes-reservations/page.tsx
new file mode 100644
index 0000000..3859e0a
--- /dev/null
+++ b/src/app/mes-reservations/page.tsx
@@ -0,0 +1,135 @@
+import Link from "next/link";
+
+import { requireAuth } from "@/lib/authorization";
+import { BookingStatus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { formatAverageRating } from "@/lib/reviews";
+
+import { ReviewForm } from "../carbets/_components/review-form";
+import { StarRating } from "../carbets/_components/star-rating";
+
+export const dynamic = "force-dynamic";
+
+const dateFormatter = new Intl.DateTimeFormat("fr-FR", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+});
+
+const statusLabels: Record = {
+ PENDING: "En attente",
+ CONFIRMED: "Confirmée",
+ CANCELLED: "Annulée",
+ COMPLETED: "Terminée",
+};
+
+export default async function MyBookingsPage() {
+ const session = await requireAuth();
+
+ const bookings = await prisma.booking.findMany({
+ where: { tenantId: session.user.id },
+ orderBy: [{ startDate: "desc" }],
+ select: {
+ id: true,
+ startDate: true,
+ endDate: true,
+ status: true,
+ guestCount: true,
+ carbet: { select: { slug: true, title: true, river: true } },
+ review: {
+ select: {
+ id: true,
+ rating: true,
+ comment: true,
+ hostResponse: true,
+ createdAt: true,
+ },
+ },
+ },
+ });
+
+ return (
+
+ Mes réservations
+
+ Retrouvez ici vos séjours passés et à venir. Après un séjour terminé,
+ partagez votre expérience en laissant un avis.
+
+
+ {bookings.length === 0 ? (
+
+ Vous n'avez pas encore de réservation.{" "}
+
+ Découvrez les carbets disponibles
+
+ .
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts
index 5735b6c..afc59e0 100644
--- a/src/lib/carbet-public.ts
+++ b/src/lib/carbet-public.ts
@@ -3,6 +3,11 @@ import { cache } from "react";
import { prisma } from "@/lib/prisma";
import { amenityLabel } from "@/lib/amenities";
import { CarbetStatus, MediaType } from "@/generated/prisma/enums";
+import type { CarbetReview, CarbetReviewStats } from "@/lib/reviews";
+import {
+ getCarbetReviewStats,
+ listCarbetReviews,
+} from "@/lib/reviews-server";
export type PublicCarbetMedia = {
id: string;
@@ -21,9 +26,12 @@ export type PublicCarbetDetail = {
capacity: number;
latitude: string;
longitude: string;
+ ownerId: string;
ownerFirstName: string;
media: PublicCarbetMedia[];
amenities: { key: string; label: string }[];
+ reviewStats: CarbetReviewStats;
+ reviews: CarbetReview[];
};
// Memoized within a single request so generateMetadata() and the page itself
@@ -43,6 +51,7 @@ export const getPublicCarbet = cache(
capacity: true,
latitude: true,
longitude: true,
+ ownerId: true,
owner: { select: { firstName: true } },
media: {
orderBy: { sortOrder: "asc" },
@@ -56,6 +65,11 @@ export const getPublicCarbet = cache(
if (!carbet) return null;
+ const [reviewStats, reviews] = await Promise.all([
+ getCarbetReviewStats(carbet.id),
+ listCarbetReviews(carbet.id, 20),
+ ]);
+
return {
id: carbet.id,
slug: carbet.slug,
@@ -67,6 +81,7 @@ export const getPublicCarbet = cache(
capacity: carbet.capacity,
latitude: carbet.latitude.toString(),
longitude: carbet.longitude.toString(),
+ ownerId: carbet.ownerId,
ownerFirstName: carbet.owner.firstName,
media: carbet.media.map((m) => ({
id: m.id,
@@ -80,6 +95,8 @@ export const getPublicCarbet = cache(
label: amenityLabel(entry.amenity.key) || entry.amenity.label,
}))
.sort((a, b) => a.label.localeCompare(b.label, "fr")),
+ reviewStats,
+ reviews,
};
},
);
diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts
index d6c2fd8..b463430 100644
--- a/src/lib/carbet-search.ts
+++ b/src/lib/carbet-search.ts
@@ -5,6 +5,7 @@ import {
AvailabilityScope,
CarbetStatus,
} from "@/generated/prisma/enums";
+import { getCarbetReviewStatsMany } from "@/lib/reviews-server";
export type CarbetSearchFilters = {
river?: string;
@@ -73,6 +74,8 @@ export type CarbetSearchResult = {
description: string;
coverUrl: string | null;
mediaCount: number;
+ reviewCount: number;
+ averageRating: number | null;
};
// Build the Prisma where-clause for a public carbet search. A carbet is only
@@ -132,18 +135,28 @@ export async function searchCarbets(
},
});
- return carbets.map((carbet) => ({
- id: carbet.id,
- slug: carbet.slug,
- title: carbet.title,
- river: carbet.river,
- embarkPoint: carbet.embarkPoint,
- pirogueDurationMin: carbet.pirogueDurationMin,
- capacity: carbet.capacity,
- description: carbet.description,
- coverUrl: carbet.media[0]?.s3Url ?? null,
- mediaCount: carbet._count.media,
- }));
+ const statsMap = await getCarbetReviewStatsMany(carbets.map((c) => c.id));
+
+ return carbets.map((carbet) => {
+ const stats = statsMap.get(carbet.id) ?? {
+ count: 0,
+ averageRating: null,
+ };
+ return {
+ id: carbet.id,
+ slug: carbet.slug,
+ title: carbet.title,
+ river: carbet.river,
+ embarkPoint: carbet.embarkPoint,
+ pirogueDurationMin: carbet.pirogueDurationMin,
+ capacity: carbet.capacity,
+ description: carbet.description,
+ coverUrl: carbet.media[0]?.s3Url ?? null,
+ mediaCount: carbet._count.media,
+ reviewCount: stats.count,
+ averageRating: stats.averageRating,
+ };
+ });
}
// Distinct list of rivers across the published catalogue, for filter UI hints.
diff --git a/src/lib/reviews-server.ts b/src/lib/reviews-server.ts
new file mode 100644
index 0000000..2bc4e5a
--- /dev/null
+++ b/src/lib/reviews-server.ts
@@ -0,0 +1,88 @@
+// Server-only: this module pulls in Prisma. Do not import from client
+// components — `@/lib/reviews` (constants/types) is the safe surface.
+
+import { prisma } from "@/lib/prisma";
+
+import type { CarbetReview, CarbetReviewStats } from "@/lib/reviews";
+
+// Aggregate stats used on cards and detail pages. Returns null average when
+// there are no reviews so the caller can render a neutral "no reviews yet"
+// state rather than a misleading 0.0.
+export async function getCarbetReviewStats(
+ carbetId: string,
+): Promise {
+ const agg = await prisma.review.aggregate({
+ where: { carbetId },
+ _count: { _all: true },
+ _avg: { rating: true },
+ });
+
+ const count = agg._count._all;
+ const avg = agg._avg.rating;
+
+ return {
+ count,
+ averageRating: count > 0 && avg !== null ? Number(avg) : null,
+ };
+}
+
+export async function getCarbetReviewStatsMany(
+ carbetIds: string[],
+): Promise