diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
new file mode 100644
index 0000000..c148849
--- /dev/null
+++ b/.forgejo/workflows/ci.yml
@@ -0,0 +1,59 @@
+name: CI
+
+# Lance lint + typecheck + tests + build sur push/PR.
+#
+# Workflow dormant tant qu'aucun runner Forgejo n'est enregistré.
+# Pour activer :
+# 1) Sur git.cosmolan.fr, générer un token runner :
+# Admin → Actions → Runners → Create new Runner Token
+# (ou pour ce repo seul : Settings → Actions → Runners → Create)
+# 2) Sur la machine d'exécution :
+# wget https://codeberg.org/forgejo/runner/releases/download/v6.7.0/forgejo-runner-6.7.0-linux-amd64
+# chmod +x forgejo-runner-6.7.0-linux-amd64
+# ./forgejo-runner-6.7.0-linux-amd64 register \
+# --instance https://git.cosmolan.fr \
+# --token \
+# --name karbe-ci \
+# --labels "ubuntu-latest:docker://node:20"
+# 3) Démarrer :
+# ./forgejo-runner-6.7.0-linux-amd64 daemon
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+
+ - name: Install dependencies
+ run: npm ci --no-audit --no-fund
+
+ - name: Generate Prisma client
+ run: npx prisma generate
+
+ - name: Lint
+ run: npm run lint
+
+ - name: Typecheck
+ run: npm run typecheck
+
+ - name: Test
+ run: npm test
+
+ - name: Build (smoke)
+ run: npm run build
+ env:
+ # Stubs nécessaires au build statique — pas de connexion réelle.
+ DATABASE_URL: "postgresql://stub:stub@localhost:5432/stub?schema=public"
+ NEXTAUTH_SECRET: "ci-secret-not-for-production"
+ AUTH_SECRET: "ci-secret-not-for-production"
+ NEXT_PUBLIC_SITE_URL: "https://example.invalid"
diff --git a/package-lock.json b/package-lock.json
index f80d6c5..7d8475d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,14 +10,23 @@
"hasInstallScript": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.1056.0",
+ "@aws-sdk/s3-request-presigner": "^3.1058.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^8.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
+ "@types/leaflet": "^1.9.21",
"bcryptjs": "^3.0.3",
+ "leaflet": "^1.9.4",
"next": "16.2.6",
"next-auth": "^5.0.0-beta.31",
"pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "react-leaflet": "^5.0.0",
+ "resend": "^4.8.0",
+ "sharp": "^0.34.5",
"stripe": "^18.3.0"
},
"devDependencies": {
@@ -25,12 +34,14 @@
"@types/node": "^20.19.41",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
+ "@vitest/coverage-v8": "^3.2.4",
"dotenv": "^17.4.2",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.6",
"prisma": "^7.8.0",
"tailwindcss": "^4",
- "typescript": "^5.9.3"
+ "typescript": "^5.9.3",
+ "vitest": "^3.2.4"
}
},
"node_modules/@alloc/quick-lru": {
@@ -46,6 +57,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@auth/core": {
"version": "0.41.2",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz",
@@ -489,6 +514,23 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws-sdk/s3-request-presigner": {
+ "version": "3.1058.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1058.0.tgz",
+ "integrity": "sha512-IRgNfn8U3zfsZ0JkpmwjS59R/XyHMHxpuwW6HVuJhik+FsbClhNkujEO0w1WqJvXrF4FX+7qIAwUrvlwNvaZ7Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.974.15",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.30",
+ "@aws-sdk/types": "^3.973.9",
+ "@smithy/core": "^3.24.5",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.30",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz",
@@ -705,7 +747,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -715,7 +757,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -749,7 +791,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
@@ -799,7 +841,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
@@ -809,6 +851,69 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
+ "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.1.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@electric-sql/pglite": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz",
@@ -872,6 +977,448 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -1100,7 +1647,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=18"
}
@@ -1561,6 +2107,34 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
+ "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1850,6 +2424,17 @@
"url": "https://github.com/sponsors/panva"
}
},
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@prisma/adapter-pg": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz",
@@ -2231,6 +2816,424 @@
}
}
},
+ "node_modules/@react-email/render": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz",
+ "integrity": "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==",
+ "license": "MIT",
+ "dependencies": {
+ "html-to-text": "^9.0.5",
+ "prettier": "^3.5.3",
+ "react-promise-suspense": "^0.3.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/@react-leaflet/core": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
+ "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
+ "license": "Hippocratic-2.1",
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
+ "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
+ "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
+ "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
+ "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
+ "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
+ "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
+ "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
+ "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
+ "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
+ "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
+ "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
+ "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
+ "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
+ "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
+ "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
+ "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
+ "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
+ "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
+ "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
+ "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
+ "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
+ "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
+ "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2238,6 +3241,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@selderee/plugin-htmlparser2": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
+ "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
+ "license": "MIT",
+ "dependencies": {
+ "domhandler": "^5.0.3",
+ "selderee": "^0.11.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
"node_modules/@smithy/core": {
"version": "3.24.5",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz",
@@ -2656,6 +3672,24 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2663,6 +3697,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2677,6 +3717,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/leaflet": {
+ "version": "1.9.21",
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
+ "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
@@ -2711,7 +3760,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -3325,6 +4374,155 @@
"win32"
]
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
+ "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.3.0",
+ "@bcoe/v8-coverage": "^1.0.2",
+ "ast-v8-to-istanbul": "^0.3.3",
+ "debug": "^4.4.1",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-lib-source-maps": "^5.0.6",
+ "istanbul-reports": "^3.1.7",
+ "magic-string": "^0.30.17",
+ "magicast": "^0.3.5",
+ "std-env": "^3.9.0",
+ "test-exclude": "^7.0.1",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "3.2.4",
+ "vitest": "3.2.4"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3365,6 +4563,19 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -3558,6 +4769,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -3565,6 +4786,25 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.12",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
+ "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/async-function": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@@ -3749,6 +4989,16 @@
}
}
},
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -3827,6 +5077,23 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3857,6 +5124,16 @@
"pnpm": ">=8"
}
},
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -4021,6 +5298,16 @@
}
}
},
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4028,6 +5315,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/deepmerge-ts": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
@@ -4102,7 +5398,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -4121,6 +5416,61 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -4148,6 +5498,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/effect": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
@@ -4197,6 +5554,18 @@
"node": ">=10.13.0"
}
},
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/env-paths": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
@@ -4325,6 +5694,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -4384,6 +5760,48 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -4803,6 +6221,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -4813,6 +6241,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
@@ -5055,6 +6493,21 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -5210,6 +6663,28 @@
"giget": "dist/cli.mjs"
}
},
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -5223,6 +6698,32 @@
"node": ">=10.13.0"
}
},
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
+ "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -5405,6 +6906,48 @@
"node": ">=16.9.0"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/html-to-text": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
+ "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
+ "license": "MIT",
+ "dependencies": {
+ "@selderee/plugin-htmlparser2": "^0.11.0",
+ "deepmerge": "^4.3.1",
+ "dom-serializer": "^2.0.0",
+ "htmlparser2": "^8.0.2",
+ "selderee": "^0.11.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/htmlparser2": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
+ "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1",
+ "entities": "^4.4.0"
+ }
+ },
"node_modules/http-status-codes": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
@@ -5665,6 +7208,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -5917,6 +7470,60 @@
"devOptional": true,
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-source-maps": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
+ "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.23",
+ "debug": "^4.1.1",
+ "istanbul-lib-coverage": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -5935,6 +7542,22 @@
"node": ">= 0.4"
}
},
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -6067,6 +7690,21 @@
"node": ">=0.10"
}
},
+ "node_modules/leac": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
+ "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -6385,6 +8023,13 @@
"loose-envify": "cli.js"
}
},
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -6421,6 +8066,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
+ "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.25.4",
+ "@babel/types": "^7.25.4",
+ "source-map-js": "^1.2.0"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.8.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
+ "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -6477,6 +8163,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6902,6 +8598,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6915,6 +8618,19 @@
"node": ">=6"
}
},
+ "node_modules/parseley": {
+ "version": "0.12.1",
+ "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
+ "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
+ "license": "MIT",
+ "dependencies": {
+ "leac": "^0.6.0",
+ "peberminta": "^0.9.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6957,6 +8673,30 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-scurry/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -6964,6 +8704,25 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/peberminta": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
+ "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
"node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
@@ -7221,6 +8980,21 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prettier": {
+ "version": "3.8.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
+ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/prisma": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz",
@@ -7388,6 +9162,35 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/react-leaflet": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
+ "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
+ "license": "Hippocratic-2.1",
+ "dependencies": {
+ "@react-leaflet/core": "^3.0.0"
+ },
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
+ "node_modules/react-promise-suspense": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
+ "integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^2.0.1"
+ }
+ },
+ "node_modules/react-promise-suspense/node_modules/fast-deep-equal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
+ "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==",
+ "license": "MIT"
+ },
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -7466,6 +9269,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/resend": {
+ "version": "4.8.0",
+ "resolved": "https://registry.npmjs.org/resend/-/resend-4.8.0.tgz",
+ "integrity": "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-email/render": "1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/resolve": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -7531,6 +9346,58 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rollup": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
+ "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.4",
+ "@rollup/rollup-android-arm64": "4.60.4",
+ "@rollup/rollup-darwin-arm64": "4.60.4",
+ "@rollup/rollup-darwin-x64": "4.60.4",
+ "@rollup/rollup-freebsd-arm64": "4.60.4",
+ "@rollup/rollup-freebsd-x64": "4.60.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.4",
+ "@rollup/rollup-linux-arm64-musl": "4.60.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.4",
+ "@rollup/rollup-linux-loong64-musl": "4.60.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-musl": "4.60.4",
+ "@rollup/rollup-openbsd-x64": "4.60.4",
+ "@rollup/rollup-openharmony-arm64": "4.60.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.4",
+ "@rollup/rollup-win32-x64-gnu": "4.60.4",
+ "@rollup/rollup-win32-x64-msvc": "4.60.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rollup/node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -7623,6 +9490,18 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
+ "node_modules/selderee": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
+ "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
+ "license": "MIT",
+ "dependencies": {
+ "parseley": "^0.12.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -7694,7 +9573,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
- "optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -7738,7 +9616,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
- "optional": true,
"bin": {
"semver": "bin/semver.js"
},
@@ -7841,6 +9718,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -7889,6 +9773,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@@ -7910,6 +9801,70 @@
"node": ">= 0.4"
}
},
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -8023,6 +9978,46 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -8046,6 +10041,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stripe": {
"version": "18.5.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-18.5.0.tgz",
@@ -8148,6 +10163,74 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/test-exclude": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
+ "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^10.4.1",
+ "minimatch": "^10.2.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/test-exclude/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/test-exclude/node_modules/brace-expansion": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/test-exclude/node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -8196,6 +10279,36 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -8502,6 +10615,221 @@
}
}
},
+ "node_modules/vite": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
+ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vite/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8607,6 +10935,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -8617,6 +10962,101 @@
"node": ">=0.10.0"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
diff --git a/package.json b/package.json
index 4b2d77d..e0a10f1 100644
--- a/package.json
+++ b/package.json
@@ -7,18 +7,30 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
- "postinstall": "prisma generate"
+ "postinstall": "prisma generate",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1056.0",
+ "@aws-sdk/s3-request-presigner": "^3.1058.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^8.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
+ "@types/leaflet": "^1.9.21",
"bcryptjs": "^3.0.3",
+ "leaflet": "^1.9.4",
"next": "16.2.6",
"next-auth": "^5.0.0-beta.31",
"pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "react-leaflet": "^5.0.0",
+ "resend": "^4.8.0",
+ "sharp": "^0.34.5",
"stripe": "^18.3.0"
},
"devDependencies": {
@@ -26,11 +38,13 @@
"@types/node": "^20.19.41",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
+ "@vitest/coverage-v8": "^3.2.4",
"dotenv": "^17.4.2",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.6",
"prisma": "^7.8.0",
"tailwindcss": "^4",
- "typescript": "^5.9.3"
+ "typescript": "^5.9.3",
+ "vitest": "^3.2.4"
}
}
diff --git a/prisma/migrations/20260531120000_add_seasonality_and_min_stay/migration.sql b/prisma/migrations/20260531120000_add_seasonality_and_min_stay/migration.sql
new file mode 100644
index 0000000..0cf671c
--- /dev/null
+++ b/prisma/migrations/20260531120000_add_seasonality_and_min_stay/migration.sql
@@ -0,0 +1,7 @@
+-- Plugin seasonality + min-stay : champs sur Carbet
+
+ALTER TABLE "Carbet"
+ ADD COLUMN "seasonalConstraints" JSONB,
+ ADD COLUMN "minStayNights" INTEGER,
+ ADD COLUMN "maxStayNights" INTEGER,
+ ADD COLUMN "minCapacity" INTEGER;
diff --git a/prisma/migrations/20260531180000_add_content_pages/migration.sql b/prisma/migrations/20260531180000_add_content_pages/migration.sql
new file mode 100644
index 0000000..4306682
--- /dev/null
+++ b/prisma/migrations/20260531180000_add_content_pages/migration.sql
@@ -0,0 +1,16 @@
+-- Plugin content-pages + legal-pages : table ContentPage
+
+CREATE TABLE "ContentPage" (
+ "slug" TEXT PRIMARY KEY,
+ "title" TEXT NOT NULL,
+ "body" TEXT NOT NULL,
+ "lang" TEXT NOT NULL DEFAULT 'fr',
+ "category" TEXT NOT NULL DEFAULT 'general',
+ "published" BOOLEAN NOT NULL DEFAULT true,
+ "lastEditedBy" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL
+);
+
+CREATE INDEX "ContentPage_category_idx" ON "ContentPage" ("category");
+CREATE INDEX "ContentPage_published_idx" ON "ContentPage" ("published");
diff --git a/prisma/migrations/20260531200000_add_pirogue_providers/migration.sql b/prisma/migrations/20260531200000_add_pirogue_providers/migration.sql
new file mode 100644
index 0000000..3f25550
--- /dev/null
+++ b/prisma/migrations/20260531200000_add_pirogue_providers/migration.sql
@@ -0,0 +1,29 @@
+-- Plugin pirogue-providers : modèle PirogueProvider + transportMode sur Carbet
+
+CREATE TYPE "TransportMode" AS ENUM ('OWNER_PROVIDES', 'SELF_ARRANGE', 'PARTNER_PROVIDER');
+
+CREATE TABLE "PirogueProvider" (
+ "id" TEXT PRIMARY KEY,
+ "name" TEXT NOT NULL,
+ "contactEmail" TEXT,
+ "contactPhone" TEXT,
+ "rivers" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
+ "pricingNote" TEXT,
+ "description" TEXT,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL
+);
+
+CREATE INDEX "PirogueProvider_active_idx" ON "PirogueProvider" ("active");
+
+ALTER TABLE "Carbet"
+ ADD COLUMN "transportMode" "TransportMode",
+ ADD COLUMN "pirogueProviderId" TEXT;
+
+ALTER TABLE "Carbet"
+ ADD CONSTRAINT "Carbet_pirogueProviderId_fkey"
+ FOREIGN KEY ("pirogueProviderId") REFERENCES "PirogueProvider"("id")
+ ON DELETE SET NULL;
+
+CREATE INDEX "Carbet_pirogueProviderId_idx" ON "Carbet" ("pirogueProviderId");
diff --git a/prisma/migrations/20260531220000_content_page_composite_key/migration.sql b/prisma/migrations/20260531220000_content_page_composite_key/migration.sql
new file mode 100644
index 0000000..c3ccfd9
--- /dev/null
+++ b/prisma/migrations/20260531220000_content_page_composite_key/migration.sql
@@ -0,0 +1,8 @@
+-- Plugin i18n-fr-en + content-pages :
+-- ContentPage devient bilingue → PK composite (slug, lang)
+-- pour pouvoir stocker une version FR et une version EN du même slug.
+
+ALTER TABLE "ContentPage" DROP CONSTRAINT "ContentPage_pkey";
+ALTER TABLE "ContentPage" ADD CONSTRAINT "ContentPage_pkey" PRIMARY KEY ("slug", "lang");
+
+CREATE INDEX IF NOT EXISTS "ContentPage_slug_idx" ON "ContentPage" ("slug");
diff --git a/prisma/migrations/20260601000000_audit_and_settings/migration.sql b/prisma/migrations/20260601000000_audit_and_settings/migration.sql
new file mode 100644
index 0000000..15779de
--- /dev/null
+++ b/prisma/migrations/20260601000000_audit_and_settings/migration.sql
@@ -0,0 +1,22 @@
+CREATE TABLE "AuditLog" (
+ "id" TEXT NOT NULL,
+ "scope" TEXT NOT NULL,
+ "event" TEXT NOT NULL,
+ "target" TEXT,
+ "actorEmail" TEXT,
+ "details" JSONB NOT NULL DEFAULT '{}',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
+);
+CREATE INDEX "AuditLog_scope_idx" ON "AuditLog"("scope");
+CREATE INDEX "AuditLog_event_idx" ON "AuditLog"("event");
+CREATE INDEX "AuditLog_actorEmail_idx" ON "AuditLog"("actorEmail");
+CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
+
+CREATE TABLE "Setting" (
+ "key" TEXT NOT NULL,
+ "value" JSONB NOT NULL DEFAULT '{}',
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "updatedBy" TEXT,
+ CONSTRAINT "Setting_pkey" PRIMARY KEY ("key")
+);
diff --git a/prisma/migrations/20260601060000_password_reset_token/migration.sql b/prisma/migrations/20260601060000_password_reset_token/migration.sql
new file mode 100644
index 0000000..50033de
--- /dev/null
+++ b/prisma/migrations/20260601060000_password_reset_token/migration.sql
@@ -0,0 +1,9 @@
+CREATE TABLE "PasswordResetToken" (
+ "tokenHash" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("tokenHash")
+);
+CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId");
+CREATE INDEX "PasswordResetToken_expiresAt_idx" ON "PasswordResetToken"("expiresAt");
diff --git a/prisma/migrations/20260601120000_translation_overrides/migration.sql b/prisma/migrations/20260601120000_translation_overrides/migration.sql
new file mode 100644
index 0000000..5f3bfb5
--- /dev/null
+++ b/prisma/migrations/20260601120000_translation_overrides/migration.sql
@@ -0,0 +1,9 @@
+CREATE TABLE "Translation" (
+ "key" TEXT NOT NULL,
+ "lang" TEXT NOT NULL,
+ "value" TEXT NOT NULL,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "updatedBy" TEXT,
+ CONSTRAINT "Translation_pkey" PRIMARY KEY ("key", "lang")
+);
+CREATE INDEX "Translation_lang_idx" ON "Translation"("lang");
diff --git a/prisma/migrations/20260601150000_carbet_nightly_price/migration.sql b/prisma/migrations/20260601150000_carbet_nightly_price/migration.sql
new file mode 100644
index 0000000..e53b6bf
--- /dev/null
+++ b/prisma/migrations/20260601150000_carbet_nightly_price/migration.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "Carbet" ADD COLUMN "nightlyPrice" DECIMAL(10,2) NOT NULL DEFAULT 0;
+UPDATE "Carbet" SET "nightlyPrice" = 80 WHERE "nightlyPrice" = 0;
diff --git a/prisma/migrations/20260602030000_operational_criteria/migration.sql b/prisma/migrations/20260602030000_operational_criteria/migration.sql
new file mode 100644
index 0000000..5bdca5f
--- /dev/null
+++ b/prisma/migrations/20260602030000_operational_criteria/migration.sql
@@ -0,0 +1,15 @@
+CREATE TYPE "RoadAccess" AS ENUM ('NONE', 'DRY_SEASON_ONLY', 'ALL_YEAR');
+CREATE TYPE "Electricity" AS ENUM ('NONE', 'SOLAR', 'GENERATOR_READY', 'EDF');
+
+ALTER TABLE "Carbet" ADD COLUMN "roadAccess" "RoadAccess";
+ALTER TABLE "Carbet" ADD COLUMN "electricity" "Electricity";
+ALTER TABLE "Carbet" ADD COLUMN "gsmAtCarbet" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "Carbet" ADD COLUMN "gsmExitDistanceKm" DECIMAL(4,2);
+
+-- Seed des 6 carbets démo avec valeurs réalistes
+UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 1.5 WHERE id = 'demo-carbet-awara';
+UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-kourou';
+UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-mahury';
+UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'GENERATOR_READY', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 4.0 WHERE id = 'demo-carbet-maripa';
+UPDATE "Carbet" SET "roadAccess" = 'DRY_SEASON_ONLY', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 0.5 WHERE id = 'demo-carbet-paripou';
+UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-wapa';
diff --git a/prisma/migrations/20260602100000_favorite/migration.sql b/prisma/migrations/20260602100000_favorite/migration.sql
new file mode 100644
index 0000000..8abf012
--- /dev/null
+++ b/prisma/migrations/20260602100000_favorite/migration.sql
@@ -0,0 +1,8 @@
+CREATE TABLE "Favorite" (
+ "userId" TEXT NOT NULL,
+ "carbetId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "Favorite_pkey" PRIMARY KEY ("userId", "carbetId")
+);
+CREATE INDEX "Favorite_userId_idx" ON "Favorite"("userId");
+CREATE INDEX "Favorite_carbetId_idx" ON "Favorite"("carbetId");
diff --git a/prisma/migrations/20260603000000_rental_marketplace/migration.sql b/prisma/migrations/20260603000000_rental_marketplace/migration.sql
new file mode 100644
index 0000000..65b4eb1
--- /dev/null
+++ b/prisma/migrations/20260603000000_rental_marketplace/migration.sql
@@ -0,0 +1,112 @@
+-- UserRole : ajouter RENTAL_PROVIDER
+ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'RENTAL_PROVIDER';
+
+-- Enums dédiés
+CREATE TYPE "RentalCategory" AS ENUM ('SLEEP', 'NAVIGATION', 'FISHING', 'COOKING', 'SAFETY');
+CREATE TYPE "RentalBookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'HANDED_OVER', 'RETURNED', 'CANCELLED');
+
+-- RentalProvider
+CREATE TABLE "RentalProvider" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "isSystemD" BOOLEAN NOT NULL DEFAULT false,
+ "managedByUserId" TEXT,
+ "contactEmail" TEXT,
+ "contactPhone" TEXT,
+ "rivers" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "description" TEXT,
+ "commissionPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "approved" BOOLEAN NOT NULL DEFAULT false,
+ "approvedAt" TIMESTAMP(3),
+ "approvedBy" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "RentalProvider_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalProvider_managedByUserId_fkey" FOREIGN KEY ("managedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE
+);
+CREATE INDEX "RentalProvider_active_approved_idx" ON "RentalProvider"("active", "approved");
+CREATE INDEX "RentalProvider_managedByUserId_idx" ON "RentalProvider"("managedByUserId");
+
+-- RentalItem
+CREATE TABLE "RentalItem" (
+ "id" TEXT NOT NULL,
+ "providerId" TEXT NOT NULL,
+ "category" "RentalCategory" NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+ "imageUrl" TEXT,
+ "pricePerDay" DECIMAL(8,2) NOT NULL,
+ "pricePerWeek" DECIMAL(8,2),
+ "deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
+ "totalQty" INTEGER NOT NULL DEFAULT 1,
+ "withMotor" BOOLEAN NOT NULL DEFAULT false,
+ "fuelIncluded" BOOLEAN NOT NULL DEFAULT false,
+ "requiresLicense" BOOLEAN NOT NULL DEFAULT false,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "RentalItem_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalItem_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+CREATE INDEX "RentalItem_providerId_idx" ON "RentalItem"("providerId");
+CREATE INDEX "RentalItem_category_active_idx" ON "RentalItem"("category", "active");
+
+-- RentalItemAvailability
+CREATE TABLE "RentalItemAvailability" (
+ "id" TEXT NOT NULL,
+ "itemId" TEXT NOT NULL,
+ "startDate" TIMESTAMP(3) NOT NULL,
+ "endDate" TIMESTAMP(3) NOT NULL,
+ "qty" INTEGER NOT NULL,
+ "reason" TEXT NOT NULL,
+ "rentalBookingId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "RentalItemAvailability_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalItemAvailability_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+CREATE INDEX "RentalItemAvailability_itemId_startDate_endDate_idx" ON "RentalItemAvailability"("itemId", "startDate", "endDate");
+CREATE INDEX "RentalItemAvailability_rentalBookingId_idx" ON "RentalItemAvailability"("rentalBookingId");
+
+-- RentalBooking
+CREATE TABLE "RentalBooking" (
+ "id" TEXT NOT NULL,
+ "bookingId" TEXT,
+ "tenantId" TEXT NOT NULL,
+ "providerId" TEXT NOT NULL,
+ "startDate" TIMESTAMP(3) NOT NULL,
+ "endDate" TIMESTAMP(3) NOT NULL,
+ "status" "RentalBookingStatus" NOT NULL DEFAULT 'PENDING',
+ "paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
+ "itemsTotal" DECIMAL(10,2) NOT NULL,
+ "depositTotal" DECIMAL(10,2) NOT NULL,
+ "commissionAmount" DECIMAL(10,2) NOT NULL DEFAULT 0,
+ "amount" DECIMAL(10,2) NOT NULL,
+ "currency" TEXT NOT NULL DEFAULT 'EUR',
+ "stripeSessionId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "RentalBooking_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE,
+ CONSTRAINT "RentalBooking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
+ CONSTRAINT "RentalBooking_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+CREATE INDEX "RentalBooking_tenantId_status_idx" ON "RentalBooking"("tenantId", "status");
+CREATE INDEX "RentalBooking_providerId_status_idx" ON "RentalBooking"("providerId", "status");
+CREATE INDEX "RentalBooking_bookingId_idx" ON "RentalBooking"("bookingId");
+CREATE INDEX "RentalBooking_startDate_endDate_idx" ON "RentalBooking"("startDate", "endDate");
+
+-- RentalLine
+CREATE TABLE "RentalLine" (
+ "id" TEXT NOT NULL,
+ "rentalBookingId" TEXT NOT NULL,
+ "itemId" TEXT NOT NULL,
+ "qty" INTEGER NOT NULL,
+ "pricePerDay" DECIMAL(8,2) NOT NULL,
+ "deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
+ "lineTotal" DECIMAL(10,2) NOT NULL,
+ CONSTRAINT "RentalLine_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalLine_rentalBookingId_fkey" FOREIGN KEY ("rentalBookingId") REFERENCES "RentalBooking"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "RentalLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+CREATE INDEX "RentalLine_rentalBookingId_idx" ON "RentalLine"("rentalBookingId");
diff --git a/prisma/migrations/20260603100000_rental_item_media/migration.sql b/prisma/migrations/20260603100000_rental_item_media/migration.sql
new file mode 100644
index 0000000..67a2d76
--- /dev/null
+++ b/prisma/migrations/20260603100000_rental_item_media/migration.sql
@@ -0,0 +1,22 @@
+-- Sprint F : RentalItemMedia (photos & vidéos pour items rental).
+-- Mêmes conventions que Media (carbet) : MediaType enum existant, s3Key/s3Url,
+-- sortOrder pour cover (0). Cascade sur RentalItem.
+
+CREATE TABLE "RentalItemMedia" (
+ "id" TEXT NOT NULL,
+ "itemId" TEXT NOT NULL,
+ "type" "MediaType" NOT NULL,
+ "s3Key" TEXT NOT NULL,
+ "s3Url" TEXT NOT NULL,
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "RentalItemMedia_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "RentalItemMedia_itemId_sortOrder_idx"
+ ON "RentalItemMedia"("itemId", "sortOrder");
+
+ALTER TABLE "RentalItemMedia"
+ ADD CONSTRAINT "RentalItemMedia_itemId_fkey"
+ FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/20260603200000_ce_management/migration.sql b/prisma/migrations/20260603200000_ce_management/migration.sql
new file mode 100644
index 0000000..eb2cc87
--- /dev/null
+++ b/prisma/migrations/20260603200000_ce_management/migration.sql
@@ -0,0 +1,54 @@
+-- Sprint G : CE management.
+-- * Organization gagne le workflow d'approbation (approved + approvedAt + approvedBy)
+-- + un contactEmail dédié pour les notifications admin.
+-- * Nouveau modèle OrganizationCarbetMembership : co-gestion des carbets par les
+-- CE_MANAGERs d'une org liée. Pas de unique sur carbet → un Carbet pourrait être
+-- co-publié par plusieurs orgs (cas rare mais autorisé).
+-- * RentalProvider gagne organizationId (nullable) : un CE peut posséder son provider.
+
+ALTER TABLE "Organization"
+ ADD COLUMN "contactEmail" TEXT,
+ ADD COLUMN "approved" BOOLEAN NOT NULL DEFAULT FALSE,
+ ADD COLUMN "approvedAt" TIMESTAMP(3),
+ ADD COLUMN "approvedBy" TEXT;
+
+CREATE INDEX "Organization_approved_idx" ON "Organization"("approved");
+
+-- Backfill : toutes les orgs existantes sont considérées validées.
+-- (Aujourd'hui : CMCK uniquement. Les futures orgs créées via signup arriveront
+-- en approved=false par défaut.)
+UPDATE "Organization"
+ SET "approved" = TRUE,
+ "approvedAt" = NOW()
+ WHERE "approved" = FALSE;
+
+CREATE TABLE "OrganizationCarbetMembership" (
+ "organizationId" TEXT NOT NULL,
+ "carbetId" TEXT NOT NULL,
+ "addedByUserId" TEXT,
+ "addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "OrganizationCarbetMembership_pkey" PRIMARY KEY ("organizationId", "carbetId")
+);
+
+CREATE INDEX "OrganizationCarbetMembership_carbetId_idx"
+ ON "OrganizationCarbetMembership"("carbetId");
+
+ALTER TABLE "OrganizationCarbetMembership"
+ ADD CONSTRAINT "OrganizationCarbetMembership_organizationId_fkey"
+ FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "OrganizationCarbetMembership"
+ ADD CONSTRAINT "OrganizationCarbetMembership_carbetId_fkey"
+ FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "RentalProvider"
+ ADD COLUMN "organizationId" TEXT;
+
+CREATE INDEX "RentalProvider_organizationId_idx" ON "RentalProvider"("organizationId");
+
+ALTER TABLE "RentalProvider"
+ ADD CONSTRAINT "RentalProvider_organizationId_fkey"
+ FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
+ ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/migrations/20260603300000_org_invite_token/migration.sql b/prisma/migrations/20260603300000_org_invite_token/migration.sql
new file mode 100644
index 0000000..7ce99e5
--- /dev/null
+++ b/prisma/migrations/20260603300000_org_invite_token/migration.sql
@@ -0,0 +1,22 @@
+-- Sprint K : tokens d'invitation CE_MEMBER.
+-- Le CE_MANAGER génère un lien /inscription?invite=TOKEN, le destinataire s'inscrit
+-- automatiquement comme CE_MEMBER de l'organisation. usedAt à la consommation.
+
+CREATE TABLE "OrgInviteToken" (
+ "tokenHash" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "email" TEXT,
+ "createdByUserId" TEXT,
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+ "usedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "OrgInviteToken_pkey" PRIMARY KEY ("tokenHash")
+);
+
+CREATE INDEX "OrgInviteToken_organizationId_idx" ON "OrgInviteToken"("organizationId");
+CREATE INDEX "OrgInviteToken_expiresAt_idx" ON "OrgInviteToken"("expiresAt");
+
+ALTER TABLE "OrgInviteToken"
+ ADD CONSTRAINT "OrgInviteToken_organizationId_fkey"
+ FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/20260603400000_rental_payout_mark/migration.sql b/prisma/migrations/20260603400000_rental_payout_mark/migration.sql
new file mode 100644
index 0000000..cff28de
--- /dev/null
+++ b/prisma/migrations/20260603400000_rental_payout_mark/migration.sql
@@ -0,0 +1,28 @@
+-- Sprint O : reversements prestataires.
+-- RentalPayoutMark trace les virements bancaires manuels effectués par System D
+-- vers les RentalProvider tiers (le marketplace encaisse centralisé, redistribue
+-- hors plateforme une fois par mois). Unique (provider, mois) pour empêcher
+-- les marquages en doublon.
+
+CREATE TABLE "RentalPayoutMark" (
+ "id" TEXT NOT NULL,
+ "providerId" TEXT NOT NULL,
+ "periodMonth" TIMESTAMP(3) NOT NULL,
+ "amount" DECIMAL(10, 2) NOT NULL,
+ "reference" TEXT,
+ "paidAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "paidByEmail" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "RentalPayoutMark_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "RentalPayoutMark_providerId_periodMonth_key"
+ ON "RentalPayoutMark"("providerId", "periodMonth");
+
+CREATE INDEX "RentalPayoutMark_periodMonth_idx"
+ ON "RentalPayoutMark"("periodMonth");
+
+ALTER TABLE "RentalPayoutMark"
+ ADD CONSTRAINT "RentalPayoutMark_providerId_fkey"
+ FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b8c9cd6..6ae7e3f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -13,6 +13,7 @@ enum UserRole {
CE_MEMBER
TOURIST
ADMIN
+ RENTAL_PROVIDER
}
enum CarbetStatus {
@@ -59,17 +60,71 @@ enum SubscriptionStatus {
CANCELED
}
-model Organization {
- id String @id @default(cuid())
- name String
- slug String @unique
- description String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+enum AccessType {
+ ROAD_AND_RIVER
+ RIVER_ONLY
+}
- members User[]
+enum TransportMode {
+ OWNER_PROVIDES
+ SELF_ARRANGE
+ PARTNER_PROVIDER
+}
+
+model Organization {
+ id String @id @default(cuid())
+ name String
+ slug String @unique
+ description String?
+ contactEmail String?
+ approved Boolean @default(false)
+ approvedAt DateTime?
+ approvedBy String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ members User[]
+ carbetMemberships OrganizationCarbetMembership[]
+ rentalProviders RentalProvider[]
+ invites OrgInviteToken[]
@@index([name])
+ @@index([approved])
+}
+
+/// Token d'invitation pour rejoindre une organisation comme CE_MEMBER.
+/// Le CE_MANAGER génère un lien, le destinataire s'inscrit via /inscription?invite=TOKEN.
+/// Pas de unique sur email pour permettre plusieurs invites pendants par destinataire.
+model OrgInviteToken {
+ tokenHash String @id
+ organizationId String
+ email String?
+ createdByUserId String?
+ expiresAt DateTime
+ usedAt DateTime?
+ createdAt DateTime @default(now())
+
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+ @@index([organizationId])
+ @@index([expiresAt])
+}
+
+/// Co-gestion des carbets côté CE. Un Carbet a toujours un ownerId (créateur initial),
+/// et zéro ou plusieurs orgs liées : un CE_MANAGER d'une org liée peut gérer le carbet
+/// en plus de l'owner. Pour un hôte individuel : aucune membership ; pour un carbet CE :
+/// 1 membership pour l'org du créateur. Plusieurs orgs possibles si co-publication.
+model OrganizationCarbetMembership {
+ organizationId String
+ carbetId String
+ addedByUserId String?
+ addedAt DateTime @default(now())
+
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
+
+ @@id([organizationId, carbetId])
+ @@index([carbetId])
}
model User {
@@ -86,11 +141,13 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
- carbets Carbet[] @relation("CarbetOwner")
- bookings Booking[] @relation("BookingTenant")
- reviews Review[] @relation("ReviewAuthor")
- subscriptions Subscription[]
+ organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
+ carbets Carbet[] @relation("CarbetOwner")
+ bookings Booking[] @relation("BookingTenant")
+ reviews Review[] @relation("ReviewAuthor")
+ subscriptions Subscription[]
+ rentalProviders RentalProvider[]
+ rentalBookings RentalBooking[] @relation("RentalBookingTenant")
@@index([organizationId])
@@index([role])
@@ -113,23 +170,60 @@ model Carbet {
// Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste).
roadAccessNote String?
capacity Int
+ // 4 critères opérationnels dealbreakers (dispo en filtres + badges UI)
+ roadAccess RoadAccess?
+ electricity Electricity?
+ gsmAtCarbet Boolean @default(false)
+ gsmExitDistanceKm Decimal? @db.Decimal(4, 2)
+ // Prix par nuit pour le carbet entier (toute capacité). En euros.
+ nightlyPrice Decimal @db.Decimal(10, 2) @default(0)
+ // Contraintes séjour (plugin min-stay). null = pas de contrainte.
+ minStayNights Int?
+ maxStayNights Int?
+ minCapacity Int?
+ // Contraintes saisonnières (plugin seasonality). JSON libre, schéma type :
+ // { closedInLowWater: bool, closedSeasons: ["WET"|"DRY"|"LOW_WATER"][], note: string }
+ seasonalConstraints Json?
+ // Plugin pirogue-providers : qui organise le transport ?
+ transportMode TransportMode?
+ pirogueProviderId String?
status CarbetStatus @default(DRAFT)
lastBookedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict)
- amenities CarbetAmenity[]
- media Media[]
- availabilities Availability[]
- bookings Booking[]
- reviews Review[]
+ owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict)
+ pirogueProvider PirogueProvider? @relation(fields: [pirogueProviderId], references: [id], onDelete: SetNull)
+ amenities CarbetAmenity[]
+ media Media[]
+ availabilities Availability[]
+ bookings Booking[]
+ reviews Review[]
subscriptions Subscription[]
+ organizations OrganizationCarbetMembership[]
@@index([ownerId])
@@index([status])
@@index([river])
@@index([accessType])
+ @@index([pirogueProviderId])
+}
+
+model PirogueProvider {
+ id String @id @default(cuid())
+ name String
+ contactEmail String?
+ contactPhone String?
+ rivers String[] @default([])
+ pricingNote String?
+ description String?
+ active Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ carbets Carbet[]
+
+ @@index([active])
}
model Amenity {
@@ -202,7 +296,8 @@ model Booking {
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
- review Review?
+ review Review?
+ rentalBookings RentalBooking[]
@@index([carbetId])
@@index([tenantId])
@@ -268,3 +363,257 @@ model Plugin {
@@index([category])
@@index([enabled])
}
+
+model ContentPage {
+ slug String
+ lang String @default("fr")
+ title String
+ body String
+ // 'general' (about, faq, ...) ou 'legal' (cgv, mentions, ...)
+ category String @default("general")
+ published Boolean @default(true)
+ lastEditedBy String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@id([slug, lang])
+ @@index([slug])
+ @@index([category])
+ @@index([published])
+}
+
+model AuditLog {
+ id String @id @default(cuid())
+ scope String
+ event String
+ target String?
+ actorEmail String?
+ details Json @default("{}")
+ createdAt DateTime @default(now())
+
+ @@index([scope])
+ @@index([event])
+ @@index([actorEmail])
+ @@index([createdAt])
+}
+
+model Setting {
+ key String @id
+ value Json @default("{}")
+ updatedAt DateTime @updatedAt
+ updatedBy String?
+}
+
+model Translation {
+ key String
+ lang String
+ value String
+ updatedAt DateTime @updatedAt
+ updatedBy String?
+
+ @@id([key, lang])
+ @@index([lang])
+}
+
+model PasswordResetToken {
+ tokenHash String @id
+ userId String
+ expiresAt DateTime
+ createdAt DateTime @default(now())
+
+ @@index([userId])
+ @@index([expiresAt])
+}
+
+model Favorite {
+ userId String
+ carbetId String
+ createdAt DateTime @default(now())
+
+ @@id([userId, carbetId])
+ @@index([userId])
+ @@index([carbetId])
+}
+
+enum RoadAccess {
+ NONE
+ DRY_SEASON_ONLY
+ ALL_YEAR
+}
+
+enum Electricity {
+ NONE
+ SOLAR
+ GENERATOR_READY
+ EDF
+}
+
+enum RentalCategory {
+ SLEEP
+ NAVIGATION
+ FISHING
+ COOKING
+ SAFETY
+}
+
+enum RentalBookingStatus {
+ PENDING
+ CONFIRMED
+ HANDED_OVER
+ RETURNED
+ CANCELLED
+}
+
+model RentalProvider {
+ id String @id @default(cuid())
+ name String
+ isSystemD Boolean @default(false)
+ managedByUserId String?
+ /// Si renseigné, le provider appartient à une organisation (CE) ; tout CE_MANAGER
+ /// membre de l'org peut gérer items et réservations en plus du manager nominal.
+ organizationId String?
+ contactEmail String?
+ contactPhone String?
+ rivers String[] @default([])
+ description String?
+ commissionPct Decimal @db.Decimal(5, 2) @default(0)
+ active Boolean @default(true)
+ approved Boolean @default(false)
+ approvedAt DateTime?
+ approvedBy String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ manager User? @relation(fields: [managedByUserId], references: [id], onDelete: SetNull)
+ organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
+ items RentalItem[]
+ rentalBookings RentalBooking[]
+ payoutMarks RentalPayoutMark[]
+
+ @@index([active, approved])
+ @@index([managedByUserId])
+ @@index([organizationId])
+}
+
+/// Trace les reversements bancaires manuels (System D paie le provider hors plateforme).
+/// La période est représentée par le mois (1er du mois minuit UTC) ; unique par
+/// (provider, période) pour empêcher de marquer 2 fois le même mois.
+model RentalPayoutMark {
+ id String @id @default(cuid())
+ providerId String
+ /// 1er du mois minuit UTC — sert de clé de période.
+ periodMonth DateTime
+ /// Montant effectivement viré au provider, en euros.
+ amount Decimal @db.Decimal(10, 2)
+ /// Référence de virement (optionnelle, à coller depuis la banque).
+ reference String?
+ paidAt DateTime @default(now())
+ paidByEmail String?
+ createdAt DateTime @default(now())
+
+ provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
+
+ @@unique([providerId, periodMonth])
+ @@index([periodMonth])
+}
+
+model RentalItem {
+ id String @id @default(cuid())
+ providerId String
+ category RentalCategory
+ name String
+ description String?
+ imageUrl String?
+ pricePerDay Decimal @db.Decimal(8, 2)
+ pricePerWeek Decimal? @db.Decimal(8, 2)
+ deposit Decimal @db.Decimal(8, 2) @default(0)
+ totalQty Int @default(1)
+ withMotor Boolean @default(false)
+ fuelIncluded Boolean @default(false)
+ requiresLicense Boolean @default(false)
+ active Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
+ availabilities RentalItemAvailability[]
+ lines RentalLine[]
+ media RentalItemMedia[]
+
+ @@index([providerId])
+ @@index([category, active])
+}
+
+model RentalItemMedia {
+ id String @id @default(cuid())
+ itemId String
+ type MediaType
+ s3Key String
+ s3Url String
+ sortOrder Int @default(0)
+ createdAt DateTime @default(now())
+
+ item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
+
+ @@index([itemId, sortOrder])
+}
+
+model RentalItemAvailability {
+ id String @id @default(cuid())
+ itemId String
+ startDate DateTime
+ endDate DateTime
+ qty Int
+ reason String
+ rentalBookingId String?
+ createdAt DateTime @default(now())
+
+ item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
+
+ @@index([itemId, startDate, endDate])
+ @@index([rentalBookingId])
+}
+
+model RentalBooking {
+ id String @id @default(cuid())
+ bookingId String?
+ tenantId String
+ providerId String
+ startDate DateTime
+ endDate DateTime
+ status RentalBookingStatus @default(PENDING)
+ paymentStatus PaymentStatus @default(PENDING)
+ itemsTotal Decimal @db.Decimal(10, 2)
+ depositTotal Decimal @db.Decimal(10, 2)
+ commissionAmount Decimal @db.Decimal(10, 2) @default(0)
+ amount Decimal @db.Decimal(10, 2)
+ currency String @default("EUR")
+ stripeSessionId String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
+ tenant User @relation("RentalBookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
+ provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Restrict)
+ lines RentalLine[]
+
+ @@index([tenantId, status])
+ @@index([providerId, status])
+ @@index([bookingId])
+ @@index([startDate, endDate])
+}
+
+model RentalLine {
+ id String @id @default(cuid())
+ rentalBookingId String
+ itemId String
+ qty Int
+ pricePerDay Decimal @db.Decimal(8, 2)
+ deposit Decimal @db.Decimal(8, 2) @default(0)
+ lineTotal Decimal @db.Decimal(10, 2)
+
+ rentalBooking RentalBooking @relation(fields: [rentalBookingId], references: [id], onDelete: Cascade)
+ item RentalItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
+
+ @@index([rentalBookingId])
+}
diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png
new file mode 100644
index 0000000..a185b67
Binary files /dev/null and b/public/icons/apple-touch-icon.png differ
diff --git a/public/icons/favicon-32.png b/public/icons/favicon-32.png
new file mode 100644
index 0000000..c062acf
Binary files /dev/null and b/public/icons/favicon-32.png differ
diff --git a/public/icons/icon-192-maskable.png b/public/icons/icon-192-maskable.png
new file mode 100644
index 0000000..e80f811
Binary files /dev/null and b/public/icons/icon-192-maskable.png differ
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000..cb0fd13
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-512-maskable.png b/public/icons/icon-512-maskable.png
new file mode 100644
index 0000000..5041e00
Binary files /dev/null and b/public/icons/icon-512-maskable.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 0000000..abb04bf
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..2f32e8d
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,60 @@
+{
+ "name": "Karbé — carbets fluviaux de Guyane",
+ "short_name": "Karbé",
+ "description": "Au fil de l'eau : louez des carbets le long des fleuves de Guyane.",
+ "start_url": "/decouvrir",
+ "id": "/decouvrir",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait",
+ "background_color": "#000000",
+ "theme_color": "#059669",
+ "lang": "fr",
+ "categories": ["travel", "lifestyle"],
+ "icons": [
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-192-maskable.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/icons/icon-512-maskable.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "shortcuts": [
+ {
+ "name": "Au fil de l'eau",
+ "short_name": "Découvrir",
+ "url": "/decouvrir",
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
+ },
+ {
+ "name": "Mes favoris",
+ "short_name": "Favoris",
+ "url": "/mes-favoris",
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
+ },
+ {
+ "name": "Mon compte",
+ "short_name": "Compte",
+ "url": "/mon-compte",
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
+ }
+ ]
+}
diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh
new file mode 100755
index 0000000..abe63d4
--- /dev/null
+++ b/scripts/backup-postgres.sh
@@ -0,0 +1,54 @@
+#!/bin/bash
+#
+# Backup nightly du PostgreSQL Karbé vers MinIO.
+# Lancé par un systemd timer (karbe-backup.timer).
+#
+# Rétention 30 jours côté MinIO (s'appuyer sur une lifecycle policy ou un
+# nettoyage côté `mc rm` planifié — TODO si on veut être propre).
+
+set -euo pipefail
+
+STAMP=$(date -u +%Y%m%d-%H%M%S)
+DUMP_DIR=/tmp/karbe-backup
+DUMP_FILE="$DUMP_DIR/karbe-${STAMP}.sql.gz"
+BUCKET_DEST="karbe-backups/postgres/karbe-${STAMP}.sql.gz"
+
+mkdir -p "$DUMP_DIR"
+
+# Dump compressé depuis le conteneur postgres
+docker compose -f /home/ubuntu/karbe/docker-compose.prod.yml \
+ -f /home/ubuntu/karbe/docker-compose.override.yml \
+ exec -T postgres pg_dump -U karbe -d karbe \
+ | gzip > "$DUMP_FILE"
+
+SIZE=$(stat -c %s "$DUMP_FILE")
+echo "[$(date -u +%FT%TZ)] dump created size=${SIZE}B path=${DUMP_FILE}"
+
+# Push vers MinIO via mc Docker
+docker run --rm --network karbe-net \
+ --entrypoint /bin/sh \
+ -v "$DUMP_DIR:/dump" \
+ -e MINIO_ROOT_USER \
+ -e MINIO_ROOT_PASSWORD \
+ minio/mc:latest -c "
+ mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \
+ mc mb karbe/karbe-backups --ignore-existing >/dev/null 2>&1 && \
+ mc cp /dump/karbe-${STAMP}.sql.gz karbe/${BUCKET_DEST}
+ "
+
+echo "[$(date -u +%FT%TZ)] uploaded to karbe/${BUCKET_DEST}"
+
+# Nettoyage local
+rm -f "$DUMP_FILE"
+
+# Rétention : supprime les backups > 30 jours dans MinIO
+docker run --rm --network karbe-net \
+ --entrypoint /bin/sh \
+ -e MINIO_ROOT_USER \
+ -e MINIO_ROOT_PASSWORD \
+ minio/mc:latest -c "
+ mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \
+ mc rm --recursive --force --older-than 30d karbe/karbe-backups/ 2>/dev/null || true
+ "
+
+echo "[$(date -u +%FT%TZ)] retention sweep done (>30d removed)"
diff --git a/scripts/upload-aquarelles.sh b/scripts/upload-aquarelles.sh
new file mode 100755
index 0000000..a208a38
--- /dev/null
+++ b/scripts/upload-aquarelles.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# Upload des illustrations aquarelles dans MinIO sous karbe-medias/seed/aquarelle/
+# + applique policy download (public-read) pour qu'elles soient servies via
+# media.karbe.cosmolan.fr.
+#
+# Prerequis :
+# - Fichiers présents dans /tmp/karbe-aquarelles/
+# - MinIO container karbe-minio en up + bucket karbe-medias existant
+# - .env.production accessible pour récupérer MINIO_ROOT_USER/PASSWORD
+#
+# Usage : ./scripts/upload-aquarelles.sh
+
+set -euo pipefail
+
+SRC="${1:-/tmp/karbe-aquarelles}"
+BUCKET="karbe-medias"
+PREFIX="seed/aquarelle"
+
+ENV_FILE="/home/ubuntu/karbe/.env.production"
+USER=$(sudo grep -oP '^MINIO_ROOT_USER=\K.*' "$ENV_FILE")
+PASS=$(sudo grep -oP '^MINIO_ROOT_PASSWORD=\K.*' "$ENV_FILE")
+
+echo " upload depuis $SRC vers minio://$BUCKET/$PREFIX/"
+docker run --rm \
+ --network karbe-net \
+ -v "$SRC:/data:ro" \
+ --entrypoint sh \
+ minio/mc:latest \
+ -c "
+ mc alias set karbe http://karbe-minio:9000 '$USER' '$PASS' >/dev/null
+ mc cp /data/*.jpg /data/*.png karbe/$BUCKET/$PREFIX/
+ mc anonymous set download karbe/$BUCKET || true
+ echo '---'
+ mc ls karbe/$BUCKET/$PREFIX/ | head -20
+ "
diff --git a/src/app/a-propos/page.tsx b/src/app/a-propos/page.tsx
new file mode 100644
index 0000000..3446a4b
--- /dev/null
+++ b/src/app/a-propos/page.tsx
@@ -0,0 +1,19 @@
+import { notFound } from "next/navigation";
+import { getContentPage } from "@/lib/content-pages";
+import { getLocale } from "@/lib/i18n/server";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { ContentPageRenderer } from "@/components/ContentPageRenderer";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ const page = await getContentPage("a-propos", await getLocale());
+ return { title: page?.title ?? "À propos" };
+}
+
+export default async function AboutPage() {
+ if (!(await isPluginEnabled("content-pages"))) notFound();
+ const page = await getContentPage("a-propos", await getLocale());
+ if (!page) notFound();
+ return ;
+}
diff --git a/src/app/accueil/page.tsx b/src/app/accueil/page.tsx
new file mode 100644
index 0000000..513e1ac
--- /dev/null
+++ b/src/app/accueil/page.tsx
@@ -0,0 +1,60 @@
+import Link from "next/link";
+import { IfPluginEnabled } from "@/components/IfPluginEnabled";
+import { HeroSection } from "@/components/landing/HeroSection";
+import { ExperiencesSection } from "@/components/landing/ExperiencesSection";
+import { HowItWorksSection } from "@/components/landing/HowItWorksSection";
+import { CESection } from "@/components/landing/CESection";
+import { TestimonialsSection } from "@/components/landing/TestimonialsSection";
+import { LandingFooter } from "@/components/landing/Footer";
+
+export const metadata = { title: "Accueil — Karbé" };
+
+/**
+ * Landing « marketing » historique (hero + sections + footer riche). Conservée
+ * à /accueil après la promotion de /decouvrir comme nouvelle page d'index.
+ */
+export default function LandingPage() {
+ return (
+ <>
+
+
+
+ Karbé — carbets fluviaux de Guyane
+
+
+ La marketplace pour louer des carbets le long des fleuves de Guyane.
+
+
+
+ Au fil de l'eau
+
+
+ Catalogue
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx
new file mode 100644
index 0000000..0ec2427
--- /dev/null
+++ b/src/app/admin/analytics/page.tsx
@@ -0,0 +1,169 @@
+import Link from "next/link";
+
+import { MonthlyRevenueChart } from "@/components/analytics/MonthlyRevenueChart";
+import { getAdminGlobalKpis, getMonthlyRevenueSeries } from "@/lib/analytics";
+
+export const dynamic = "force-dynamic";
+export const metadata = { title: "Analytics globaux — Karbé admin" };
+
+const ROLE_LABEL: Record = {
+ ADMIN: "Admin",
+ OWNER: "Hôte",
+ RENTAL_PROVIDER: "Loueur matériel",
+ CE_MANAGER: "CE Manager",
+ CE_MEMBER: "CE Membre",
+ TOURIST: "Voyageur",
+};
+
+function fmtEur(n: number): string {
+ return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
+}
+
+export default async function AdminAnalyticsPage() {
+ const [kpis, series] = await Promise.all([
+ getAdminGlobalKpis(),
+ getMonthlyRevenueSeries({ monthsBack: 12 }),
+ ]);
+
+ return (
+
+
+
+
+
+
+
+
+ Utilisateurs par rôle
+
+ {kpis.usersTotal === 0 ? (
+
Aucun utilisateur.
+ ) : (
+
+ )}
+
+
+
+
+ Activité 30 derniers jours
+
+
+
+ Bookings carbet
+ {kpis.bookings30d}
+
+
+ Locations matériel
+ {kpis.rentals30d}
+
+
+ Total CA 30j
+
+ {fmtEur(kpis.revenue30d)}
+
+
+
+
+
+
+
+
+ Chiffre d'affaires mensuel
+
+
+
+
+
+
+
+ Top carbets (30j)
+
+ {kpis.topCarbets.length === 0 ? (
+
Aucune réservation sur les 30 derniers jours.
+ ) : (
+
+ {kpis.topCarbets.map((c, i) => (
+
+
+ #{i + 1}
+
+ {c.title}
+
+
+ {fmtEur(c.revenue)}
+
+ ))}
+
+ )}
+
+
+
+
+ Top prestataires rental (30j)
+
+ {kpis.topProviders.length === 0 ? (
+
Aucune location sur les 30 derniers jours.
+ ) : (
+
+ {kpis.topProviders.map((p, i) => (
+
+
+ #{i + 1}
+
+ {p.name}
+
+
+ {fmtEur(p.revenue)}
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+function KpiCard({ label, value }: { label: string; value: string | number }) {
+ return (
+
+ );
+}
diff --git a/src/app/admin/audit/page.tsx b/src/app/admin/audit/page.tsx
new file mode 100644
index 0000000..52cc4fd
--- /dev/null
+++ b/src/app/admin/audit/page.tsx
@@ -0,0 +1,134 @@
+import Link from "next/link";
+import { listAuditAdmin, listAuditScopes } from "@/lib/admin/audit";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ scope?: string;
+ actor?: string;
+ from?: string;
+ to?: string;
+ }>;
+};
+
+function parseDate(v?: string): Date | undefined {
+ if (!v) return undefined;
+ const d = new Date(v);
+ return isNaN(d.getTime()) ? undefined : d;
+}
+
+export default async function AuditAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ scope: sp.scope?.trim() || undefined,
+ actor: sp.actor?.trim() || undefined,
+ from: parseDate(sp.from),
+ to: parseDate(sp.to),
+ };
+ const [rows, scopes] = await Promise.all([listAuditAdmin(filters), listAuditScopes()]);
+ const dateTimeFmt = new Intl.DateTimeFormat("fr-FR", {
+ day: "2-digit", month: "short", year: "2-digit", hour: "2-digit", minute: "2-digit",
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+ Quand
+ Scope
+ Événement
+ Cible
+ Acteur
+ Détails
+
+
+
+ {rows.length === 0 ? (
+
+
+ Aucune entrée d'audit ne correspond aux filtres.
+
+
+ ) : null}
+ {rows.map((r) => (
+
+
+ {dateTimeFmt.format(r.createdAt)}
+
+ {r.scope}
+ {r.event}
+
+ {r.target ? r.target.slice(0, 24) + (r.target.length > 24 ? "…" : "") : "—"}
+
+ {r.actorEmail ?? "—"}
+
+ {r.details && typeof r.details === "object" && Object.keys(r.details as object).length > 0
+ ? JSON.stringify(r.details)
+ : "—"}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/bookings/[id]/_components/BookingActions.tsx b/src/app/admin/bookings/[id]/_components/BookingActions.tsx
new file mode 100644
index 0000000..b220b27
--- /dev/null
+++ b/src/app/admin/bookings/[id]/_components/BookingActions.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { BookingStatus, PaymentStatus } from "@/generated/prisma/enums";
+import {
+ refundBookingAction,
+ updateBookingPaymentAction,
+ updateBookingStatusAction,
+} from "../../actions";
+
+type Status = (typeof BookingStatus)[keyof typeof BookingStatus];
+type Payment = (typeof PaymentStatus)[keyof typeof PaymentStatus];
+
+const btnBase =
+ "rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:opacity-50";
+
+export function BookingActions({
+ id,
+ status,
+ paymentStatus,
+}: {
+ id: string;
+ status: Status;
+ paymentStatus: Payment;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [confirmRefund, setConfirmRefund] = useState(false);
+
+ function setStatus(next: Status) {
+ setError(null);
+ startTransition(async () => {
+ const res = await updateBookingStatusAction(id, next);
+ if (res && res.ok === false) setError(res.error);
+ router.refresh();
+ });
+ }
+
+ function setPayment(next: Payment) {
+ setError(null);
+ startTransition(async () => {
+ const res = await updateBookingPaymentAction(id, next);
+ if (res && res.ok === false) setError(res.error);
+ router.refresh();
+ });
+ }
+
+ function refund() {
+ setError(null);
+ startTransition(async () => {
+ await refundBookingAction(id);
+ setConfirmRefund(false);
+ router.refresh();
+ });
+ }
+
+ return (
+
+
+ Statut résa :
+ {status === BookingStatus.PENDING ? (
+ setStatus(BookingStatus.CONFIRMED)}
+ className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
+ >
+ Confirmer
+
+ ) : null}
+ {status === BookingStatus.CONFIRMED ? (
+ setStatus(BookingStatus.COMPLETED)}
+ className={`${btnBase} border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-50`}
+ >
+ Marquer terminé
+
+ ) : null}
+ {status !== BookingStatus.CANCELLED && status !== BookingStatus.COMPLETED ? (
+ setStatus(BookingStatus.CANCELLED)}
+ className={`${btnBase} border border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100`}
+ >
+ Annuler
+
+ ) : null}
+
+
+
+
Paiement :
+ {paymentStatus !== PaymentStatus.SUCCEEDED && paymentStatus !== PaymentStatus.REFUNDED ? (
+
setPayment(PaymentStatus.SUCCEEDED)}
+ className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
+ >
+ Marquer payé
+
+ ) : null}
+ {paymentStatus !== PaymentStatus.FAILED && paymentStatus !== PaymentStatus.REFUNDED ? (
+
setPayment(PaymentStatus.FAILED)}
+ className={`${btnBase} border border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100`}
+ >
+ Marquer échec
+
+ ) : null}
+ {paymentStatus === PaymentStatus.SUCCEEDED ? (
+ confirmRefund ? (
+
+ Rembourser & annuler ?
+
+ Oui
+
+ setConfirmRefund(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
setConfirmRefund(true)}
+ disabled={pending}
+ className={`${btnBase} border border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100`}
+ >
+ Rembourser
+
+ )
+ ) : null}
+
+
+ {error ? (
+
{error}
+ ) : null}
+
+ );
+}
diff --git a/src/app/admin/bookings/[id]/page.tsx b/src/app/admin/bookings/[id]/page.tsx
new file mode 100644
index 0000000..185de60
--- /dev/null
+++ b/src/app/admin/bookings/[id]/page.tsx
@@ -0,0 +1,121 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { getBookingForAdmin } from "@/lib/admin/bookings";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+import { BookingActions } from "./_components/BookingActions";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ id: string }> };
+
+export default async function BookingDetailPage({ params }: PageProps) {
+ const { id } = await params;
+ const booking = await getBookingForAdmin(id);
+ if (!booking) notFound();
+
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
+ const dateTimeFmt = new Intl.DateTimeFormat("fr-FR", {
+ day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit",
+ });
+ const nights = Math.max(1, Math.round((booking.endDate.getTime() - booking.startDate.getTime()) / 86400000));
+
+ return (
+
+
+
+
+
+
+
+ Séjour
+
+
+
+ 1 ? "s" : ""}`} />
+
+
+
+
+
+
+ Carbet
+
+
+ {booking.carbet.title}
+
+ }
+ />
+ /{booking.carbet.slug}} />
+
+
+ {booking.carbet.owner.firstName} {booking.carbet.owner.lastName}
+
+ }
+ />
+
+
+
+
+ Locataire
+
+
+ {booking.tenant.firstName} {booking.tenant.lastName}
+
+ }
+ />
+
+ {booking.tenant.phone ?
: null}
+
+
+
+
+
+ Avis
+ {booking.review ? (
+
+ Note {booking.review.rating}/5 · déposé le {dateFmt.format(booking.review.createdAt)} ·{" "}
+
+ Voir l'avis
+
+
+ ) : (
+ Pas encore d'avis pour cette réservation.
+ )}
+
+
+
+ );
+}
+
+function Row({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+
{label}
+ {value}
+
+ );
+}
diff --git a/src/app/admin/bookings/actions.ts b/src/app/admin/bookings/actions.ts
new file mode 100644
index 0000000..ca9e401
--- /dev/null
+++ b/src/app/admin/bookings/actions.ts
@@ -0,0 +1,108 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { auth } from "@/auth";
+import { BookingStatus, PaymentStatus, UserRole } from "@/generated/prisma/enums";
+import { requireRole } from "@/lib/authorization";
+import { prisma } from "@/lib/prisma";
+import { recordAudit } from "@/lib/admin/audit";
+import { sendBookingConfirmed, sendBookingRefunded } from "@/lib/email";
+
+async function audit(event: string, target: string, actor: string | null, details: Record) {
+ await recordAudit({ scope: "admin.bookings", event, target, actorEmail: actor, details });
+}
+
+const ALLOWED_STATUS = new Set([
+ BookingStatus.PENDING,
+ BookingStatus.CONFIRMED,
+ BookingStatus.CANCELLED,
+ BookingStatus.COMPLETED,
+]);
+const ALLOWED_PAYMENT = new Set([
+ PaymentStatus.PENDING,
+ PaymentStatus.AUTHORIZED,
+ PaymentStatus.SUCCEEDED,
+ PaymentStatus.FAILED,
+ PaymentStatus.REFUNDED,
+]);
+
+export async function updateBookingStatusAction(id: string, status: string) {
+ await requireRole([UserRole.ADMIN]);
+ if (!ALLOWED_STATUS.has(status)) {
+ return { ok: false as const, error: "Statut invalide" };
+ }
+ const session = await auth();
+ const before = await prisma.booking.findUnique({
+ where: { id },
+ select: { status: true },
+ });
+ const updated = await prisma.booking.update({
+ where: { id },
+ data: { status: status as BookingStatus },
+ include: {
+ tenant: { select: { email: true, firstName: true } },
+ carbet: { select: { title: true } },
+ },
+ });
+ await audit("booking.status.update", id, session?.user?.email ?? null, { status });
+ if (
+ before?.status !== BookingStatus.CONFIRMED &&
+ updated.status === BookingStatus.CONFIRMED
+ ) {
+ sendBookingConfirmed(
+ updated.tenant.email,
+ updated.tenant.firstName,
+ updated.id,
+ updated.carbet.title,
+ updated.startDate,
+ updated.endDate,
+ ).catch(() => {});
+ }
+ revalidatePath("/admin/bookings");
+ revalidatePath(`/admin/bookings/${id}`);
+ return { ok: true as const };
+}
+
+export async function updateBookingPaymentAction(id: string, paymentStatus: string) {
+ await requireRole([UserRole.ADMIN]);
+ if (!ALLOWED_PAYMENT.has(paymentStatus)) {
+ return { ok: false as const, error: "Statut de paiement invalide" };
+ }
+ const session = await auth();
+ await prisma.booking.update({
+ where: { id },
+ data: { paymentStatus: paymentStatus as PaymentStatus },
+ });
+ await audit("booking.payment.update", id, session?.user?.email ?? null, { paymentStatus });
+ revalidatePath("/admin/bookings");
+ revalidatePath(`/admin/bookings/${id}`);
+ return { ok: true as const };
+}
+
+export async function refundBookingAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const updated = await prisma.booking.update({
+ where: { id },
+ data: {
+ paymentStatus: PaymentStatus.REFUNDED,
+ status: BookingStatus.CANCELLED,
+ },
+ include: {
+ tenant: { select: { email: true, firstName: true } },
+ carbet: { select: { title: true } },
+ },
+ });
+ await audit("booking.refund", id, session?.user?.email ?? null, {});
+ sendBookingRefunded(
+ updated.tenant.email,
+ updated.tenant.firstName,
+ updated.id,
+ updated.carbet.title,
+ updated.amount.toString(),
+ updated.currency,
+ ).catch(() => {});
+ revalidatePath("/admin/bookings");
+ revalidatePath(`/admin/bookings/${id}`);
+ return { ok: true as const };
+}
diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx
new file mode 100644
index 0000000..c938c17
--- /dev/null
+++ b/src/app/admin/bookings/page.tsx
@@ -0,0 +1,184 @@
+import Link from "next/link";
+import { BookingStatus, PaymentStatus } from "@/generated/prisma/enums";
+import { listBookingsAdmin } from "@/lib/admin/bookings";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ status?: string;
+ paymentStatus?: string;
+ from?: string;
+ to?: string;
+ }>;
+};
+
+const STATUS_VALUES = new Set([
+ BookingStatus.PENDING,
+ BookingStatus.CONFIRMED,
+ BookingStatus.CANCELLED,
+ BookingStatus.COMPLETED,
+]);
+const PAYMENT_VALUES = new Set([
+ PaymentStatus.PENDING,
+ PaymentStatus.AUTHORIZED,
+ PaymentStatus.SUCCEEDED,
+ PaymentStatus.FAILED,
+ PaymentStatus.REFUNDED,
+]);
+
+function parseDate(v?: string): Date | undefined {
+ if (!v) return undefined;
+ const d = new Date(v);
+ return isNaN(d.getTime()) ? undefined : d;
+}
+
+export default async function BookingsAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ status: STATUS_VALUES.has(sp.status ?? "") ? (sp.status as BookingStatus) : undefined,
+ paymentStatus: PAYMENT_VALUES.has(sp.paymentStatus ?? "")
+ ? (sp.paymentStatus as PaymentStatus)
+ : undefined,
+ from: parseDate(sp.from),
+ to: parseDate(sp.to),
+ };
+ const bookings = await listBookingsAdmin(filters);
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+
+
+
+ ID
+ Carbet
+ Locataire
+ Séjour
+ Pers.
+ Montant
+ Statut
+ Paiement
+ Créé
+
+
+
+ {bookings.length === 0 ? (
+
+
+ Aucune réservation ne correspond aux filtres.
+
+
+ ) : null}
+ {bookings.map((b) => (
+
+
+
+ {b.id.slice(0, 10)}…
+
+
+
+
+ {b.carbet.title}
+
+
+ /{b.carbet.slug}
+
+
+
+ {b.tenant.firstName} {b.tenant.lastName}
+ {b.tenant.email}
+
+
+ {dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)}
+
+ {b.guestCount}
+
+ {Number(b.amount).toFixed(2)} {b.currency}
+
+
+
+
+ {dateFmt.format(b.createdAt)}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx b/src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx
new file mode 100644
index 0000000..95bfc88
--- /dev/null
+++ b/src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import { useState, useTransition } from "react";
+
+type Org = { id: string; name: string; slug: string; approved: boolean };
+type LinkedOrg = Org & { addedAt: Date };
+
+type Props = {
+ carbetId: string;
+ linked: LinkedOrg[];
+ available: Org[];
+ linkAction: (carbetId: string, orgId: string) => Promise<{ ok: true; alreadyLinked: boolean } | { ok: false; error?: string }>;
+ unlinkAction: (carbetId: string, orgId: string) => Promise<{ ok: true } | { ok: false; error?: string }>;
+};
+
+export function CarbetMemberships({
+ carbetId,
+ linked,
+ available,
+ linkAction,
+ unlinkAction,
+}: Props) {
+ const [pending, startTransition] = useTransition();
+ const [selectedOrgId, setSelectedOrgId] = useState("");
+ const [error, setError] = useState(null);
+
+ // Filtre les orgs non encore liées
+ const linkedIds = new Set(linked.map((l) => l.id));
+ const options = available.filter((o) => !linkedIds.has(o.id));
+
+ function link() {
+ if (!selectedOrgId) return;
+ setError(null);
+ startTransition(async () => {
+ const res = await linkAction(carbetId, selectedOrgId);
+ if (!res.ok) setError(res.error || "Échec de la liaison");
+ else setSelectedOrgId("");
+ });
+ }
+
+ function unlink(orgId: string) {
+ setError(null);
+ startTransition(async () => {
+ const res = await unlinkAction(carbetId, orgId);
+ if (!res.ok) setError(res.error || "Échec");
+ });
+ }
+
+ return (
+
+ {linked.length === 0 ? (
+
+ Aucune organisation liée. Le carbet est géré uniquement par son propriétaire individuel.
+
+ ) : (
+
+ )}
+
+ {options.length > 0 ? (
+
+ setSelectedOrgId(e.target.value)}
+ className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
+ >
+ — Choisir une organisation à lier —
+ {options.map((o) => (
+
+ {o.name} {o.approved ? "" : "(pending)"}
+
+ ))}
+
+
+ {pending ? "…" : "Lier"}
+
+
+ ) : (
+
+ Toutes les organisations existantes sont déjà liées à ce carbet.
+
+ )}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ Une organisation liée signifie que ses CE_MANAGERs peuvent éditer ce carbet en plus du
+ propriétaire nominal.
+
+
+ );
+}
diff --git a/src/app/admin/carbets/[id]/_components/MediaManager.tsx b/src/app/admin/carbets/[id]/_components/MediaManager.tsx
new file mode 100644
index 0000000..ab91606
--- /dev/null
+++ b/src/app/admin/carbets/[id]/_components/MediaManager.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { addMediaAction, removeMediaAction, reorderMediaAction } from "../../actions";
+import { FormField, inputCls, selectCls } from "@/components/admin/FormField";
+
+type MediaItem = {
+ id: string;
+ type: "PHOTO" | "VIDEO";
+ s3Key: string;
+ s3Url: string;
+ sortOrder: number;
+};
+
+export function MediaManager({ carbetId, media: initial }: { carbetId: string; media: MediaItem[] }) {
+ const [media, setMedia] = useState(initial);
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+
+ async function refresh() {
+ const r = await fetch(`/api/admin/carbets/${carbetId}/media`);
+ if (r.ok) setMedia(await r.json());
+ }
+
+ function addByUrl(fd: FormData) {
+ setError(null);
+ startTransition(async () => {
+ const res = await addMediaAction(carbetId, fd);
+ if (res?.ok === false) {
+ setError(res.error);
+ } else {
+ await refresh();
+ }
+ });
+ }
+
+ function remove(mediaId: string) {
+ startTransition(async () => {
+ await removeMediaAction(carbetId, mediaId);
+ await refresh();
+ });
+ }
+
+ function reorder(mediaId: string, dir: "up" | "down") {
+ startTransition(async () => {
+ await reorderMediaAction(carbetId, mediaId, dir);
+ await refresh();
+ });
+ }
+
+ return (
+
+
Médias ({media.length})
+
+ {media.length === 0 ? (
+
+ Aucun média. Ajoute une URL ci-dessous (MinIO, CDN externe, …).
+
+ ) : (
+
+ {media.map((m, i) => (
+
+ #{i + 1}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
{m.s3Url}
+
+ {m.type} · {m.s3Key}
+
+
+
+ reorder(m.id, "up")}
+ disabled={pending || i === 0}
+ className="rounded border border-zinc-200 px-2 py-1 text-xs hover:bg-zinc-50 disabled:opacity-30"
+ >
+ ↑
+
+ reorder(m.id, "down")}
+ disabled={pending || i === media.length - 1}
+ className="rounded border border-zinc-200 px-2 py-1 text-xs hover:bg-zinc-50 disabled:opacity-30"
+ >
+ ↓
+
+ remove(m.id)}
+ disabled={pending}
+ className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700 hover:bg-rose-100 disabled:opacity-50"
+ >
+ Supprimer
+
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/admin/carbets/[id]/_components/StatusActions.tsx b/src/app/admin/carbets/[id]/_components/StatusActions.tsx
new file mode 100644
index 0000000..7d585ef
--- /dev/null
+++ b/src/app/admin/carbets/[id]/_components/StatusActions.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { CarbetStatus } from "@/generated/prisma/enums";
+import { deleteCarbetAction, updateCarbetStatusAction } from "../../actions";
+
+type Status = (typeof CarbetStatus)[keyof typeof CarbetStatus];
+
+export function StatusActions({ id, current }: { id: string; current: Status }) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [confirmArchive, setConfirmArchive] = useState(false);
+
+ function setStatus(next: Status) {
+ startTransition(async () => {
+ await updateCarbetStatusAction(id, next);
+ router.refresh();
+ });
+ }
+
+ function archive() {
+ startTransition(async () => {
+ await deleteCarbetAction(id);
+ });
+ }
+
+ return (
+
+ {current === CarbetStatus.DRAFT ? (
+
setStatus(CarbetStatus.PUBLISHED)}
+ disabled={pending}
+ className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
+ >
+ Publier
+
+ ) : null}
+ {current === CarbetStatus.PUBLISHED ? (
+
setStatus(CarbetStatus.DRAFT)}
+ disabled={pending}
+ className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-50 disabled:opacity-50"
+ >
+ Dépublier (brouillon)
+
+ ) : null}
+ {current !== CarbetStatus.ARCHIVED ? (
+ confirmArchive ? (
+
+ Sûr ?
+
+ Oui, archiver
+
+ setConfirmArchive(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
setConfirmArchive(true)}
+ disabled={pending}
+ className="rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
+ >
+ Archiver
+
+ )
+ ) : (
+
setStatus(CarbetStatus.DRAFT)}
+ disabled={pending}
+ className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-50 disabled:opacity-50"
+ >
+ Réactiver (brouillon)
+
+ )}
+
+ );
+}
diff --git a/src/app/admin/carbets/[id]/page.tsx b/src/app/admin/carbets/[id]/page.tsx
new file mode 100644
index 0000000..6a69e2e
--- /dev/null
+++ b/src/app/admin/carbets/[id]/page.tsx
@@ -0,0 +1,149 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+
+import { MediaUploader } from "@/components/MediaUploader";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+import {
+ getCarbetForEdit,
+ listOrganizationsForLink,
+ listOwners,
+ listPirogueProviders,
+} from "@/lib/admin/carbets";
+
+import { CarbetForm } from "../_components/CarbetForm";
+import {
+ linkCarbetToOrganizationAction,
+ unlinkCarbetFromOrganizationAction,
+ updateCarbetAction,
+} from "../actions";
+import { CarbetMemberships } from "./_components/CarbetMemberships";
+import { StatusActions } from "./_components/StatusActions";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ id: string }> };
+
+export default async function EditCarbetPage({ params }: PageProps) {
+ const { id } = await params;
+ const [carbet, owners, providers, organizations] = await Promise.all([
+ getCarbetForEdit(id),
+ listOwners(),
+ listPirogueProviders(),
+ listOrganizationsForLink(),
+ ]);
+ if (!carbet) notFound();
+
+ const updateThis = async (fd: FormData) => {
+ "use server";
+ return await updateCarbetAction(id, fd);
+ };
+ const linkThis = async (carbetId: string, orgId: string) => {
+ "use server";
+ return await linkCarbetToOrganizationAction(carbetId, orgId);
+ };
+ const unlinkThis = async (carbetId: string, orgId: string) => {
+ "use server";
+ return await unlinkCarbetFromOrganizationAction(carbetId, orgId);
+ };
+
+ return (
+
+
+
+
+
+ Organisations co-gestionnaires (CE)
+
+ ({
+ id: m.organization.id,
+ name: m.organization.name,
+ slug: m.organization.slug,
+ approved: m.organization.approved,
+ addedAt: m.addedAt,
+ }))}
+ available={organizations}
+ linkAction={linkThis}
+ unlinkAction={unlinkThis}
+ />
+
+
+
+
+ Médias
+
+ ({
+ id: m.id,
+ type: m.type,
+ s3Key: m.s3Key,
+ s3Url: m.s3Url,
+ sortOrder: m.sortOrder,
+ }))}
+ />
+
+
+
+
+ );
+}
diff --git a/src/app/admin/carbets/_components/CarbetForm.tsx b/src/app/admin/carbets/_components/CarbetForm.tsx
new file mode 100644
index 0000000..4ddabe8
--- /dev/null
+++ b/src/app/admin/carbets/_components/CarbetForm.tsx
@@ -0,0 +1,342 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField";
+import {
+ ACCESS_TYPE_OPTIONS,
+ STATUS_OPTIONS,
+ TRANSPORT_MODE_OPTIONS,
+} from "@/lib/admin/carbet-options";
+
+export type CarbetFormInitial = {
+ ownerId?: string;
+ title?: string;
+ slug?: string;
+ description?: string;
+ river?: string;
+ embarkPoint?: string;
+ latitude?: number | string;
+ longitude?: number | string;
+ capacity?: number;
+ nightlyPrice?: number | string;
+ accessType?: string;
+ roadAccess?: string | null;
+ electricity?: string | null;
+ gsmAtCarbet?: boolean;
+ gsmExitDistanceKm?: number | string | null;
+ roadAccessNote?: string | null;
+ pirogueDurationMin?: number | null;
+ minStayNights?: number | null;
+ maxStayNights?: number | null;
+ minCapacity?: number | null;
+ transportMode?: string | null;
+ pirogueProviderId?: string | null;
+ status?: string;
+};
+
+type Props = {
+ initial?: CarbetFormInitial;
+ owners: { id: string; firstName: string; lastName: string; email: string }[];
+ providers: { id: string; name: string; rivers: string[] }[];
+ action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
+ submitLabel?: string;
+};
+
+export function CarbetForm({ initial = {}, owners, providers, action, submitLabel = "Enregistrer" }: Props) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ function onSubmit(formData: FormData) {
+ setError(null);
+ setSuccess(null);
+ startTransition(async () => {
+ const res = await action(formData);
+ if (res && res.ok === false) {
+ setError(res.error);
+ } else if (res && res.ok === true) {
+ setSuccess("Carbet enregistré.");
+ }
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/admin/carbets/actions.ts b/src/app/admin/carbets/actions.ts
new file mode 100644
index 0000000..f85950a
--- /dev/null
+++ b/src/app/admin/carbets/actions.ts
@@ -0,0 +1,265 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+import { auth } from "@/auth";
+import { requireRole } from "@/lib/authorization";
+import { recordAudit } from "@/lib/admin/audit";
+import { prisma } from "@/lib/prisma";
+import {
+ AccessType,
+ CarbetStatus,
+ Electricity,
+ MediaType,
+ RoadAccess,
+ TransportMode,
+ UserRole,
+} from "@/generated/prisma/enums";
+
+const slugRe = /^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$/;
+
+const baseCarbetSchema = z.object({
+ ownerId: z.string().min(1, "Propriétaire requis"),
+ title: z.string().trim().min(1).max(200),
+ slug: z.string().trim().regex(slugRe, "Slug invalide (a-z, 0-9, -)"),
+ description: z.string().trim().min(10).max(20000),
+ river: z.string().trim().min(2).max(100),
+ embarkPoint: z.string().trim().min(2).max(200),
+ latitude: z.coerce.number().min(-90).max(90),
+ longitude: z.coerce.number().min(-180).max(180),
+ capacity: z.coerce.number().int().min(1).max(100),
+ nightlyPrice: z.coerce.number().min(0).max(100000),
+ accessType: z.enum([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]),
+ roadAccess: z
+ .enum([RoadAccess.NONE, RoadAccess.DRY_SEASON_ONLY, RoadAccess.ALL_YEAR])
+ .optional()
+ .nullable(),
+ electricity: z
+ .enum([Electricity.NONE, Electricity.SOLAR, Electricity.GENERATOR_READY, Electricity.EDF])
+ .optional()
+ .nullable(),
+ gsmAtCarbet: z.preprocess((v) => v === "yes" || v === true, z.boolean()),
+ gsmExitDistanceKm: z.coerce.number().min(0).max(50).optional().nullable(),
+ roadAccessNote: z.string().trim().max(1000).optional().nullable(),
+ pirogueDurationMin: z.coerce.number().int().min(0).max(1440).optional().nullable(),
+ minStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(),
+ maxStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(),
+ minCapacity: z.coerce.number().int().min(1).max(100).optional().nullable(),
+ transportMode: z
+ .enum([TransportMode.OWNER_PROVIDES, TransportMode.SELF_ARRANGE, TransportMode.PARTNER_PROVIDER])
+ .optional()
+ .nullable(),
+ pirogueProviderId: z.string().optional().nullable(),
+ status: z.enum([CarbetStatus.DRAFT, CarbetStatus.PUBLISHED, CarbetStatus.ARCHIVED]).default(CarbetStatus.DRAFT),
+});
+
+function normalizeNullable(v: T | "" | undefined | null): T | null {
+ if (v === undefined || v === null || v === "") return null;
+ return v;
+}
+
+function parseFromFormData(fd: FormData) {
+ const obj: Record = {};
+ for (const [k, v] of fd.entries()) {
+ if (typeof v === "string") obj[k] = v;
+ }
+ // Normalise les champs optionnels nullables
+ ["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId", "roadAccess", "electricity", "gsmExitDistanceKm"].forEach(
+ (k) => (obj[k] = normalizeNullable(obj[k] as string | null | undefined)),
+ );
+ // gsmAtCarbet : si pas posté, on garde la valeur (sera traité par preprocess Zod)
+ if (!("gsmAtCarbet" in obj)) obj.gsmAtCarbet = "no";
+ return obj;
+}
+
+export async function createCarbetAction(fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = baseCarbetSchema.safeParse(parseFromFormData(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ try {
+ const created = await prisma.carbet.create({
+ data: {
+ ...parsed.data,
+ lastBookedAt: null,
+ },
+ });
+ await audit("carbet.create", created.id, session?.user?.email ?? null, {
+ slug: created.slug,
+ status: created.status,
+ });
+ revalidatePath("/admin/carbets");
+ redirect(`/admin/carbets/${created.id}`);
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("Unique constraint")) {
+ return { ok: false as const, error: "Slug déjà utilisé" };
+ }
+ throw e;
+ }
+}
+
+export async function updateCarbetAction(id: string, fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = baseCarbetSchema.safeParse(parseFromFormData(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ try {
+ const updated = await prisma.carbet.update({
+ where: { id },
+ data: parsed.data,
+ });
+ await audit("carbet.update", updated.id, session?.user?.email ?? null, {
+ slug: updated.slug,
+ status: updated.status,
+ });
+ revalidatePath("/admin/carbets");
+ revalidatePath(`/admin/carbets/${id}`);
+ revalidatePath(`/carbets/${updated.slug}`);
+ return { ok: true as const };
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("Unique constraint")) {
+ return { ok: false as const, error: "Slug déjà utilisé" };
+ }
+ throw e;
+ }
+}
+
+export async function updateCarbetStatusAction(id: string, status: CarbetStatus) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ await prisma.carbet.update({ where: { id }, data: { status } });
+ await audit("carbet.status", id, session?.user?.email ?? null, { status });
+ revalidatePath("/admin/carbets");
+ revalidatePath(`/admin/carbets/${id}`);
+ return { ok: true as const };
+}
+
+export async function deleteCarbetAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ // Soft : on archive plutôt que supprimer (bookings/reviews FK Restrict).
+ const archived = await prisma.carbet.update({
+ where: { id },
+ data: { status: CarbetStatus.ARCHIVED },
+ });
+ await audit("carbet.archive", id, session?.user?.email ?? null, { slug: archived.slug });
+ revalidatePath("/admin/carbets");
+ redirect("/admin/carbets");
+}
+
+const mediaSchema = z.object({
+ url: z.string().url().max(2000),
+ type: z.enum([MediaType.PHOTO, MediaType.VIDEO]).default(MediaType.PHOTO),
+ s3Key: z.string().max(500).optional(),
+});
+
+export async function addMediaAction(carbetId: string, fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = mediaSchema.safeParse({
+ url: fd.get("url"),
+ type: fd.get("type") ?? "PHOTO",
+ s3Key: fd.get("s3Key") ?? undefined,
+ });
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => i.message).join(" · ") };
+ }
+ const existing = await prisma.media.count({ where: { carbetId } });
+ const session = await auth();
+ const m = await prisma.media.create({
+ data: {
+ carbetId,
+ type: parsed.data.type,
+ s3Url: parsed.data.url,
+ s3Key: parsed.data.s3Key ?? `external/${Date.now()}`,
+ sortOrder: existing,
+ },
+ });
+ await audit("media.create", m.id, session?.user?.email ?? null, { carbetId, url: parsed.data.url });
+ revalidatePath(`/admin/carbets/${carbetId}`);
+ return { ok: true as const };
+}
+
+export async function removeMediaAction(carbetId: string, mediaId: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ await prisma.media.delete({ where: { id: mediaId } });
+ await audit("media.delete", mediaId, session?.user?.email ?? null, { carbetId });
+ revalidatePath(`/admin/carbets/${carbetId}`);
+ return { ok: true as const };
+}
+
+export async function reorderMediaAction(carbetId: string, mediaId: string, direction: "up" | "down") {
+ await requireRole([UserRole.ADMIN]);
+ const all = await prisma.media.findMany({
+ where: { carbetId },
+ orderBy: { sortOrder: "asc" },
+ });
+ const idx = all.findIndex((m) => m.id === mediaId);
+ if (idx === -1) return { ok: false as const };
+ const swap = direction === "up" ? idx - 1 : idx + 1;
+ if (swap < 0 || swap >= all.length) return { ok: false as const };
+ const a = all[idx];
+ const b = all[swap];
+ await prisma.$transaction([
+ prisma.media.update({ where: { id: a.id }, data: { sortOrder: b.sortOrder } }),
+ prisma.media.update({ where: { id: b.id }, data: { sortOrder: a.sortOrder } }),
+ ]);
+ revalidatePath(`/admin/carbets/${carbetId}`);
+ return { ok: true as const };
+}
+
+export async function linkCarbetToOrganizationAction(carbetId: string, organizationId: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const actorEmail = session?.user?.email ?? null;
+ // findFirst pour idempotence : si déjà lié, on ne touche pas + on ne crash pas.
+ const existing = await prisma.organizationCarbetMembership.findUnique({
+ where: { organizationId_carbetId: { organizationId, carbetId } },
+ select: { organizationId: true },
+ });
+ if (existing) {
+ return { ok: true as const, alreadyLinked: true };
+ }
+ await prisma.organizationCarbetMembership.create({
+ data: {
+ organizationId,
+ carbetId,
+ addedByUserId: session?.user?.id ?? null,
+ },
+ });
+ await audit("carbet.org.link", carbetId, actorEmail, { organizationId });
+ revalidatePath(`/admin/carbets/${carbetId}`);
+ return { ok: true as const, alreadyLinked: false };
+}
+
+export async function unlinkCarbetFromOrganizationAction(carbetId: string, organizationId: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const actorEmail = session?.user?.email ?? null;
+ await prisma.organizationCarbetMembership
+ .delete({ where: { organizationId_carbetId: { organizationId, carbetId } } })
+ .catch(() => {});
+ await audit("carbet.org.unlink", carbetId, actorEmail, { organizationId });
+ revalidatePath(`/admin/carbets/${carbetId}`);
+ return { ok: true as const };
+}
+
+async function audit(
+ event: string,
+ entityId: string,
+ actor: string | null,
+ payload: Record,
+) {
+ await recordAudit({
+ scope: "admin.carbets",
+ event,
+ target: entityId,
+ actorEmail: actor,
+ details: payload,
+ });
+}
diff --git a/src/app/admin/carbets/new/page.tsx b/src/app/admin/carbets/new/page.tsx
new file mode 100644
index 0000000..8f2903a
--- /dev/null
+++ b/src/app/admin/carbets/new/page.tsx
@@ -0,0 +1,20 @@
+import { listOwners, listPirogueProviders } from "@/lib/admin/carbets";
+import { CarbetForm } from "../_components/CarbetForm";
+import { createCarbetAction } from "../actions";
+
+export const dynamic = "force-dynamic";
+
+export default async function NewCarbetPage() {
+ const [owners, providers] = await Promise.all([listOwners(), listPirogueProviders()]);
+ return (
+
+ );
+}
diff --git a/src/app/admin/carbets/page.tsx b/src/app/admin/carbets/page.tsx
new file mode 100644
index 0000000..5bf1989
--- /dev/null
+++ b/src/app/admin/carbets/page.tsx
@@ -0,0 +1,148 @@
+import Link from "next/link";
+import { AccessType, CarbetStatus } from "@/generated/prisma/enums";
+import { listCarbetsAdmin, listDistinctRivers } from "@/lib/admin/carbets";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ river?: string;
+ status?: string;
+ accessType?: string;
+ }>;
+};
+
+const STATUS_VALUES = new Set([CarbetStatus.DRAFT, CarbetStatus.PUBLISHED, CarbetStatus.ARCHIVED]);
+const ACCESS_VALUES = new Set([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]);
+
+export default async function CarbetsAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ river: sp.river || undefined,
+ status: STATUS_VALUES.has(sp.status ?? "") ? (sp.status as CarbetStatus) : undefined,
+ accessType: ACCESS_VALUES.has(sp.accessType ?? "") ? (sp.accessType as AccessType) : undefined,
+ };
+ const [carbets, rivers] = await Promise.all([listCarbetsAdmin(filters), listDistinctRivers()]);
+
+ return (
+
+
+
+
+
+
+ Tous les fleuves
+ {rivers.map((r) => (
+ {r}
+ ))}
+
+
+ Tous statuts
+ Brouillon
+ Publié
+ Archivé
+
+
+ Tous accès
+ 🛣️ Route + fleuve
+ 🛶 Expédition
+
+
+ Filtrer
+
+ {(filters.q || filters.river || filters.status || filters.accessType) ? (
+
+ Réinit.
+
+ ) : null}
+
+
+
+
+
+
+ Titre
+ Fleuve
+ Accès
+ Cap.
+ €/nuit
+ Médias
+ Résas
+ Propriétaire
+ Statut
+ MAJ
+
+
+
+ {carbets.length === 0 ? (
+
+
+ Aucun carbet ne correspond aux filtres.
+
+
+ ) : null}
+ {carbets.map((c) => (
+
+
+
+ {c.title}
+
+
+ /{c.slug}
+
+
+ {c.river}
+
+ {c.accessType === AccessType.RIVER_ONLY ? "🛶 Fleuve" : "🛣️ Route+fleuve"}
+
+ {c.capacity}
+ {Number(c.nightlyPrice).toFixed(0)}
+ {c.mediaCount}
+ {c.bookingsCount}
+ {c.ownerName}
+
+
+ {new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short" }).format(c.updatedAt)}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx b/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx
new file mode 100644
index 0000000..0f2d54a
--- /dev/null
+++ b/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+type Page = {
+ slug: string;
+ lang: string;
+ title: string;
+ body: string;
+ category: string;
+ published: boolean;
+};
+
+export default function EditorForm({ page }: { page: Page }) {
+ const router = useRouter();
+ const [title, setTitle] = useState(page.title);
+ const [body, setBody] = useState(page.body);
+ const [published, setPublished] = useState(page.published);
+ const [busy, setBusy] = useState(false);
+ const [msg, setMsg] = useState(null);
+ const [err, setErr] = useState(null);
+
+ async function save() {
+ setBusy(true);
+ setMsg(null);
+ setErr(null);
+ try {
+ const res = await fetch(
+ `/api/admin/content-pages/${encodeURIComponent(page.slug)}?lang=${encodeURIComponent(page.lang)}`,
+ {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title, body, published }),
+ },
+ );
+ if (!res.ok) {
+ const j = await res.json().catch(() => ({}));
+ throw new Error(j?.error || `HTTP ${res.status}`);
+ }
+ setMsg("Sauvegardé.");
+ router.refresh();
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+ Titre
+ setTitle(e.target.value)}
+ className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
+ />
+
+
+
+
+ Contenu (markdown léger : # ## ### gras italique [link](url) listes - 1. ---)
+
+ setBody(e.target.value)}
+ rows={24}
+ className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm leading-relaxed"
+ />
+
+
+
+ setPublished(e.target.checked)}
+ />
+ Publié
+
+
+
+
+ {busy ? "Sauvegarde…" : "Sauvegarder"}
+
+ {msg ? {msg} : null}
+ {err ? {err} : null}
+
+
+ );
+}
diff --git a/src/app/admin/content-pages/[slug]/page.tsx b/src/app/admin/content-pages/[slug]/page.tsx
new file mode 100644
index 0000000..db82c4b
--- /dev/null
+++ b/src/app/admin/content-pages/[slug]/page.tsx
@@ -0,0 +1,94 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { requireRole } from "@/lib/authorization";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import EditorForm from "./_components/EditorForm";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ params: Promise<{ slug: string }>;
+ searchParams: Promise<{ lang?: string }>;
+};
+
+function normalizeLang(v: string | undefined): string {
+ if (!v) return "fr";
+ const l = v.toLowerCase().trim();
+ return /^[a-z]{2}$/.test(l) ? l : "fr";
+}
+
+export default async function EditContentPage({ params, searchParams }: PageProps) {
+ await requireRole([UserRole.ADMIN]);
+ const { slug } = await params;
+ const sp = await searchParams;
+ const lang = normalizeLang(sp.lang);
+
+ const [row, siblings] = await Promise.all([
+ prisma.contentPage.findUnique({ where: { slug_lang: { slug, lang } } }),
+ prisma.contentPage.findMany({
+ where: { slug },
+ select: { lang: true, title: true, published: true, updatedAt: true },
+ orderBy: { lang: "asc" },
+ }),
+ ]);
+ if (!row) notFound();
+
+ const page = {
+ slug: row.slug,
+ lang: row.lang,
+ title: row.title,
+ body: row.body,
+ category: row.category,
+ published: row.published,
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/admin/content-pages/page.tsx b/src/app/admin/content-pages/page.tsx
new file mode 100644
index 0000000..0f9f2ab
--- /dev/null
+++ b/src/app/admin/content-pages/page.tsx
@@ -0,0 +1,158 @@
+import Link from "next/link";
+import { requireRole } from "@/lib/authorization";
+import { UserRole } from "@/generated/prisma/enums";
+import { listContentPages } from "@/lib/content-pages";
+
+export const dynamic = "force-dynamic";
+
+const CATEGORY_LABEL: Record = {
+ general: "Général",
+ legal: "Légales",
+};
+
+type Translation = {
+ lang: string;
+ title: string;
+ published: boolean;
+ updatedAt: Date;
+};
+
+type GroupedPage = {
+ slug: string;
+ category: string;
+ translations: Translation[];
+};
+
+export default async function ContentPagesAdminPage() {
+ await requireRole([UserRole.ADMIN]);
+ const rows = await listContentPages();
+
+ // Regrouper par slug — chaque slug peut avoir plusieurs traductions.
+ const bySlug = new Map();
+ for (const r of rows) {
+ const existing = bySlug.get(r.slug);
+ const t: Translation = {
+ lang: r.lang,
+ title: r.title,
+ published: r.published,
+ updatedAt: r.updatedAt,
+ };
+ if (existing) {
+ existing.translations.push(t);
+ } else {
+ bySlug.set(r.slug, { slug: r.slug, category: r.category, translations: [t] });
+ }
+ }
+ const pages = Array.from(bySlug.values()).sort((a, b) => a.slug.localeCompare(b.slug));
+
+ const byCategory = pages.reduce>((acc, p) => {
+ (acc[p.category] ??= []).push(p);
+ return acc;
+ }, {});
+
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+ {Object.entries(byCategory).map(([cat, list]) => (
+
+
+ {CATEGORY_LABEL[cat] ?? cat}
+
+
+
+
+
+ Slug
+ Titre (FR)
+ Traductions
+ MAJ
+ Éditer
+
+
+
+ {list.map((p) => {
+ const fr = p.translations.find((t) => t.lang === "fr");
+ const others = p.translations.filter((t) => t.lang !== "fr").sort((a, b) => a.lang.localeCompare(b.lang));
+ const lastUpdated = p.translations
+ .map((t) => t.updatedAt.getTime())
+ .reduce((a, b) => Math.max(a, b), 0);
+ return (
+
+ /{p.slug}
+
+ {fr ? (
+ <>
+ {fr.title}
+ {!fr.published ? (
+
+ dépublié
+
+ ) : null}
+ >
+ ) : (
+ — (pas de version FR)
+ )}
+
+
+ {others.length === 0 ? (
+ —
+ ) : (
+
+ {others.map((t) => (
+
+ {t.lang}
+
+ ))}
+
+ )}
+
+
+ {lastUpdated ? dateFmt.format(new Date(lastUpdated)) : "—"}
+
+
+
+ {p.translations
+ .sort((a, b) => (a.lang === "fr" ? -1 : b.lang === "fr" ? 1 : a.lang.localeCompare(b.lang)))
+ .map((t) => (
+
+ {t.lang}
+
+ ))}
+
+
+
+ );
+ })}
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/admin/home/_components/HomeTranslationsForm.tsx b/src/app/admin/home/_components/HomeTranslationsForm.tsx
new file mode 100644
index 0000000..e0bc7c0
--- /dev/null
+++ b/src/app/admin/home/_components/HomeTranslationsForm.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import { useMemo, useState, useTransition } from "react";
+import { saveHomeTranslationsAction } from "../actions";
+
+type Row = {
+ key: string;
+ baseFr: string;
+ baseEn: string;
+ overrideFr: string | null;
+ overrideEn: string | null;
+};
+
+type Section = {
+ id: string;
+ label: string;
+ description: string;
+ rows: Row[];
+};
+
+type Props = {
+ sections: Section[];
+};
+
+function autoRows(text: string): number {
+ const lines = text.split("\n").length;
+ return Math.min(8, Math.max(1, lines));
+}
+
+export function HomeTranslationsForm({ sections }: Props) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ // État local : on garde uniquement la valeur courante (initialisée avec override ?? base).
+ // Le baseValue est posé en input caché et sert au backend pour décider override vs reset.
+ const initial = useMemo(() => {
+ const m = new Map();
+ for (const s of sections) {
+ for (const r of s.rows) {
+ m.set(r.key, { fr: r.overrideFr ?? r.baseFr, en: r.overrideEn ?? r.baseEn });
+ }
+ }
+ return m;
+ }, [sections]);
+
+ function onSubmit(formData: FormData) {
+ setError(null);
+ setSuccess(null);
+ startTransition(async () => {
+ const res = await saveHomeTranslationsAction(formData);
+ if (res.ok === false) {
+ setError(res.error);
+ } else {
+ const parts: string[] = [];
+ if (res.saved) parts.push(`${res.saved} sauvegardé${res.saved > 1 ? "s" : ""}`);
+ if (res.reset) parts.push(`${res.reset} réinitialisé${res.reset > 1 ? "s" : ""} (valeur de base)`);
+ setSuccess(parts.length > 0 ? parts.join(" · ") : "Aucun changement.");
+ }
+ });
+ }
+
+ // On crée un seul formulaire global qui contient toutes les sections.
+ let counter = 0;
+
+ return (
+
+
+ {sections.map((section) => (
+
+
+
+ {section.label}
+
+ {section.description}
+
+
+
+ {section.rows.map((r) => {
+ const idxFr = counter++;
+ const idxEn = counter++;
+ const init = initial.get(r.key)!;
+ const hasOverrideFr = r.overrideFr !== null;
+ const hasOverrideEn = r.overrideEn !== null;
+ return (
+
+
+ {r.key}
+
+ {hasOverrideFr ? (
+
+ FR modifié
+
+ ) : null}
+ {hasOverrideEn ? (
+
+ EN modifié
+
+ ) : null}
+
+
+
+
+
+
+ FR
+
+
+
+
+
+
+ Base : {r.baseFr.slice(0, 80)}{r.baseFr.length > 80 ? "…" : ""}
+
+
+
+
+ EN
+
+
+
+
+
+
+ Base : {r.baseEn.slice(0, 80)}{r.baseEn.length > 80 ? "…" : ""}
+
+
+
+
+ );
+ })}
+
+
+ ))}
+
+ {error ? (
+ {error}
+ ) : null}
+ {success ? (
+ {success}
+ ) : null}
+
+
+
+ Laisser une case vide ou identique au texte de base réinitialise l'override.
+
+
+ {pending ? "Enregistrement…" : "Enregistrer les modifications"}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/home/actions.ts b/src/app/admin/home/actions.ts
new file mode 100644
index 0000000..c244d4a
--- /dev/null
+++ b/src/app/admin/home/actions.ts
@@ -0,0 +1,67 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { z } from "zod";
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { requireRole } from "@/lib/authorization";
+import { recordAudit } from "@/lib/admin/audit";
+import { deleteTranslationOverride, upsertTranslation } from "@/lib/admin/translations";
+import { invalidateTranslationCache } from "@/lib/i18n/overrides";
+import { isHomeKey } from "@/lib/admin/home-keys";
+
+const entrySchema = z.object({
+ key: z.string().min(1).max(200),
+ lang: z.enum(["fr", "en"]),
+ value: z.string().max(4000),
+ baseValue: z.string().max(4000),
+});
+
+type SaveResult = { ok: true; saved: number; reset: number } | { ok: false; error: string };
+
+export async function saveHomeTranslationsAction(fd: FormData): Promise {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const actorEmail = session?.user?.email ?? null;
+
+ // FormData arrive avec entries[N][key], entries[N][lang], entries[N][value], entries[N][baseValue].
+ const grouped = new Map>();
+ for (const [name, val] of fd.entries()) {
+ if (typeof val !== "string") continue;
+ const m = name.match(/^entries\[(\d+)\]\[(key|lang|value|baseValue)\]$/);
+ if (!m) continue;
+ const [, idx, field] = m;
+ if (!grouped.has(idx)) grouped.set(idx, {});
+ grouped.get(idx)![field] = val;
+ }
+
+ let saved = 0;
+ let reset = 0;
+ for (const raw of grouped.values()) {
+ const parsed = entrySchema.safeParse(raw);
+ if (!parsed.success) continue;
+ if (!isHomeKey(parsed.data.key)) continue;
+
+ const trimmed = parsed.data.value.trim();
+ const base = parsed.data.baseValue;
+ if (trimmed === "" || trimmed === base) {
+ // Suppression de l'override : on revient à la valeur du fichier.
+ await deleteTranslationOverride(parsed.data.key, parsed.data.lang);
+ reset++;
+ } else {
+ await upsertTranslation(parsed.data.key, parsed.data.lang, trimmed, actorEmail);
+ saved++;
+ }
+ }
+
+ invalidateTranslationCache();
+ await recordAudit({
+ scope: "admin.home",
+ event: "translations.save",
+ actorEmail,
+ details: { saved, reset },
+ });
+ revalidatePath("/admin/home");
+ revalidatePath("/");
+ return { ok: true, saved, reset };
+}
diff --git a/src/app/admin/home/page.tsx b/src/app/admin/home/page.tsx
new file mode 100644
index 0000000..a068b6c
--- /dev/null
+++ b/src/app/admin/home/page.tsx
@@ -0,0 +1,39 @@
+import { HOME_SECTIONS } from "@/lib/admin/home-keys";
+import { listTranslationsForKeys } from "@/lib/admin/translations";
+import { HomeTranslationsForm } from "./_components/HomeTranslationsForm";
+
+export const dynamic = "force-dynamic";
+
+export default async function HomeAdminPage() {
+ const allKeys = await listTranslationsForKeys(HOME_SECTIONS.flatMap((s) => s.prefixes));
+ const keysBySection = HOME_SECTIONS.map((s) => ({
+ id: s.id,
+ label: s.label,
+ description: s.description,
+ rows: allKeys.filter((r) => s.prefixes.some((p) => r.key.startsWith(p))),
+ }));
+
+ const totalOverrides = allKeys.reduce(
+ (acc, r) => acc + (r.overrideFr !== null ? 1 : 0) + (r.overrideEn !== null ? 1 : 0),
+ 0,
+ );
+
+ return (
+
+
+ Page d'accueil
+
+ Édition des textes affichés sur la page d'accueil publique, en français et en anglais.
+ Les modifications sont appliquées immédiatement (cache rafraîchi sous 10 secondes).
+
+
+ {totalOverrides === 0
+ ? "Aucun texte personnalisé pour l'instant — les valeurs par défaut viennent des fichiers de traduction."
+ : `${totalOverrides} valeur${totalOverrides > 1 ? "s" : ""} personnalisée${totalOverrides > 1 ? "s" : ""} actuellement active${totalOverrides > 1 ? "s" : ""}.`}
+
+
+
+
+
+ );
+}
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
new file mode 100644
index 0000000..e853a28
--- /dev/null
+++ b/src/app/admin/layout.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from "react";
+import { requireRole } from "@/lib/authorization";
+import { UserRole } from "@/generated/prisma/enums";
+import { Sidebar } from "@/components/admin/Sidebar";
+import { TopBar } from "@/components/admin/TopBar";
+import { Breadcrumbs } from "@/components/admin/Breadcrumbs";
+import { CommandPalette } from "@/components/admin/CommandPalette";
+
+export const dynamic = "force-dynamic";
+
+export default async function AdminLayout({ children }: { children: ReactNode }) {
+ const session = await requireRole([UserRole.ADMIN]);
+ return (
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/src/app/admin/media/page.tsx b/src/app/admin/media/page.tsx
new file mode 100644
index 0000000..36dc856
--- /dev/null
+++ b/src/app/admin/media/page.tsx
@@ -0,0 +1,137 @@
+import Link from "next/link";
+import { MediaType } from "@/generated/prisma/enums";
+import { getMediaStats, listMediaAdmin } from "@/lib/admin/media";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{ q?: string; type?: string; carbetId?: string }>;
+};
+
+const TYPE_VALUES = new Set([MediaType.PHOTO, MediaType.VIDEO]);
+
+export default async function MediaAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ type: TYPE_VALUES.has(sp.type ?? "") ? (sp.type as MediaType) : undefined,
+ carbetId: sp.carbetId || undefined,
+ };
+ const [items, stats] = await Promise.all([listMediaAdmin(filters), getMediaStats()]);
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+
+
+ Photos + vidéos
+ Photos
+ Vidéos
+
+
+ Filtrer
+
+ {(filters.q || filters.type || filters.carbetId) ? (
+
+ Réinit.
+
+ ) : null}
+
+
+ {items.length === 0 ? (
+
+ Aucun média ne correspond aux filtres.
+
+ ) : (
+
+ {items.map((m) => (
+
+
+ {m.type === MediaType.PHOTO ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
▶
+ )}
+
+ {m.type}
+
+
+
+
+ {m.carbet.title}
+
+
+
+ {m.s3Key}
+ {dateFmt.format(m.createdAt)}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+function Stat({
+ label,
+ value,
+ tone = "neutral",
+}: {
+ label: string;
+ value: number;
+ tone?: "neutral" | "warn";
+}) {
+ return (
+ 0 ? "border-amber-300" : "border-zinc-200")
+ }
+ >
+
{label}
+
0 ? "text-amber-700" : "text-zinc-900")}>
+ {value}
+
+
+ );
+}
diff --git a/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx b/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx
new file mode 100644
index 0000000..d53a21c
--- /dev/null
+++ b/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { useState, useTransition } from "react";
+
+type Props = {
+ action: () => Promise<{ ok: false; error: string } | { ok: true } | undefined | void>;
+};
+
+export function ApproveOrgButton({ action }: Props) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+
+ function run() {
+ setError(null);
+ startTransition(async () => {
+ const res = await action();
+ if (res && (res as { ok?: boolean }).ok === false) {
+ setError((res as { error: string }).error);
+ }
+ });
+ }
+
+ return (
+
+
+ {pending ? "Validation…" : "Valider l'organisation"}
+
+ {error ? {error} : null}
+
+ );
+}
diff --git a/src/app/admin/organizations/[id]/_components/DeleteOrgButton.tsx b/src/app/admin/organizations/[id]/_components/DeleteOrgButton.tsx
new file mode 100644
index 0000000..4b54f5b
--- /dev/null
+++ b/src/app/admin/organizations/[id]/_components/DeleteOrgButton.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { useState, useTransition } from "react";
+
+type Props = {
+ action: () => Promise<{ ok: false; error: string } | { ok: true } | undefined | void>;
+ memberCount: number;
+};
+
+export function DeleteOrgButton({ action, memberCount }: Props) {
+ const [pending, startTransition] = useTransition();
+ const [confirm, setConfirm] = useState(false);
+ const [error, setError] = useState(null);
+
+ function run() {
+ setError(null);
+ startTransition(async () => {
+ const res = await action();
+ if (res && (res as { ok?: boolean }).ok === false) {
+ setError((res as { error: string }).error);
+ setConfirm(false);
+ }
+ });
+ }
+
+ if (memberCount > 0) {
+ return (
+
+ Suppression impossible — {memberCount} membre{memberCount > 1 ? "s" : ""} rattaché{memberCount > 1 ? "s" : ""}
+
+ );
+ }
+
+ return (
+
+ {confirm ? (
+
+ Supprimer définitivement ?
+
+ Oui, supprimer
+
+ setConfirm(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
setConfirm(true)}
+ disabled={pending}
+ className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
+ >
+ Supprimer l'organisation
+
+ )}
+ {error ? (
+
{error}
+ ) : null}
+
+ );
+}
diff --git a/src/app/admin/organizations/[id]/page.tsx b/src/app/admin/organizations/[id]/page.tsx
new file mode 100644
index 0000000..810ba23
--- /dev/null
+++ b/src/app/admin/organizations/[id]/page.tsx
@@ -0,0 +1,116 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { getOrganizationForAdmin } from "@/lib/admin/organizations";
+import { OrgForm } from "../_components/OrgForm";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+import { approveOrganizationAction, deleteOrganizationAction, updateOrganizationAction } from "../actions";
+import { ApproveOrgButton } from "./_components/ApproveOrgButton";
+import { DeleteOrgButton } from "./_components/DeleteOrgButton";
+
+export const dynamic = "force-dynamic";
+
+const ROLE_LABEL: Record = {
+ OWNER: "Propriétaire",
+ CE_MANAGER: "CE — Manager",
+ CE_MEMBER: "CE — Membre",
+ TOURIST: "Touriste",
+ ADMIN: "Admin",
+};
+
+type PageProps = { params: Promise<{ id: string }> };
+
+export default async function EditOrgPage({ params }: PageProps) {
+ const { id } = await params;
+ const org = await getOrganizationForAdmin(id);
+ if (!org) notFound();
+
+ const updateThis = async (fd: FormData) => {
+ "use server";
+ return await updateOrganizationAction(id, fd);
+ };
+ const deleteThis = async () => {
+ "use server";
+ return await deleteOrganizationAction(id);
+ };
+ const approveThis = async () => {
+ "use server";
+ return await approveOrganizationAction(id);
+ };
+
+ return (
+
+
+
+
+ ← Toutes les organisations
+
+
+ {org.name}
+ {org.approved ? (
+
+ Validée
+
+ ) : (
+
+ À valider
+
+ )}
+
+
+ /{org.slug} · {org.members.length} membre{org.members.length > 1 ? "s" : ""} ·{" "}
+ {org._count.carbetMemberships} carbet{org._count.carbetMemberships > 1 ? "s" : ""} co-géré
+ {org._count.carbetMemberships > 1 ? "s" : ""} · {org._count.rentalProviders} provider rental
+
+ {org.contactEmail ? (
+
+ Contact : {org.contactEmail}
+
+ ) : null}
+
+
+ {!org.approved ?
: null}
+
+
+
+
+
+
+
+
+ Membres ({org.members.length})
+
+ {org.members.length === 0 ? (
+
+ Aucun membre. Rattachez un utilisateur via{" "}
+
+ la page Utilisateurs
+
+ .
+
+ ) : (
+
+ {org.members.map((m) => (
+
+
+ {m.firstName} {m.lastName}
+ {m.email}
+
+
+ {ROLE_LABEL[m.role] ?? m.role}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/admin/organizations/_components/OrgForm.tsx b/src/app/admin/organizations/_components/OrgForm.tsx
new file mode 100644
index 0000000..be0f724
--- /dev/null
+++ b/src/app/admin/organizations/_components/OrgForm.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
+
+type Props = {
+ initial?: {
+ name?: string;
+ slug?: string;
+ description?: string | null;
+ };
+ action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
+ submitLabel?: string;
+};
+
+export function OrgForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ function onSubmit(formData: FormData) {
+ setError(null);
+ setSuccess(null);
+ startTransition(async () => {
+ const res = await action(formData);
+ if (res && res.ok === false) setError(res.error);
+ else if (res && res.ok === true) setSuccess("Organisation enregistrée.");
+ });
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {error ? (
+ {error}
+ ) : null}
+ {success ? (
+ {success}
+ ) : null}
+
+
+
+ {pending ? "Enregistrement…" : submitLabel}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/organizations/actions.ts b/src/app/admin/organizations/actions.ts
new file mode 100644
index 0000000..6a0ae6f
--- /dev/null
+++ b/src/app/admin/organizations/actions.ts
@@ -0,0 +1,123 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { approveOrganization as approveOrganizationLib } from "@/lib/admin/organizations";
+import { requireRole } from "@/lib/authorization";
+import { sendCeApproved } from "@/lib/email";
+import { prisma } from "@/lib/prisma";
+import { recordAudit } from "@/lib/admin/audit";
+
+async function audit(event: string, target: string, actor: string | null, details: Record) {
+ await recordAudit({ scope: "admin.organizations", event, target, actorEmail: actor, details });
+}
+
+const slugRe = /^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$/;
+
+const orgSchema = z.object({
+ name: z.string().trim().min(2).max(200),
+ slug: z.string().trim().regex(slugRe, "Slug invalide (a-z, 0-9, -)"),
+ description: z.string().trim().max(5000).optional().nullable(),
+});
+
+function parseFD(fd: FormData) {
+ return {
+ name: (fd.get("name") as string | null) ?? "",
+ slug: (fd.get("slug") as string | null) ?? "",
+ description: ((fd.get("description") as string | null) ?? "") || null,
+ };
+}
+
+export async function createOrganizationAction(fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = orgSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ try {
+ const created = await prisma.organization.create({
+ data: { name: parsed.data.name, slug: parsed.data.slug, description: parsed.data.description ?? null },
+ });
+ await audit("organization.create", created.id, session?.user?.email ?? null, { slug: created.slug });
+ revalidatePath("/admin/organizations");
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("Unique")) {
+ return { ok: false as const, error: "Ce slug existe déjà." };
+ }
+ return { ok: false as const, error: "Erreur lors de la création." };
+ }
+ redirect("/admin/organizations");
+}
+
+export async function updateOrganizationAction(id: string, fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = orgSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ try {
+ await prisma.organization.update({
+ where: { id },
+ data: { name: parsed.data.name, slug: parsed.data.slug, description: parsed.data.description ?? null },
+ });
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("Unique")) {
+ return { ok: false as const, error: "Ce slug est déjà pris." };
+ }
+ return { ok: false as const, error: "Erreur lors de la mise à jour." };
+ }
+ await audit("organization.update", id, session?.user?.email ?? null, { slug: parsed.data.slug });
+ revalidatePath("/admin/organizations");
+ revalidatePath(`/admin/organizations/${id}`);
+ return { ok: true as const };
+}
+
+export async function approveOrganizationAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const actor = session?.user?.email ?? null;
+ const res = await approveOrganizationLib(id, actor ?? "admin");
+ if (!res.ok) return res;
+ if (!res.alreadyApproved) {
+ await audit("organization.approve", id, actor, {});
+ // Notifier les CE_MANAGERs de l'org : leur compte vient d'être débloqué.
+ try {
+ const data = await prisma.organization.findUnique({
+ where: { id },
+ select: {
+ name: true,
+ members: {
+ where: { role: UserRole.CE_MANAGER, isActive: true },
+ select: { email: true, firstName: true },
+ },
+ },
+ });
+ for (const m of data?.members ?? []) {
+ await sendCeApproved(m.email, m.firstName, data?.name ?? "");
+ }
+ } catch (e) {
+ console.error("[admin.org.approve] email send failed:", e instanceof Error ? e.message : e);
+ }
+ }
+ revalidatePath("/admin/organizations");
+ revalidatePath(`/admin/organizations/${id}`);
+ return { ok: true as const };
+}
+
+export async function deleteOrganizationAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const count = await prisma.user.count({ where: { organizationId: id } });
+ if (count > 0) {
+ return { ok: false as const, error: `Impossible : ${count} membre(s) encore rattaché(s).` };
+ }
+ await prisma.organization.delete({ where: { id } });
+ await audit("organization.delete", id, session?.user?.email ?? null, {});
+ revalidatePath("/admin/organizations");
+ redirect("/admin/organizations");
+}
diff --git a/src/app/admin/organizations/new/page.tsx b/src/app/admin/organizations/new/page.tsx
new file mode 100644
index 0000000..b7c5979
--- /dev/null
+++ b/src/app/admin/organizations/new/page.tsx
@@ -0,0 +1,21 @@
+import Link from "next/link";
+import { OrgForm } from "../_components/OrgForm";
+import { createOrganizationAction } from "../actions";
+
+export const dynamic = "force-dynamic";
+
+export default function NewOrgPage() {
+ return (
+
+
+
+ ← Toutes les organisations
+
+ Nouvelle organisation
+
+
+
+ );
+}
diff --git a/src/app/admin/organizations/page.tsx b/src/app/admin/organizations/page.tsx
new file mode 100644
index 0000000..b6a5e95
--- /dev/null
+++ b/src/app/admin/organizations/page.tsx
@@ -0,0 +1,140 @@
+import Link from "next/link";
+import { countPendingOrganizations, listOrganizationsAdmin } from "@/lib/admin/organizations";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{ q?: string; status?: string }>;
+};
+
+const STATUS_VALUES = ["all", "pending", "approved"] as const;
+type StatusFilter = (typeof STATUS_VALUES)[number];
+
+function isStatusFilter(s: string | undefined): s is StatusFilter {
+ return STATUS_VALUES.includes(s as StatusFilter);
+}
+
+export default async function OrgsAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const approved = isStatusFilter(sp.status) ? sp.status : "all";
+ const filters = { q: sp.q?.trim() || undefined, approved };
+ const [orgs, pendingCount] = await Promise.all([
+ listOrganizationsAdmin(filters),
+ countPendingOrganizations(),
+ ]);
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+ {(
+ [
+ { key: "all", label: "Toutes" },
+ { key: "pending", label: pendingCount > 0 ? `À valider (${pendingCount})` : "À valider" },
+ { key: "approved", label: "Validées" },
+ ] as { key: StatusFilter; label: string }[]
+ ).map((t) => {
+ const href = `/admin/organizations?status=${t.key}${filters.q ? `&q=${encodeURIComponent(filters.q)}` : ""}`;
+ const active = approved === t.key;
+ return (
+
+ {t.label}
+
+ );
+ })}
+
+
+
+ {approved !== "all" ? (
+
+ ) : null}
+
+
+ Filtrer
+
+ {filters.q ? (
+
+ Réinit.
+
+ ) : null}
+
+
+
+
+
+
+ Nom
+ Statut
+ Slug
+ Membres
+ Créée
+
+
+
+ {orgs.length === 0 ? (
+
+
+ Aucune organisation.
+
+
+ ) : null}
+ {orgs.map((o) => (
+
+
+
+ {o.name}
+
+ {o.description ? (
+ {o.description.slice(0, 80)}{o.description.length > 80 ? "…" : ""}
+ ) : null}
+
+
+ {o.approved ? (
+
+ Validée
+
+ ) : (
+
+ À valider
+
+ )}
+
+ /{o.slug}
+ {o.membersCount}
+ {dateFmt.format(o.createdAt)}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 731159d..59af7be 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -1,14 +1,103 @@
-import { requireRole } from "@/lib/authorization";
+import Link from "next/link";
+import { formatEur, getAdminKpis } from "@/lib/admin/kpis";
+import { KPICard } from "@/components/admin/KPICard";
-export default async function AdminPage() {
- const session = await requireRole(["ADMIN"]);
+export const dynamic = "force-dynamic";
+
+export default async function AdminDashboard() {
+ const kpis = await getAdminKpis();
return (
-
- Espace administrateur
-
- Accès autorisé pour {session.user.email} ({session.user.role}).
-
-
+
+
+
+
+
+
+
+ 50 ? "ok" : "neutral"}
+ />
+
+
+ 5 ? "warn" : "neutral"}
+ />
+
+
+
+
+ Raccourcis fréquents
+
+
+
+
+ Gérer les carbets
+
+
+
+
+ Voir les réservations
+
+
+
+
+ Éditer les pages
+
+
+
+
+ Activer / désactiver des plugins
+
+
+
+
+ Modérer les utilisateurs
+
+
+
+
+ Paramètres
+
+
+
+
+
);
}
diff --git a/src/app/admin/payouts/_components/MarkPaidForm.tsx b/src/app/admin/payouts/_components/MarkPaidForm.tsx
new file mode 100644
index 0000000..b2129d1
--- /dev/null
+++ b/src/app/admin/payouts/_components/MarkPaidForm.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+import type { ProviderPayout } from "@/lib/payouts";
+
+type Props = {
+ payout: ProviderPayout;
+ markAction: (
+ providerId: string,
+ periodMonthISO: string,
+ fd: FormData,
+ ) => Promise<{ ok: false; error: string } | { ok: true; alreadyExists: boolean }>;
+ unmarkAction: (providerId: string, periodMonthISO: string) => Promise;
+};
+
+function fmtEur(n: number): string {
+ return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
+}
+
+export function MarkPaidForm({ payout, markAction, unmarkAction }: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [opened, setOpened] = useState(false);
+ const [error, setError] = useState(null);
+
+ function onSubmit(fd: FormData) {
+ setError(null);
+ startTransition(async () => {
+ const res = await markAction(payout.providerId, payout.periodMonth.toISOString(), fd);
+ if (!res.ok) {
+ setError(res.error);
+ return;
+ }
+ setOpened(false);
+ router.refresh();
+ });
+ }
+
+ function onUnmark() {
+ startTransition(async () => {
+ await unmarkAction(payout.providerId, payout.periodMonth.toISOString());
+ router.refresh();
+ });
+ }
+
+ if (payout.paid) {
+ return (
+
+
+ Payé {fmtEur(payout.paid.amount)}
+
+ {payout.paid.reference ? (
+ Ref : {payout.paid.reference}
+ ) : null}
+
+ Annuler marquage
+
+
+ );
+ }
+
+ if (payout.netAmount <= 0) {
+ return — ;
+ }
+
+ if (!opened) {
+ return (
+ setOpened(true)}
+ className="rounded-md bg-emerald-600 px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-emerald-700"
+ >
+ Marquer payé
+
+ );
+ }
+
+ return (
+
+
+
+ {error ? {error} : null}
+
+ {
+ setOpened(false);
+ setError(null);
+ }}
+ disabled={pending}
+ className="text-[10px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ {pending ? "…" : "Confirmer"}
+
+
+
+ );
+}
diff --git a/src/app/admin/payouts/actions.ts b/src/app/admin/payouts/actions.ts
new file mode 100644
index 0000000..ac92fd2
--- /dev/null
+++ b/src/app/admin/payouts/actions.ts
@@ -0,0 +1,96 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { recordAudit } from "@/lib/admin/audit";
+import { requireRole } from "@/lib/authorization";
+import {
+ createPayoutMark,
+ deletePayoutMark,
+} from "@/lib/payouts";
+import { prisma } from "@/lib/prisma";
+import { sendPayoutSent } from "@/lib/email";
+
+export async function markPayoutPaidAction(
+ providerId: string,
+ periodMonthISO: string,
+ fd: FormData,
+): Promise<{ ok: false; error: string } | { ok: true; alreadyExists: boolean }> {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const actor = session?.user?.email ?? null;
+ const amount = Number(fd.get("amount") ?? 0);
+ const reference = ((fd.get("reference") as string | null) ?? "").trim() || null;
+
+ if (!Number.isFinite(amount) || amount < 0) {
+ return { ok: false, error: "Montant invalide." };
+ }
+ const periodMonth = new Date(periodMonthISO);
+ if (Number.isNaN(periodMonth.getTime())) {
+ return { ok: false, error: "Période invalide." };
+ }
+
+ const res = await createPayoutMark({
+ providerId,
+ periodMonth,
+ amount,
+ reference,
+ paidByEmail: actor,
+ });
+ if (!res.ok) return res;
+
+ await recordAudit({
+ scope: "admin.payouts",
+ event: res.alreadyExists ? "payout.already_marked" : "payout.mark",
+ target: providerId,
+ actorEmail: actor,
+ details: {
+ periodMonth: periodMonth.toISOString().slice(0, 7),
+ amount,
+ reference,
+ },
+ });
+
+ // Notif provider best-effort (n'envoie que si on a un contactEmail)
+ if (!res.alreadyExists) {
+ try {
+ const provider = await prisma.rentalProvider.findUnique({
+ where: { id: providerId },
+ select: { name: true, contactEmail: true },
+ });
+ if (provider?.contactEmail) {
+ await sendPayoutSent(
+ provider.contactEmail,
+ provider.name,
+ periodMonth,
+ amount.toFixed(2),
+ reference,
+ );
+ }
+ } catch (e) {
+ console.error("[payouts] email send failed:", e instanceof Error ? e.message : e);
+ }
+ }
+
+ revalidatePath("/admin/payouts");
+ return { ok: true, alreadyExists: res.alreadyExists };
+}
+
+export async function unmarkPayoutPaidAction(providerId: string, periodMonthISO: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const actor = session?.user?.email ?? null;
+ const periodMonth = new Date(periodMonthISO);
+ if (Number.isNaN(periodMonth.getTime())) return;
+ await deletePayoutMark(providerId, periodMonth);
+ await recordAudit({
+ scope: "admin.payouts",
+ event: "payout.unmark",
+ target: providerId,
+ actorEmail: actor,
+ details: { periodMonth: periodMonth.toISOString().slice(0, 7) },
+ });
+ revalidatePath("/admin/payouts");
+}
diff --git a/src/app/admin/payouts/page.tsx b/src/app/admin/payouts/page.tsx
new file mode 100644
index 0000000..0c40c19
--- /dev/null
+++ b/src/app/admin/payouts/page.tsx
@@ -0,0 +1,155 @@
+import Link from "next/link";
+
+import { formatMonth, listProviderPayouts } from "@/lib/payouts";
+
+import { markPayoutPaidAction, unmarkPayoutPaidAction } from "./actions";
+import { MarkPaidForm } from "./_components/MarkPaidForm";
+
+export const dynamic = "force-dynamic";
+export const metadata = { title: "Reversements prestataires — Karbé admin" };
+
+function fmtEur(n: number): string {
+ return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
+}
+
+export default async function PayoutsAdminPage() {
+ const payouts = await listProviderPayouts({ monthsBack: 6 });
+
+ // Group by month
+ const byMonth = new Map();
+ for (const p of payouts) {
+ const k = p.periodMonth.getTime();
+ if (!byMonth.has(k)) byMonth.set(k, []);
+ byMonth.get(k)!.push(p);
+ }
+
+ // Globals
+ const totalDue = payouts
+ .filter((p) => !p.paid && p.netAmount > 0)
+ .reduce((s, p) => s + p.netAmount, 0);
+ const totalPaid = payouts
+ .filter((p) => p.paid)
+ .reduce((s, p) => s + (p.paid!.amount), 0);
+
+ return (
+
+
+
+
+
+ {Array.from(byMonth.entries())
+ .sort((a, b) => b[0] - a[0])
+ .map(([periodTs, rows]) => {
+ const period = new Date(periodTs);
+ const monthDue = rows
+ .filter((r) => !r.paid && r.netAmount > 0)
+ .reduce((s, r) => s + r.netAmount, 0);
+ return (
+
+
+
+ {formatMonth(period)}
+
+
+ Reste à payer ce mois :{" "}
+ {fmtEur(monthDue)}
+
+
+
+
+
+ Prestataire
+ Résa
+ CA brut
+ Commission
+ Net dû
+ Statut
+
+
+
+ {rows
+ .sort((a, b) => b.netAmount - a.netAmount)
+ .map((p) => (
+
+
+
+ {p.providerName}
+
+
+
+ {p.bookingsCount}
+
+
+ {fmtEur(p.grossAmount)}
+
+
+ {fmtEur(p.commission)}
+
+
+ {fmtEur(p.netAmount)}
+
+
+
+
+
+
+
+ ))}
+
+
+
+ );
+ })}
+
+ );
+}
+
+function KpiCard({
+ label,
+ value,
+ highlight,
+}: {
+ label: string;
+ value: string;
+ highlight?: boolean;
+}) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+}
diff --git a/src/app/admin/pirogue-providers/[id]/_components/ProviderInlineActions.tsx b/src/app/admin/pirogue-providers/[id]/_components/ProviderInlineActions.tsx
new file mode 100644
index 0000000..e92e31d
--- /dev/null
+++ b/src/app/admin/pirogue-providers/[id]/_components/ProviderInlineActions.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+type Props = {
+ active: boolean;
+ carbetsCount: number;
+ toggleAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
+ deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
+};
+
+export function ProviderInlineActions({ active, carbetsCount, toggleAction, deleteAction }: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [error, setError] = useState(null);
+
+ function toggle() {
+ setError(null);
+ startTransition(async () => {
+ const res = await toggleAction(!active);
+ if (res && (res as { ok?: boolean }).ok === false) {
+ setError((res as { error: string }).error);
+ }
+ router.refresh();
+ });
+ }
+
+ function del() {
+ setError(null);
+ startTransition(async () => {
+ const res = await deleteAction();
+ if (res && (res as { ok?: boolean }).ok === false) {
+ setError((res as { error: string }).error);
+ setConfirmDelete(false);
+ }
+ });
+ }
+
+ return (
+
+
+
+ {active ? "Désactiver" : "Réactiver"}
+
+ {carbetsCount === 0 ? (
+ confirmDelete ? (
+
+ Supprimer ?
+
+ Oui, supprimer
+
+ setConfirmDelete(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
setConfirmDelete(true)}
+ disabled={pending}
+ className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
+ >
+ Supprimer
+
+ )
+ ) : (
+
+ Suppression impossible — {carbetsCount} carbet{carbetsCount > 1 ? "s" : ""} rattaché{carbetsCount > 1 ? "s" : ""}
+
+ )}
+
+ {error ? (
+
{error}
+ ) : null}
+
+ );
+}
diff --git a/src/app/admin/pirogue-providers/[id]/page.tsx b/src/app/admin/pirogue-providers/[id]/page.tsx
new file mode 100644
index 0000000..024eba8
--- /dev/null
+++ b/src/app/admin/pirogue-providers/[id]/page.tsx
@@ -0,0 +1,105 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { getPirogueProviderForAdmin } from "@/lib/admin/pirogue-providers";
+import { ProviderForm } from "../_components/ProviderForm";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+import {
+ deletePirogueProviderAction,
+ togglePirogueActiveAction,
+ updatePirogueProviderAction,
+} from "../actions";
+import { ProviderInlineActions } from "./_components/ProviderInlineActions";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ id: string }> };
+
+export default async function EditPirogueProviderPage({ params }: PageProps) {
+ const { id } = await params;
+ const p = await getPirogueProviderForAdmin(id);
+ if (!p) notFound();
+
+ const updateThis = async (fd: FormData) => {
+ "use server";
+ return await updatePirogueProviderAction(id, fd);
+ };
+ const toggleThis = async (active: boolean) => {
+ "use server";
+ return await togglePirogueActiveAction(id, active);
+ };
+ const deleteThis = async () => {
+ "use server";
+ return await deletePirogueProviderAction(id);
+ };
+
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+
+ Carbets référencés ({p.carbets.length})
+
+ {p.carbets.length === 0 ? (
+ Aucun carbet ne référence ce prestataire pour le moment.
+ ) : (
+
+ {p.carbets.map((c) => (
+
+
+ {c.title}
+
+ /{c.slug} · {c.river}
+
+
+
+
+ {dateFmt.format(c.updatedAt)}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/admin/pirogue-providers/_components/ProviderForm.tsx b/src/app/admin/pirogue-providers/_components/ProviderForm.tsx
new file mode 100644
index 0000000..6033d70
--- /dev/null
+++ b/src/app/admin/pirogue-providers/_components/ProviderForm.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
+
+type Props = {
+ initial?: {
+ name?: string;
+ contactEmail?: string | null;
+ contactPhone?: string | null;
+ rivers?: string[];
+ pricingNote?: string | null;
+ description?: string | null;
+ active?: boolean;
+ };
+ action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
+ submitLabel?: string;
+};
+
+export function ProviderForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ function onSubmit(formData: FormData) {
+ setError(null);
+ setSuccess(null);
+ startTransition(async () => {
+ const res = await action(formData);
+ if (res && res.ok === false) setError(res.error);
+ else if (res && res.ok === true) setSuccess("Prestataire enregistré.");
+ });
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Prestataire actif (sélectionnable sur un carbet)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {error ? (
+ {error}
+ ) : null}
+ {success ? (
+ {success}
+ ) : null}
+
+
+
+ {pending ? "Enregistrement…" : submitLabel}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/pirogue-providers/actions.ts b/src/app/admin/pirogue-providers/actions.ts
new file mode 100644
index 0000000..5111fd8
--- /dev/null
+++ b/src/app/admin/pirogue-providers/actions.ts
@@ -0,0 +1,95 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { requireRole } from "@/lib/authorization";
+import { prisma } from "@/lib/prisma";
+import { recordAudit } from "@/lib/admin/audit";
+
+async function audit(event: string, target: string, actor: string | null, details: Record) {
+ await recordAudit({ scope: "admin.pirogue", event, target, actorEmail: actor, details });
+}
+
+const providerSchema = z.object({
+ name: z.string().trim().min(2).max(200),
+ contactEmail: z.string().trim().email().max(200).optional().nullable(),
+ contactPhone: z.string().trim().max(50).optional().nullable(),
+ rivers: z.array(z.string().trim().min(1).max(80)).max(20),
+ pricingNote: z.string().trim().max(2000).optional().nullable(),
+ description: z.string().trim().max(5000).optional().nullable(),
+ active: z.boolean(),
+});
+
+function parseFD(fd: FormData) {
+ const riversRaw = (fd.get("rivers") as string | null) ?? "";
+ const rivers = riversRaw
+ .split(/[,;\n]/)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+ const get = (k: string) => {
+ const v = (fd.get(k) as string | null) ?? "";
+ return v.trim() === "" ? null : v.trim();
+ };
+ return {
+ name: ((fd.get("name") as string | null) ?? "").trim(),
+ contactEmail: get("contactEmail"),
+ contactPhone: get("contactPhone"),
+ rivers,
+ pricingNote: get("pricingNote"),
+ description: get("description"),
+ active: fd.get("active") === "on",
+ };
+}
+
+export async function createPirogueProviderAction(fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = providerSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ const created = await prisma.pirogueProvider.create({ data: parsed.data });
+ await audit("pirogue.create", created.id, session?.user?.email ?? null, { name: created.name });
+ revalidatePath("/admin/pirogue-providers");
+ redirect(`/admin/pirogue-providers/${created.id}`);
+}
+
+export async function updatePirogueProviderAction(id: string, fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = providerSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ await prisma.pirogueProvider.update({ where: { id }, data: parsed.data });
+ await audit("pirogue.update", id, session?.user?.email ?? null, { name: parsed.data.name, active: parsed.data.active });
+ revalidatePath("/admin/pirogue-providers");
+ revalidatePath(`/admin/pirogue-providers/${id}`);
+ return { ok: true as const };
+}
+
+export async function togglePirogueActiveAction(id: string, active: boolean) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ await prisma.pirogueProvider.update({ where: { id }, data: { active } });
+ await audit("pirogue.active.update", id, session?.user?.email ?? null, { active });
+ revalidatePath("/admin/pirogue-providers");
+ revalidatePath(`/admin/pirogue-providers/${id}`);
+ return { ok: true as const };
+}
+
+export async function deletePirogueProviderAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const count = await prisma.carbet.count({ where: { pirogueProviderId: id } });
+ if (count > 0) {
+ return { ok: false as const, error: `Impossible : ${count} carbet(s) référencent ce prestataire.` };
+ }
+ await prisma.pirogueProvider.delete({ where: { id } });
+ await audit("pirogue.delete", id, session?.user?.email ?? null, {});
+ revalidatePath("/admin/pirogue-providers");
+ redirect("/admin/pirogue-providers");
+}
diff --git a/src/app/admin/pirogue-providers/new/page.tsx b/src/app/admin/pirogue-providers/new/page.tsx
new file mode 100644
index 0000000..2983f12
--- /dev/null
+++ b/src/app/admin/pirogue-providers/new/page.tsx
@@ -0,0 +1,21 @@
+import Link from "next/link";
+import { ProviderForm } from "../_components/ProviderForm";
+import { createPirogueProviderAction } from "../actions";
+
+export const dynamic = "force-dynamic";
+
+export default function NewPirogueProviderPage() {
+ return (
+
+
+
+ ← Tous les prestataires
+
+ Nouveau prestataire pirogue
+
+
+
+ );
+}
diff --git a/src/app/admin/pirogue-providers/page.tsx b/src/app/admin/pirogue-providers/page.tsx
new file mode 100644
index 0000000..988f61f
--- /dev/null
+++ b/src/app/admin/pirogue-providers/page.tsx
@@ -0,0 +1,124 @@
+import Link from "next/link";
+import { listPirogueProvidersAdmin, listPirogueRivers } from "@/lib/admin/pirogue-providers";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ river?: string;
+ active?: string;
+ }>;
+};
+
+export default async function PirogueProvidersAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ river: sp.river || undefined,
+ active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
+ };
+ const [rows, rivers] = await Promise.all([listPirogueProvidersAdmin(filters), listPirogueRivers()]);
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+ Tous fleuves
+ {rivers.map((r) => (
+ {r}
+ ))}
+
+
+ Actifs + inactifs
+ Actifs
+ Inactifs
+
+
+ Filtrer
+
+ {(filters.q || filters.river || filters.active) ? (
+
+ Réinit.
+
+ ) : null}
+
+
+
+
+
+
+ Nom
+ Fleuves
+ Contact
+ Carbets
+ État
+ MAJ
+
+
+
+ {rows.length === 0 ? (
+
+
+ Aucun prestataire ne correspond aux filtres.
+
+
+ ) : null}
+ {rows.map((p) => (
+
+
+
+ {p.name}
+
+
+
+ {p.rivers.length === 0 ? — : p.rivers.join(", ")}
+
+
+ {p.contactEmail ? {p.contactEmail}
: null}
+ {p.contactPhone ? {p.contactPhone}
: null}
+ {!p.contactEmail && !p.contactPhone ? — : null}
+
+ {p.carbetsCount}
+
+ {dateFmt.format(p.updatedAt)}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx b/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx
new file mode 100644
index 0000000..8a6a00f
--- /dev/null
+++ b/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+type Props = {
+ active: boolean;
+ toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
+ deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
+};
+
+export function ItemInlineActions({ active, toggleActiveAction, deleteAction }: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [error, setError] = useState(null);
+
+ function toggle() {
+ setError(null);
+ startTransition(async () => {
+ const res = await toggleActiveAction(!active);
+ if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
+ router.refresh();
+ });
+ }
+ function del() {
+ setError(null);
+ startTransition(async () => {
+ const res = await deleteAction();
+ if (res && (res as { ok?: boolean }).ok === false) {
+ setError((res as { error: string }).error);
+ setConfirmDelete(false);
+ }
+ });
+ }
+
+ return (
+
+
+
+ {active ? "Désactiver" : "Réactiver"}
+
+ {confirmDelete ? (
+
+ Supprimer ?
+
+ Oui
+
+ setConfirmDelete(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
setConfirmDelete(true)}
+ disabled={pending}
+ className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
+ >
+ Supprimer
+
+ )}
+
+ {error ?
{error}
: null}
+
+ );
+}
diff --git a/src/app/admin/rental-items/[id]/page.tsx b/src/app/admin/rental-items/[id]/page.tsx
new file mode 100644
index 0000000..59295d2
--- /dev/null
+++ b/src/app/admin/rental-items/[id]/page.tsx
@@ -0,0 +1,92 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+
+import { StatusBadge } from "@/components/admin/StatusBadge";
+import { MediaUploader } from "@/components/MediaUploader";
+import { getRentalItemForAdmin, listProvidersForSelect, RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
+
+import { ItemForm } from "../_components/ItemForm";
+import { ItemInlineActions } from "./_components/ItemInlineActions";
+import { deleteRentalItemAction, toggleRentalItemActiveAction, updateRentalItemAction } from "../actions";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ id: string }> };
+
+export default async function EditRentalItemPage({ params }: PageProps) {
+ const { id } = await params;
+ const [item, providers] = await Promise.all([getRentalItemForAdmin(id), listProvidersForSelect()]);
+ if (!item) notFound();
+
+ const updateThis = async (fd: FormData) => {
+ "use server";
+ return await updateRentalItemAction(id, fd);
+ };
+ const toggleActiveThis = async (active: boolean) => {
+ "use server";
+ return await toggleRentalItemActiveAction(id, active);
+ };
+ const deleteThis = async () => {
+ "use server";
+ return await deleteRentalItemAction(id);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/admin/rental-items/_components/ItemForm.tsx b/src/app/admin/rental-items/_components/ItemForm.tsx
new file mode 100644
index 0000000..27ad4b2
--- /dev/null
+++ b/src/app/admin/rental-items/_components/ItemForm.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField";
+import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels";
+
+type Props = {
+ providers: { id: string; name: string; isSystemD: boolean }[];
+ initial?: {
+ providerId?: string;
+ category?: string;
+ name?: string;
+ description?: string | null;
+ imageUrl?: string | null;
+ pricePerDay?: string | number;
+ pricePerWeek?: string | number | null;
+ deposit?: string | number;
+ totalQty?: number;
+ withMotor?: boolean;
+ fuelIncluded?: boolean;
+ requiresLicense?: boolean;
+ active?: boolean;
+ };
+ action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
+ submitLabel?: string;
+};
+
+export function ItemForm({ providers, initial = {}, action, submitLabel = "Enregistrer" }: Props) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ function onSubmit(fd: FormData) {
+ setError(null);
+ setSuccess(null);
+ startTransition(async () => {
+ const res = await action(fd);
+ if (res && res.ok === false) setError(res.error);
+ else if (res && res.ok === true) setSuccess("Enregistré.");
+ });
+ }
+
+ return (
+
+
+
+
+
+ — sélectionner —
+ {providers.map((p) => (
+
+ {p.name}{p.isSystemD ? " (System D)" : ""}
+
+ ))}
+
+
+
+
+ — sélectionner —
+ {RENTAL_CATEGORIES.map((c) => (
+ {RENTAL_CATEGORY_LABEL[c]}
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Actif (visible au catalogue)
+
+
+
+
+
+
+ Spécifications navigation
+
+
+
+
+ Avec moteur
+
+
+
+ Essence incluse
+
+
+
+ Permis bateau requis
+
+
+
+
+ {error ? (
+ {error}
+ ) : null}
+ {success ? (
+ {success}
+ ) : null}
+
+
+
+ {pending ? "Enregistrement…" : submitLabel}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/rental-items/actions.ts b/src/app/admin/rental-items/actions.ts
new file mode 100644
index 0000000..c5eaad2
--- /dev/null
+++ b/src/app/admin/rental-items/actions.ts
@@ -0,0 +1,129 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { RentalCategory, UserRole } from "@/generated/prisma/enums";
+import { requireRole } from "@/lib/authorization";
+import { recordAudit } from "@/lib/admin/audit";
+import { prisma } from "@/lib/prisma";
+
+const itemSchema = z.object({
+ providerId: z.string().min(1),
+ category: z.enum([
+ RentalCategory.SLEEP,
+ RentalCategory.NAVIGATION,
+ RentalCategory.FISHING,
+ RentalCategory.COOKING,
+ RentalCategory.SAFETY,
+ ]),
+ name: z.string().trim().min(2).max(200),
+ description: z.string().trim().max(5000).nullable().optional(),
+ imageUrl: z.string().trim().url().max(500).nullable().optional(),
+ pricePerDay: z.coerce.number().min(0).max(10000),
+ pricePerWeek: z.coerce.number().min(0).max(50000).nullable().optional(),
+ deposit: z.coerce.number().min(0).max(10000),
+ totalQty: z.coerce.number().int().min(1).max(1000),
+ withMotor: z.boolean(),
+ fuelIncluded: z.boolean(),
+ requiresLicense: z.boolean(),
+ active: z.boolean(),
+});
+
+function parseFD(fd: FormData) {
+ const get = (k: string) => {
+ const v = (fd.get(k) as string | null) ?? "";
+ return v.trim() === "" ? null : v.trim();
+ };
+ return {
+ providerId: ((fd.get("providerId") as string | null) ?? "").trim(),
+ category: ((fd.get("category") as string | null) ?? "").trim(),
+ name: ((fd.get("name") as string | null) ?? "").trim(),
+ description: get("description"),
+ imageUrl: get("imageUrl"),
+ pricePerDay: fd.get("pricePerDay"),
+ pricePerWeek: get("pricePerWeek"),
+ deposit: fd.get("deposit") ?? "0",
+ totalQty: fd.get("totalQty") ?? "1",
+ withMotor: fd.get("withMotor") === "on",
+ fuelIncluded: fd.get("fuelIncluded") === "on",
+ requiresLicense: fd.get("requiresLicense") === "on",
+ active: fd.get("active") === "on",
+ };
+}
+
+export async function createRentalItemAction(fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = itemSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ const created = await prisma.rentalItem.create({ data: parsed.data });
+ await recordAudit({
+ scope: "admin.rental-items",
+ event: "create",
+ target: created.id,
+ actorEmail: session?.user?.email ?? null,
+ details: { name: created.name, providerId: created.providerId },
+ });
+ revalidatePath("/admin/rental-items");
+ redirect(`/admin/rental-items/${created.id}`);
+}
+
+export async function updateRentalItemAction(id: string, fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = itemSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ await prisma.rentalItem.update({ where: { id }, data: parsed.data });
+ await recordAudit({
+ scope: "admin.rental-items",
+ event: "update",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: { name: parsed.data.name },
+ });
+ revalidatePath("/admin/rental-items");
+ revalidatePath(`/admin/rental-items/${id}`);
+ return { ok: true as const };
+}
+
+export async function toggleRentalItemActiveAction(id: string, active: boolean) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ await prisma.rentalItem.update({ where: { id }, data: { active } });
+ await recordAudit({
+ scope: "admin.rental-items",
+ event: "active.update",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: { active },
+ });
+ revalidatePath("/admin/rental-items");
+ revalidatePath(`/admin/rental-items/${id}`);
+ return { ok: true as const };
+}
+
+export async function deleteRentalItemAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const linesCount = await prisma.rentalLine.count({ where: { itemId: id } });
+ if (linesCount > 0) {
+ return { ok: false as const, error: `Impossible : ${linesCount} ligne(s) de réservation pointe(nt) sur cet item.` };
+ }
+ await prisma.rentalItem.delete({ where: { id } });
+ await recordAudit({
+ scope: "admin.rental-items",
+ event: "delete",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: {},
+ });
+ revalidatePath("/admin/rental-items");
+ redirect("/admin/rental-items");
+}
diff --git a/src/app/admin/rental-items/new/page.tsx b/src/app/admin/rental-items/new/page.tsx
new file mode 100644
index 0000000..fec17d0
--- /dev/null
+++ b/src/app/admin/rental-items/new/page.tsx
@@ -0,0 +1,31 @@
+import Link from "next/link";
+import { ItemForm } from "../_components/ItemForm";
+import { createRentalItemAction } from "../actions";
+import { listProvidersForSelect } from "@/lib/admin/rental-items";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { searchParams: Promise<{ providerId?: string }> };
+
+export default async function NewRentalItemPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const providers = await listProvidersForSelect();
+ return (
+
+
+
+ ← Tous les items
+
+ Nouvel item locable
+
+
+
+ );
+}
diff --git a/src/app/admin/rental-items/page.tsx b/src/app/admin/rental-items/page.tsx
new file mode 100644
index 0000000..d67a556
--- /dev/null
+++ b/src/app/admin/rental-items/page.tsx
@@ -0,0 +1,152 @@
+import Link from "next/link";
+import { RentalCategory } from "@/generated/prisma/enums";
+import {
+ RENTAL_CATEGORY_LABEL,
+ isRentalCategory,
+ listProvidersForSelect,
+ listRentalItemsAdmin,
+} from "@/lib/admin/rental-items";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ category?: string;
+ providerId?: string;
+ active?: string;
+ }>;
+};
+
+export default async function RentalItemsAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ category: sp.category && isRentalCategory(sp.category) ? sp.category : undefined,
+ providerId: sp.providerId || undefined,
+ active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
+ };
+ const [rows, providers] = await Promise.all([listRentalItemsAdmin(filters), listProvidersForSelect()]);
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+ Toutes catégories
+ {Object.values(RentalCategory).map((c) => (
+ {RENTAL_CATEGORY_LABEL[c]}
+ ))}
+
+
+ Tous prestataires
+ {providers.map((p) => (
+ {p.name}
+ ))}
+
+
+ Actifs + inactifs
+ Actifs
+ Inactifs
+
+
+ Filtrer
+
+ {(filters.q || filters.category || filters.providerId || filters.active) ? (
+ Réinit.
+ ) : null}
+
+
+
+
+
+
+ Nom
+ Catégorie
+ Prestataire
+ € / jour
+ Stock
+ Caution
+ État
+ MAJ
+
+
+
+ {rows.length === 0 ? (
+
+
+ Aucun item.
+
+
+ ) : null}
+ {rows.map((i) => (
+
+
+
+ {i.name}
+
+
+ {i.withMotor ? "⚙️ moteur · " : ""}
+ {i.requiresLicense ? "🪪 permis · " : ""}
+ {i.fuelIncluded ? "⛽ essence · " : ""}
+
+
+ {RENTAL_CATEGORY_LABEL[i.category]}
+
+
+ {i.providerName}
+
+ {i.providerIsSystemD ? (
+
+ SD
+
+ ) : null}
+
+ {Number(i.pricePerDay).toFixed(0)}
+ {i.totalQty}
+ {Number(i.deposit).toFixed(0)}
+
+ {dateFmt.format(i.updatedAt)}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/rental-providers/[id]/_components/ProviderInlineActions.tsx b/src/app/admin/rental-providers/[id]/_components/ProviderInlineActions.tsx
new file mode 100644
index 0000000..1839fae
--- /dev/null
+++ b/src/app/admin/rental-providers/[id]/_components/ProviderInlineActions.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+type Props = {
+ approved: boolean;
+ active: boolean;
+ itemsCount: number;
+ approveAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
+ toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
+ deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
+};
+
+export function ProviderInlineActions({
+ approved,
+ active,
+ itemsCount,
+ approveAction,
+ toggleActiveAction,
+ deleteAction,
+}: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [error, setError] = useState(null);
+
+ function approve() {
+ setError(null);
+ startTransition(async () => {
+ const res = await approveAction();
+ if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
+ router.refresh();
+ });
+ }
+ function toggle() {
+ setError(null);
+ startTransition(async () => {
+ const res = await toggleActiveAction(!active);
+ if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
+ router.refresh();
+ });
+ }
+ function del() {
+ setError(null);
+ startTransition(async () => {
+ const res = await deleteAction();
+ if (res && (res as { ok?: boolean }).ok === false) {
+ setError((res as { error: string }).error);
+ setConfirmDelete(false);
+ }
+ });
+ }
+
+ return (
+
+
+ {!approved ? (
+
+ ✓ Approuver
+
+ ) : null}
+
+ {active ? "Désactiver" : "Réactiver"}
+
+ {itemsCount === 0 ? (
+ confirmDelete ? (
+
+ Supprimer ?
+
+ Oui
+
+ setConfirmDelete(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
setConfirmDelete(true)}
+ disabled={pending}
+ className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
+ >
+ Supprimer
+
+ )
+ ) : (
+
+ {itemsCount} item(s) — supprimez-les d'abord
+
+ )}
+
+ {error ?
{error}
: null}
+
+ );
+}
diff --git a/src/app/admin/rental-providers/[id]/page.tsx b/src/app/admin/rental-providers/[id]/page.tsx
new file mode 100644
index 0000000..5358bd1
--- /dev/null
+++ b/src/app/admin/rental-providers/[id]/page.tsx
@@ -0,0 +1,136 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+
+import { StatusBadge } from "@/components/admin/StatusBadge";
+import { getRentalProviderForAdmin } from "@/lib/admin/rental-providers";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
+
+import { ProviderForm } from "../_components/ProviderForm";
+import { ProviderInlineActions } from "./_components/ProviderInlineActions";
+import {
+ approveRentalProviderAction,
+ deleteRentalProviderAction,
+ toggleRentalProviderActiveAction,
+ updateRentalProviderAction,
+} from "../actions";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ id: string }> };
+
+export default async function EditRentalProviderPage({ params }: PageProps) {
+ const { id } = await params;
+ const p = await getRentalProviderForAdmin(id);
+ if (!p) notFound();
+
+ const updateThis = async (fd: FormData) => {
+ "use server";
+ return await updateRentalProviderAction(id, fd);
+ };
+ const approveThis = async () => {
+ "use server";
+ return await approveRentalProviderAction(id);
+ };
+ const toggleActiveThis = async (active: boolean) => {
+ "use server";
+ return await toggleRentalProviderActiveAction(id, active);
+ };
+ const deleteThis = async () => {
+ "use server";
+ return await deleteRentalProviderAction(id);
+ };
+
+ return (
+
+
+
+
+
+
+
+ Items ({p.items.length})
+
+ Voir tous les items
+
+
+ {p.items.length === 0 ? (
+
+ Pas encore d'item.{" "}
+
+ Créer un premier item
+
+
+ ) : (
+
+ {p.items.map((i) => (
+
+
+ {i.name}
+
+ {RENTAL_CATEGORY_LABEL[i.category]}
+
+
+
+ {Number(i.pricePerDay).toFixed(0)} €/j
+ qty {i.totalQty}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/admin/rental-providers/_components/ProviderForm.tsx b/src/app/admin/rental-providers/_components/ProviderForm.tsx
new file mode 100644
index 0000000..baf84a9
--- /dev/null
+++ b/src/app/admin/rental-providers/_components/ProviderForm.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
+
+type Props = {
+ initial?: {
+ name?: string;
+ isSystemD?: boolean;
+ contactEmail?: string | null;
+ contactPhone?: string | null;
+ rivers?: string[];
+ description?: string | null;
+ commissionPct?: number | string;
+ active?: boolean;
+ };
+ action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
+ submitLabel?: string;
+};
+
+export function ProviderForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ function onSubmit(fd: FormData) {
+ setError(null);
+ setSuccess(null);
+ startTransition(async () => {
+ const res = await action(fd);
+ if (res && res.ok === false) setError(res.error);
+ else if (res && res.ok === true) setSuccess("Enregistré.");
+ });
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ Fournisseur officiel System D (0 % commission)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Actif
+
+
+
+
+
+
+
+
+
+
+
+
+ {error ? (
+ {error}
+ ) : null}
+ {success ? (
+ {success}
+ ) : null}
+
+
+
+ {pending ? "Enregistrement…" : submitLabel}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/rental-providers/actions.ts b/src/app/admin/rental-providers/actions.ts
new file mode 100644
index 0000000..3561471
--- /dev/null
+++ b/src/app/admin/rental-providers/actions.ts
@@ -0,0 +1,150 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { requireRole } from "@/lib/authorization";
+import { recordAudit } from "@/lib/admin/audit";
+import { prisma } from "@/lib/prisma";
+
+const providerSchema = z.object({
+ name: z.string().trim().min(2).max(200),
+ isSystemD: z.boolean(),
+ managedByUserId: z.string().nullable().optional(),
+ contactEmail: z.string().trim().email().max(200).nullable().optional(),
+ contactPhone: z.string().trim().max(50).nullable().optional(),
+ rivers: z.array(z.string().trim().min(1).max(80)).max(20),
+ description: z.string().trim().max(5000).nullable().optional(),
+ commissionPct: z.coerce.number().min(0).max(50),
+ active: z.boolean(),
+});
+
+function parseFD(fd: FormData) {
+ const riversRaw = (fd.get("rivers") as string | null) ?? "";
+ const rivers = riversRaw
+ .split(/[,;\n]/)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+ const get = (k: string) => {
+ const v = (fd.get(k) as string | null) ?? "";
+ return v.trim() === "" ? null : v.trim();
+ };
+ return {
+ name: ((fd.get("name") as string | null) ?? "").trim(),
+ isSystemD: fd.get("isSystemD") === "on",
+ managedByUserId: get("managedByUserId"),
+ contactEmail: get("contactEmail"),
+ contactPhone: get("contactPhone"),
+ rivers,
+ description: get("description"),
+ commissionPct: fd.get("commissionPct"),
+ active: fd.get("active") === "on",
+ };
+}
+
+export async function createRentalProviderAction(fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = providerSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ const created = await prisma.rentalProvider.create({
+ data: {
+ ...parsed.data,
+ approved: true, // créé par admin → approuvé d'office
+ approvedAt: new Date(),
+ approvedBy: session?.user?.email ?? null,
+ },
+ });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "create",
+ target: created.id,
+ actorEmail: session?.user?.email ?? null,
+ details: { name: created.name, isSystemD: created.isSystemD },
+ });
+ revalidatePath("/admin/rental-providers");
+ redirect(`/admin/rental-providers/${created.id}`);
+}
+
+export async function updateRentalProviderAction(id: string, fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = providerSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ await prisma.rentalProvider.update({ where: { id }, data: parsed.data });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "update",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: { name: parsed.data.name },
+ });
+ revalidatePath("/admin/rental-providers");
+ revalidatePath(`/admin/rental-providers/${id}`);
+ return { ok: true as const };
+}
+
+export async function approveRentalProviderAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ await prisma.rentalProvider.update({
+ where: { id },
+ data: {
+ approved: true,
+ approvedAt: new Date(),
+ approvedBy: session?.user?.email ?? null,
+ },
+ });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "approve",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: {},
+ });
+ revalidatePath("/admin/rental-providers");
+ revalidatePath(`/admin/rental-providers/${id}`);
+ return { ok: true as const };
+}
+
+export async function toggleRentalProviderActiveAction(id: string, active: boolean) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ await prisma.rentalProvider.update({ where: { id }, data: { active } });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "active.update",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: { active },
+ });
+ revalidatePath("/admin/rental-providers");
+ revalidatePath(`/admin/rental-providers/${id}`);
+ return { ok: true as const };
+}
+
+export async function deleteRentalProviderAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const itemsCount = await prisma.rentalItem.count({ where: { providerId: id } });
+ if (itemsCount > 0) {
+ return { ok: false as const, error: `Impossible : ${itemsCount} item(s) attaché(s).` };
+ }
+ await prisma.rentalProvider.delete({ where: { id } });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "delete",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: {},
+ });
+ revalidatePath("/admin/rental-providers");
+ redirect("/admin/rental-providers");
+}
diff --git a/src/app/admin/rental-providers/new/page.tsx b/src/app/admin/rental-providers/new/page.tsx
new file mode 100644
index 0000000..c836fea
--- /dev/null
+++ b/src/app/admin/rental-providers/new/page.tsx
@@ -0,0 +1,21 @@
+import Link from "next/link";
+import { ProviderForm } from "../_components/ProviderForm";
+import { createRentalProviderAction } from "../actions";
+
+export const dynamic = "force-dynamic";
+
+export default function NewRentalProviderPage() {
+ return (
+
+
+
+ ← Tous les prestataires
+
+ Nouveau prestataire location
+
+
+
+ );
+}
diff --git a/src/app/admin/rental-providers/page.tsx b/src/app/admin/rental-providers/page.tsx
new file mode 100644
index 0000000..d2548e3
--- /dev/null
+++ b/src/app/admin/rental-providers/page.tsx
@@ -0,0 +1,149 @@
+import Link from "next/link";
+import { listRentalProvidersAdmin, listRentalProviderRivers } from "@/lib/admin/rental-providers";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ approved?: string;
+ active?: string;
+ river?: string;
+ }>;
+};
+
+export default async function RentalProvidersAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ approved: sp.approved === "yes" || sp.approved === "no" ? (sp.approved as "yes" | "no") : undefined,
+ active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
+ river: sp.river || undefined,
+ };
+ const [rows, rivers] = await Promise.all([listRentalProvidersAdmin(filters), listRentalProviderRivers()]);
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+ Tous statuts approbation
+ Approuvés
+ En attente
+
+
+ Actifs + inactifs
+ Actifs
+ Inactifs
+
+
+ Tous fleuves
+ {rivers.map((r) => (
+ {r}
+ ))}
+
+
+ Filtrer
+
+ {(filters.q || filters.approved || filters.active || filters.river) ? (
+
+ Réinit.
+
+ ) : null}
+
+
+
+
+
+
+ Nom
+ Fleuves
+ Items
+ Comm.
+ Approbation
+ État
+ MAJ
+
+
+
+ {rows.length === 0 ? (
+
+
+ Aucun prestataire ne correspond aux filtres.
+
+
+ ) : null}
+ {rows.map((p) => (
+
+
+
+ {p.name}
+
+ {p.isSystemD ? (
+
+ System D
+
+ ) : null}
+ {p.contactEmail ?? "—"}
+
+
+ {p.rivers.length === 0 ? — : p.rivers.join(", ")}
+
+ {p.itemsCount}
+ {Number(p.commissionPct).toFixed(1)}%
+
+ {p.approved ? (
+
+ Approuvé
+
+ ) : (
+
+ En attente
+
+ )}
+
+
+ {dateFmt.format(p.updatedAt)}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/rentals/page.tsx b/src/app/admin/rentals/page.tsx
new file mode 100644
index 0000000..34ddb12
--- /dev/null
+++ b/src/app/admin/rentals/page.tsx
@@ -0,0 +1,141 @@
+import Link from "next/link";
+import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
+import { listRentalBookingsAdmin, RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ status?: string;
+ paymentStatus?: string;
+ providerId?: string;
+ }>;
+};
+
+const RENTAL_STATUS_VALUES = new Set([
+ RentalBookingStatus.PENDING,
+ RentalBookingStatus.CONFIRMED,
+ RentalBookingStatus.HANDED_OVER,
+ RentalBookingStatus.RETURNED,
+ RentalBookingStatus.CANCELLED,
+]);
+
+const PAYMENT_VALUES = new Set([
+ PaymentStatus.PENDING,
+ PaymentStatus.AUTHORIZED,
+ PaymentStatus.SUCCEEDED,
+ PaymentStatus.FAILED,
+ PaymentStatus.REFUNDED,
+]);
+
+export default async function RentalsAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ status: RENTAL_STATUS_VALUES.has(sp.status ?? "") ? (sp.status as RentalBookingStatus) : undefined,
+ paymentStatus: PAYMENT_VALUES.has(sp.paymentStatus ?? "") ? (sp.paymentStatus as PaymentStatus) : undefined,
+ providerId: sp.providerId || undefined,
+ };
+ const rows = await listRentalBookingsAdmin(filters);
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+ Tous statuts
+ {Object.values(RentalBookingStatus).map((s) => (
+ {RENTAL_STATUS_LABEL[s]}
+ ))}
+
+
+ Tous paiements
+ {Object.values(PaymentStatus).map((s) => (
+ {s}
+ ))}
+
+
+ Filtrer
+
+ {(filters.q || filters.status || filters.paymentStatus) ? (
+ Réinit.
+ ) : null}
+
+
+
+
+
+
+ ID
+ Locataire
+ Prestataire
+ Items
+ Période
+ Montant
+ Statut
+ Paiement
+
+
+
+ {rows.length === 0 ? (
+
+
+ Aucune réservation matériel.
+
+
+ ) : null}
+ {rows.map((r) => (
+
+ {r.id.slice(0, 10)}…
+
+ {r.tenant.firstName} {r.tenant.lastName}
+ {r.tenant.email}
+
+
+
+ {r.provider.name}
+
+ {r.provider.isSystemD ? SD : null}
+
+
+ {r.lines.length} ligne{r.lines.length > 1 ? "s" : ""}
+
+ {r.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ")}
+
+
+
+ {dateFmt.format(r.startDate)} → {dateFmt.format(r.endDate)}
+
+
+ {Number(r.amount).toFixed(2)} {r.currency}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/reviews/[id]/_components/ReviewForm.tsx b/src/app/admin/reviews/[id]/_components/ReviewForm.tsx
new file mode 100644
index 0000000..a7b1662
--- /dev/null
+++ b/src/app/admin/reviews/[id]/_components/ReviewForm.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { deleteReviewAction, updateReviewAction } from "../../actions";
+import { inputCls, textareaCls } from "@/components/admin/FormField";
+
+type Props = {
+ id: string;
+ initial: {
+ rating: number;
+ comment: string | null;
+ hostResponse: string | null;
+ };
+};
+
+export function ReviewForm({ id, initial }: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+ const [confirmDelete, setConfirmDelete] = useState(false);
+
+ function onSubmit(formData: FormData) {
+ setError(null);
+ setSuccess(null);
+ startTransition(async () => {
+ const res = await updateReviewAction(id, formData);
+ if (res && res.ok === false) {
+ setError(res.error);
+ } else {
+ setSuccess("Avis enregistré.");
+ router.refresh();
+ }
+ });
+ }
+
+ function onDelete() {
+ setError(null);
+ startTransition(async () => {
+ await deleteReviewAction(id);
+ router.push("/admin/reviews");
+ });
+ }
+
+ return (
+
+
+
+ Note
+
+ {[1, 2, 3, 4, 5].map((n) => (
+ {n} ★
+ ))}
+
+
+
+
+
+ Commentaire du voyageur
+
+
+
+
+
+
+ Réponse de l'hôte
+
+
+
+
+ {error ? (
+ {error}
+ ) : null}
+ {success ? (
+ {success}
+ ) : null}
+
+
+ {confirmDelete ? (
+
+ Supprimer définitivement ?
+
+ Oui, supprimer
+
+ setConfirmDelete(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
setConfirmDelete(true)}
+ disabled={pending}
+ className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
+ >
+ Supprimer l'avis
+
+ )}
+
+ {pending ? "Enregistrement…" : "Enregistrer"}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/reviews/[id]/page.tsx b/src/app/admin/reviews/[id]/page.tsx
new file mode 100644
index 0000000..ca479d1
--- /dev/null
+++ b/src/app/admin/reviews/[id]/page.tsx
@@ -0,0 +1,52 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { getReviewForAdmin } from "@/lib/admin/reviews";
+import { ReviewForm } from "./_components/ReviewForm";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ id: string }> };
+
+export default async function ReviewDetailPage({ params }: PageProps) {
+ const { id } = await params;
+ const review = await getReviewForAdmin(id);
+ if (!review) notFound();
+
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
+
+ return (
+
+ );
+}
diff --git a/src/app/admin/reviews/actions.ts b/src/app/admin/reviews/actions.ts
new file mode 100644
index 0000000..6cb5eec
--- /dev/null
+++ b/src/app/admin/reviews/actions.ts
@@ -0,0 +1,60 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { z } from "zod";
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { requireRole } from "@/lib/authorization";
+import { prisma } from "@/lib/prisma";
+import { recordAudit } from "@/lib/admin/audit";
+
+async function audit(event: string, target: string, actor: string | null, details: Record) {
+ await recordAudit({ scope: "admin.reviews", event, target, actorEmail: actor, details });
+}
+
+const updateSchema = z.object({
+ rating: z.coerce.number().int().min(1).max(5),
+ comment: z.string().trim().max(5000).optional().nullable(),
+ hostResponse: z.string().trim().max(5000).optional().nullable(),
+});
+
+export async function updateReviewAction(id: string, fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const obj = Object.fromEntries(fd.entries());
+ const parsed = updateSchema.safeParse({
+ rating: obj.rating,
+ comment: obj.comment === "" ? null : obj.comment,
+ hostResponse: obj.hostResponse === "" ? null : obj.hostResponse,
+ });
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ const current = await prisma.review.findUnique({ where: { id }, select: { hostResponse: true, hostRespondedAt: true } });
+ const hostRespondedAt =
+ parsed.data.hostResponse && parsed.data.hostResponse !== current?.hostResponse
+ ? new Date()
+ : current?.hostRespondedAt ?? null;
+ await prisma.review.update({
+ where: { id },
+ data: {
+ rating: parsed.data.rating,
+ comment: parsed.data.comment ?? null,
+ hostResponse: parsed.data.hostResponse ?? null,
+ hostRespondedAt: parsed.data.hostResponse ? hostRespondedAt : null,
+ },
+ });
+ await audit("review.update", id, session?.user?.email ?? null, { rating: parsed.data.rating });
+ revalidatePath("/admin/reviews");
+ revalidatePath(`/admin/reviews/${id}`);
+ return { ok: true as const };
+}
+
+export async function deleteReviewAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ await prisma.review.delete({ where: { id } });
+ await audit("review.delete", id, session?.user?.email ?? null, {});
+ revalidatePath("/admin/reviews");
+ return { ok: true as const };
+}
diff --git a/src/app/admin/reviews/page.tsx b/src/app/admin/reviews/page.tsx
new file mode 100644
index 0000000..7fb435a
--- /dev/null
+++ b/src/app/admin/reviews/page.tsx
@@ -0,0 +1,134 @@
+import Link from "next/link";
+import { listReviewsAdmin } from "@/lib/admin/reviews";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ rating?: string;
+ withResponse?: string;
+ }>;
+};
+
+function Stars({ rating }: { rating: number }) {
+ return (
+
+ {"★".repeat(rating)}
+ {"★".repeat(5 - rating)}
+
+ );
+}
+
+export default async function ReviewsAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const rating = sp.rating && /^[1-5]$/.test(sp.rating) ? Number(sp.rating) : undefined;
+ const withResponse = sp.withResponse === "yes" || sp.withResponse === "no" ? (sp.withResponse as "yes" | "no") : undefined;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ rating,
+ withResponse,
+ };
+ const reviews = await listReviewsAdmin(filters);
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+ Toutes notes
+ {[5, 4, 3, 2, 1].map((r) => (
+ {r} étoile{r > 1 ? "s" : ""}
+ ))}
+
+
+ Avec ou sans réponse
+ Avec réponse hôte
+ Sans réponse hôte
+
+
+ Filtrer
+
+ {(filters.q || filters.rating || filters.withResponse) ? (
+
+ Réinit.
+
+ ) : null}
+
+
+
+ {reviews.length === 0 ? (
+
+ Aucun avis ne correspond aux filtres.
+
+ ) : null}
+ {reviews.map((r) => (
+
+
+
+
+
+ {r.author.firstName} {r.author.lastName}
+
+ {r.author.email}
+
+
+
+ {r.carbet.title}
+
+ ·
+ résa {r.booking.id.slice(0, 8)}…
+
+ · {dateFmt.format(r.createdAt)}
+
+
+ {r.comment ? (
+ {r.comment}
+ ) : (
+ Pas de commentaire.
+ )}
+ {r.hostResponse ? (
+
+
Réponse hôte
+
{r.hostResponse}
+
+ ) : null}
+
+
+ Modérer →
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/admin/settings/_components/SettingsForms.tsx b/src/app/admin/settings/_components/SettingsForms.tsx
new file mode 100644
index 0000000..ca13106
--- /dev/null
+++ b/src/app/admin/settings/_components/SettingsForms.tsx
@@ -0,0 +1,171 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { FormField, inputCls, selectCls } from "@/components/admin/FormField";
+import {
+ savePlatformSettingsAction,
+ saveStripeSettingsAction,
+ saveThemeSettingsAction,
+} from "../actions";
+
+type Action = (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
+
+function FormWrapper({
+ action,
+ children,
+ submitLabel = "Enregistrer",
+}: {
+ action: Action;
+ children: React.ReactNode;
+ submitLabel?: string;
+}) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ function onSubmit(fd: FormData) {
+ setError(null);
+ setSuccess(null);
+ startTransition(async () => {
+ const res = await action(fd);
+ if (res && res.ok === false) setError(res.error);
+ else if (res && res.ok === true) setSuccess("Enregistré.");
+ });
+ }
+
+ return (
+
+
+ {children}
+ {error ? (
+ {error}
+ ) : null}
+ {success ? (
+ {success}
+ ) : null}
+
+
+ {pending ? "Enregistrement…" : submitLabel}
+
+
+
+
+ );
+}
+
+export function PlatformForm({
+ initial,
+}: {
+ initial: { name: string; defaultLang: string; activeLangs: string[]; currency: string; commissionPercent: number };
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function ThemeForm({ initial }: { initial: { active: string } }) {
+ return (
+
+
+
+ default — sobre (admin-like)
+ theme-aquarelle — carnet naturaliste XIXᵉ
+ theme-guyane — palette tropicale
+
+
+
+ );
+}
+
+export function StripeForm({
+ initial,
+}: {
+ initial: { currency: string; commissionMode: string; perBookingFeePercent: number };
+}) {
+ return (
+
+
+
+
+
+
+
+ Aucune monétisation (preview)
+ Abonnement loueur (revenu plateforme)
+ Commission par réservation
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/admin/settings/actions.ts b/src/app/admin/settings/actions.ts
new file mode 100644
index 0000000..7da4291
--- /dev/null
+++ b/src/app/admin/settings/actions.ts
@@ -0,0 +1,100 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { z } from "zod";
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { requireRole } from "@/lib/authorization";
+import { recordAudit } from "@/lib/admin/audit";
+import { setSetting } from "@/lib/admin/settings";
+import { togglePlugin } from "@/lib/plugins/server";
+
+const platformSchema = z.object({
+ name: z.string().trim().min(2).max(80),
+ defaultLang: z.string().trim().length(2),
+ activeLangs: z.array(z.string().trim().length(2)).min(1).max(10),
+ currency: z.string().trim().length(3),
+ commissionPercent: z.coerce.number().min(0).max(100),
+});
+
+const themeSchema = z.object({
+ active: z.enum(["default", "theme-aquarelle", "theme-guyane"]),
+});
+
+const stripeSchema = z.object({
+ currency: z.string().trim().length(3),
+ commissionMode: z.enum(["none", "owner-subscription", "per-booking"]),
+ perBookingFeePercent: z.coerce.number().min(0).max(100),
+});
+
+async function actor() {
+ const session = await auth();
+ return session?.user?.email ?? null;
+}
+
+export async function savePlatformSettingsAction(fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const langsRaw = (fd.get("activeLangs") as string | null) ?? "";
+ const activeLangs = langsRaw
+ .split(/[,;\s]+/)
+ .map((s) => s.trim().toLowerCase())
+ .filter((s) => s.length === 2);
+ const parsed = platformSchema.safeParse({
+ name: fd.get("name"),
+ defaultLang: ((fd.get("defaultLang") as string | null) ?? "").toLowerCase(),
+ activeLangs,
+ currency: ((fd.get("currency") as string | null) ?? "").toUpperCase(),
+ commissionPercent: fd.get("commissionPercent"),
+ });
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ if (!parsed.data.activeLangs.includes(parsed.data.defaultLang)) {
+ return { ok: false as const, error: "La langue par défaut doit faire partie des langues actives." };
+ }
+ const who = await actor();
+ await setSetting("platform", parsed.data, who);
+ await recordAudit({ scope: "admin.settings", event: "platform.update", actorEmail: who, details: parsed.data });
+ revalidatePath("/admin/settings");
+ return { ok: true as const };
+}
+
+export async function saveThemeSettingsAction(fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = themeSchema.safeParse({ active: fd.get("active") });
+ if (!parsed.success) {
+ return { ok: false as const, error: "Thème invalide." };
+ }
+ const who = await actor();
+ await setSetting("theme", parsed.data, who);
+
+ // Le rendu du site public est piloté par l'état des plugins thème.
+ // On synchronise : un seul plugin actif (ou aucun pour "default").
+ const wantAquarelle = parsed.data.active === "theme-aquarelle";
+ const wantGuyane = parsed.data.active === "theme-guyane";
+ await togglePlugin("theme-aquarelle", wantAquarelle);
+ await togglePlugin("theme-guyane", wantGuyane);
+
+ await recordAudit({ scope: "admin.settings", event: "theme.update", actorEmail: who, details: parsed.data });
+ revalidatePath("/admin/settings");
+ revalidatePath("/admin/plugins");
+ revalidatePath("/");
+ return { ok: true as const };
+}
+
+export async function saveStripeSettingsAction(fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = stripeSchema.safeParse({
+ currency: ((fd.get("currency") as string | null) ?? "").toUpperCase(),
+ commissionMode: fd.get("commissionMode"),
+ perBookingFeePercent: fd.get("perBookingFeePercent"),
+ });
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const who = await actor();
+ await setSetting("stripe", parsed.data, who);
+ await recordAudit({ scope: "admin.settings", event: "stripe.update", actorEmail: who, details: parsed.data });
+ revalidatePath("/admin/settings");
+ return { ok: true as const };
+}
diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx
new file mode 100644
index 0000000..5c2ad14
--- /dev/null
+++ b/src/app/admin/settings/page.tsx
@@ -0,0 +1,100 @@
+import { getAllSettings, readEnvSnapshot } from "@/lib/admin/settings";
+import { PlatformForm, StripeForm, ThemeForm } from "./_components/SettingsForms";
+
+export const dynamic = "force-dynamic";
+
+function Badge({ ok, labelOk = "Configuré", labelKo = "Non configuré" }: { ok: boolean; labelOk?: string; labelKo?: string }) {
+ return (
+
+ {ok ? labelOk : labelKo}
+
+ );
+}
+
+function Row({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+
{label}
+ {value}
+
+ );
+}
+
+export default async function SettingsAdminPage() {
+ const [settings, env] = await Promise.all([getAllSettings(), Promise.resolve(readEnvSnapshot())]);
+
+ return (
+
+
+
+
+
+
+
+
+ Monétisation Stripe
+
+
+
+ Variables d'environnement Stripe (lecture seule)
+
+
+
} />
+
} />
+
} />
+
} />
+
+
+ Les clés et secrets restent dans les variables d'environnement du container. Modifications via le déploiement.
+
+
+
+
+
+ Stockage médias (S3 / MinIO)
+
+ {env.s3.endpoint ?? "—"}} />
+ {env.s3.region ?? "—"}} />
+ {env.s3.bucket ?? "—"}} />
+
+ {env.s3.publicUrl}
+
+ ) : "—"
+ }
+ />
+
} />
+
} />
+
+
+
+
+ Déploiement
+
+ {env.app.publicUrl ?? "—"}} />
+ {env.app.authUrl ?? "—"}} />
+ {env.app.deploymentVersion ?? "—"}} />
+
+
+
+ );
+}
diff --git a/src/app/admin/users/[id]/_components/UserActions.tsx b/src/app/admin/users/[id]/_components/UserActions.tsx
new file mode 100644
index 0000000..3ff715b
--- /dev/null
+++ b/src/app/admin/users/[id]/_components/UserActions.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { UserRole } from "@/generated/prisma/enums";
+import { toggleUserActiveAction, updateUserRoleAction } from "../../actions";
+
+const ROLE_OPTIONS: { value: string; label: string }[] = [
+ { value: UserRole.OWNER, label: "Propriétaire" },
+ { value: UserRole.CE_MANAGER, label: "CE — Manager" },
+ { value: UserRole.CE_MEMBER, label: "CE — Membre" },
+ { value: UserRole.TOURIST, label: "Touriste" },
+ { value: UserRole.ADMIN, label: "Admin" },
+];
+
+export function UserActions({
+ id,
+ role,
+ isActive,
+}: {
+ id: string;
+ role: string;
+ isActive: boolean;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [selectedRole, setSelectedRole] = useState(role);
+ const [confirmDeactivate, setConfirmDeactivate] = useState(false);
+
+ function changeRole(next: string) {
+ setError(null);
+ setSelectedRole(next);
+ startTransition(async () => {
+ const res = await updateUserRoleAction(id, next);
+ if (res && res.ok === false) {
+ setError(res.error);
+ setSelectedRole(role);
+ }
+ router.refresh();
+ });
+ }
+
+ function toggleActive(next: boolean) {
+ setError(null);
+ startTransition(async () => {
+ const res = await toggleUserActiveAction(id, next);
+ if (res && res.ok === false) setError(res.error);
+ setConfirmDeactivate(false);
+ router.refresh();
+ });
+ }
+
+ return (
+
+
+ Rôle
+ changeRole(e.target.value)}
+ className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none disabled:opacity-50"
+ >
+ {ROLE_OPTIONS.map((o) => (
+ {o.label}
+ ))}
+
+
+
+
+
État du compte
+ {isActive ? (
+ confirmDeactivate ? (
+
+ Désactiver ce compte ?
+ toggleActive(false)}
+ disabled={pending}
+ className="rounded bg-amber-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-amber-800 disabled:opacity-50"
+ >
+ Oui, désactiver
+
+ setConfirmDeactivate(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
setConfirmDeactivate(true)}
+ disabled={pending}
+ className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
+ >
+ Désactiver
+
+ )
+ ) : (
+
toggleActive(true)}
+ disabled={pending}
+ className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
+ >
+ Réactiver
+
+ )}
+
+
+ {error ? (
+
{error}
+ ) : null}
+
+ );
+}
diff --git a/src/app/admin/users/[id]/page.tsx b/src/app/admin/users/[id]/page.tsx
new file mode 100644
index 0000000..be1b88f
--- /dev/null
+++ b/src/app/admin/users/[id]/page.tsx
@@ -0,0 +1,133 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { getUserForAdmin } from "@/lib/admin/users";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+import { UserActions } from "./_components/UserActions";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ id: string }> };
+
+const ROLE_LABEL: Record = {
+ OWNER: "Propriétaire",
+ CE_MANAGER: "CE — Manager",
+ CE_MEMBER: "CE — Membre",
+ TOURIST: "Touriste",
+ ADMIN: "Admin",
+};
+
+export default async function UserDetailPage({ params }: PageProps) {
+ const { id } = await params;
+ const user = await getUserForAdmin(id);
+ if (!user) notFound();
+
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
+ const dateShortFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+
+ Identité
+
+
+ {user.phone ?
: null}
+
+
+ {user.organization ? (
+
+ {user.organization.name}
+
+ }
+ />
+ ) : null}
+
+
+
+
+ Statistiques
+
+
+
+
+
+
+
+
+
+ {user.carbets.length > 0 ? (
+
+ Carbets du propriétaire
+
+ {user.carbets.map((c) => (
+
+
+ {c.title} /{c.slug}
+
+
+
+ {dateShortFmt.format(c.updatedAt)}
+
+
+ ))}
+
+
+ ) : null}
+
+ {user.bookings.length > 0 ? (
+
+ Dernières réservations
+
+ {user.bookings.map((b) => (
+
+
+ {b.carbet.title}
+
+ {dateShortFmt.format(b.startDate)} → {dateShortFmt.format(b.endDate)}
+
+
+
+
+ {Number(b.amount).toFixed(2)} {b.currency}
+
+
+
+
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+function Row({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+
{label}
+ {value}
+
+ );
+}
diff --git a/src/app/admin/users/actions.ts b/src/app/admin/users/actions.ts
new file mode 100644
index 0000000..f44718b
--- /dev/null
+++ b/src/app/admin/users/actions.ts
@@ -0,0 +1,59 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { requireRole } from "@/lib/authorization";
+import { prisma } from "@/lib/prisma";
+import { recordAudit } from "@/lib/admin/audit";
+
+const ROLE_VALUES = new Set([
+ UserRole.OWNER,
+ UserRole.CE_MANAGER,
+ UserRole.CE_MEMBER,
+ UserRole.TOURIST,
+ UserRole.ADMIN,
+]);
+
+async function audit(event: string, target: string, actor: string | null, details: Record) {
+ await recordAudit({ scope: "admin.users", event, target, actorEmail: actor, details });
+}
+
+export async function updateUserRoleAction(id: string, role: string) {
+ await requireRole([UserRole.ADMIN]);
+ if (!ROLE_VALUES.has(role)) {
+ return { ok: false as const, error: "Rôle invalide" };
+ }
+ const session = await auth();
+ if (role !== UserRole.ADMIN) {
+ const adminCount = await prisma.user.count({ where: { role: UserRole.ADMIN, isActive: true } });
+ const current = await prisma.user.findUnique({ where: { id }, select: { role: true } });
+ if (current?.role === UserRole.ADMIN && adminCount <= 1) {
+ return { ok: false as const, error: "Impossible de retirer le dernier admin actif." };
+ }
+ }
+ await prisma.user.update({ where: { id }, data: { role: role as UserRole } });
+ await audit("user.role.update", id, session?.user?.email ?? null, { role });
+ revalidatePath("/admin/users");
+ revalidatePath(`/admin/users/${id}`);
+ return { ok: true as const };
+}
+
+export async function toggleUserActiveAction(id: string, active: boolean) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ if (!active) {
+ const target = await prisma.user.findUnique({ where: { id }, select: { role: true, isActive: true } });
+ if (target?.role === UserRole.ADMIN) {
+ const adminCount = await prisma.user.count({ where: { role: UserRole.ADMIN, isActive: true } });
+ if (adminCount <= 1) {
+ return { ok: false as const, error: "Impossible de désactiver le dernier admin." };
+ }
+ }
+ }
+ await prisma.user.update({ where: { id }, data: { isActive: active } });
+ await audit("user.active.update", id, session?.user?.email ?? null, { active });
+ revalidatePath("/admin/users");
+ revalidatePath(`/admin/users/${id}`);
+ return { ok: true as const };
+}
diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx
new file mode 100644
index 0000000..eeacb12
--- /dev/null
+++ b/src/app/admin/users/page.tsx
@@ -0,0 +1,136 @@
+import Link from "next/link";
+import { UserRole } from "@/generated/prisma/enums";
+import { listUsersAdmin } from "@/lib/admin/users";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ role?: string;
+ active?: string;
+ }>;
+};
+
+const ROLE_VALUES = new Set([
+ UserRole.OWNER,
+ UserRole.CE_MANAGER,
+ UserRole.CE_MEMBER,
+ UserRole.TOURIST,
+ UserRole.ADMIN,
+]);
+
+const ROLE_LABEL: Record = {
+ OWNER: "Propriétaire",
+ CE_MANAGER: "CE — Manager",
+ CE_MEMBER: "CE — Membre",
+ TOURIST: "Touriste",
+ ADMIN: "Admin",
+};
+
+export default async function UsersAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ role: ROLE_VALUES.has(sp.role ?? "") ? (sp.role as UserRole) : undefined,
+ active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
+ };
+ const users = await listUsersAdmin(filters);
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+ Tous rôles
+ {Object.entries(ROLE_LABEL).map(([v, l]) => (
+ {l}
+ ))}
+
+
+ Actifs + inactifs
+ Actifs
+ Inactifs
+
+
+ Filtrer
+
+ {(filters.q || filters.role || filters.active) ? (
+
+ Réinit.
+
+ ) : null}
+
+
+
+
+
+
+ Nom
+ Email
+ Rôle
+ Carbets
+ Résas
+ Avis
+ État
+ Inscrit
+
+
+
+ {users.length === 0 ? (
+
+
+ Aucun utilisateur ne correspond aux filtres.
+
+
+ ) : null}
+ {users.map((u) => (
+
+
+
+ {u.firstName} {u.lastName}
+
+
+ {u.email}
+ {ROLE_LABEL[u.role] ?? u.role}
+ {u.carbetsCount}
+ {u.bookingsCount}
+ {u.reviewsCount}
+
+
+ {dateFmt.format(u.createdAt)}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/api/admin/carbets/[id]/media/route.ts b/src/app/api/admin/carbets/[id]/media/route.ts
new file mode 100644
index 0000000..56e9971
--- /dev/null
+++ b/src/app/api/admin/carbets/[id]/media/route.ts
@@ -0,0 +1,17 @@
+import { NextResponse } from "next/server";
+import { requireRole } from "@/lib/authorization";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
+ await requireRole([UserRole.ADMIN]);
+ const { id } = await ctx.params;
+ const media = await prisma.media.findMany({
+ where: { carbetId: id },
+ orderBy: { sortOrder: "asc" },
+ select: { id: true, type: true, s3Key: true, s3Url: true, sortOrder: true },
+ });
+ return NextResponse.json(media);
+}
diff --git a/src/app/api/admin/content-pages/[slug]/route.ts b/src/app/api/admin/content-pages/[slug]/route.ts
new file mode 100644
index 0000000..df9ee1d
--- /dev/null
+++ b/src/app/api/admin/content-pages/[slug]/route.ts
@@ -0,0 +1,50 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { auth } from "@/auth";
+import { requireRole } from "@/lib/authorization";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+const patchSchema = z.object({
+ title: z.string().min(1).max(200).optional(),
+ body: z.string().max(100_000).optional(),
+ published: z.boolean().optional(),
+});
+
+function normalizeLang(v: string | null): string {
+ if (!v) return "fr";
+ const l = v.toLowerCase().trim();
+ return /^[a-z]{2}$/.test(l) ? l : "fr";
+}
+
+export async function PATCH(req: Request, ctx: { params: Promise<{ slug: string }> }) {
+ await requireRole([UserRole.ADMIN]);
+ const { slug } = await ctx.params;
+ const url = new URL(req.url);
+ const lang = normalizeLang(url.searchParams.get("lang"));
+ const session = await auth();
+ const parsed = patchSchema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+ const existing = await prisma.contentPage.findUnique({
+ where: { slug_lang: { slug, lang } },
+ });
+ if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ const updated = await prisma.contentPage.update({
+ where: { slug_lang: { slug, lang } },
+ data: {
+ ...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
+ ...(parsed.data.body !== undefined ? { body: parsed.data.body } : {}),
+ ...(parsed.data.published !== undefined ? { published: parsed.data.published } : {}),
+ lastEditedBy: session?.user?.email ?? session?.user?.id ?? null,
+ },
+ });
+ return NextResponse.json({
+ slug: updated.slug,
+ lang: updated.lang,
+ title: updated.title,
+ published: updated.published,
+ updatedAt: updated.updatedAt,
+ });
+}
diff --git a/src/app/api/admin/search/route.ts b/src/app/api/admin/search/route.ts
new file mode 100644
index 0000000..55f6523
--- /dev/null
+++ b/src/app/api/admin/search/route.ts
@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server";
+import { requireRole } from "@/lib/authorization";
+import { UserRole } from "@/generated/prisma/enums";
+import { adminSearch } from "@/lib/admin/search";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(req: Request) {
+ await requireRole([UserRole.ADMIN]);
+ const url = new URL(req.url);
+ const q = url.searchParams.get("q") ?? "";
+ const hits = await adminSearch(q);
+ return NextResponse.json({ hits });
+}
diff --git a/src/app/api/bookings/route.ts b/src/app/api/bookings/route.ts
index 11f94ad..e315ed3 100644
--- a/src/app/api/bookings/route.ts
+++ b/src/app/api/bookings/route.ts
@@ -16,6 +16,8 @@ import {
parseIsoDate,
} from "@/lib/booking";
import { prisma } from "@/lib/prisma";
+import { sendBookingRequestToOwner, sendBookingRequestToTenant } from "@/lib/email";
+import { rateLimitRequest } from "@/lib/rate-limit";
export const runtime = "nodejs";
@@ -27,6 +29,14 @@ type CreateBookingBody = {
};
export async function POST(request: Request) {
+ const rl = rateLimitRequest(request, "bookings", 60 * 60 * 1000, 10);
+ if (!rl.ok) {
+ return NextResponse.json(
+ { error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
+ { status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
+ );
+ }
+
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
@@ -78,6 +88,9 @@ export async function POST(request: Request) {
ownerId: true,
capacity: true,
status: true,
+ nightlyPrice: true,
+ title: true,
+ owner: { select: { email: true, firstName: true } },
},
});
@@ -183,6 +196,12 @@ export async function POST(request: Request) {
}
}
+ const nights = Math.max(
+ 1,
+ Math.round((endDate.getTime() - startDate.getTime()) / 86400000),
+ );
+ const computedAmount = Number(carbet.nightlyPrice) * nights;
+
const booking = await prisma.booking.create({
data: {
carbetId: carbet.id,
@@ -191,7 +210,7 @@ export async function POST(request: Request) {
endDate,
guestCount,
status: BookingStatus.PENDING,
- amount: 0,
+ amount: computedAmount.toFixed(2),
currency: "EUR",
},
select: {
@@ -207,5 +226,34 @@ export async function POST(request: Request) {
},
});
+ // Best-effort emails (n'échouent pas la réservation si Resend down).
+ const tenant = await prisma.user.findUnique({
+ where: { id: session.user.id },
+ select: { email: true, firstName: true, lastName: true },
+ });
+ if (tenant) {
+ sendBookingRequestToTenant(
+ tenant.email,
+ tenant.firstName,
+ booking.id,
+ carbet.title,
+ booking.startDate,
+ booking.endDate,
+ computedAmount.toFixed(2),
+ "EUR",
+ ).catch(() => {});
+ }
+ if (carbet.owner?.email && tenant) {
+ sendBookingRequestToOwner(
+ carbet.owner.email,
+ carbet.owner.firstName,
+ booking.id,
+ carbet.title,
+ `${tenant.firstName} ${tenant.lastName}`.trim(),
+ booking.startDate,
+ booking.endDate,
+ ).catch(() => {});
+ }
+
return NextResponse.json({ booking }, { status: 201 });
}
diff --git a/src/app/api/carbets/[carbetId]/media/route.ts b/src/app/api/carbets/[carbetId]/media/route.ts
index 9661048..1519158 100644
--- a/src/app/api/carbets/[carbetId]/media/route.ts
+++ b/src/app/api/carbets/[carbetId]/media/route.ts
@@ -26,7 +26,11 @@ export async function POST(
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
- if (session.user.role !== UserRole.OWNER && session.user.role !== UserRole.ADMIN) {
+ if (
+ session.user.role !== UserRole.OWNER &&
+ session.user.role !== UserRole.ADMIN &&
+ session.user.role !== UserRole.CE_MANAGER
+ ) {
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
}
@@ -34,12 +38,15 @@ export async function POST(
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
- select: { ownerId: true },
+ select: {
+ ownerId: true,
+ organizations: { select: { organizationId: true } },
+ },
});
if (!carbet) {
return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
}
- if (!canManageCarbet(session, carbet.ownerId)) {
+ if (!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))) {
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
}
diff --git a/src/app/api/cron/cleanup/route.ts b/src/app/api/cron/cleanup/route.ts
new file mode 100644
index 0000000..103315e
--- /dev/null
+++ b/src/app/api/cron/cleanup/route.ts
@@ -0,0 +1,113 @@
+import { NextResponse } from "next/server";
+
+import {
+ BookingStatus,
+ PaymentStatus,
+ RentalBookingStatus,
+} from "@/generated/prisma/enums";
+import { recordAudit } from "@/lib/admin/audit";
+import { isAuthorizedCronRequest } from "@/lib/cron-auth";
+import { prisma } from "@/lib/prisma";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const INVITE_EXPIRY_GRACE_DAYS = 30;
+const ABANDONED_PENDING_DAYS = 7;
+
+/**
+ * GET /api/cron/cleanup
+ *
+ * Purge :
+ * - OrgInviteToken expirés depuis plus de 30j (rétention pour audit court).
+ * - Booking carbet PENDING dont createdAt > 7j et paiement non SUCCEEDED :
+ * status passé à CANCELLED (libère le créneau via cascade des
+ * Availabilities seulement si onDelete CASCADE — ici on flip juste
+ * status pour conserver le log).
+ * - RentalBooking PENDING idem + delete RentalItemAvailability associée
+ * (libère le stock).
+ *
+ * Auth : Bearer CRON_TOKEN.
+ */
+export async function GET(req: Request) {
+ if (!isAuthorizedCronRequest(req)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const now = new Date();
+ const inviteCutoff = new Date(now.getTime() - INVITE_EXPIRY_GRACE_DAYS * 86_400_000);
+ const abandonedCutoff = new Date(now.getTime() - ABANDONED_PENDING_DAYS * 86_400_000);
+
+ // 1. Invites expirés (expiresAt < cutoff)
+ const { count: invitesDeleted } = await prisma.orgInviteToken.deleteMany({
+ where: { expiresAt: { lt: inviteCutoff } },
+ });
+
+ // 2. Bookings carbet PENDING abandonnés
+ const abandonedBookings = await prisma.booking.findMany({
+ where: {
+ status: BookingStatus.PENDING,
+ paymentStatus: { not: PaymentStatus.SUCCEEDED },
+ createdAt: { lt: abandonedCutoff },
+ },
+ select: { id: true, carbetId: true },
+ });
+ let bookingsCancelled = 0;
+ if (abandonedBookings.length > 0) {
+ const { count } = await prisma.booking.updateMany({
+ where: { id: { in: abandonedBookings.map((b) => b.id) } },
+ data: { status: BookingStatus.CANCELLED, paymentStatus: PaymentStatus.FAILED },
+ });
+ bookingsCancelled = count;
+ }
+
+ // 3. RentalBookings PENDING abandonnés + delete availability associée
+ const abandonedRentals = await prisma.rentalBooking.findMany({
+ where: {
+ status: RentalBookingStatus.PENDING,
+ paymentStatus: { not: PaymentStatus.SUCCEEDED },
+ createdAt: { lt: abandonedCutoff },
+ },
+ select: { id: true },
+ });
+ let rentalsCancelled = 0;
+ let availabilityFreed = 0;
+ if (abandonedRentals.length > 0) {
+ const ids = abandonedRentals.map((r) => r.id);
+ const [rentalRes, availRes] = await prisma.$transaction([
+ prisma.rentalBooking.updateMany({
+ where: { id: { in: ids } },
+ data: {
+ status: RentalBookingStatus.CANCELLED,
+ paymentStatus: PaymentStatus.FAILED,
+ },
+ }),
+ prisma.rentalItemAvailability.deleteMany({
+ where: { rentalBookingId: { in: ids } },
+ }),
+ ]);
+ rentalsCancelled = rentalRes.count;
+ availabilityFreed = availRes.count;
+ }
+
+ await recordAudit({
+ scope: "cron",
+ event: "cron.cleanup.run",
+ target: null,
+ actorEmail: "system:cron",
+ details: {
+ invitesDeleted,
+ bookingsCancelled,
+ rentalsCancelled,
+ availabilityFreed,
+ },
+ });
+
+ return NextResponse.json({
+ ok: true,
+ invitesDeleted,
+ bookingsCancelled,
+ rentalsCancelled,
+ availabilityFreed,
+ });
+}
diff --git a/src/app/api/cron/reminders/route.ts b/src/app/api/cron/reminders/route.ts
new file mode 100644
index 0000000..96e0573
--- /dev/null
+++ b/src/app/api/cron/reminders/route.ts
@@ -0,0 +1,128 @@
+import { NextResponse } from "next/server";
+
+import {
+ BookingStatus,
+ RentalBookingStatus,
+} from "@/generated/prisma/enums";
+import { recordAudit } from "@/lib/admin/audit";
+import { isAuthorizedCronRequest } from "@/lib/cron-auth";
+import { sendBookingReminder, sendRentalReminder } from "@/lib/email";
+import { prisma } from "@/lib/prisma";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/**
+ * GET /api/cron/reminders
+ *
+ * Envoie des rappels J-1 (24h avant le début) pour :
+ * - Booking CONFIRMED dont startDate ∈ [now+22h, now+26h]
+ * - RentalBooking CONFIRMED idem
+ *
+ * Idempotent à l'échelle d'une journée : le filtre temporel narrow limite
+ * naturellement le risque de double-envoi (en pratique le cron tourne 1× par
+ * jour à heure fixe). Pour une garantie at-most-once stricte il faudrait
+ * stocker un flag `reminderSentAt` sur Booking/RentalBooking — défensif
+ * mais pas critique pour v1.
+ *
+ * Auth : Bearer CRON_TOKEN.
+ */
+export async function GET(req: Request) {
+ if (!isAuthorizedCronRequest(req)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const now = new Date();
+ const from = new Date(now.getTime() + 22 * 60 * 60 * 1000);
+ const to = new Date(now.getTime() + 26 * 60 * 60 * 1000);
+
+ const [carbetBookings, rentalBookings] = await Promise.all([
+ prisma.booking.findMany({
+ where: {
+ status: BookingStatus.CONFIRMED,
+ startDate: { gte: from, lt: to },
+ },
+ include: {
+ tenant: { select: { email: true, firstName: true } },
+ carbet: { select: { title: true, slug: true } },
+ },
+ }),
+ prisma.rentalBooking.findMany({
+ where: {
+ status: RentalBookingStatus.CONFIRMED,
+ startDate: { gte: from, lt: to },
+ },
+ include: {
+ tenant: { select: { email: true, firstName: true } },
+ provider: { select: { name: true, contactEmail: true, contactPhone: true } },
+ },
+ }),
+ ]);
+
+ let bookingSent = 0;
+ let bookingErrors = 0;
+ for (const b of carbetBookings) {
+ if (!b.tenant.email) continue;
+ try {
+ await sendBookingReminder(
+ b.tenant.email,
+ b.tenant.firstName,
+ b.id,
+ b.carbet.title,
+ b.startDate,
+ b.carbet.slug,
+ );
+ bookingSent++;
+ } catch (e) {
+ bookingErrors++;
+ console.error(
+ "[cron.reminders] booking email failed:",
+ b.id,
+ e instanceof Error ? e.message : e,
+ );
+ }
+ }
+
+ let rentalSent = 0;
+ let rentalErrors = 0;
+ for (const r of rentalBookings) {
+ if (!r.tenant.email) continue;
+ try {
+ await sendRentalReminder(
+ r.tenant.email,
+ r.tenant.firstName,
+ r.id,
+ r.provider.name,
+ r.startDate,
+ { email: r.provider.contactEmail, phone: r.provider.contactPhone },
+ );
+ rentalSent++;
+ } catch (e) {
+ rentalErrors++;
+ console.error(
+ "[cron.reminders] rental email failed:",
+ r.id,
+ e instanceof Error ? e.message : e,
+ );
+ }
+ }
+
+ await recordAudit({
+ scope: "cron",
+ event: "cron.reminders.run",
+ target: null,
+ actorEmail: "system:cron",
+ details: {
+ window: { from: from.toISOString(), to: to.toISOString() },
+ booking: { candidates: carbetBookings.length, sent: bookingSent, errors: bookingErrors },
+ rental: { candidates: rentalBookings.length, sent: rentalSent, errors: rentalErrors },
+ },
+ });
+
+ return NextResponse.json({
+ ok: true,
+ window: { from: from.toISOString(), to: to.toISOString() },
+ booking: { candidates: carbetBookings.length, sent: bookingSent, errors: bookingErrors },
+ rental: { candidates: rentalBookings.length, sent: rentalSent, errors: rentalErrors },
+ });
+}
diff --git a/src/app/api/cron/run/[task]/route.ts b/src/app/api/cron/run/[task]/route.ts
new file mode 100644
index 0000000..ff2beba
--- /dev/null
+++ b/src/app/api/cron/run/[task]/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from "next/server";
+
+import { SCHEDULED_TASKS, type ScheduledTaskName } from "@/lib/scheduled";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+function authorized(req: Request): boolean {
+ const secret = (process.env.CRON_TOKEN ?? "").trim();
+ if (!secret) return false;
+ const header = req.headers.get("authorization") ?? "";
+ const token = header.startsWith("Bearer ") ? header.slice(7) : "";
+ return token === secret;
+}
+
+export async function POST(req: Request, ctx: { params: Promise<{ task: string }> }) {
+ if (!authorized(req)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ const { task } = await ctx.params;
+ const fn = SCHEDULED_TASKS[task as ScheduledTaskName];
+ if (!fn) {
+ return NextResponse.json(
+ { error: `Unknown task. Available: ${Object.keys(SCHEDULED_TASKS).join(", ")}` },
+ { status: 404 },
+ );
+ }
+ try {
+ const result = await fn();
+ return NextResponse.json({ ok: true, task, result });
+ } catch (e) {
+ return NextResponse.json(
+ { error: e instanceof Error ? e.message : String(e) },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/favorites/route.ts b/src/app/api/favorites/route.ts
new file mode 100644
index 0000000..14824d5
--- /dev/null
+++ b/src/app/api/favorites/route.ts
@@ -0,0 +1,61 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { prisma } from "@/lib/prisma";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ carbetId: z.string().min(1),
+});
+
+async function requireSelf() {
+ const session = await auth();
+ if (!session?.user?.id) throw new Error("Unauth");
+ return session.user.id;
+}
+
+export async function GET() {
+ try {
+ const userId = await requireSelf();
+ const rows = await prisma.favorite.findMany({
+ where: { userId },
+ orderBy: { createdAt: "desc" },
+ select: { carbetId: true },
+ });
+ return NextResponse.json({ ids: rows.map((r) => r.carbetId) });
+ } catch {
+ return NextResponse.json({ ids: [] });
+ }
+}
+
+export async function POST(req: Request) {
+ try {
+ const userId = await requireSelf();
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
+ await prisma.favorite.upsert({
+ where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } },
+ create: { userId, carbetId: parsed.data.carbetId },
+ update: {},
+ });
+ return NextResponse.json({ ok: true });
+ } catch {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+}
+
+export async function DELETE(req: Request) {
+ try {
+ const userId = await requireSelf();
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
+ await prisma.favorite
+ .delete({ where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } } })
+ .catch(() => null);
+ return NextResponse.json({ ok: true });
+ } catch {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+}
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
index 5f5ced5..776677d 100644
--- a/src/app/api/health/route.ts
+++ b/src/app/api/health/route.ts
@@ -1,7 +1,101 @@
import { NextResponse } from "next/server";
+import { S3Client, HeadBucketCommand } from "@aws-sdk/client-s3";
+
+import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+type Probe = {
+ name: string;
+ ok: boolean;
+ latencyMs: number;
+ details?: string;
+};
+
+async function probeDb(): Promise {
+ const t0 = Date.now();
+ try {
+ await prisma.$queryRaw`SELECT 1 AS ok`;
+ return { name: "database", ok: true, latencyMs: Date.now() - t0 };
+ } catch (e) {
+ return {
+ name: "database",
+ ok: false,
+ latencyMs: Date.now() - t0,
+ details: e instanceof Error ? e.message : String(e),
+ };
+ }
+}
+
+async function probeS3(): Promise {
+ const t0 = Date.now();
+ const bucket = process.env.S3_BUCKET;
+ const endpoint = process.env.S3_ENDPOINT;
+ if (!bucket || !endpoint) {
+ return { name: "s3", ok: false, latencyMs: 0, details: "S3_BUCKET ou S3_ENDPOINT manquant" };
+ }
+ try {
+ const client = new S3Client({
+ endpoint,
+ region: process.env.S3_REGION ?? "us-east-1",
+ forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true",
+ credentials: {
+ accessKeyId: process.env.MINIO_ROOT_USER ?? process.env.S3_ACCESS_KEY ?? "",
+ secretAccessKey: process.env.MINIO_ROOT_PASSWORD ?? process.env.S3_SECRET_KEY ?? "",
+ },
+ });
+ await client.send(new HeadBucketCommand({ Bucket: bucket }));
+ return { name: "s3", ok: true, latencyMs: Date.now() - t0 };
+ } catch (e) {
+ return {
+ name: "s3",
+ ok: false,
+ latencyMs: Date.now() - t0,
+ details: e instanceof Error ? e.message : String(e),
+ };
+ }
+}
+
+function probeResend(): Probe {
+ return {
+ name: "resend",
+ ok: Boolean(process.env.RESEND_API_KEY?.trim()),
+ latencyMs: 0,
+ details: process.env.RESEND_API_KEY ? undefined : "RESEND_API_KEY non configuré (dry-run)",
+ };
+}
+
+function probeStripe(): Probe {
+ const key = (process.env.STRIPE_SECRET_KEY ?? "").trim();
+ const configured = key.length > 0 && !key.includes("REPLACE_ME");
+ return {
+ name: "stripe",
+ ok: configured,
+ latencyMs: 0,
+ details: configured ? undefined : "STRIPE_SECRET_KEY non configuré",
+ };
+}
export async function GET() {
- return NextResponse.json({ status: "ok" });
+ const t0 = Date.now();
+ const [db, s3] = await Promise.all([probeDb(), probeS3()]);
+ const resend = probeResend();
+ const stripe = probeStripe();
+ const probes = [db, s3, resend, stripe];
+
+ // DB est critique (503 si down). Le reste = non bloquant.
+ const critical = db.ok;
+ const status = critical ? 200 : 503;
+
+ return NextResponse.json(
+ {
+ status: critical ? "ok" : "degraded",
+ version: process.env.DEPLOYMENT_VERSION ?? "unknown",
+ uptimeSeconds: Math.round(process.uptime()),
+ latencyMs: Date.now() - t0,
+ probes,
+ },
+ { status },
+ );
}
diff --git a/src/app/api/me/export/route.ts b/src/app/api/me/export/route.ts
new file mode 100644
index 0000000..a235301
--- /dev/null
+++ b/src/app/api/me/export/route.ts
@@ -0,0 +1,103 @@
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import { prisma } from "@/lib/prisma";
+import { recordAudit } from "@/lib/admin/audit";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/** RGPD article 20 — droit à la portabilité. Renvoie un JSON avec toutes les données utilisateur. */
+export async function GET() {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const userId = session.user.id;
+
+ const [user, bookings, reviews, carbets, subscriptions] = await Promise.all([
+ prisma.user.findUnique({
+ where: { id: userId },
+ select: {
+ id: true,
+ email: true,
+ firstName: true,
+ lastName: true,
+ phone: true,
+ role: true,
+ avatarUrl: true,
+ isActive: true,
+ createdAt: true,
+ updatedAt: true,
+ organizationId: true,
+ },
+ }),
+ prisma.booking.findMany({
+ where: { tenantId: userId },
+ select: {
+ id: true,
+ carbetId: true,
+ startDate: true,
+ endDate: true,
+ guestCount: true,
+ status: true,
+ paymentStatus: true,
+ amount: true,
+ currency: true,
+ createdAt: true,
+ },
+ }),
+ prisma.review.findMany({
+ where: { authorId: userId },
+ select: {
+ id: true,
+ bookingId: true,
+ carbetId: true,
+ rating: true,
+ comment: true,
+ createdAt: true,
+ },
+ }),
+ prisma.carbet.findMany({
+ where: { ownerId: userId },
+ select: { id: true, slug: true, title: true, status: true, createdAt: true },
+ }),
+ prisma.subscription.findMany({
+ where: { ownerId: userId },
+ select: { id: true, carbetId: true, status: true, provider: true, startedAt: true },
+ }),
+ ]);
+
+ await recordAudit({
+ scope: "public.profile",
+ event: "data.export",
+ target: userId,
+ actorEmail: session.user.email ?? null,
+ details: {},
+ });
+
+ const filename = `karbe-mes-donnees-${new Date().toISOString().slice(0, 10)}.json`;
+ return new NextResponse(
+ JSON.stringify(
+ {
+ exportedAt: new Date().toISOString(),
+ rgpdNotice:
+ "Conformément à l'article 20 du RGPD. Pour exercer vos autres droits, contactez contact@karbe.cosmolan.fr.",
+ user,
+ bookings,
+ reviews,
+ carbets,
+ subscriptions,
+ },
+ null,
+ 2,
+ ),
+ {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json; charset=utf-8",
+ "Content-Disposition": `attachment; filename="${filename}"`,
+ },
+ },
+ );
+}
diff --git a/src/app/api/media/[id]/route.ts b/src/app/api/media/[id]/route.ts
new file mode 100644
index 0000000..56bebef
--- /dev/null
+++ b/src/app/api/media/[id]/route.ts
@@ -0,0 +1,41 @@
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { recordAudit } from "@/lib/admin/audit";
+
+export const runtime = "nodejs";
+
+async function requireOwnership(mediaId: string) {
+ const session = await auth();
+ if (!session?.user?.id) throw new Error("Non authentifié");
+ const m = await prisma.media.findUnique({
+ where: { id: mediaId },
+ select: { id: true, carbetId: true, carbet: { select: { ownerId: true } } },
+ });
+ if (!m) throw new Error("Média introuvable");
+ const isAdmin = session.user.role === UserRole.ADMIN;
+ if (!isAdmin && m.carbet.ownerId !== session.user.id) throw new Error("Accès refusé");
+ return { session, media: m };
+}
+
+export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
+ const { id } = await ctx.params;
+ try {
+ const { session, media } = await requireOwnership(id);
+ await prisma.media.delete({ where: { id } });
+ await recordAudit({
+ scope: "uploads",
+ event: "media.delete",
+ target: id,
+ actorEmail: session.user.email ?? null,
+ details: { carbetId: media.carbetId },
+ });
+ return NextResponse.json({ ok: true });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ const status = msg === "Non authentifié" ? 401 : msg === "Accès refusé" ? 403 : 404;
+ return NextResponse.json({ error: msg }, { status });
+ }
+}
diff --git a/src/app/api/media/reorder/route.ts b/src/app/api/media/reorder/route.ts
new file mode 100644
index 0000000..e463118
--- /dev/null
+++ b/src/app/api/media/reorder/route.ts
@@ -0,0 +1,55 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { recordAudit } from "@/lib/admin/audit";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ carbetId: z.string().min(1),
+ orderedIds: z.array(z.string()).min(1).max(50),
+});
+
+export async function POST(req: Request) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
+ }
+ const { carbetId, orderedIds } = parsed.data;
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: carbetId },
+ select: { ownerId: true },
+ });
+ if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
+ const isAdmin = session.user.role === UserRole.ADMIN;
+ if (!isAdmin && carbet.ownerId !== session.user.id) {
+ return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
+ }
+ const existing = await prisma.media.findMany({
+ where: { carbetId, id: { in: orderedIds } },
+ select: { id: true },
+ });
+ if (existing.length !== orderedIds.length) {
+ return NextResponse.json({ error: "Certains médias n'appartiennent pas au carbet." }, { status: 400 });
+ }
+ await prisma.$transaction(
+ orderedIds.map((id, idx) =>
+ prisma.media.update({ where: { id }, data: { sortOrder: idx } }),
+ ),
+ );
+ await recordAudit({
+ scope: "uploads",
+ event: "media.reorder",
+ target: carbetId,
+ actorEmail: session.user.email ?? null,
+ details: { count: orderedIds.length },
+ });
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/metrics/route.ts b/src/app/api/metrics/route.ts
new file mode 100644
index 0000000..6bbae30
--- /dev/null
+++ b/src/app/api/metrics/route.ts
@@ -0,0 +1,78 @@
+import { NextResponse } from "next/server";
+
+import { BookingStatus, CarbetStatus, UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/**
+ * Metrics publiques, agrégées (jamais de PII).
+ * Format JSON simple — consommable par un script cron ou un dashboard léger.
+ */
+export async function GET() {
+ const now = new Date();
+ const last24h = new Date(now.getTime() - 86_400_000);
+ const last7d = new Date(now.getTime() - 7 * 86_400_000);
+ const last30d = new Date(now.getTime() - 30 * 86_400_000);
+
+ const [
+ carbetsPublished,
+ carbetsTotal,
+ bookings24h,
+ bookings7d,
+ bookings30d,
+ bookingsByStatus,
+ usersTotal,
+ usersByRole,
+ mediaTotal,
+ auditLast24h,
+ ] = await Promise.all([
+ prisma.carbet.count({ where: { status: CarbetStatus.PUBLISHED } }),
+ prisma.carbet.count(),
+ prisma.booking.count({ where: { createdAt: { gte: last24h } } }),
+ prisma.booking.count({ where: { createdAt: { gte: last7d } } }),
+ prisma.booking.count({ where: { createdAt: { gte: last30d } } }),
+ prisma.booking.groupBy({
+ by: ["status"],
+ _count: { _all: true },
+ }),
+ prisma.user.count(),
+ prisma.user.groupBy({
+ by: ["role"],
+ _count: { _all: true },
+ }),
+ prisma.media.count(),
+ prisma.auditLog.count({ where: { createdAt: { gte: last24h } } }),
+ ]);
+
+ return NextResponse.json({
+ generatedAt: now.toISOString(),
+ carbets: {
+ total: carbetsTotal,
+ published: carbetsPublished,
+ },
+ bookings: {
+ last24h: bookings24h,
+ last7d: bookings7d,
+ last30d: bookings30d,
+ byStatus: Object.fromEntries(
+ Object.values(BookingStatus).map((s) => [
+ s,
+ bookingsByStatus.find((b) => b.status === s)?._count._all ?? 0,
+ ]),
+ ),
+ },
+ users: {
+ total: usersTotal,
+ byRole: Object.fromEntries(
+ Object.values(UserRole).map((r) => [
+ r,
+ usersByRole.find((u) => u.role === r)?._count._all ?? 0,
+ ]),
+ ),
+ },
+ media: { total: mediaTotal },
+ audit: { last24h: auditLast24h },
+ });
+}
diff --git a/src/app/api/password/reset-request/route.ts b/src/app/api/password/reset-request/route.ts
new file mode 100644
index 0000000..9bcbc32
--- /dev/null
+++ b/src/app/api/password/reset-request/route.ts
@@ -0,0 +1,58 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { createPasswordResetToken } from "@/lib/password-reset";
+import { prisma } from "@/lib/prisma";
+import { sendPasswordReset } from "@/lib/email";
+import { recordAudit } from "@/lib/admin/audit";
+import { rateLimitRequest } from "@/lib/rate-limit";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ email: z.string().trim().toLowerCase().email(),
+});
+
+const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
+
+export async function POST(req: Request) {
+ const rl = rateLimitRequest(req, "password-reset", 60 * 60 * 1000, 3);
+ if (!rl.ok) {
+ return NextResponse.json(
+ { error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
+ { status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
+ );
+ }
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
+ }
+ const parsed = schema.safeParse(body);
+ if (!parsed.success) {
+ // Réponse générique pour ne pas leak la validité du format à un attaquant.
+ return NextResponse.json({ ok: true });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: parsed.data.email },
+ select: { id: true, email: true, firstName: true, isActive: true },
+ });
+
+ if (user && user.isActive) {
+ const token = await createPasswordResetToken(user.id);
+ const resetUrl = `${SITE_URL}/mot-de-passe-oublie/${token}`;
+ sendPasswordReset(user.email, resetUrl).catch(() => {});
+ await recordAudit({
+ scope: "public.password",
+ event: "reset.request",
+ target: user.id,
+ actorEmail: user.email,
+ details: {},
+ });
+ }
+
+ // Réponse identique que l'email existe ou non (énumération-safe).
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/password/reset/route.ts b/src/app/api/password/reset/route.ts
new file mode 100644
index 0000000..1883076
--- /dev/null
+++ b/src/app/api/password/reset/route.ts
@@ -0,0 +1,40 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { consumePasswordResetToken } from "@/lib/password-reset";
+import { recordAudit } from "@/lib/admin/audit";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ token: z.string().min(20).max(200),
+ password: z.string().min(8).max(200),
+});
+
+export async function POST(req: Request) {
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
+ }
+ const parsed = schema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues.map((i) => i.message).join(" · ") },
+ { status: 400 },
+ );
+ }
+ const result = await consumePasswordResetToken(parsed.data.token, parsed.data.password);
+ if (!result.ok) {
+ return NextResponse.json({ error: result.reason }, { status: 400 });
+ }
+ await recordAudit({
+ scope: "public.password",
+ event: "reset.success",
+ target: result.userId,
+ actorEmail: null,
+ details: {},
+ });
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/rental-media/[id]/route.ts b/src/app/api/rental-media/[id]/route.ts
new file mode 100644
index 0000000..8420d8c
--- /dev/null
+++ b/src/app/api/rental-media/[id]/route.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import { recordAudit } from "@/lib/admin/audit";
+import { prisma } from "@/lib/prisma";
+import { canManageRentalProvider } from "@/lib/rental-access";
+
+export const runtime = "nodejs";
+
+export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
+ const { id } = await ctx.params;
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const media = await prisma.rentalItemMedia.findUnique({
+ where: { id },
+ select: { id: true, itemId: true, item: { select: { providerId: true } } },
+ });
+ if (!media) return NextResponse.json({ error: "Média introuvable" }, { status: 404 });
+
+ const allowed = await canManageRentalProvider(
+ session.user.id,
+ session.user.role,
+ media.item.providerId,
+ session.user.organizationId,
+ );
+ if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
+
+ await prisma.rentalItemMedia.delete({ where: { id } });
+ await recordAudit({
+ scope: "uploads",
+ event: "rental.media.delete",
+ target: id,
+ actorEmail: session.user.email ?? null,
+ details: { itemId: media.itemId },
+ });
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/rental-media/reorder/route.ts b/src/app/api/rental-media/reorder/route.ts
new file mode 100644
index 0000000..d375aa0
--- /dev/null
+++ b/src/app/api/rental-media/reorder/route.ts
@@ -0,0 +1,75 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { recordAudit } from "@/lib/admin/audit";
+import { prisma } from "@/lib/prisma";
+import { canManageRentalProvider } from "@/lib/rental-access";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ itemId: z.string().min(1),
+ orderedIds: z.array(z.string()).min(1).max(50),
+});
+
+export async function POST(req: Request) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
+ }
+ const { itemId, orderedIds } = parsed.data;
+
+ const item = await prisma.rentalItem.findUnique({
+ where: { id: itemId },
+ select: { providerId: true },
+ });
+ if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
+
+ const allowed = await canManageRentalProvider(
+ session.user.id,
+ session.user.role,
+ item.providerId,
+ session.user.organizationId,
+ );
+ if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
+
+ const existing = await prisma.rentalItemMedia.findMany({
+ where: { itemId, id: { in: orderedIds } },
+ select: { id: true },
+ });
+ if (existing.length !== orderedIds.length) {
+ return NextResponse.json({ error: "Certains médias n'appartiennent pas à l'item." }, { status: 400 });
+ }
+ await prisma.$transaction(
+ orderedIds.map((id, idx) =>
+ prisma.rentalItemMedia.update({ where: { id }, data: { sortOrder: idx } }),
+ ),
+ );
+
+ // Cover = sortOrder 0 → hydrate imageUrl pour rétro-compat listings
+ const firstId = orderedIds[0];
+ const firstMedia = await prisma.rentalItemMedia.findUnique({
+ where: { id: firstId },
+ select: { s3Url: true, type: true },
+ });
+ if (firstMedia && firstMedia.type === "PHOTO") {
+ await prisma.rentalItem.update({
+ where: { id: itemId },
+ data: { imageUrl: firstMedia.s3Url },
+ });
+ }
+
+ await recordAudit({
+ scope: "uploads",
+ event: "rental.media.reorder",
+ target: itemId,
+ actorEmail: session.user.email ?? null,
+ details: { count: orderedIds.length },
+ });
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/rentals/[id]/cancel/route.ts b/src/app/api/rentals/[id]/cancel/route.ts
new file mode 100644
index 0000000..49aa9ec
--- /dev/null
+++ b/src/app/api/rentals/[id]/cancel/route.ts
@@ -0,0 +1,193 @@
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import {
+ PaymentStatus,
+ RentalBookingStatus,
+ UserRole,
+} from "@/generated/prisma/enums";
+import { recordAudit } from "@/lib/admin/audit";
+import { canManageRentalProvider } from "@/lib/rental-access";
+import { sendRentalCancelled } from "@/lib/email";
+import { isStripeConfigured, getStripeClient } from "@/lib/stripe";
+import { prisma } from "@/lib/prisma";
+import { computeRentalRefund } from "@/lib/rental-refund";
+
+export const runtime = "nodejs";
+
+const CANCELLABLE_STATUSES: RentalBookingStatus[] = [
+ RentalBookingStatus.PENDING,
+ RentalBookingStatus.CONFIRMED,
+];
+
+type Body = { reason?: string };
+
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
+ }
+ const { id } = await params;
+ const body: Body = await req.json().catch(() => ({}));
+ const reason = body.reason?.toString().trim().slice(0, 500) ?? null;
+
+ const rb = await prisma.rentalBooking.findUnique({
+ where: { id },
+ include: {
+ provider: { select: { id: true, name: true, contactEmail: true, organizationId: true } },
+ tenant: { select: { id: true, email: true, firstName: true } },
+ lines: { select: { qty: true, item: { select: { name: true } } } },
+ },
+ });
+ if (!rb) {
+ return NextResponse.json({ error: "Réservation introuvable." }, { status: 404 });
+ }
+
+ // Détecte qui annule pour l'auth + l'email :
+ // - tenant de la booking
+ // - provider's manager (RENTAL_PROVIDER nominal ou CE_MANAGER de l'org du provider)
+ // - admin
+ const role = session.user.role;
+ const isTenant = rb.tenantId === session.user.id;
+ const isAdmin = role === UserRole.ADMIN;
+ const canManage = await canManageRentalProvider(
+ session.user.id,
+ role,
+ rb.providerId,
+ session.user.organizationId,
+ );
+ const cancelledBy: "tenant" | "provider" | "admin" = isAdmin
+ ? "admin"
+ : canManage
+ ? "provider"
+ : "tenant";
+
+ if (!isAdmin && !canManage && !isTenant) {
+ return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
+ }
+
+ if (!CANCELLABLE_STATUSES.includes(rb.status)) {
+ return NextResponse.json(
+ { error: `Impossible d'annuler une réservation en statut ${rb.status}.` },
+ { status: 409 },
+ );
+ }
+
+ // Calcule le remboursement selon la politique
+ const refund = computeRentalRefund({
+ startDate: rb.startDate,
+ itemsTotal: rb.itemsTotal,
+ depositTotal: rb.depositTotal,
+ });
+
+ // Stripe refund best-effort si paiement déjà SUCCEEDED + session Stripe existante
+ let stripeRefundId: string | null = null;
+ let stripeRefundError: string | null = null;
+ if (
+ rb.paymentStatus === PaymentStatus.SUCCEEDED &&
+ rb.stripeSessionId &&
+ isStripeConfigured() &&
+ refund.totalRefund.gt(0)
+ ) {
+ try {
+ const stripe = getStripeClient();
+ const sess = await stripe.checkout.sessions.retrieve(rb.stripeSessionId, {
+ expand: ["payment_intent"],
+ });
+ const piId =
+ typeof sess.payment_intent === "string"
+ ? sess.payment_intent
+ : sess.payment_intent?.id;
+ if (piId) {
+ const stripeRefund = await stripe.refunds.create({
+ payment_intent: piId,
+ amount: Math.round(Number(refund.totalRefund) * 100),
+ reason: "requested_by_customer",
+ });
+ stripeRefundId = stripeRefund.id;
+ }
+ } catch (e) {
+ stripeRefundError = e instanceof Error ? e.message : String(e);
+ console.error("[rental.cancel] Stripe refund failed:", stripeRefundError);
+ }
+ }
+
+ // Transaction : update booking + delete availability blocks
+ await prisma.$transaction([
+ prisma.rentalBooking.update({
+ where: { id },
+ data: {
+ status: RentalBookingStatus.CANCELLED,
+ paymentStatus:
+ rb.paymentStatus === PaymentStatus.SUCCEEDED
+ ? PaymentStatus.REFUNDED
+ : PaymentStatus.FAILED,
+ },
+ }),
+ prisma.rentalItemAvailability.deleteMany({
+ where: { rentalBookingId: id },
+ }),
+ ]);
+
+ await recordAudit({
+ scope: "rental",
+ event: "rental.cancel",
+ target: id,
+ actorEmail: session.user.email ?? null,
+ details: {
+ cancelledBy,
+ reason,
+ policy: refund.policy,
+ itemsRefund: refund.itemsRefund.toString(),
+ depositRefund: refund.depositRefund.toString(),
+ totalRefund: refund.totalRefund.toString(),
+ stripeRefundId,
+ stripeRefundError,
+ },
+ });
+
+ // Email best-effort : tenant + provider
+ try {
+ await sendRentalCancelled(
+ rb.tenant.email,
+ rb.tenant.firstName,
+ rb.id,
+ rb.provider.name,
+ refund.totalRefund.toString(),
+ rb.currency,
+ refund.policyLabel,
+ cancelledBy,
+ );
+ if (rb.provider.contactEmail && cancelledBy !== "provider") {
+ await sendRentalCancelled(
+ rb.provider.contactEmail,
+ rb.provider.name,
+ rb.id,
+ rb.provider.name,
+ refund.totalRefund.toString(),
+ rb.currency,
+ refund.policyLabel,
+ cancelledBy,
+ );
+ }
+ } catch (e) {
+ console.error("[rental.cancel] email send failed:", e instanceof Error ? e.message : e);
+ }
+
+ return NextResponse.json({
+ ok: true,
+ rentalBookingId: id,
+ refund: {
+ itemsRefund: refund.itemsRefund.toNumber(),
+ depositRefund: refund.depositRefund.toNumber(),
+ totalRefund: refund.totalRefund.toNumber(),
+ policy: refund.policy,
+ policyLabel: refund.policyLabel,
+ },
+ stripeRefundId,
+ stripeRefundError,
+ });
+}
diff --git a/src/app/api/rentals/checkout/route.ts b/src/app/api/rentals/checkout/route.ts
new file mode 100644
index 0000000..06fccb6
--- /dev/null
+++ b/src/app/api/rentals/checkout/route.ts
@@ -0,0 +1,361 @@
+import { cookies } from "next/headers";
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
+import { Prisma } from "@/generated/prisma/client";
+import { recordAudit } from "@/lib/admin/audit";
+import {
+ sendRentalRequestedProvider,
+ sendRentalRequestedTenant,
+} from "@/lib/email";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { prisma } from "@/lib/prisma";
+import { CART_COOKIE, EMPTY_CART, diffDays, parseCart } from "@/lib/rental-cart";
+import {
+ getStripeClient,
+ isStripeConfigured,
+ toStripeAmountCents,
+} from "@/lib/stripe";
+
+export const runtime = "nodejs";
+
+type LineInput = {
+ itemId: string;
+ qty: number;
+ startDate: Date;
+ endDate: Date;
+ nights: number;
+};
+
+function parseDateOnly(s: string): Date {
+ return new Date(s + "T00:00:00Z");
+}
+
+export async function POST() {
+ if (!(await isPluginEnabled("gear-rental"))) {
+ return NextResponse.json({ error: "Service de location indisponible." }, { status: 404 });
+ }
+ const session = await auth();
+ if (!session?.user?.id || !session.user.email) {
+ return NextResponse.json({ error: "Connectez-vous pour finaliser." }, { status: 401 });
+ }
+
+ const jar = await cookies();
+ const cart = parseCart(jar.get(CART_COOKIE)?.value);
+ if (cart.items.length === 0) {
+ return NextResponse.json({ error: "Panier vide." }, { status: 400 });
+ }
+
+ // Charge tous les items du panier
+ const itemIds = Array.from(new Set(cart.items.map((e) => e.itemId)));
+ const items = await prisma.rentalItem.findMany({
+ where: { id: { in: itemIds }, active: true },
+ include: {
+ provider: {
+ select: {
+ id: true,
+ name: true,
+ active: true,
+ approved: true,
+ commissionPct: true,
+ isSystemD: true,
+ },
+ },
+ },
+ });
+ const itemById = new Map(items.map((i) => [i.id, i]));
+
+ // Validations préliminaires : items valides + provider actif/approved
+ for (const entry of cart.items) {
+ const it = itemById.get(entry.itemId);
+ if (!it) {
+ return NextResponse.json(
+ { error: `Item ${entry.itemId} introuvable ou désactivé.` },
+ { status: 409 },
+ );
+ }
+ if (!it.provider.active || !it.provider.approved) {
+ return NextResponse.json(
+ { error: `Prestataire ${it.provider.name} indisponible.` },
+ { status: 409 },
+ );
+ }
+ if (entry.qty < 1 || entry.qty > it.totalQty) {
+ return NextResponse.json(
+ { error: `Quantité invalide pour « ${it.name} ».` },
+ { status: 400 },
+ );
+ }
+ const start = parseDateOnly(entry.startDate);
+ const end = parseDateOnly(entry.endDate);
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) {
+ return NextResponse.json(
+ { error: `Dates invalides pour « ${it.name} ».` },
+ { status: 400 },
+ );
+ }
+ }
+
+ // Groupe par provider
+ type Group = {
+ providerId: string;
+ providerName: string;
+ commissionPct: number;
+ lines: LineInput[];
+ itemsTotal: Prisma.Decimal;
+ depositTotal: Prisma.Decimal;
+ startDate: Date;
+ endDate: Date;
+ };
+
+ const groups = new Map();
+ for (const entry of cart.items) {
+ const it = itemById.get(entry.itemId)!;
+ const start = parseDateOnly(entry.startDate);
+ const end = parseDateOnly(entry.endDate);
+ const nights = Math.max(1, diffDays(entry.startDate, entry.endDate));
+ const lineSub = new Prisma.Decimal(it.pricePerDay).mul(entry.qty).mul(nights);
+ const lineDeposit = new Prisma.Decimal(it.deposit).mul(entry.qty);
+
+ let g = groups.get(it.provider.id);
+ if (!g) {
+ g = {
+ providerId: it.provider.id,
+ providerName: it.provider.name,
+ commissionPct: Number(it.provider.commissionPct),
+ lines: [],
+ itemsTotal: new Prisma.Decimal(0),
+ depositTotal: new Prisma.Decimal(0),
+ startDate: start,
+ endDate: end,
+ };
+ groups.set(it.provider.id, g);
+ }
+ g.lines.push({ itemId: it.id, qty: entry.qty, startDate: start, endDate: end, nights });
+ g.itemsTotal = g.itemsTotal.add(lineSub);
+ g.depositTotal = g.depositTotal.add(lineDeposit);
+ if (start < g.startDate) g.startDate = start;
+ if (end > g.endDate) g.endDate = end;
+ }
+
+ // Transaction : recheck stock + crée RentalBookings + Lines + Availabilities
+ let grandTotal = new Prisma.Decimal(0);
+ let grandDeposit = new Prisma.Decimal(0);
+ let rentalBookingIds: string[] = [];
+
+ try {
+ rentalBookingIds = await prisma.$transaction(async (tx) => {
+ const created: string[] = [];
+
+ for (const g of groups.values()) {
+ // Recheck stock disponible pour chaque ligne
+ for (const line of g.lines) {
+ const blocked = await tx.rentalItemAvailability.aggregate({
+ where: {
+ itemId: line.itemId,
+ startDate: { lt: line.endDate },
+ endDate: { gt: line.startDate },
+ },
+ _sum: { qty: true },
+ });
+ const item = itemById.get(line.itemId)!;
+ const used = Number(blocked._sum.qty ?? 0);
+ const free = item.totalQty - used;
+ if (line.qty > free) {
+ throw new Error(`Stock insuffisant pour « ${item.name} » sur les dates demandées (libre: ${free}).`);
+ }
+ }
+
+ const commissionAmount = g.itemsTotal
+ .mul(g.commissionPct)
+ .div(100)
+ .toDecimalPlaces(2);
+ const amount = g.itemsTotal.add(g.depositTotal).toDecimalPlaces(2);
+
+ const rb = await tx.rentalBooking.create({
+ data: {
+ tenantId: session.user!.id!,
+ providerId: g.providerId,
+ startDate: g.startDate,
+ endDate: g.endDate,
+ status: RentalBookingStatus.PENDING,
+ paymentStatus: PaymentStatus.PENDING,
+ itemsTotal: g.itemsTotal.toDecimalPlaces(2),
+ depositTotal: g.depositTotal.toDecimalPlaces(2),
+ commissionAmount,
+ amount,
+ currency: "EUR",
+ lines: {
+ create: g.lines.map((line) => {
+ const item = itemById.get(line.itemId)!;
+ const lineTotal = new Prisma.Decimal(item.pricePerDay)
+ .mul(line.qty)
+ .mul(line.nights)
+ .toDecimalPlaces(2);
+ return {
+ itemId: line.itemId,
+ qty: line.qty,
+ pricePerDay: new Prisma.Decimal(item.pricePerDay),
+ deposit: new Prisma.Decimal(item.deposit),
+ lineTotal,
+ };
+ }),
+ },
+ },
+ select: { id: true },
+ });
+
+ // Bloque les dispos
+ for (const line of g.lines) {
+ await tx.rentalItemAvailability.create({
+ data: {
+ itemId: line.itemId,
+ startDate: line.startDate,
+ endDate: line.endDate,
+ qty: line.qty,
+ reason: "RENTAL_BOOKING",
+ rentalBookingId: rb.id,
+ },
+ });
+ }
+
+ created.push(rb.id);
+ grandTotal = grandTotal.add(g.itemsTotal);
+ grandDeposit = grandDeposit.add(g.depositTotal);
+ }
+
+ return created;
+ });
+ } catch (e) {
+ return NextResponse.json(
+ { error: e instanceof Error ? e.message : "Erreur lors de la création." },
+ { status: 409 },
+ );
+ }
+
+ const totalAmount = grandTotal.add(grandDeposit).toDecimalPlaces(2);
+
+ await recordAudit({
+ scope: "rental",
+ event: "rental.checkout.created",
+ target: rentalBookingIds.join(","),
+ actorEmail: session.user.email,
+ details: {
+ rentalBookingIds,
+ amount: totalAmount.toNumber(),
+ depositTotal: grandDeposit.toNumber(),
+ providers: Array.from(groups.keys()),
+ },
+ });
+
+ // Emails best-effort : 1 mail au locataire (récap par prestataire) + 1 mail
+ // à chaque prestataire (sa demande). En cas d'échec d'envoi, on ne bloque pas.
+ try {
+ const fullBookings = await prisma.rentalBooking.findMany({
+ where: { id: { in: rentalBookingIds } },
+ include: {
+ provider: { select: { name: true, contactEmail: true } },
+ lines: { include: { item: { select: { name: true } } } },
+ },
+ });
+ const tenantName = session.user.name ?? session.user.email!;
+ for (const rb of fullBookings) {
+ const lineSummary = rb.lines.map((l) => ({ qty: l.qty, itemName: l.item.name }));
+ await sendRentalRequestedTenant(
+ session.user.email!,
+ tenantName,
+ rb.id,
+ rb.provider.name,
+ rb.startDate,
+ rb.endDate,
+ rb.amount.toString(),
+ rb.currency,
+ lineSummary,
+ );
+ if (rb.provider.contactEmail) {
+ await sendRentalRequestedProvider(
+ rb.provider.contactEmail,
+ rb.provider.name,
+ rb.id,
+ tenantName,
+ rb.startDate,
+ rb.endDate,
+ lineSummary,
+ );
+ }
+ }
+ } catch (e) {
+ console.error("[rental.checkout] email send failed:", e instanceof Error ? e.message : e);
+ }
+
+ // Vide le panier
+ jar.set(CART_COOKIE, JSON.stringify(EMPTY_CART), {
+ httpOnly: false,
+ sameSite: "lax",
+ path: "/",
+ maxAge: 0,
+ });
+
+ // Stripe ou paiement différé
+ if (!isStripeConfigured()) {
+ return NextResponse.json(
+ { rentalBookingIds, totalAmount: totalAmount.toNumber() },
+ { status: 201 },
+ );
+ }
+
+ const appUrl = process.env.APP_URL;
+ if (!appUrl) {
+ return NextResponse.json({ error: "APP_URL manquante." }, { status: 500 });
+ }
+
+ // Une session Stripe avec une ligne par RentalBooking (agrégée)
+ const stripe = getStripeClient();
+ const bookingDetails = await prisma.rentalBooking.findMany({
+ where: { id: { in: rentalBookingIds } },
+ include: {
+ provider: { select: { name: true } },
+ lines: { select: { qty: true, item: { select: { name: true } } } },
+ },
+ });
+
+ const line_items = bookingDetails.map((rb) => ({
+ quantity: 1,
+ price_data: {
+ currency: "eur",
+ unit_amount: toStripeAmountCents(Number(rb.amount)),
+ product_data: {
+ name: `Matériel — ${rb.provider.name}`,
+ description: rb.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ").slice(0, 500),
+ },
+ },
+ }));
+
+ const checkoutSession = await stripe.checkout.sessions.create({
+ mode: "payment",
+ success_url: `${appUrl}/mes-locations?payment=success&ids=${rentalBookingIds.join(",")}`,
+ cancel_url: `${appUrl}/panier?payment=cancel`,
+ customer_email: session.user.email,
+ line_items,
+ metadata: {
+ type: "rental-bundle",
+ rentalBookingIds: rentalBookingIds.join(","),
+ },
+ });
+
+ await prisma.rentalBooking.updateMany({
+ where: { id: { in: rentalBookingIds } },
+ data: { stripeSessionId: checkoutSession.id },
+ });
+
+ return NextResponse.json(
+ {
+ rentalBookingIds,
+ totalAmount: totalAmount.toNumber(),
+ checkoutSessionId: checkoutSession.id,
+ checkoutUrl: checkoutSession.url,
+ },
+ { status: 201 },
+ );
+}
diff --git a/src/app/api/rentals/items/[id]/availability/route.ts b/src/app/api/rentals/items/[id]/availability/route.ts
new file mode 100644
index 0000000..dc3b8b2
--- /dev/null
+++ b/src/app/api/rentals/items/[id]/availability/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+import { getItemAvailability } from "@/lib/rentals-public";
+import { parseIsoDate, normalizeUtcDayStart } from "@/lib/booking";
+
+export const runtime = "nodejs";
+
+export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
+ const { id } = await ctx.params;
+ const from = parseIsoDate(req.nextUrl.searchParams.get("from"));
+ const to = parseIsoDate(req.nextUrl.searchParams.get("to"));
+ if (!from || !to) {
+ return NextResponse.json(
+ { error: "Paramètres from et to (YYYY-MM-DD) requis." },
+ { status: 400 },
+ );
+ }
+ const start = normalizeUtcDayStart(from);
+ const end = normalizeUtcDayStart(to);
+ if (end <= start) {
+ return NextResponse.json({ error: "to doit être > from." }, { status: 400 });
+ }
+ const calendar = await getItemAvailability(id, start, end);
+ return NextResponse.json({
+ itemId: id,
+ from: start.toISOString(),
+ to: end.toISOString(),
+ calendar,
+ });
+}
diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts
new file mode 100644
index 0000000..739bf1b
--- /dev/null
+++ b/src/app/api/signup/route.ts
@@ -0,0 +1,199 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { UserRole } from "@/generated/prisma/enums";
+import { getOrgInviteByToken, markOrgInviteConsumed } from "@/lib/ce-invites";
+import { hashPassword } from "@/lib/password";
+import { prisma } from "@/lib/prisma";
+import { recordAudit } from "@/lib/admin/audit";
+import { sendNewCeRequest, sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email";
+import { rateLimitRequest } from "@/lib/rate-limit";
+import { slugify } from "@/lib/slug";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ email: z.string().trim().toLowerCase().email().max(200),
+ password: z.string().min(8).max(200),
+ firstName: z.string().trim().min(1).max(100),
+ lastName: z.string().trim().min(1).max(100),
+ phone: z.string().trim().max(40).optional().nullable(),
+ role: z
+ .enum([UserRole.TOURIST, UserRole.OWNER, UserRole.RENTAL_PROVIDER, UserRole.CE_MANAGER])
+ .default(UserRole.TOURIST),
+ providerName: z.string().trim().min(2).max(200).optional(),
+ providerRivers: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
+ orgName: z.string().trim().min(2).max(200).optional(),
+ inviteToken: z.string().trim().min(8).max(200).optional(),
+});
+
+export async function POST(req: Request) {
+ const rl = rateLimitRequest(req, "signup", 60 * 60 * 1000, 5);
+ if (!rl.ok) {
+ return NextResponse.json(
+ { error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
+ { status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
+ );
+ }
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
+ }
+ const parsed = schema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") },
+ { status: 400 },
+ );
+ }
+ const data = parsed.data;
+
+ if (data.role === UserRole.RENTAL_PROVIDER && (!data.providerName || data.providerName.trim().length < 2)) {
+ return NextResponse.json({ error: "Nom de votre activité requis." }, { status: 400 });
+ }
+ if (data.role === UserRole.CE_MANAGER && (!data.orgName || data.orgName.trim().length < 2)) {
+ return NextResponse.json({ error: "Nom de votre Comité d'Entreprise requis." }, { status: 400 });
+ }
+
+ // Invitation CE_MEMBER : si un inviteToken est fourni, on force le rôle CE_MEMBER
+ // et on rattache à l'org du token (org déjà validée — pas de bannière pending).
+ let inviteOrgId: string | null = null;
+ if (data.inviteToken) {
+ const invite = await getOrgInviteByToken(data.inviteToken);
+ if (!invite) {
+ return NextResponse.json({ error: "Lien d'invitation invalide ou expiré." }, { status: 400 });
+ }
+ if (invite.email && invite.email.toLowerCase() !== data.email) {
+ return NextResponse.json(
+ { error: "Ce lien d'invitation est réservé à un autre email." },
+ { status: 400 },
+ );
+ }
+ inviteOrgId = invite.organizationId;
+ }
+
+ const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } });
+ if (existing) {
+ return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 });
+ }
+
+ const passwordHash = await hashPassword(data.password);
+
+ // CE_MANAGER : transaction atomique User + Organization. Le slug est unique
+ // sur Organization → on retente avec un suffixe en cas de collision.
+ let createdProviderId: string | null = null;
+ let createdOrgId: string | null = null;
+ let user: { id: string; email: string; role: UserRole };
+
+ if (inviteOrgId) {
+ // Branche invite CE_MEMBER : rattache le user à l'org du token, ignore data.role.
+ user = await prisma.user.create({
+ data: {
+ email: data.email,
+ passwordHash,
+ firstName: data.firstName,
+ lastName: data.lastName,
+ phone: data.phone?.trim() || null,
+ role: UserRole.CE_MEMBER,
+ organizationId: inviteOrgId,
+ isActive: true,
+ },
+ select: { id: true, email: true, role: true },
+ });
+ createdOrgId = inviteOrgId;
+ await markOrgInviteConsumed(data.inviteToken!).catch(() => {});
+ } else if (data.role === UserRole.CE_MANAGER) {
+ const orgName = data.orgName!.trim();
+ const baseSlug = slugify(orgName);
+ const result = await prisma.$transaction(async (tx) => {
+ // Trouve un slug libre
+ let candidate = baseSlug || "ce";
+ let suffix = 1;
+ for (;;) {
+ const exists = await tx.organization.findUnique({ where: { slug: candidate }, select: { id: true } });
+ if (!exists) break;
+ suffix += 1;
+ candidate = `${baseSlug}-${suffix}`;
+ }
+ // candidate now holds a free slug
+ const org = await tx.organization.create({
+ data: {
+ name: orgName,
+ slug: candidate,
+ contactEmail: data.email,
+ approved: false,
+ },
+ select: { id: true },
+ });
+ const u = await tx.user.create({
+ data: {
+ email: data.email,
+ passwordHash,
+ firstName: data.firstName,
+ lastName: data.lastName,
+ phone: data.phone?.trim() || null,
+ role: UserRole.CE_MANAGER,
+ organizationId: org.id,
+ isActive: true,
+ },
+ select: { id: true, email: true, role: true },
+ });
+ return { user: u, orgId: org.id };
+ });
+ user = result.user;
+ createdOrgId = result.orgId;
+ sendNewCeRequest(orgName, user.email).catch(() => {});
+ } else {
+ user = await prisma.user.create({
+ data: {
+ email: data.email,
+ passwordHash,
+ firstName: data.firstName,
+ lastName: data.lastName,
+ phone: data.phone?.trim() || null,
+ role: data.role,
+ isActive: true,
+ },
+ select: { id: true, email: true, role: true },
+ });
+
+ // Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation.
+ if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) {
+ const provider = await prisma.rentalProvider.create({
+ data: {
+ name: data.providerName,
+ isSystemD: false,
+ managedByUserId: user.id,
+ contactEmail: user.email,
+ contactPhone: data.phone?.trim() || null,
+ rivers: data.providerRivers ?? [],
+ commissionPct: 10, // valeur par défaut, ajustable par admin
+ active: true,
+ approved: false,
+ },
+ select: { id: true, name: true },
+ });
+ createdProviderId = provider.id;
+ sendNewRentalProviderRequest(provider.name, user.email).catch(() => {});
+ }
+ }
+
+ await recordAudit({
+ scope: "public.signup",
+ event: "user.create",
+ target: user.id,
+ actorEmail: user.email,
+ details: { role: user.role, rentalProviderId: createdProviderId, organizationId: createdOrgId },
+ });
+
+ sendSignupWelcome(user.email, data.firstName).catch(() => {});
+
+ return NextResponse.json({
+ ok: true,
+ userId: user.id,
+ providerId: createdProviderId,
+ organizationId: createdOrgId,
+ });
+}
diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts
index 1e4296f..6a896ec 100644
--- a/src/app/api/stripe/webhook/route.ts
+++ b/src/app/api/stripe/webhook/route.ts
@@ -4,9 +4,11 @@ import Stripe from "stripe";
import {
BookingStatus,
PaymentStatus,
+ RentalBookingStatus,
SubscriptionStatus,
} from "@/generated/prisma/enums";
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
+import { sendRentalConfirmed } from "@/lib/email";
import { prisma } from "@/lib/prisma";
import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe";
@@ -51,6 +53,43 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
return;
}
+ if (type === "rental-bundle") {
+ const idsRaw = session.metadata?.rentalBookingIds;
+ if (!idsRaw) return;
+ const ids = idsRaw.split(",").map((s) => s.trim()).filter(Boolean);
+ if (ids.length === 0) return;
+ await prisma.rentalBooking.updateMany({
+ where: { id: { in: ids } },
+ data: {
+ paymentStatus: PaymentStatus.SUCCEEDED,
+ status: RentalBookingStatus.CONFIRMED,
+ },
+ });
+ try {
+ const rentals = await prisma.rentalBooking.findMany({
+ where: { id: { in: ids } },
+ include: {
+ provider: { select: { name: true } },
+ tenant: { select: { email: true, firstName: true } },
+ },
+ });
+ for (const rb of rentals) {
+ if (!rb.tenant.email) continue;
+ await sendRentalConfirmed(
+ rb.tenant.email,
+ rb.tenant.firstName ?? rb.tenant.email,
+ rb.id,
+ rb.provider.name,
+ rb.startDate,
+ rb.endDate,
+ );
+ }
+ } catch (e) {
+ console.error("[webhook.rental] email send failed:", e instanceof Error ? e.message : e);
+ }
+ return;
+ }
+
if (type === "owner_subscription") {
const ownerId = session.metadata?.ownerId;
const carbetId = session.metadata?.carbetId;
@@ -79,6 +118,27 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) {
const bookingId = paymentIntent.metadata?.bookingId;
+ const rentalIdsRaw = paymentIntent.metadata?.rentalBookingIds;
+
+ if (rentalIdsRaw) {
+ const ids = rentalIdsRaw.split(",").map((s) => s.trim()).filter(Boolean);
+ if (ids.length > 0) {
+ // Marque les paiements échoués + libère les blocages de dispo
+ await prisma.$transaction([
+ prisma.rentalBooking.updateMany({
+ where: { id: { in: ids } },
+ data: {
+ paymentStatus: PaymentStatus.FAILED,
+ status: RentalBookingStatus.CANCELLED,
+ },
+ }),
+ prisma.rentalItemAvailability.deleteMany({
+ where: { rentalBookingId: { in: ids } },
+ }),
+ ]);
+ }
+ }
+
if (!bookingId) {
return;
}
diff --git a/src/app/api/uploads/finalize/route.ts b/src/app/api/uploads/finalize/route.ts
new file mode 100644
index 0000000..c9f7cd8
--- /dev/null
+++ b/src/app/api/uploads/finalize/route.ts
@@ -0,0 +1,89 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { MediaType, UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { classifyMime } from "@/lib/uploads";
+import { recordAudit } from "@/lib/admin/audit";
+import { generateImageVariants } from "@/lib/variants-server";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ carbetId: z.string().min(1),
+ s3Key: z.string().min(5).max(500),
+ s3Url: z.string().url(),
+ mime: z.string().min(3).max(100),
+});
+
+export async function POST(req: Request) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Payload invalide" }, { status: 400 });
+ }
+ const kind = classifyMime(parsed.data.mime);
+ if (!kind) return NextResponse.json({ error: "Type non supporté" }, { status: 400 });
+
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: parsed.data.carbetId },
+ select: { id: true, ownerId: true },
+ });
+ if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
+ const isOwner = carbet.ownerId === session.user.id;
+ const isAdmin = session.user.role === UserRole.ADMIN;
+ if (!isOwner && !isAdmin) {
+ return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
+ }
+
+ // S3Key doit appartenir au carbet — verrou pour éviter qu'un user finalise une key étrangère.
+ if (!parsed.data.s3Key.startsWith(`carbets/${carbet.id}/`)) {
+ return NextResponse.json({ error: "s3Key invalide pour ce carbet" }, { status: 400 });
+ }
+
+ const existingCount = await prisma.media.count({ where: { carbetId: carbet.id } });
+ const media = await prisma.media.create({
+ data: {
+ carbetId: carbet.id,
+ type: kind === "photo" ? MediaType.PHOTO : MediaType.VIDEO,
+ s3Key: parsed.data.s3Key,
+ s3Url: parsed.data.s3Url,
+ sortOrder: existingCount,
+ },
+ select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
+ });
+ await recordAudit({
+ scope: "uploads",
+ event: "media.finalize",
+ target: media.id,
+ actorEmail: session.user.email ?? null,
+ details: { carbetId: carbet.id, kind },
+ });
+
+ // Génération des variantes responsives (best-effort, n'échoue pas la requête).
+ // L'utilisateur attend quelques secondes mais l'expérience derrière est bien meilleure.
+ try {
+ const variants = await generateImageVariants({
+ originalS3Key: parsed.data.s3Key,
+ mime: parsed.data.mime,
+ });
+ if (!variants.skipped) {
+ const okCount = variants.results.filter((r) => r.ok).length;
+ await recordAudit({
+ scope: "uploads",
+ event: "media.variants",
+ target: media.id,
+ actorEmail: session.user.email ?? null,
+ details: { generated: okCount, total: variants.results.length },
+ });
+ }
+ } catch (e) {
+ console.error("[uploads] variants generation error:", e);
+ }
+
+ return NextResponse.json({ media });
+}
diff --git a/src/app/api/uploads/presign/route.ts b/src/app/api/uploads/presign/route.ts
new file mode 100644
index 0000000..cbf60c6
--- /dev/null
+++ b/src/app/api/uploads/presign/route.ts
@@ -0,0 +1,55 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { presignCarbetUpload } from "@/lib/uploads";
+import { rateLimitRequest } from "@/lib/rate-limit";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ carbetId: z.string().min(1),
+ mime: z.string().min(3).max(100),
+ sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024),
+});
+
+export async function POST(req: Request) {
+ const rl = rateLimitRequest(req, "presign", 60_000, 60);
+ if (!rl.ok) {
+ return NextResponse.json(
+ { error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` },
+ { status: 429 },
+ );
+ }
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Payload invalide" }, { status: 400 });
+ }
+
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: parsed.data.carbetId },
+ select: { id: true, ownerId: true },
+ });
+ if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
+ const isOwner = carbet.ownerId === session.user.id;
+ const isAdmin = session.user.role === UserRole.ADMIN;
+ if (!isOwner && !isAdmin) {
+ return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
+ }
+
+ const result = await presignCarbetUpload({
+ carbetId: carbet.id,
+ mime: parsed.data.mime,
+ sizeBytes: parsed.data.sizeBytes,
+ });
+ if ("error" in result) {
+ return NextResponse.json({ error: result.error }, { status: 400 });
+ }
+ return NextResponse.json(result);
+}
diff --git a/src/app/api/uploads/rental-finalize/route.ts b/src/app/api/uploads/rental-finalize/route.ts
new file mode 100644
index 0000000..befc16f
--- /dev/null
+++ b/src/app/api/uploads/rental-finalize/route.ts
@@ -0,0 +1,105 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { MediaType } from "@/generated/prisma/enums";
+import { recordAudit } from "@/lib/admin/audit";
+import { prisma } from "@/lib/prisma";
+import { canManageRentalProvider } from "@/lib/rental-access";
+import { classifyMime } from "@/lib/uploads";
+import { generateImageVariants } from "@/lib/variants-server";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ itemId: z.string().min(1),
+ s3Key: z.string().min(5).max(500),
+ s3Url: z.string().url(),
+ mime: z.string().min(3).max(100),
+});
+
+export async function POST(req: Request) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues[0]?.message ?? "Payload invalide" },
+ { status: 400 },
+ );
+ }
+ const kind = classifyMime(parsed.data.mime);
+ if (!kind) return NextResponse.json({ error: "Type non supporté" }, { status: 400 });
+
+ const item = await prisma.rentalItem.findUnique({
+ where: { id: parsed.data.itemId },
+ select: { id: true, providerId: true },
+ });
+ if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
+
+ const allowed = await canManageRentalProvider(
+ session.user.id,
+ session.user.role,
+ item.providerId,
+ session.user.organizationId,
+ );
+ if (!allowed) {
+ return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
+ }
+
+ if (!parsed.data.s3Key.startsWith(`rental-items/${item.id}/`)) {
+ return NextResponse.json({ error: "s3Key invalide pour cet item" }, { status: 400 });
+ }
+
+ const existingCount = await prisma.rentalItemMedia.count({ where: { itemId: item.id } });
+ const media = await prisma.rentalItemMedia.create({
+ data: {
+ itemId: item.id,
+ type: kind === "photo" ? MediaType.PHOTO : MediaType.VIDEO,
+ s3Key: parsed.data.s3Key,
+ s3Url: parsed.data.s3Url,
+ sortOrder: existingCount,
+ },
+ select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
+ });
+
+ // Si c'est la première photo de l'item, hydrate imageUrl pour rétro-compat
+ // avec les listings (RentalItemCard, /carbets/[slug] panel).
+ if (existingCount === 0 && kind === "photo") {
+ await prisma.rentalItem.update({
+ where: { id: item.id },
+ data: { imageUrl: parsed.data.s3Url },
+ });
+ }
+
+ await recordAudit({
+ scope: "uploads",
+ event: "rental.media.finalize",
+ target: media.id,
+ actorEmail: session.user.email ?? null,
+ details: { itemId: item.id, kind },
+ });
+
+ try {
+ const variants = await generateImageVariants({
+ originalS3Key: parsed.data.s3Key,
+ mime: parsed.data.mime,
+ });
+ if (!variants.skipped) {
+ const okCount = variants.results.filter((r) => r.ok).length;
+ await recordAudit({
+ scope: "uploads",
+ event: "rental.media.variants",
+ target: media.id,
+ actorEmail: session.user.email ?? null,
+ details: { generated: okCount, total: variants.results.length },
+ });
+ }
+ } catch (e) {
+ console.error("[rental-uploads] variants generation error:", e);
+ }
+
+ return NextResponse.json({ media });
+}
diff --git a/src/app/api/uploads/rental-presign/route.ts b/src/app/api/uploads/rental-presign/route.ts
new file mode 100644
index 0000000..73244e0
--- /dev/null
+++ b/src/app/api/uploads/rental-presign/route.ts
@@ -0,0 +1,63 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { prisma } from "@/lib/prisma";
+import { canManageRentalProvider } from "@/lib/rental-access";
+import { rateLimitRequest } from "@/lib/rate-limit";
+import { presignRentalItemUpload } from "@/lib/uploads";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ itemId: z.string().min(1),
+ mime: z.string().min(3).max(100),
+ sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024),
+});
+
+export async function POST(req: Request) {
+ const rl = rateLimitRequest(req, "rental-presign", 60_000, 60);
+ if (!rl.ok) {
+ return NextResponse.json(
+ { error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` },
+ { status: 429 },
+ );
+ }
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues[0]?.message ?? "Payload invalide" },
+ { status: 400 },
+ );
+ }
+
+ const item = await prisma.rentalItem.findUnique({
+ where: { id: parsed.data.itemId },
+ select: { id: true, providerId: true },
+ });
+ if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
+
+ const allowed = await canManageRentalProvider(
+ session.user.id,
+ session.user.role,
+ item.providerId,
+ session.user.organizationId,
+ );
+ if (!allowed) {
+ return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
+ }
+
+ const result = await presignRentalItemUpload({
+ itemId: item.id,
+ mime: parsed.data.mime,
+ sizeBytes: parsed.data.sizeBytes,
+ });
+ if ("error" in result) {
+ return NextResponse.json({ error: result.error }, { status: 400 });
+ }
+ return NextResponse.json(result);
+}
diff --git a/src/app/carbets/[slug]/_components/CompleteYourStay.tsx b/src/app/carbets/[slug]/_components/CompleteYourStay.tsx
new file mode 100644
index 0000000..a1b8b42
--- /dev/null
+++ b/src/app/carbets/[slug]/_components/CompleteYourStay.tsx
@@ -0,0 +1,105 @@
+import Link from "next/link";
+
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { prisma } from "@/lib/prisma";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
+
+type Props = {
+ river: string;
+ capacity: number;
+};
+
+const EMOJI: Record = {
+ SLEEP: "💤",
+ NAVIGATION: "🛶",
+ FISHING: "🎣",
+ COOKING: "🍳",
+ SAFETY: "🦺",
+};
+
+export async function CompleteYourStay({ river, capacity }: Props) {
+ if (!(await isPluginEnabled("gear-rental"))) return null;
+ const providers = await prisma.rentalProvider.findMany({
+ where: {
+ active: true,
+ approved: true,
+ OR: [
+ { isSystemD: true },
+ { rivers: { has: river } },
+ ],
+ },
+ select: {
+ id: true,
+ items: {
+ where: { active: true },
+ orderBy: [{ category: "asc" }, { pricePerDay: "asc" }],
+ take: 24,
+ select: {
+ id: true,
+ name: true,
+ category: true,
+ imageUrl: true,
+ pricePerDay: true,
+ provider: { select: { name: true, isSystemD: true } },
+ },
+ },
+ },
+ });
+
+ const items = providers.flatMap((p) => p.items).slice(0, 9);
+ if (items.length === 0) return null;
+
+ return (
+
+
+
+
+ {items.map((it) => (
+
+
+
+ {it.imageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
{EMOJI[it.category] ?? "🎒"}
+ )}
+
+
+
{it.name}
+
+ {RENTAL_CATEGORY_LABEL[it.category]}
+
+ {Number(it.pricePerDay).toFixed(0)} €/j
+
+
+ {it.provider.isSystemD ? (
+
+ Karbé
+
+ ) : null}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx
index 6acb95b..dbaeeaf 100644
--- a/src/app/carbets/[slug]/page.tsx
+++ b/src/app/carbets/[slug]/page.tsx
@@ -12,10 +12,18 @@ import {
import { MediaType, UserRole } from "@/generated/prisma/enums";
import { formatAverageRating } from "@/lib/reviews";
+import { isStripeConfigured } from "@/lib/stripe";
+
+import { BookingForm } from "../_components/booking-form";
+import { CompleteYourStay } from "./_components/CompleteYourStay";
import { CarbetGallery } from "../_components/carbet-gallery";
+import { CarbetMap } from "../_components/carbet-map";
import { ReviewsSection } from "../_components/reviews-section";
import { StarRating } from "../_components/star-rating";
import { AccessTypeBadge } from "@/components/AccessTypeBadge";
+import { OperationalBadges } from "@/components/OperationalBadges";
+import { StayConstraints } from "@/components/StayConstraints";
+import { PirogueTransportBlock } from "@/components/PirogueTransportBlock";
type PageProps = {
params: Promise<{ slug: string }>;
@@ -107,6 +115,17 @@ export default async function PublicCarbetPage({ params }: PageProps) {
? ` · Route + ${formatPirogueDuration(carbet.pirogueDurationMin)} pirogue depuis ${carbet.embarkPoint}`
: ` · Route directe (embarquement ${carbet.embarkPoint})`}
+ {carbet.organizations.length > 0 ? (
+
+ Géré par le CE{" "}
+ {carbet.organizations.map((o, i) => (
+
+ {o.name}
+ {i < carbet.organizations.length - 1 ? ", " : ""}
+
+ ))}
+
+ ) : null}
{carbet.reviewStats.count > 0 &&
carbet.reviewStats.averageRating !== null ? (
@@ -125,6 +144,20 @@ export default async function PublicCarbetPage({ params }: PageProps) {
+
+
+ Critères opérationnels
+
+
+
+
@@ -136,6 +169,30 @@ export default async function PublicCarbetPage({ params }: PageProps) {
+
+
+
+
+ Où se trouve ce carbet
+
+
+ Fleuve {carbet.river} · embarquement à{" "}
+ {carbet.embarkPoint}
+
+
+
+
+
+
{carbet.amenities.length > 0 ? (
@@ -197,6 +254,18 @@ export default async function PublicCarbetPage({ params }: PageProps) {
{formatCoordinate(carbet.longitude)}
+ {(carbet.minStayNights || carbet.maxStayNights || carbet.minCapacity) ? (
+
+
Séjour
+
+
+
+
+ ) : null}
Capacité
@@ -207,13 +276,21 @@ export default async function PublicCarbetPage({ params }: PageProps) {
-
- La réservation en ligne arrive bientôt. En attendant, contactez
- l'équipe Karbé pour organiser votre séjour.
-
+
+
+
(null);
+ const [endDate, setEndDate] = useState(null);
+ const [guestCount, setGuestCount] = useState(Math.min(2, capacity));
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [blockedDates, setBlockedDates] = useState>(new Set());
+
+ // Fetch availability sur les 90 prochains jours pour griser/avertir.
+ useEffect(() => {
+ const ctrl = new AbortController();
+ const from = todayPlus(0);
+ const to = todayPlus(90);
+ fetch(`/api/carbets/${carbetId}/availability?from=${from}&to=${to}`, { signal: ctrl.signal })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((j) => {
+ if (!j?.calendar) return;
+ const blocked = new Set();
+ for (const d of j.calendar as { date: string; isAvailable: boolean }[]) {
+ if (!d.isAvailable) blocked.add(d.date);
+ }
+ setBlockedDates(blocked);
+ })
+ .catch(() => {});
+ return () => ctrl.abort();
+ }, [carbetId]);
+
+ const nights = useMemo(
+ () => (startDate && endDate ? Math.max(0, diffDays(startDate, endDate)) : 0),
+ [startDate, endDate],
+ );
+ const total = nights * nightlyPrice;
+ const minN = minStayNights ?? 1;
+ const maxN = maxStayNights ?? 365;
+ const datesSelected = Boolean(startDate && endDate);
+ const nightsOk = datesSelected && nights >= minN && nights <= maxN;
+ const guestOk = guestCount >= 1 && guestCount <= capacity;
+
+ const canSubmit = nightsOk && guestOk && !busy;
+
+ async function submit() {
+ if (!isAuthenticated) {
+ const next = `/carbets/${slug}`;
+ router.push(`/connexion?next=${encodeURIComponent(next)}`);
+ return;
+ }
+ setBusy(true);
+ setError(null);
+ try {
+ if (stripeEnabled) {
+ // Checkout Stripe : crée la résa + une session Checkout, redirige le user.
+ const res = await fetch("/api/stripe/checkout/booking", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ carbetId,
+ startDate,
+ endDate,
+ guestCount,
+ amount: nights * nightlyPrice,
+ currency: "EUR",
+ }),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(json?.error || `Erreur ${res.status}`);
+ }
+ if (json.checkoutUrl) {
+ window.location.assign(json.checkoutUrl);
+ return;
+ }
+ // Fallback si pas d'URL retournée → page de la résa créée.
+ router.push(`/reservations/${json.bookingId ?? ""}`);
+ return;
+ }
+
+ // Pas de Stripe configuré → flux direct, résa en PENDING manuel.
+ const res = await fetch("/api/bookings", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ carbetId, startDate, endDate, guestCount }),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(json?.error || `Erreur ${res.status}`);
+ }
+ router.push(`/reservations/${json.id ?? json.booking?.id ?? ""}`);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+ {nightlyPrice.toFixed(0)} €
+ / nuit
+
+
jusqu'à {capacity} voyageurs
+
+
+
{
+ setStartDate(s);
+ setEndDate(e);
+ setError(null);
+ }}
+ />
+
+ {datesSelected ? (
+
+
+ {startDate} → {endDate}
+
+ {
+ setStartDate(null);
+ setEndDate(null);
+ }}
+ className="text-zinc-500 hover:text-zinc-900"
+ >
+ Réinitialiser
+
+
+ ) : null}
+
+
+ Voyageurs
+ setGuestCount(Math.max(1, Math.min(capacity, Number(e.target.value) || 1)))}
+ inputMode="numeric"
+ className="mt-0.5 block w-full min-h-[44px] rounded-md border border-zinc-300 px-3 py-2 text-base"
+ />
+
+
+ {datesSelected ? (
+
+
+
+ {nightlyPrice.toFixed(0)} € × {nights} nuit{nights > 1 ? "s" : ""}
+
+ {(nightlyPrice * nights).toFixed(2)} €
+
+
+ Total
+ {total.toFixed(2)} €
+
+
+ ) : null}
+
+ {datesSelected && !nightsOk ? (
+
+ Séjour entre {minN} et {maxN} nuits requis.
+
+ ) : null}
+
+ {error ? (
+ {error}
+ ) : null}
+
+
+ {busy
+ ? "Envoi…"
+ : !isAuthenticated
+ ? "Se connecter pour réserver"
+ : stripeEnabled
+ ? "Payer et réserver"
+ : "Réserver"}
+
+
+ {!isAuthenticated ? (
+
+ Pas encore de compte ?{" "}
+
+ Créer un compte
+
+
+ ) : null}
+
+
+ {stripeEnabled
+ ? "Vous serez redirigé vers Stripe pour le paiement sécurisé."
+ : "Le créneau est bloqué dès l'envoi. Statut « En attente » jusqu'à confirmation."}
+
+
+ );
+}
diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx
index a757b1a..f32cc8e 100644
--- a/src/app/carbets/_components/carbet-card.tsx
+++ b/src/app/carbets/_components/carbet-card.tsx
@@ -3,7 +3,10 @@ import Link from "next/link";
import type { CarbetSearchResult } from "@/lib/carbet-search";
import { formatPirogueDuration, truncate } from "@/lib/format";
import { formatAverageRating } from "@/lib/reviews";
+import { buildSrcSet } from "@/lib/image-variants";
import { AccessTypeBadge } from "@/components/AccessTypeBadge";
+import { OperationalBadges } from "@/components/OperationalBadges";
+import { StayConstraints } from "@/components/StayConstraints";
import { StarRating } from "./star-rating";
@@ -13,13 +16,14 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
{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
) : (
@@ -38,9 +42,25 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
- Fleuve {carbet.river} · {carbet.capacity} voyageur
- {carbet.capacity > 1 ? "s" : ""}
+ Fleuve {carbet.river}
+
+
+
+
+
+
{carbet.reviewCount > 0 && carbet.averageRating !== null ? (
diff --git a/src/app/carbets/_components/carbet-gallery.tsx b/src/app/carbets/_components/carbet-gallery.tsx
index 807adda..4122a35 100644
--- a/src/app/carbets/_components/carbet-gallery.tsx
+++ b/src/app/carbets/_components/carbet-gallery.tsx
@@ -1,14 +1,46 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+
import type { PublicCarbetMedia } from "@/lib/carbet-public";
import { MediaType } from "@/generated/prisma/enums";
+import { buildSrcSet } from "@/lib/image-variants";
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.
+/**
+ * Galerie publique : grille de vignettes ; clic = lightbox plein écran avec
+ * navigation prev/next, fermeture par Esc ou clic backdrop. Pas de dep externe.
+ */
export function CarbetGallery({ title, media }: Props) {
+ const [active, setActive] = useState(null);
+
+ const close = useCallback(() => setActive(null), []);
+ const next = useCallback(() => {
+ setActive((i) => (i === null ? null : (i + 1) % media.length));
+ }, [media.length]);
+ const prev = useCallback(() => {
+ setActive((i) => (i === null ? null : (i - 1 + media.length) % media.length));
+ }, [media.length]);
+
+ useEffect(() => {
+ if (active === null) return;
+ function onKey(e: KeyboardEvent) {
+ if (e.key === "Escape") close();
+ else if (e.key === "ArrowRight") next();
+ else if (e.key === "ArrowLeft") prev();
+ }
+ window.addEventListener("keydown", onKey);
+ document.body.style.overflow = "hidden";
+ return () => {
+ window.removeEventListener("keydown", onKey);
+ document.body.style.overflow = "";
+ };
+ }, [active, close, next, prev]);
+
if (media.length === 0) {
return (
@@ -17,57 +49,159 @@ export function CarbetGallery({ title, media }: Props) {
);
}
- const [cover, ...rest] = media;
+ const cover = media[0];
+ const rest = media.slice(1);
+ const current = active === null ? null : media[active];
return (
-
-
- {cover.type === MediaType.VIDEO ? (
-
- ) : (
- // eslint-disable-next-line @next/next/no-img-element
-
- )}
-
+ <>
+
+
setActive(0)}
+ className="block w-full overflow-hidden rounded-lg bg-zinc-100 transition hover:opacity-95"
+ aria-label="Ouvrir la photo principale en grand"
+ >
+ {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
-
- )}
-
- ))}
-
+ {rest.length > 0 ? (
+
+ {rest.map((item, idx) => (
+
+ setActive(idx + 1)}
+ className="block w-full"
+ aria-label="Ouvrir en grand"
+ >
+ {item.type === MediaType.VIDEO ? (
+
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+
+ ))}
+
+ ) : null}
+
+
+ {current ? (
+
+
+
+
+
+
+
+ {media.length > 1 ? (
+ <>
+
{
+ e.stopPropagation();
+ prev();
+ }}
+ className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
+ aria-label="Précédent"
+ >
+
+
+
+
+
{
+ e.stopPropagation();
+ next();
+ }}
+ className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
+ aria-label="Suivant"
+ >
+
+
+
+
+ >
+ ) : null}
+
+
e.stopPropagation()}
+ >
+ {current.type === MediaType.VIDEO ? (
+
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+
+
+ {active! + 1} / {media.length}
+
+
) : null}
-
+ >
);
}
diff --git a/src/app/carbets/_components/carbet-map-inner.tsx b/src/app/carbets/_components/carbet-map-inner.tsx
new file mode 100644
index 0000000..63d1ab6
--- /dev/null
+++ b/src/app/carbets/_components/carbet-map-inner.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
+import L from "leaflet";
+
+import "leaflet/dist/leaflet.css";
+
+// Fix icône Leaflet (les paths par défaut pointent vers un CDN qui n'existe plus).
+// On utilise un SVG inline en data URL.
+const ICON = L.divIcon({
+ className: "karbe-leaflet-marker",
+ html: `
+
+ `,
+ iconSize: [32, 40],
+ iconAnchor: [16, 40],
+ popupAnchor: [0, -36],
+});
+
+type Props = {
+ latitude: number;
+ longitude: number;
+ title: string;
+ river: string;
+ embarkPoint: string;
+};
+
+export function CarbetMapInner({ latitude, longitude, title, river, embarkPoint }: Props) {
+ const position: [number, number] = [latitude, longitude];
+
+ return (
+
+ );
+}
diff --git a/src/app/carbets/_components/carbet-map.tsx b/src/app/carbets/_components/carbet-map.tsx
new file mode 100644
index 0000000..31b9718
--- /dev/null
+++ b/src/app/carbets/_components/carbet-map.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+/**
+ * Carte interactive sur la fiche carbet — Leaflet + OpenStreetMap.
+ *
+ * Chargée dynamiquement (ssr:false) car Leaflet manipule window.
+ */
+
+import dynamic from "next/dynamic";
+
+const CarbetMapInner = dynamic(
+ () => import("./carbet-map-inner").then((m) => m.CarbetMapInner),
+ {
+ ssr: false,
+ loading: () => (
+
+ ),
+ },
+);
+
+type Props = {
+ latitude: number;
+ longitude: number;
+ title: string;
+ river: string;
+ embarkPoint: string;
+};
+
+export function CarbetMap(props: Props) {
+ return
;
+}
diff --git a/src/app/carbets/_components/catalog-map-inner.tsx b/src/app/carbets/_components/catalog-map-inner.tsx
new file mode 100644
index 0000000..1abac02
--- /dev/null
+++ b/src/app/carbets/_components/catalog-map-inner.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import { useMemo } from "react";
+import Link from "next/link";
+import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
+import L, { LatLngBoundsExpression } from "leaflet";
+
+import "leaflet/dist/leaflet.css";
+
+import type { CatalogMapPoint } from "./catalog-map";
+
+const ICON = L.divIcon({
+ className: "karbe-catalog-marker",
+ html: `
+
+ `,
+ iconSize: [28, 36],
+ iconAnchor: [14, 36],
+ popupAnchor: [0, -32],
+});
+
+export function CatalogMapInner({ points }: { points: CatalogMapPoint[] }) {
+ const bounds = useMemo
(() => {
+ if (points.length === 0) {
+ // Centre par défaut sur la Guyane (Cayenne).
+ return [
+ [3.5, -54.5],
+ [5.5, -52.0],
+ ];
+ }
+ const lats = points.map((p) => p.latitude);
+ const lngs = points.map((p) => p.longitude);
+ const minLat = Math.min(...lats);
+ const maxLat = Math.max(...lats);
+ const minLng = Math.min(...lngs);
+ const maxLng = Math.max(...lngs);
+ // Padding 0.1°
+ return [
+ [minLat - 0.1, minLng - 0.1],
+ [maxLat + 0.1, maxLng + 0.1],
+ ];
+ }, [points]);
+
+ return (
+
+
+
+ {points.map((p) => (
+
+
+
+ {p.coverUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+
{p.title}
+
+
+ Fleuve {p.river}
+
+
+
+ {Number(p.nightlyPrice).toFixed(0)} €
+
+
/ nuit
+
+
+ Voir la fiche →
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/carbets/_components/catalog-map.tsx b/src/app/carbets/_components/catalog-map.tsx
new file mode 100644
index 0000000..5f65463
--- /dev/null
+++ b/src/app/carbets/_components/catalog-map.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import dynamic from "next/dynamic";
+
+const CatalogMapInner = dynamic(
+ () => import("./catalog-map-inner").then((m) => m.CatalogMapInner),
+ {
+ ssr: false,
+ loading: () => (
+
+ ),
+ },
+);
+
+export type CatalogMapPoint = {
+ id: string;
+ slug: string;
+ title: string;
+ river: string;
+ nightlyPrice: string;
+ latitude: number;
+ longitude: number;
+ coverUrl: string | null;
+};
+
+export function CatalogMap({ points }: { points: CatalogMapPoint[] }) {
+ if (points.length === 0) return null;
+ return ;
+}
diff --git a/src/app/carbets/_components/mini-calendar.tsx b/src/app/carbets/_components/mini-calendar.tsx
new file mode 100644
index 0000000..bdcbb0d
--- /dev/null
+++ b/src/app/carbets/_components/mini-calendar.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import { useMemo, useState } from "react";
+
+type Props = {
+ startDate: string | null;
+ endDate: string | null;
+ blockedDates: Set;
+ onChange: (start: string | null, end: string | null) => void;
+};
+
+const MONTH_LABEL = [
+ "Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
+ "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
+];
+const DOW_LABEL = ["L", "M", "M", "J", "V", "S", "D"];
+
+function isoDay(d: Date): string {
+ return d.toISOString().slice(0, 10);
+}
+
+function startOfMonth(d: Date): Date {
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
+}
+
+function addMonths(d: Date, n: number): Date {
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
+}
+
+/** Génère la grille du mois : 6 semaines × 7 jours, en commençant un lundi. */
+function monthGrid(monthStart: Date): (Date | null)[] {
+ const year = monthStart.getUTCFullYear();
+ const month = monthStart.getUTCMonth();
+ // Premier jour du mois — décale pour que la semaine commence un lundi (0=L, 6=D)
+ const firstDay = new Date(Date.UTC(year, month, 1));
+ const firstDow = (firstDay.getUTCDay() + 6) % 7;
+ const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
+ const cells: (Date | null)[] = [];
+ for (let i = 0; i < firstDow; i++) cells.push(null);
+ for (let d = 1; d <= lastDay; d++) {
+ cells.push(new Date(Date.UTC(year, month, d)));
+ }
+ while (cells.length % 7 !== 0) cells.push(null);
+ // Toujours 6 lignes pour éviter le saut de hauteur
+ while (cells.length < 42) cells.push(null);
+ return cells;
+}
+
+export function MiniCalendar({ startDate, endDate, blockedDates, onChange }: Props) {
+ const today = useMemo(() => {
+ const d = new Date();
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
+ }, []);
+
+ const [viewMonth, setViewMonth] = useState(() => {
+ const ref = startDate ? new Date(startDate + "T00:00:00Z") : today;
+ return startOfMonth(ref);
+ });
+
+ const cells = useMemo(() => monthGrid(viewMonth), [viewMonth]);
+
+ const startISO = startDate;
+ const endISO = endDate;
+
+ function onClick(day: Date) {
+ const iso = isoDay(day);
+ if (day.getTime() < today.getTime()) return;
+ if (blockedDates.has(iso)) return;
+
+ // Aucune sélection ou les deux déjà posées → reset + nouvelle start
+ if (!startISO || (startISO && endISO)) {
+ onChange(iso, null);
+ return;
+ }
+ // Une seule (start) déjà sélectionnée
+ if (iso === startISO) {
+ onChange(null, null);
+ return;
+ }
+ if (iso < startISO) {
+ onChange(iso, null);
+ return;
+ }
+ // Vérifie qu'aucun jour intermédiaire n'est bloqué
+ const startMs = new Date(startISO + "T00:00:00Z").getTime();
+ const endMs = day.getTime();
+ for (let t = startMs; t < endMs; t += 86_400_000) {
+ const d = new Date(t).toISOString().slice(0, 10);
+ if (blockedDates.has(d)) {
+ // Tombe sur un jour bloqué → on resélectionne start
+ onChange(iso, null);
+ return;
+ }
+ }
+ onChange(startISO, iso);
+ }
+
+ const canGoBack = viewMonth > startOfMonth(today);
+
+ return (
+
+
+ setViewMonth(addMonths(viewMonth, -1))}
+ className="rounded p-1 text-zinc-600 hover:bg-zinc-100 disabled:opacity-30"
+ aria-label="Mois précédent"
+ >
+
+
+
+
+
+ {MONTH_LABEL[viewMonth.getUTCMonth()]} {viewMonth.getUTCFullYear()}
+
+ setViewMonth(addMonths(viewMonth, 1))}
+ className="rounded p-1 text-zinc-600 hover:bg-zinc-100"
+ aria-label="Mois suivant"
+ >
+
+
+
+
+
+
+
+ {DOW_LABEL.map((d, i) => (
+
+ {d}
+
+ ))}
+
+
+
+ {cells.map((cell, i) => {
+ if (!cell) return
;
+ const iso = isoDay(cell);
+ const isPast = cell.getTime() < today.getTime();
+ const isBlocked = blockedDates.has(iso);
+ const isStart = iso === startISO;
+ const isEnd = iso === endISO;
+ const inRange = startISO && endISO && iso > startISO && iso < endISO;
+ const isToday = iso === isoDay(today);
+ const disabled = isPast || isBlocked;
+
+ let cls =
+ "relative h-7 rounded text-xs flex items-center justify-center transition";
+ if (disabled) {
+ cls += " text-zinc-300 cursor-not-allowed";
+ if (isBlocked && !isPast) cls += " line-through";
+ } else if (isStart || isEnd) {
+ cls += " bg-emerald-600 text-white font-semibold cursor-pointer";
+ } else if (inRange) {
+ cls += " bg-emerald-100 text-emerald-900 cursor-pointer";
+ } else {
+ cls += " text-zinc-800 hover:bg-zinc-100 cursor-pointer";
+ if (isToday) cls += " ring-1 ring-zinc-400";
+ }
+
+ return (
+
onClick(cell)}
+ className={cls}
+ >
+ {cell.getUTCDate()}
+
+ );
+ })}
+
+
+
+ {!startISO
+ ? "Choisissez votre date d'arrivée."
+ : !endISO
+ ? "Choisissez votre date de départ."
+ : ""}
+
+
+ );
+}
diff --git a/src/app/carbets/_components/search-filters.tsx b/src/app/carbets/_components/search-filters.tsx
index 999f66c..13454f4 100644
--- a/src/app/carbets/_components/search-filters.tsx
+++ b/src/app/carbets/_components/search-filters.tsx
@@ -1,6 +1,8 @@
import Link from "next/link";
import type { CarbetSearchFilters } from "@/lib/carbet-search";
+import { AMENITY_CATALOG } from "@/lib/amenities";
+import { Electricity, RoadAccess } from "@/generated/prisma/enums";
type SearchFiltersProps = {
filters: CarbetSearchFilters;
@@ -61,18 +63,165 @@ export function SearchFilters({ filters, rivers }: SearchFiltersProps) {
- Voyageurs
+ Voyageurs min
+
+ Voyageurs max
+
+
+
+
+ Budget max (€/nuit)
+
+
+
+
+ Accès route
+
+ {[
+ { value: RoadAccess.ALL_YEAR, label: "🛣️ Route toute saison" },
+ { value: RoadAccess.DRY_SEASON_ONLY, label: "🟠 Route saison sèche" },
+ { value: RoadAccess.NONE, label: "🛶 Pirogue uniquement" },
+ ].map((opt) => {
+ const checked = (filters.roadAccess ?? []).includes(opt.value);
+ return (
+
+
+ {opt.label}
+
+ );
+ })}
+
+
+
+
+ Électricité
+
+ {[
+ { value: Electricity.EDF, label: "⚡ EDF / raccordé" },
+ { value: Electricity.GENERATOR_READY, label: "🔌 Préinstall groupe" },
+ { value: Electricity.SOLAR, label: "☀️ Solaire" },
+ { value: Electricity.NONE, label: "🕯️ Aucune" },
+ ].map((opt) => {
+ const checked = (filters.electricity ?? []).includes(opt.value);
+ return (
+
+
+ {opt.label}
+
+ );
+ })}
+
+
+
+
+
+ 📶 Réseau GSM accessible — distance max{" "}
+
+ {filters.gsmMaxKm === 0 ? "(au carbet)" : filters.gsmMaxKm ? `≤ ${filters.gsmMaxKm} km` : "(non filtré)"}
+
+
+
+ Au carbet
+
+ 10 km
+
+
+ 0 km = exige le réseau directement au carbet · 10 km = peu importe.
+
+
+
+
+ Équipements souhaités
+
+ {AMENITY_CATALOG.map((a) => {
+ const checked = (filters.amenities ?? []).includes(a.key);
+ return (
+
+
+ {a.label}
+
+ );
+ })}
+
+
+
+
+ Profils de séjour
+
+
+ {SEARCH_PROFILES.map((p) => (
+
+
+ {p.emoji}
+ {p.label}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/carbets/page.tsx b/src/app/carbets/page.tsx
index a49ed1b..512c79f 100644
--- a/src/app/carbets/page.tsx
+++ b/src/app/carbets/page.tsx
@@ -8,7 +8,9 @@ import {
} from "@/lib/carbet-search";
import { CarbetCard } from "./_components/carbet-card";
+import { CatalogMap } from "./_components/catalog-map";
import { SearchFilters } from "./_components/search-filters";
+import { SearchProfiles } from "./_components/search-profiles";
export const metadata: Metadata = {
title: "Rechercher un carbet",
@@ -56,6 +58,7 @@ export default async function CarbetsSearchPage({
+
@@ -72,6 +75,20 @@ export default async function CarbetsSearchPage({
{results.length} carbet{results.length > 1 ? "s" : ""} trouvé
{results.length > 1 ? "s" : ""}.
+
+ ({
+ id: c.id,
+ slug: c.slug,
+ title: c.title,
+ river: c.river,
+ nightlyPrice: c.nightlyPrice,
+ latitude: c.latitude,
+ longitude: c.longitude,
+ coverUrl: c.coverUrl,
+ }))}
+ />
+
+
+
+
+ Critères opérationnels
+
+
+ Les 4 dealbreakers d'un séjour en carbet. Ces critères apparaissent
+ en grand sur votre fiche et alimentent les filtres recherche.
+
+
+
+
+
+ 🛣️ Accès route
+
+
+ — non précisé —
+ 🛣️ Toute saison
+ 🟠 Saison sèche uniquement
+ 🛶 Pirogue uniquement
+
+
+
+
+
+ ⚡ Électricité
+
+
+ — non précisé —
+ ⚡ EDF / raccordé réseau
+ 🔌 Préinstall groupe électrogène
+ ☀️ Solaire
+ 🕯️ Aucune électricité
+
+
+
+
+
+ 📶 Réseau GSM au carbet
+
+
+ ✅ Oui, signal au carbet
+ ❌ Non, zone sans réseau
+
+
+
+
+
+ 📵 Distance pour atteindre le réseau (km)
+
+
+
+ Laissez vide si réseau au carbet
+
+
+
+
+
+
Commodités
diff --git a/src/app/espace-hote/carbets/actions.ts b/src/app/espace-hote/carbets/actions.ts
index 8964376..470d1ad 100644
--- a/src/app/espace-hote/carbets/actions.ts
+++ b/src/app/espace-hote/carbets/actions.ts
@@ -9,7 +9,7 @@ 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 { CarbetStatus, Electricity, RoadAccess, UserRole } from "@/generated/prisma/enums";
import type { CarbetFormState } from "./form-types";
@@ -22,10 +22,26 @@ type ParsedCarbet = {
embarkPoint: string;
pirogueDurationMin: number;
capacity: number;
+ roadAccess: RoadAccess | null;
+ electricity: Electricity | null;
+ gsmAtCarbet: boolean;
+ gsmExitDistanceKm: number | null;
status: CarbetStatus;
amenities: string[];
};
+function isRoadAccess(v: string): v is RoadAccess {
+ return v === RoadAccess.NONE || v === RoadAccess.DRY_SEASON_ONLY || v === RoadAccess.ALL_YEAR;
+}
+function isElectricity(v: string): v is Electricity {
+ return (
+ v === Electricity.NONE ||
+ v === Electricity.SOLAR ||
+ v === Electricity.GENERATOR_READY ||
+ v === Electricity.EDF
+ );
+}
+
function isCarbetStatus(value: string): value is CarbetStatus {
return (Object.values(CarbetStatus) as string[]).includes(value);
}
@@ -107,6 +123,29 @@ function parseCarbetForm(formData: FormData): {
const status = isCarbetStatus(statusRaw) ? statusRaw : CarbetStatus.DRAFT;
+ // Critères opérationnels
+ const roadAccessRaw = String(formData.get("roadAccess") ?? "").trim();
+ const roadAccess = isRoadAccess(roadAccessRaw) ? roadAccessRaw : null;
+
+ const electricityRaw = String(formData.get("electricity") ?? "").trim();
+ const electricity = isElectricity(electricityRaw) ? electricityRaw : null;
+
+ const gsmAtCarbet = String(formData.get("gsmAtCarbet") ?? "no") === "yes";
+
+ const gsmExitRaw = String(formData.get("gsmExitDistanceKm") ?? "").trim();
+ let gsmExitDistanceKm: number | null = null;
+ if (gsmExitRaw) {
+ const n = Number(gsmExitRaw);
+ if (Number.isFinite(n) && n >= 0 && n <= 50) {
+ gsmExitDistanceKm = n;
+ } else {
+ errors.gsmExitDistanceKm = "Distance invalide (0 à 50 km).";
+ }
+ }
+
+ // Cohérence : si GSM au carbet, on ignore la distance
+ const finalGsmExitDistanceKm = gsmAtCarbet ? null : gsmExitDistanceKm;
+
return {
data: {
title,
@@ -117,6 +156,10 @@ function parseCarbetForm(formData: FormData): {
embarkPoint,
pirogueDurationMin,
capacity,
+ roadAccess,
+ electricity,
+ gsmAtCarbet,
+ gsmExitDistanceKm: finalGsmExitDistanceKm,
status,
amenities,
},
@@ -170,6 +213,10 @@ export async function createCarbet(
const slug = await ensureUniqueCarbetSlug(data.title);
+ // Si CE_MANAGER : on lie automatiquement le carbet à son org via OrganizationCarbetMembership.
+ const isCeCreator =
+ session.user.role === UserRole.CE_MANAGER && Boolean(session.user.organizationId);
+
const carbet = await prisma.$transaction(async (tx) => {
const created = await tx.carbet.create({
data: {
@@ -183,14 +230,31 @@ export async function createCarbet(
embarkPoint: data.embarkPoint,
pirogueDurationMin: data.pirogueDurationMin,
capacity: data.capacity,
+ roadAccess: data.roadAccess,
+ electricity: data.electricity,
+ gsmAtCarbet: data.gsmAtCarbet,
+ gsmExitDistanceKm: data.gsmExitDistanceKm,
status: CarbetStatus.DRAFT,
},
select: { id: true },
});
await syncAmenities(tx, created.id, data.amenities);
+ if (isCeCreator) {
+ await tx.organizationCarbetMembership.create({
+ data: {
+ organizationId: session.user.organizationId!,
+ carbetId: created.id,
+ addedByUserId: session.user.id,
+ },
+ });
+ }
return created;
});
+ if (isCeCreator) {
+ revalidatePath("/espace-ce/carbets");
+ redirect(`/espace-ce/carbets/${carbet.id}`);
+ }
revalidatePath("/espace-hote/carbets");
redirect(`/espace-hote/carbets/${carbet.id}`);
}
@@ -204,10 +268,17 @@ export async function updateCarbet(
const existing = await prisma.carbet.findUnique({
where: { id: carbetId },
- select: { ownerId: true, _count: { select: { media: true } } },
+ select: {
+ ownerId: true,
+ organizations: { select: { organizationId: true } },
+ _count: { select: { media: true } },
+ },
});
- if (!existing || !canManageCarbet(session, existing.ownerId)) {
+ if (
+ !existing ||
+ !canManageCarbet(session, existing.ownerId, existing.organizations.map((o) => o.organizationId))
+ ) {
return {
ok: false,
errors: { _global: "Carbet introuvable ou accès refusé." },
@@ -239,6 +310,10 @@ export async function updateCarbet(
embarkPoint: data.embarkPoint,
pirogueDurationMin: data.pirogueDurationMin,
capacity: data.capacity,
+ roadAccess: data.roadAccess,
+ electricity: data.electricity,
+ gsmAtCarbet: data.gsmAtCarbet,
+ gsmExitDistanceKm: data.gsmExitDistanceKm,
status: data.status,
},
});
@@ -262,10 +337,17 @@ export async function setCarbetStatus(formData: FormData): Promise
{
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
- select: { ownerId: true, _count: { select: { media: true } } },
+ select: {
+ ownerId: true,
+ organizations: { select: { organizationId: true } },
+ _count: { select: { media: true } },
+ },
});
- if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
+ if (
+ !carbet ||
+ !canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
+ ) {
notFound();
}
@@ -289,10 +371,17 @@ export async function deleteCarbet(formData: FormData): Promise {
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
- select: { ownerId: true, media: { select: { s3Key: true } } },
+ select: {
+ ownerId: true,
+ organizations: { select: { organizationId: true } },
+ media: { select: { s3Key: true } },
+ },
});
- if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
+ if (
+ !carbet ||
+ !canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
+ ) {
notFound();
}
@@ -315,10 +404,17 @@ export async function reorderMedia(
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
- select: { ownerId: true, media: { select: { id: true } } },
+ select: {
+ ownerId: true,
+ organizations: { select: { organizationId: true } },
+ media: { select: { id: true } },
+ },
});
- if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
+ if (
+ !carbet ||
+ !canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
+ ) {
return { ok: false };
}
@@ -345,10 +441,26 @@ export async function deleteMedia(
const media = await prisma.media.findUnique({
where: { id: mediaId },
- select: { s3Key: true, carbetId: true, carbet: { select: { ownerId: true } } },
+ select: {
+ s3Key: true,
+ carbetId: true,
+ carbet: {
+ select: {
+ ownerId: true,
+ organizations: { select: { organizationId: true } },
+ },
+ },
+ },
});
- if (!media || !canManageCarbet(session, media.carbet.ownerId)) {
+ if (
+ !media ||
+ !canManageCarbet(
+ session,
+ media.carbet.ownerId,
+ media.carbet.organizations.map((o) => o.organizationId),
+ )
+ ) {
return { ok: false };
}
diff --git a/src/app/espace-hote/page.tsx b/src/app/espace-hote/page.tsx
index d412d73..32f3d03 100644
--- a/src/app/espace-hote/page.tsx
+++ b/src/app/espace-hote/page.tsx
@@ -1,25 +1,287 @@
import Link from "next/link";
+import { auth } from "@/auth";
import { requireRole } from "@/lib/authorization";
+import { BookingStatus, UserRole } from "@/generated/prisma/enums";
+import {
+ getHostKpis,
+ listHostCarbets,
+ listHostRecentBookings,
+ isScopeAdmin,
+} from "@/lib/host-dashboard";
-export default async function HostPage() {
- const session = await requireRole(["OWNER", "ADMIN"]);
+import { BookingDecision } from "./_components/BookingDecision";
+
+export const dynamic = "force-dynamic";
+
+const STATUS_TONES: Record = {
+ PENDING: "bg-sky-100 text-sky-800 ring-sky-300",
+ CONFIRMED: "bg-emerald-100 text-emerald-800 ring-emerald-300",
+ CANCELLED: "bg-rose-100 text-rose-700 ring-rose-300",
+ COMPLETED: "bg-zinc-100 text-zinc-700 ring-zinc-300",
+ SUCCEEDED: "bg-emerald-100 text-emerald-800 ring-emerald-300",
+ REFUNDED: "bg-amber-100 text-amber-800 ring-amber-300",
+ FAILED: "bg-rose-100 text-rose-700 ring-rose-300",
+ AUTHORIZED: "bg-indigo-100 text-indigo-800 ring-indigo-300",
+ DRAFT: "bg-zinc-100 text-zinc-700 ring-zinc-300",
+ PUBLISHED: "bg-emerald-100 text-emerald-800 ring-emerald-300",
+ ARCHIVED: "bg-amber-100 text-amber-800 ring-amber-300",
+};
+
+const STATUS_LABEL: Record = {
+ PENDING: "En attente",
+ CONFIRMED: "Confirmée",
+ CANCELLED: "Annulée",
+ COMPLETED: "Terminée",
+ SUCCEEDED: "Payé",
+ REFUNDED: "Remboursé",
+ FAILED: "Échec",
+ AUTHORIZED: "Autorisé",
+ DRAFT: "Brouillon",
+ PUBLISHED: "Publié",
+ ARCHIVED: "Archivé",
+};
+
+function Badge({ value }: { value: string }) {
+ const tone = STATUS_TONES[value] ?? STATUS_TONES.PENDING;
+ return (
+
+ {STATUS_LABEL[value] ?? value}
+
+ );
+}
+
+function fmtEur(amount: string, currency: string): string {
+ const n = Number(amount);
+ return n.toLocaleString("fr-FR", { style: "currency", currency: currency || "EUR" });
+}
+
+const dateFmt = new Intl.DateTimeFormat("fr-FR", {
+ day: "2-digit",
+ month: "short",
+ year: "2-digit",
+});
+
+export default async function HostDashboardPage() {
+ await requireRole([UserRole.OWNER, UserRole.ADMIN]);
+ const session = await auth();
+ const userId = session!.user.id;
+ const isAdmin = isScopeAdmin(session?.user?.role);
+ const scope = { ownerId: userId, isAdmin };
+
+ const [kpis, recent, carbets] = await Promise.all([
+ getHostKpis(scope),
+ listHostRecentBookings(scope, 12),
+ listHostCarbets(scope),
+ ]);
+
+ const pendingBookings = recent.filter((b) => b.status === BookingStatus.PENDING);
return (
-
- Espace hôte
-
- Accès autorisé pour {session.user.email} ({session.user.role}).
-
+
+
-
-
- Gérer mes carbets
-
-
+
+
+
+
+ 0 ? "warn" : "neutral"}
+ />
+
+
+
+
+ {kpis.nextArrival ? (
+
+ Prochaine arrivée
+
+ {kpis.nextArrival.tenantName} · {kpis.nextArrival.carbetTitle}
+
+
+ {dateFmt.format(kpis.nextArrival.startDate)}
+
+
+ ) : null}
+
+ {pendingBookings.length > 0 ? (
+
+
+ Demandes en attente ({pendingBookings.length})
+
+
+ {pendingBookings.map((b) => (
+
+
+
+ {b.tenantName} — {b.carbetTitle}
+
+
+ {dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)} ·{" "}
+ {b.guestCount} pers · {fmtEur(b.amount, b.currency)}
+
+
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+ Mes carbets ({carbets.length})
+
+ {carbets.length === 0 ? (
+
+ Aucun carbet pour l'instant.{" "}
+
+ Créer mon premier carbet
+
+
+ ) : (
+
+
+
+
+ Titre
+ Fleuve
+ €/nuit
+ Cap.
+ Médias
+ Résas
+ Avis
+ Statut
+
+
+
+ {carbets.map((c) => (
+
+
+
+ {c.title}
+
+
+ /{c.slug}
+
+
+ {c.river}
+
+ {Number(c.nightlyPrice).toFixed(0)}
+
+ {c.capacity}
+
+ {c._count.media}
+
+
+ {c._count.bookings}
+
+
+ {c._count.reviews}
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ {recent.length > 0 ? (
+
+
+ Activité récente
+
+
+
+
+
+ Carbet
+ Locataire
+ Séjour
+ Montant
+ Résa
+ Paiement
+
+
+
+ {recent.map((b) => (
+
+ {b.carbetTitle}
+ {b.tenantName}
+
+ {dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)}
+
+
+ {fmtEur(b.amount, b.currency)}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ ) : null}
);
}
+
+function Kpi({
+ label,
+ value,
+ tone = "neutral",
+}: {
+ label: string;
+ value: string;
+ tone?: "neutral" | "warn";
+}) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+}
diff --git a/src/app/espace-prestataire/actions.ts b/src/app/espace-prestataire/actions.ts
new file mode 100644
index 0000000..0d790a5
--- /dev/null
+++ b/src/app/espace-prestataire/actions.ts
@@ -0,0 +1,250 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { RentalBookingStatus, RentalCategory, UserRole } from "@/generated/prisma/enums";
+import { canManageRentalProvider, getCurrentRentalProvider } from "@/lib/rental-access";
+import { recordAudit } from "@/lib/admin/audit";
+import { prisma } from "@/lib/prisma";
+
+const itemSchema = z.object({
+ category: z.enum([
+ RentalCategory.SLEEP,
+ RentalCategory.NAVIGATION,
+ RentalCategory.FISHING,
+ RentalCategory.COOKING,
+ RentalCategory.SAFETY,
+ ]),
+ name: z.string().trim().min(2).max(200),
+ description: z.string().trim().max(5000).nullable().optional(),
+ imageUrl: z.string().trim().url().max(500).nullable().optional(),
+ pricePerDay: z.coerce.number().min(0).max(10000),
+ pricePerWeek: z.coerce.number().min(0).max(50000).nullable().optional(),
+ deposit: z.coerce.number().min(0).max(10000),
+ totalQty: z.coerce.number().int().min(1).max(1000),
+ withMotor: z.boolean(),
+ fuelIncluded: z.boolean(),
+ requiresLicense: z.boolean(),
+ active: z.boolean(),
+});
+
+async function requireOwnedProvider(): Promise<{
+ providerId: string;
+ actorEmail: string | null;
+ basePath: string;
+}> {
+ const session = await auth();
+ if (!session?.user?.id) throw new Error("Non authentifié");
+ const provider = await getCurrentRentalProvider();
+ if (!provider) throw new Error("Aucun provider associé");
+ // Un CE_MANAGER reste sous /espace-ce/materiel ; un RENTAL_PROVIDER/ADMIN
+ // reste sous /espace-prestataire. Les actions sont mutualisées et redirigent
+ // vers l'espace contextuel du user.
+ const basePath =
+ session.user.role === UserRole.CE_MANAGER ? "/espace-ce/materiel" : "/espace-prestataire";
+ return {
+ providerId: provider.id,
+ actorEmail: session.user.email ?? null,
+ basePath,
+ };
+}
+
+function parseItemFD(fd: FormData) {
+ const get = (k: string) => {
+ const v = (fd.get(k) as string | null) ?? "";
+ return v.trim() === "" ? null : v.trim();
+ };
+ return {
+ category: ((fd.get("category") as string | null) ?? "").trim(),
+ name: ((fd.get("name") as string | null) ?? "").trim(),
+ description: get("description"),
+ imageUrl: get("imageUrl"),
+ pricePerDay: fd.get("pricePerDay"),
+ pricePerWeek: get("pricePerWeek"),
+ deposit: fd.get("deposit") ?? "0",
+ totalQty: fd.get("totalQty") ?? "1",
+ withMotor: fd.get("withMotor") === "on",
+ fuelIncluded: fd.get("fuelIncluded") === "on",
+ requiresLicense: fd.get("requiresLicense") === "on",
+ active: fd.get("active") === "on",
+ };
+}
+
+export async function createHostItemAction(fd: FormData) {
+ const { providerId, actorEmail, basePath } = await requireOwnedProvider();
+ const parsed = itemSchema.safeParse(parseItemFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const created = await prisma.rentalItem.create({ data: { ...parsed.data, providerId } });
+ await recordAudit({
+ scope: "host.rental-items",
+ event: "create",
+ target: created.id,
+ actorEmail,
+ details: { name: created.name, providerId },
+ });
+ revalidatePath(`${basePath}/items`);
+ redirect(`${basePath}/items/${created.id}`);
+}
+
+export async function updateHostItemAction(itemId: string, fd: FormData) {
+ const { providerId, actorEmail, basePath } = await requireOwnedProvider();
+ const session = await auth();
+ if (!(await canManageRentalProvider(session!.user.id, session?.user?.role, providerId, session?.user?.organizationId))) {
+ return { ok: false as const, error: "Accès refusé" };
+ }
+ const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } });
+ if (!existing || existing.providerId !== providerId) {
+ return { ok: false as const, error: "Item introuvable." };
+ }
+ const parsed = itemSchema.safeParse(parseItemFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ await prisma.rentalItem.update({ where: { id: itemId }, data: parsed.data });
+ await recordAudit({
+ scope: "host.rental-items",
+ event: "update",
+ target: itemId,
+ actorEmail,
+ details: { name: parsed.data.name },
+ });
+ revalidatePath(`${basePath}/items`);
+ revalidatePath(`${basePath}/items/${itemId}`);
+ return { ok: true as const };
+}
+
+export async function deleteHostItemAction(itemId: string) {
+ const { providerId, actorEmail, basePath } = await requireOwnedProvider();
+ const existing = await prisma.rentalItem.findUnique({
+ where: { id: itemId },
+ select: { providerId: true, _count: { select: { lines: true } } },
+ });
+ if (!existing || existing.providerId !== providerId) {
+ return { ok: false as const, error: "Item introuvable." };
+ }
+ if (existing._count.lines > 0) {
+ return { ok: false as const, error: "Impossible : item référencé par des locations." };
+ }
+ await prisma.rentalItem.delete({ where: { id: itemId } });
+ await recordAudit({
+ scope: "host.rental-items",
+ event: "delete",
+ target: itemId,
+ actorEmail,
+ details: {},
+ });
+ revalidatePath(`${basePath}/items`);
+ redirect(`${basePath}/items`);
+}
+
+const blockSchema = z.object({
+ startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+ endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+ qty: z.coerce.number().int().min(1).max(1000),
+ reason: z.enum(["MAINTENANCE", "MANUAL_BLOCK"]),
+});
+
+export async function addItemBlockAction(itemId: string, fd: FormData) {
+ const { providerId, actorEmail, basePath } = await requireOwnedProvider();
+ const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } });
+ if (!existing || existing.providerId !== providerId) {
+ return { ok: false as const, error: "Item introuvable." };
+ }
+ const parsed = blockSchema.safeParse({
+ startDate: fd.get("startDate"),
+ endDate: fd.get("endDate"),
+ qty: fd.get("qty"),
+ reason: fd.get("reason"),
+ });
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const start = new Date(`${parsed.data.startDate}T00:00:00.000Z`);
+ const end = new Date(`${parsed.data.endDate}T00:00:00.000Z`);
+ if (end <= start) return { ok: false as const, error: "Date de fin doit être après début." };
+
+ await prisma.rentalItemAvailability.create({
+ data: {
+ itemId,
+ startDate: start,
+ endDate: end,
+ qty: parsed.data.qty,
+ reason: parsed.data.reason,
+ },
+ });
+ await recordAudit({
+ scope: "host.rental-items",
+ event: "block.add",
+ target: itemId,
+ actorEmail,
+ details: { ...parsed.data },
+ });
+ revalidatePath(`${basePath}/items/${itemId}`);
+ return { ok: true as const };
+}
+
+export async function removeItemBlockAction(blockId: string) {
+ const { providerId, actorEmail, basePath } = await requireOwnedProvider();
+ const block = await prisma.rentalItemAvailability.findUnique({
+ where: { id: blockId },
+ select: { itemId: true, rentalBookingId: true, item: { select: { providerId: true } } },
+ });
+ if (!block || block.item.providerId !== providerId) {
+ return { ok: false as const, error: "Blocage introuvable." };
+ }
+ if (block.rentalBookingId) {
+ return { ok: false as const, error: "Blocage lié à une réservation : annulez la réservation à la place." };
+ }
+ await prisma.rentalItemAvailability.delete({ where: { id: blockId } });
+ await recordAudit({
+ scope: "host.rental-items",
+ event: "block.remove",
+ target: blockId,
+ actorEmail,
+ details: { itemId: block.itemId },
+ });
+ revalidatePath(`${basePath}/items/${block.itemId}`);
+ return { ok: true as const };
+}
+
+const statusSchema = z.enum([
+ RentalBookingStatus.PENDING,
+ RentalBookingStatus.CONFIRMED,
+ RentalBookingStatus.HANDED_OVER,
+ RentalBookingStatus.RETURNED,
+ RentalBookingStatus.CANCELLED,
+]);
+
+export async function updateBookingStatusAction(bookingId: string, status: string) {
+ const { providerId, actorEmail, basePath } = await requireOwnedProvider();
+ const session = await auth();
+ const role = session?.user?.role;
+ const parsed = statusSchema.safeParse(status);
+ if (!parsed.success) return { ok: false as const, error: "Statut invalide." };
+
+ const existing = await prisma.rentalBooking.findUnique({
+ where: { id: bookingId },
+ select: { providerId: true },
+ });
+ if (!existing || (existing.providerId !== providerId && role !== UserRole.ADMIN)) {
+ return { ok: false as const, error: "Réservation introuvable." };
+ }
+ await prisma.rentalBooking.update({
+ where: { id: bookingId },
+ data: { status: parsed.data },
+ });
+ await recordAudit({
+ scope: "host.rental-bookings",
+ event: "status.update",
+ target: bookingId,
+ actorEmail,
+ details: { status: parsed.data },
+ });
+ revalidatePath(`${basePath}/reservations`);
+ return { ok: true as const };
+}
diff --git a/src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx b/src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx
new file mode 100644
index 0000000..e83e53b
--- /dev/null
+++ b/src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+type Block = {
+ id: string;
+ startDate: string;
+ endDate: string;
+ qty: number;
+ reason: string;
+ isBooking: boolean;
+};
+
+type Props = {
+ blocks: Block[];
+ totalQty: number;
+ addAction: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
+ removeAction: (blockId: string) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
+};
+
+const REASON_LABEL: Record = {
+ MAINTENANCE: "🔧 Maintenance",
+ MANUAL_BLOCK: "⛔ Blocage personnel",
+ RENTAL_BOOKING: "🛒 Réservation",
+};
+
+export function ItemBlocksManager({ blocks, totalQty, addAction, removeAction }: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+
+ function onAdd(fd: FormData) {
+ setError(null);
+ startTransition(async () => {
+ const res = await addAction(fd);
+ if (res && res.ok === false) setError(res.error);
+ router.refresh();
+ });
+ }
+
+ function onRemove(blockId: string) {
+ setError(null);
+ startTransition(async () => {
+ const res = await removeAction(blockId);
+ if (res && res.ok === false) setError(res.error);
+ router.refresh();
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx b/src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx
new file mode 100644
index 0000000..bc81c8b
--- /dev/null
+++ b/src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { useState, useTransition } from "react";
+
+type Props = {
+ canDelete: boolean;
+ deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
+};
+
+export function ItemInlineDelete({ canDelete, deleteAction }: Props) {
+ const [pending, startTransition] = useTransition();
+ const [confirm, setConfirm] = useState(false);
+ const [error, setError] = useState(null);
+
+ function run() {
+ setError(null);
+ startTransition(async () => {
+ const res = await deleteAction();
+ if (res && (res as { ok?: boolean }).ok === false) {
+ setError((res as { error: string }).error);
+ setConfirm(false);
+ }
+ });
+ }
+
+ if (!canDelete) {
+ return (
+
+ Suppression impossible — item référencé par des locations
+
+ );
+ }
+
+ return (
+
+ {confirm ? (
+
+ Supprimer ?
+
+ Oui
+
+ setConfirm(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
setConfirm(true)}
+ disabled={pending}
+ className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
+ >
+ Supprimer l'item
+
+ )}
+ {error ? (
+
{error}
+ ) : null}
+
+ );
+}
diff --git a/src/app/espace-prestataire/items/[itemId]/page.tsx b/src/app/espace-prestataire/items/[itemId]/page.tsx
new file mode 100644
index 0000000..ee46102
--- /dev/null
+++ b/src/app/espace-prestataire/items/[itemId]/page.tsx
@@ -0,0 +1,118 @@
+import Link from "next/link";
+import { notFound, redirect } from "next/navigation";
+
+import { MediaUploader } from "@/components/MediaUploader";
+import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
+import { getHostItem } from "@/lib/rental-host";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
+
+import { HostItemForm } from "../_components/ItemForm";
+import { ItemBlocksManager } from "./_components/ItemBlocksManager";
+import { ItemInlineDelete } from "./_components/ItemInlineDelete";
+import {
+ addItemBlockAction,
+ deleteHostItemAction,
+ removeItemBlockAction,
+ updateHostItemAction,
+} from "../../actions";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ itemId: string }> };
+
+export default async function EditHostItemPage({ params }: PageProps) {
+ await requireRentalProviderSession();
+ const provider = await getCurrentRentalProvider();
+ if (!provider) redirect("/admin/rental-providers");
+ const { itemId } = await params;
+ const item = await getHostItem(provider.id, itemId);
+ if (!item) notFound();
+
+ const updateThis = async (fd: FormData) => {
+ "use server";
+ return await updateHostItemAction(itemId, fd);
+ };
+ const deleteThis = async () => {
+ "use server";
+ return await deleteHostItemAction(itemId);
+ };
+ const addBlockThis = async (fd: FormData) => {
+ "use server";
+ return await addItemBlockAction(itemId, fd);
+ };
+ const removeBlockThis = async (blockId: string) => {
+ "use server";
+ return await removeItemBlockAction(blockId);
+ };
+
+ return (
+
+
+
+
+
+ Photos & vidéos
+
+
+
+
+
+
+
+
+ Calendrier de disponibilité
+
+
+ Bloquez ici des dates pour maintenance, indisponibilité personnelle, etc. Les réservations
+ confirmées sont gérées automatiquement.
+
+ ({
+ id: a.id,
+ startDate: a.startDate.toISOString().slice(0, 10),
+ endDate: a.endDate.toISOString().slice(0, 10),
+ qty: a.qty,
+ reason: a.reason,
+ isBooking: Boolean(a.rentalBookingId),
+ }))}
+ addAction={addBlockThis}
+ removeAction={removeBlockThis}
+ totalQty={item.totalQty}
+ />
+
+
+ );
+}
diff --git a/src/app/espace-prestataire/items/_components/ItemForm.tsx b/src/app/espace-prestataire/items/_components/ItemForm.tsx
new file mode 100644
index 0000000..c0033ad
--- /dev/null
+++ b/src/app/espace-prestataire/items/_components/ItemForm.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { useState, useTransition } from "react";
+
+import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels";
+
+const inputCls =
+ "mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none";
+const labelCls = "block text-sm font-medium text-zinc-800";
+
+type Props = {
+ initial?: {
+ category?: string;
+ name?: string;
+ description?: string | null;
+ imageUrl?: string | null;
+ pricePerDay?: string | number;
+ pricePerWeek?: string | number | null;
+ deposit?: string | number;
+ totalQty?: number;
+ withMotor?: boolean;
+ fuelIncluded?: boolean;
+ requiresLicense?: boolean;
+ active?: boolean;
+ };
+ action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
+ submitLabel?: string;
+};
+
+export function HostItemForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ function onSubmit(fd: FormData) {
+ setError(null);
+ setSuccess(null);
+ startTransition(async () => {
+ const res = await action(fd);
+ if (res && res.ok === false) setError(res.error);
+ else if (res && res.ok === true) setSuccess("Enregistré.");
+ });
+ }
+
+ return (
+
+
+
+
+ Catégorie
+
+ — sélectionner —
+ {RENTAL_CATEGORIES.map((c) => (
+ {RENTAL_CATEGORY_LABEL[c]}
+ ))}
+
+
+
+ Statut
+
+
+ Actif (visible au catalogue)
+
+
+
+ Nom de l'item
+
+
+
+ Description
+
+
+
+ URL image
+
+
+
+ Stock total (qté)
+
+
+
+ Prix / jour (€)
+
+
+
+ Prix / semaine (€)
+
+
+
+ Caution (€)
+
+
+
+
+
+
+ Spécifications
+
+
+
+
+ Avec moteur
+
+
+
+ Essence incluse
+
+
+
+ Permis bateau requis
+
+
+
+
+ {error ? (
+ {error}
+ ) : null}
+ {success ? (
+ {success}
+ ) : null}
+
+
+
+ {pending ? "Enregistrement…" : submitLabel}
+
+
+
+
+ );
+}
diff --git a/src/app/espace-prestataire/items/new/page.tsx b/src/app/espace-prestataire/items/new/page.tsx
new file mode 100644
index 0000000..5431bdf
--- /dev/null
+++ b/src/app/espace-prestataire/items/new/page.tsx
@@ -0,0 +1,23 @@
+import Link from "next/link";
+
+import { requireRentalProviderSession } from "@/lib/rental-access";
+
+import { HostItemForm } from "../_components/ItemForm";
+import { createHostItemAction } from "../../actions";
+
+export const dynamic = "force-dynamic";
+
+export default async function NewHostItemPage() {
+ await requireRentalProviderSession();
+ return (
+
+
+ ← Mes items
+
+ Nouvel item
+
+
+ );
+}
diff --git a/src/app/espace-prestataire/items/page.tsx b/src/app/espace-prestataire/items/page.tsx
new file mode 100644
index 0000000..355a5ae
--- /dev/null
+++ b/src/app/espace-prestataire/items/page.tsx
@@ -0,0 +1,93 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+
+import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
+import { listHostItems } from "@/lib/rental-host";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
+
+export const dynamic = "force-dynamic";
+
+export default async function HostItemsPage() {
+ await requireRentalProviderSession();
+ const provider = await getCurrentRentalProvider();
+ if (!provider) redirect("/admin/rental-providers");
+
+ const items = await listHostItems(provider.id);
+
+ return (
+
+
+
+ {items.length === 0 ? (
+
+ Pas encore d'item.{" "}
+
+ Créer mon premier item
+
+
+ ) : (
+
+
+
+
+ Nom
+ Catégorie
+ €/j
+ Stock
+ Caution
+ Résa
+ État
+
+
+
+ {items.map((i) => (
+
+
+
+ {i.name}
+
+
+ {i.withMotor ? "⚙️ moteur · " : ""}
+ {i.requiresLicense ? "🪪 permis · " : ""}
+ {i.fuelIncluded ? "⛽ essence " : ""}
+
+
+ {RENTAL_CATEGORY_LABEL[i.category]}
+ {Number(i.pricePerDay).toFixed(0)}
+ {i.totalQty}
+ {Number(i.deposit).toFixed(0)}
+ {i._count.lines}
+
+ {i.active ? (
+
+ Actif
+
+ ) : (
+
+ Inactif
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/espace-prestataire/layout.tsx b/src/app/espace-prestataire/layout.tsx
new file mode 100644
index 0000000..fb8b7d1
--- /dev/null
+++ b/src/app/espace-prestataire/layout.tsx
@@ -0,0 +1,6 @@
+import { requirePluginOr404 } from "@/lib/plugins/guard";
+
+export default async function ProviderLayout({ children }: { children: React.ReactNode }) {
+ await requirePluginOr404("gear-rental");
+ return <>{children}>;
+}
diff --git a/src/app/espace-prestataire/page.tsx b/src/app/espace-prestataire/page.tsx
new file mode 100644
index 0000000..620aa63
--- /dev/null
+++ b/src/app/espace-prestataire/page.tsx
@@ -0,0 +1,153 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+
+import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
+import { getHostRentalKpis } from "@/lib/rental-host";
+
+export const dynamic = "force-dynamic";
+
+function fmtEur(amount: string | number): string {
+ const n = Number(amount);
+ return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
+}
+
+const dateFmt = new Intl.DateTimeFormat("fr-FR", {
+ day: "2-digit",
+ month: "long",
+ year: "numeric",
+});
+
+export default async function ProviderDashboardPage() {
+ await requireRentalProviderSession();
+ const provider = await getCurrentRentalProvider();
+ if (!provider) {
+ // Admin sans providerId ciblé : redirect vers liste admin
+ redirect("/admin/rental-providers");
+ }
+
+ const kpis = await getHostRentalKpis(provider.id);
+
+ return (
+
+
+
+ {!provider.approved ? (
+
+
Compte en attente de validation
+
+ Vos items ne sont pas encore visibles sur le catalogue public.
+ L'équipe Karbé contactera bientôt {provider.contactEmail ?? "votre email"} pour finaliser
+ votre adhésion. Vous pouvez toutefois préparer vos items dès maintenant.
+
+
+ ) : null}
+
+
+
+
+ 0 ? "warn" : "neutral"}
+ />
+
+
+
+
+
+ {kpis.nextHandover ? (
+
+ Prochaine remise
+
+ {kpis.nextHandover.tenantName} · {kpis.nextHandover.lineCount} ligne(s)
+
+
+ {dateFmt.format(kpis.nextHandover.startDate)}
+
+
+ Voir le détail →
+
+
+ ) : null}
+
+
+ Mon activité
+
+
+ Fleuves desservis : {" "}
+ {provider.rivers.join(", ") || "—"}
+
+
+ Commission Karbé : {" "}
+ {Number(provider.commissionPct).toFixed(1)}%
+
+
+ Statut : {" "}
+ {provider.active ? "Actif" : "Inactif"}
+ {" · "}
+ {provider.approved ? "Approuvé" : "En attente"}
+
+
+
+
+ );
+}
+
+function Kpi({
+ label,
+ value,
+ tone = "neutral",
+}: {
+ label: string;
+ value: string;
+ tone?: "neutral" | "warn";
+}) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+}
diff --git a/src/app/espace-prestataire/reservations/_components/BookingDecision.tsx b/src/app/espace-prestataire/reservations/_components/BookingDecision.tsx
new file mode 100644
index 0000000..b38622f
--- /dev/null
+++ b/src/app/espace-prestataire/reservations/_components/BookingDecision.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+import { CancelRentalButton } from "@/components/CancelRentalButton";
+import { RentalBookingStatus } from "@/generated/prisma/enums";
+
+import { updateBookingStatusAction } from "../../actions";
+
+const btnBase =
+ "rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:opacity-50";
+
+export function BookingDecision({ bookingId, status }: { bookingId: string; status: string }) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ function set(next: string) {
+ setError(null);
+ startTransition(async () => {
+ const res = await updateBookingStatusAction(bookingId, next);
+ if (res && res.ok === false) setError(res.error);
+ router.refresh();
+ });
+ }
+
+ return (
+
+ {status === RentalBookingStatus.PENDING ? (
+ set(RentalBookingStatus.CONFIRMED)}
+ disabled={pending}
+ className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
+ >
+ Confirmer
+
+ ) : null}
+ {status === RentalBookingStatus.CONFIRMED ? (
+ set(RentalBookingStatus.HANDED_OVER)}
+ disabled={pending}
+ className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
+ >
+ Marquer remis client
+
+ ) : null}
+ {status === RentalBookingStatus.HANDED_OVER ? (
+ set(RentalBookingStatus.RETURNED)}
+ disabled={pending}
+ className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
+ >
+ Marquer retourné
+
+ ) : null}
+ {status === RentalBookingStatus.PENDING || status === RentalBookingStatus.CONFIRMED ? (
+
+ ) : null}
+ {error ? {error} : null}
+
+ );
+}
diff --git a/src/app/espace-prestataire/reservations/page.tsx b/src/app/espace-prestataire/reservations/page.tsx
new file mode 100644
index 0000000..b66f063
--- /dev/null
+++ b/src/app/espace-prestataire/reservations/page.tsx
@@ -0,0 +1,137 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+
+import { RentalBookingStatus } from "@/generated/prisma/enums";
+import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
+import { listHostBookings } from "@/lib/rental-host";
+import { RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
+
+import { BookingDecision } from "./_components/BookingDecision";
+
+export const dynamic = "force-dynamic";
+
+const STATUS_VALUES = new Set([
+ RentalBookingStatus.PENDING,
+ RentalBookingStatus.CONFIRMED,
+ RentalBookingStatus.HANDED_OVER,
+ RentalBookingStatus.RETURNED,
+ RentalBookingStatus.CANCELLED,
+]);
+
+type PageProps = {
+ searchParams: Promise<{ status?: string }>;
+};
+
+const dateFmt = new Intl.DateTimeFormat("fr-FR", {
+ day: "2-digit",
+ month: "short",
+ year: "2-digit",
+});
+
+export default async function HostReservationsPage({ searchParams }: PageProps) {
+ await requireRentalProviderSession();
+ const provider = await getCurrentRentalProvider();
+ if (!provider) redirect("/admin/rental-providers");
+ const sp = await searchParams;
+ const status = STATUS_VALUES.has(sp.status ?? "")
+ ? (sp.status as RentalBookingStatus)
+ : undefined;
+
+ const bookings = await listHostBookings(provider.id, { status });
+
+ return (
+
+
+
+ {bookings.length === 0 ? (
+
+ Aucune réservation matériel.
+
+ ) : (
+
+ {bookings.map((b) => (
+
+
+
+
+ {b.tenant.firstName} {b.tenant.lastName}
+
+
+ {b.tenant.email}
+ {b.tenant.phone ? ` · ${b.tenant.phone}` : ""}
+
+ {b.booking ? (
+
+ 🏠 Lié à la résa carbet :{" "}
+
+ {b.booking.carbet.title}
+
+
+ ) : (
+
Location standalone (sans carbet)
+ )}
+
+
+
+ {dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)}
+
+
+ {Number(b.amount).toFixed(2)} {b.currency}
+
+
+
+
+
+ {b.lines.map((l) => (
+
+
+ {l.qty}× {l.item.name}
+
+
+ {Number(l.lineTotal).toFixed(2)} €
+
+
+ ))}
+
+
+
+
+
+ {RENTAL_STATUS_LABEL[b.status]}
+
+
+ {b.paymentStatus}
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/faq/page.tsx b/src/app/faq/page.tsx
new file mode 100644
index 0000000..5583b28
--- /dev/null
+++ b/src/app/faq/page.tsx
@@ -0,0 +1,19 @@
+import { notFound } from "next/navigation";
+import { getContentPage } from "@/lib/content-pages";
+import { getLocale } from "@/lib/i18n/server";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { ContentPageRenderer } from "@/components/ContentPageRenderer";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ const page = await getContentPage("faq", await getLocale());
+ return { title: page?.title ?? "FAQ" };
+}
+
+export default async function FaqPage() {
+ if (!(await isPluginEnabled("content-pages"))) notFound();
+ const page = await getContentPage("faq", await getLocale());
+ if (!page) notFound();
+ return ;
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 83ca72f..77cad1f 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -47,8 +47,48 @@ body[data-theme="guyane"] {
radial-gradient(ellipse at bottom, rgba(196, 100, 52, 0.04) 0%, transparent 60%);
}
+/* === Theme Aquarelle (plugin theme-aquarelle) === */
+/* Direction artistique « carnet naturaliste XIXᵉ ». Mutuellement exclusif
+ avec theme-guyane (le hook onEnable du plugin garantit qu'un seul est
+ actif à la fois). */
+body[data-theme="aquarelle"] {
+ --background: #faf5e9; /* papier crème teinté */
+ --foreground: #2a2418; /* encre sépia foncée */
+ font-family: var(--font-serif), Georgia, serif;
+ /* Texture grain de papier subtile via SVG inline (~1.5 KB, pas de fetch). */
+ background-image:
+ radial-gradient(ellipse at 25% 15%, rgba(196, 100, 52, 0.05) 0%, transparent 50%),
+ radial-gradient(ellipse at 75% 85%, rgba(94, 94, 50, 0.05) 0%, transparent 50%),
+ url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' seed='17'/%3E%3CfeColorMatrix values='0 0 0 0 0.65 0 0 0 0 0.55 0 0 0 0 0.40 0 0 0 0.18 0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
+ background-attachment: fixed;
+}
+
+/* Surcharges visuelles aquarelle : hairlines sépia partout en remplacement
+ des borders zinc/gray du theme-guyane. */
+body[data-theme="aquarelle"] [class*="border-zinc-"],
+body[data-theme="aquarelle"] [class*="border-gray-"] {
+ border-color: rgba(140, 61, 24, 0.25);
+}
+
+/* === Theme Admin (route /admin/...) === */
+/* Indépendant des themes publics. Sobre, gris/blanc, accent ocre Karbé,
+ typographie sans-serif neutre. Pas de texture grain. Lisible en
+ permanence peu importe le toggle Aquarelle/Guyane côté site public. */
+[data-admin] {
+ --background: #fafafa;
+ --foreground: #18181b;
+ font-family: var(--font-geist-sans), system-ui, sans-serif;
+ background-image: none !important;
+}
+[data-admin] [class*="border-zinc-"],
+[data-admin] [class*="border-gray-"] {
+ /* Restaure des borders neutres dans l'admin si theme aquarelle est actif
+ côté body (qui les surcharge en sépia). */
+ border-color: #e4e4e7;
+}
+
@media (prefers-color-scheme: dark) {
- :root:not([data-theme="guyane"]) {
+ :root:not([data-theme="guyane"]):not([data-theme="aquarelle"]) {
--background: #0a0a0a;
--foreground: #ededed;
}
diff --git a/src/app/inscription/_components/SignupForm.tsx b/src/app/inscription/_components/SignupForm.tsx
new file mode 100644
index 0000000..3bb3cd3
--- /dev/null
+++ b/src/app/inscription/_components/SignupForm.tsx
@@ -0,0 +1,295 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { signIn } from "next-auth/react";
+
+type InviteContext = { token: string; orgName: string; emailLock?: string | null };
+
+type Props = { next: string; invite?: InviteContext | null };
+
+export function SignupForm({ next, invite }: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [role, setRole] = useState<"TOURIST" | "OWNER" | "RENTAL_PROVIDER" | "CE_MANAGER">("TOURIST");
+ const [providerName, setProviderName] = useState("");
+ const [providerRivers, setProviderRivers] = useState("");
+ const [orgName, setOrgName] = useState("");
+ const isInvite = Boolean(invite);
+
+ function onSubmit(formData: FormData) {
+ setError(null);
+ const email = (formData.get("email") as string | null)?.trim() ?? "";
+ const password = (formData.get("password") as string | null) ?? "";
+ const firstName = (formData.get("firstName") as string | null)?.trim() ?? "";
+ const lastName = (formData.get("lastName") as string | null)?.trim() ?? "";
+ const phone = (formData.get("phone") as string | null)?.trim() ?? "";
+
+ if (password.length < 8) {
+ setError("Le mot de passe doit faire au moins 8 caractères.");
+ return;
+ }
+ if (role === "RENTAL_PROVIDER" && providerName.trim().length < 2) {
+ setError("Le nom de votre activité de loueur est requis.");
+ return;
+ }
+ if (role === "CE_MANAGER" && orgName.trim().length < 2) {
+ setError("Le nom de votre Comité d'Entreprise est requis.");
+ return;
+ }
+
+ startTransition(async () => {
+ const body: Record = {
+ email,
+ password,
+ firstName,
+ lastName,
+ phone: phone || null,
+ role,
+ };
+ if (role === "RENTAL_PROVIDER") {
+ body.providerName = providerName.trim();
+ body.providerRivers = providerRivers
+ .split(/[,;\n]/)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+ }
+ if (role === "CE_MANAGER") {
+ body.orgName = orgName.trim();
+ }
+ if (isInvite && invite) {
+ body.inviteToken = invite.token;
+ // L'API force le rôle CE_MEMBER quand inviteToken est valide ;
+ // on retire les champs inutiles pour ne pas créer de confusion.
+ delete (body as { providerName?: unknown }).providerName;
+ delete (body as { orgName?: unknown }).orgName;
+ }
+ const res = await fetch("/api/signup", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError(json?.error || `Erreur ${res.status}`);
+ return;
+ }
+ const result = await signIn("credentials", {
+ email,
+ password,
+ redirect: false,
+ });
+ if (result?.error) {
+ setError("Compte créé mais connexion impossible. Essayez la page de connexion.");
+ return;
+ }
+ router.push(next);
+ router.refresh();
+ });
+ }
+
+ const inputCls =
+ "w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none";
+
+ return (
+
+
+
+
+ Prénom
+
+
+
+ Nom
+
+
+
+
+
+ Email
+
+
+
+
+ Mot de passe (8 caractères min.)
+
+
+
+
+ Téléphone (optionnel)
+
+
+
+ {isInvite ? (
+
+ Vous rejoignez {invite!.orgName} comme membre CE — les autres
+ types de compte sont masqués.
+
+ ) : null}
+
+
+ Type de compte
+
+
+ setRole("TOURIST")}
+ className="sr-only"
+ />
+ Voyageur
+ Réserver un séjour.
+
+
+ setRole("OWNER")}
+ className="sr-only"
+ />
+ Hôte
+ Publier un carbet.
+
+
+ setRole("RENTAL_PROVIDER")}
+ className="sr-only"
+ />
+ Loueur matériel
+ Hamac, pirogue, kayak…
+
+
+ setRole("CE_MANAGER")}
+ className="sr-only"
+ />
+ Comité d'Entreprise
+ Gérer les carbets et matériel d'un CE.
+
+
+
+
+ {role === "CE_MANAGER" ? (
+
+
+ Votre Comité d'Entreprise sera créé en attente de validation .
+ Vous pouvez vous connecter à votre espace CE dès la création mais ne publierez
+ vos carbets et matériel qu'après validation par l'équipe Karbé.
+
+
+ Nom de votre Comité d'Entreprise
+ setOrgName(e.target.value)}
+ placeholder="ex. CE Spatiale de Kourou"
+ maxLength={200}
+ className={inputCls + " mt-0.5"}
+ />
+
+
+ ) : null}
+
+ {role === "RENTAL_PROVIDER" ? (
+
+ ) : null}
+
+ {error ? (
+ {error}
+ ) : null}
+
+
+ {pending ? "Création…" : "Créer mon compte"}
+
+
+
+ );
+}
diff --git a/src/app/inscription/page.tsx b/src/app/inscription/page.tsx
new file mode 100644
index 0000000..0ff3940
--- /dev/null
+++ b/src/app/inscription/page.tsx
@@ -0,0 +1,57 @@
+import { redirect } from "next/navigation";
+import Link from "next/link";
+
+import { auth } from "@/auth";
+import { getOrgInviteByToken } from "@/lib/ce-invites";
+
+import { SignupForm } from "./_components/SignupForm";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{ next?: string; invite?: string }>;
+};
+
+export default async function SignupPage({ searchParams }: PageProps) {
+ const session = await auth();
+ const sp = await searchParams;
+ const next = sp.next && sp.next.startsWith("/") ? sp.next : "/";
+ if (session?.user?.id) redirect(next);
+
+ // Si un token d'invitation valide est présent, on pré-remplit le contexte CE_MEMBER.
+ let invite: { token: string; orgName: string; emailLock?: string | null } | null = null;
+ if (sp.invite) {
+ const found = await getOrgInviteByToken(sp.invite);
+ if (found) {
+ invite = {
+ token: sp.invite,
+ orgName: found.organization.name,
+ emailLock: found.email,
+ };
+ }
+ }
+
+ return (
+
+
+
+ Créer un compte
+
+ {invite
+ ? `Vous avez été invité à rejoindre « ${invite.orgName} » comme membre CE.`
+ : "Un compte vous permet de réserver un séjour ou, en tant qu'hôte, de publier votre carbet."}
+
+
+
+
+
+
+ Déjà un compte ?{" "}
+
+ Se connecter
+
+
+
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index d06bd3f..ffcc89e 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,8 +1,14 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono, Cormorant_Garamond } from "next/font/google";
+import { Geist, Geist_Mono, Cormorant_Garamond, PT_Serif } from "next/font/google";
import "./globals.css";
import { PluginProvider } from "@/lib/plugins/client";
import { getEnabledPluginKeys, syncPluginsFromRegistry } from "@/lib/plugins/server";
+import { SeasonBanner } from "@/components/SeasonBanner";
+import { SiteHeaderGuard } from "@/components/SiteHeaderGuard";
+import { RentalCartProvider } from "@/components/RentalCartProvider";
+import { readCartFromCookies } from "@/lib/rental-cart-server";
+import { LocaleProvider } from "@/lib/i18n/client";
+import { dict, getLocale } from "@/lib/i18n/server";
// Le layout interroge la DB Plugin à chaque request → rendu dynamique forcé.
// Sans ça, le layout (et donc data-theme + enabledKeys passés au client) est
@@ -29,6 +35,15 @@ const cormorant = Cormorant_Garamond({
display: "swap",
});
+// PT Serif : typographie display pour le theme Aquarelle (carnet naturaliste).
+// Plus dense, plus encrée, parfaite pour les planches d'illustration.
+const ptSerif = PT_Serif({
+ variable: "--font-serif-aquarelle",
+ subsets: ["latin"],
+ weight: ["400", "700"],
+ display: "swap",
+});
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
export const metadata: Metadata = {
@@ -39,6 +54,21 @@ export const metadata: Metadata = {
},
description:
"Karbé, la marketplace de location de carbets fluviaux de Guyane.",
+ manifest: "/manifest.webmanifest",
+ applicationName: "Karbé",
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: "black-translucent",
+ title: "Karbé",
+ },
+ icons: {
+ icon: [
+ { url: "/icons/favicon-32.png", sizes: "32x32", type: "image/png" },
+ { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
+ { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
+ ],
+ apple: "/icons/apple-touch-icon.png",
+ },
openGraph: {
type: "website",
siteName: "Karbé",
@@ -49,6 +79,13 @@ export const metadata: Metadata = {
},
};
+export const viewport = {
+ themeColor: "#059669",
+ width: "device-width",
+ initialScale: 1,
+ viewportFit: "cover" as const,
+};
+
export default async function RootLayout({
children,
}: Readonly<{
@@ -65,18 +102,38 @@ export default async function RootLayout({
enabledKeys = [];
}
- const themeGuyane = enabledKeys.includes("theme-guyane");
+ // Aquarelle > Guyane si les deux activés (mutual exclusion garantie par
+ // le hook plugin, mais on est défensif au cas où).
+ const themeAquarelle = enabledKeys.includes("theme-aquarelle");
+ const themeGuyane = !themeAquarelle && enabledKeys.includes("theme-guyane");
+ const dataTheme = themeAquarelle ? "aquarelle" : themeGuyane ? "guyane" : undefined;
+
+ // En thème aquarelle, on substitue la variable --font-serif par PT Serif
+ // (au lieu de Cormorant) pour coller à l'esthétique carnet.
+ const serifVariable = themeAquarelle ? ptSerif.variable : cormorant.variable;
+
+ const locale = await getLocale();
+ const messages = await dict(locale);
+ const initialCart = await readCartFromCookies();
return (
- {children}
+
+
+
+
+
+ {children}
+
+
+
);
diff --git a/src/app/materiel/[itemId]/_components/AddToCart.tsx b/src/app/materiel/[itemId]/_components/AddToCart.tsx
new file mode 100644
index 0000000..490f0ab
--- /dev/null
+++ b/src/app/materiel/[itemId]/_components/AddToCart.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+
+import { useCart } from "@/components/RentalCartProvider";
+import { diffDays } from "@/lib/rental-cart";
+
+type Props = {
+ itemId: string;
+ pricePerDay: number;
+ deposit: number;
+ maxQty: number;
+};
+
+function todayPlus(n: number): string {
+ const d = new Date();
+ d.setHours(0, 0, 0, 0);
+ d.setDate(d.getDate() + n);
+ return d.toISOString().slice(0, 10);
+}
+
+export function AddToCart({ itemId, pricePerDay, deposit, maxQty }: Props) {
+ const { addEntry, cart } = useCart();
+ const [start, setStart] = useState(todayPlus(7));
+ const [end, setEnd] = useState(todayPlus(9));
+ const [qty, setQty] = useState(1);
+ const [added, setAdded] = useState(false);
+
+ const nights = Math.max(1, diffDays(start, end));
+ const subtotal = nights * qty * pricePerDay;
+ const depositTotal = qty * deposit;
+
+ const alreadyInCart = cart.items.some(
+ (e) => e.itemId === itemId && e.startDate === start && e.endDate === end,
+ );
+
+ function onAdd() {
+ addEntry({ itemId, qty, startDate: start, endDate: end });
+ setAdded(true);
+ }
+
+ return (
+
+
+
+ Du
+ setStart(e.target.value)}
+ className="mt-0.5 block w-full min-h-[44px] rounded-md border border-zinc-300 px-3 py-2 text-base"
+ />
+
+
+ Au
+ setEnd(e.target.value)}
+ className="mt-0.5 block w-full min-h-[44px] rounded-md border border-zinc-300 px-3 py-2 text-base"
+ />
+
+
+
+
+ Quantité
+ setQty(Math.max(1, Math.min(maxQty, Number(e.target.value) || 1)))}
+ inputMode="numeric"
+ className="mt-0.5 block w-full min-h-[44px] rounded-md border border-zinc-300 px-3 py-2 text-base"
+ />
+
+
+
+
+
+ {pricePerDay.toFixed(0)} € × {nights} jour{nights > 1 ? "s" : ""} × {qty}
+
+ {subtotal.toFixed(2)} €
+
+ {depositTotal > 0 ? (
+
+ + Caution (récupérable)
+ {depositTotal.toFixed(2)} €
+
+ ) : null}
+
+
+ {!added ? (
+
+ {alreadyInCart ? "Déjà dans le panier" : "Ajouter au panier"}
+
+ ) : (
+
+
+ ✓ Ajouté au panier
+
+
+ Voir mon panier
+
+
+ )}
+
+ );
+}
diff --git a/src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx b/src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx
new file mode 100644
index 0000000..4cdf5d9
--- /dev/null
+++ b/src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+type Day = {
+ date: string;
+ availableQty: number;
+ bookedQty: number;
+ totalQty: number;
+};
+
+export function AvailabilityPreview({ itemId }: { itemId: string }) {
+ const [calendar, setCalendar] = useState(null);
+
+ useEffect(() => {
+ const today = new Date();
+ today.setUTCHours(0, 0, 0, 0);
+ const to = new Date(today.getTime() + 30 * 86_400_000);
+ const fromStr = today.toISOString().slice(0, 10);
+ const toStr = to.toISOString().slice(0, 10);
+ fetch(`/api/rentals/items/${itemId}/availability?from=${fromStr}&to=${toStr}`)
+ .then((r) => (r.ok ? r.json() : null))
+ .then((j) => {
+ if (j?.calendar) setCalendar(j.calendar);
+ })
+ .catch(() => {});
+ }, [itemId]);
+
+ if (!calendar) {
+ return
;
+ }
+
+ return (
+
+
+ Disponibilité sur les 30 prochains jours (vert = stock dispo, gris = épuisé) :
+
+
+ {calendar.map((d) => {
+ const ratio = d.availableQty / Math.max(1, d.totalQty);
+ const tone =
+ d.availableQty === 0 ? "bg-zinc-300" :
+ ratio < 0.3 ? "bg-amber-300" :
+ "bg-emerald-400";
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/app/materiel/[itemId]/_components/ItemGallery.tsx b/src/app/materiel/[itemId]/_components/ItemGallery.tsx
new file mode 100644
index 0000000..7d6ba55
--- /dev/null
+++ b/src/app/materiel/[itemId]/_components/ItemGallery.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useState } from "react";
+
+type Media = { id: string; type: "PHOTO" | "VIDEO"; s3Url: string };
+
+export function ItemGallery({
+ media,
+ fallbackEmoji,
+ alt,
+}: {
+ media: Media[];
+ fallbackEmoji: string;
+ alt: string;
+}) {
+ const [idx, setIdx] = useState(0);
+
+ if (media.length === 0) {
+ return (
+
+ {fallbackEmoji}
+
+ );
+ }
+
+ const current = media[idx];
+
+ return (
+
+
+ {current.type === "VIDEO" ? (
+
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+ {media.length > 1 ? (
+
+ {media.map((m, i) => (
+
setIdx(i)}
+ className={
+ "aspect-square overflow-hidden rounded-md border transition " +
+ (i === idx
+ ? "border-emerald-500 ring-2 ring-emerald-200"
+ : "border-zinc-200 hover:border-zinc-400 opacity-70 hover:opacity-100")
+ }
+ aria-label={`Photo ${i + 1}`}
+ >
+ {m.type === "VIDEO" ? (
+ ▶
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/src/app/materiel/[itemId]/page.tsx b/src/app/materiel/[itemId]/page.tsx
new file mode 100644
index 0000000..d9a56b5
--- /dev/null
+++ b/src/app/materiel/[itemId]/page.tsx
@@ -0,0 +1,164 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+
+import { requirePluginOr404 } from "@/lib/plugins/guard";
+import { getPublicRentalItem } from "@/lib/rentals-public";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
+
+import { AddToCart } from "./_components/AddToCart";
+import { AvailabilityPreview } from "./_components/AvailabilityPreview";
+import { ItemGallery } from "./_components/ItemGallery";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ itemId: string }> };
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { itemId } = await params;
+ const item = await getPublicRentalItem(itemId);
+ if (!item) return { title: "Item introuvable", robots: { index: false } };
+ return {
+ title: `${item.name} — Location matériel`,
+ description: item.description ?? `Location de ${item.name} via ${item.provider.name}.`,
+ };
+}
+
+export default async function RentalItemDetailPage({ params }: PageProps) {
+ await requirePluginOr404("gear-rental");
+ const { itemId } = await params;
+ const item = await getPublicRentalItem(itemId);
+ if (!item) notFound();
+
+ const categoryEmoji =
+ item.category === "SLEEP" ? "💤" :
+ item.category === "NAVIGATION" ? "🛶" :
+ item.category === "FISHING" ? "🎣" :
+ item.category === "COOKING" ? "🍳" : "🦺";
+
+ return (
+
+
+ ← Tout le matériel
+
+
+
+
+
+
+ {RENTAL_CATEGORY_LABEL[item.category]}
+
+ {item.name}
+
+ Loué par {item.provider.name}
+ {item.provider.isSystemD ? (
+
+ Fournisseur Karbé
+
+ ) : null}
+
+
+
+
+ 0
+ ? item.media
+ : item.imageUrl
+ ? [{ id: "legacy", type: "PHOTO", s3Url: item.imageUrl }]
+ : []
+ }
+ alt={item.name}
+ fallbackEmoji={categoryEmoji}
+ />
+
+
+ {item.description ? (
+
+ Description
+ {item.description}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ {Number(item.pricePerDay).toFixed(0)} €
+
+ / jour
+
+
+ {item.pricePerWeek ? (
+
+ Forfait semaine : {Number(item.pricePerWeek).toFixed(0)} € (≥ 7 jours)
+
+ ) : null}
+
+
+
+
+
+
{item.provider.name}
+ {item.provider.isSystemD ? (
+
Fournisseur officiel Karbé (0% commission).
+ ) : null}
+ {item.provider.description ? (
+
{item.provider.description}
+ ) : null}
+
+ {item.provider.contactEmail ? (
+
📧 {item.provider.contactEmail}
+ ) : null}
+ {item.provider.contactPhone ? (
+
📞 {item.provider.contactPhone}
+ ) : null}
+
+ Fleuves desservis : {item.provider.rivers.join(", ") || "—"}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/materiel/_components/rental-filters.tsx b/src/app/materiel/_components/rental-filters.tsx
new file mode 100644
index 0000000..90dc76e
--- /dev/null
+++ b/src/app/materiel/_components/rental-filters.tsx
@@ -0,0 +1,100 @@
+import Link from "next/link";
+
+import { RentalCategory } from "@/generated/prisma/enums";
+import { RENTAL_CATEGORIES, RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
+
+type Props = {
+ filters: {
+ q?: string;
+ category?: RentalCategory;
+ providerId?: string;
+ river?: string;
+ };
+ rivers: string[];
+ providers: { id: string; name: string; isSystemD: boolean }[];
+};
+
+export function RentalFilters({ filters, rivers, providers }: Props) {
+ return (
+
+
+
+ Recherche
+
+
+
+ Fleuve
+
+ Tous fleuves
+ {rivers.map((r) => (
+ {r}
+ ))}
+
+
+
+ Prestataire
+
+ Tous prestataires
+ {providers.map((p) => (
+
+ {p.name}{p.isSystemD ? " (Karbé)" : ""}
+
+ ))}
+
+
+
+
+
+ Catégorie
+
+ {RENTAL_CATEGORIES.map((c) => {
+ const checked = filters.category === c;
+ return (
+
+
+ {RENTAL_CATEGORY_LABEL[c]}
+
+ );
+ })}
+
+
+
+
+
+ Filtrer
+
+
+ Réinit.
+
+
+
+ );
+}
diff --git a/src/app/materiel/_components/rental-item-card.tsx b/src/app/materiel/_components/rental-item-card.tsx
new file mode 100644
index 0000000..179750b
--- /dev/null
+++ b/src/app/materiel/_components/rental-item-card.tsx
@@ -0,0 +1,76 @@
+import Link from "next/link";
+
+import type { PublicRentalItem } from "@/lib/rentals-public";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
+
+export function RentalItemCard({ item }: { item: PublicRentalItem }) {
+ return (
+
+
+ {item.imageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ {item.category === "SLEEP" ? "💤" :
+ item.category === "NAVIGATION" ? "🛶" :
+ item.category === "FISHING" ? "🎣" :
+ item.category === "COOKING" ? "🍳" : "🦺"}
+
+ )}
+
+ {RENTAL_CATEGORY_LABEL[item.category]}
+
+ {item.provider.isSystemD ? (
+
+ Karbé
+
+ ) : null}
+
+
+
+ {item.name}
+
+
{item.provider.name}
+
{item.description ?? ""}
+
+ {item.withMotor ? (
+ ⚙️ moteur
+ ) : null}
+ {item.requiresLicense ? (
+ 🪪 permis
+ ) : null}
+ {item.fuelIncluded ? (
+ ⛽ essence
+ ) : null}
+ {Number(item.deposit) > 0 ? (
+
+ Caution {Number(item.deposit).toFixed(0)} €
+
+ ) : null}
+
+
+
+
+ {Number(item.pricePerDay).toFixed(0)} €
+
+ / jour
+
+ {item.pricePerWeek ? (
+
+ {Number(item.pricePerWeek).toFixed(0)} € / semaine
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/app/materiel/layout.tsx b/src/app/materiel/layout.tsx
new file mode 100644
index 0000000..d6cebd2
--- /dev/null
+++ b/src/app/materiel/layout.tsx
@@ -0,0 +1,6 @@
+import { requirePluginOr404 } from "@/lib/plugins/guard";
+
+export default async function MaterielLayout({ children }: { children: React.ReactNode }) {
+ await requirePluginOr404("gear-rental");
+ return <>{children}>;
+}
diff --git a/src/app/materiel/page.tsx b/src/app/materiel/page.tsx
new file mode 100644
index 0000000..31fcd77
--- /dev/null
+++ b/src/app/materiel/page.tsx
@@ -0,0 +1,123 @@
+import type { Metadata } from "next";
+
+import { RentalCategory } from "@/generated/prisma/enums";
+import { requirePluginOr404 } from "@/lib/plugins/guard";
+import { isRentalCategory } from "@/lib/rental-category-labels";
+import {
+ listPublicProviders,
+ listPublicRentalItems,
+ listPublicRivers,
+} from "@/lib/rentals-public";
+
+import { RentalFilters } from "./_components/rental-filters";
+import { RentalItemCard } from "./_components/rental-item-card";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = {
+ title: "Louer du matériel",
+ description:
+ "Hamac, moustiquaire, pirogue, kayak, barque, gilet, réchaud… Toutes les locations de matériel pour réussir votre séjour en carbet guyanais, fournies par l'association System D et des prestataires locaux validés.",
+};
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ category?: string;
+ providerId?: string;
+ river?: string;
+ }>;
+};
+
+export default async function MaterialPage({ searchParams }: PageProps) {
+ await requirePluginOr404("gear-rental");
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ category: sp.category && isRentalCategory(sp.category) ? (sp.category as RentalCategory) : undefined,
+ providerId: sp.providerId || undefined,
+ river: sp.river || undefined,
+ };
+ const [items, providers, rivers] = await Promise.all([
+ listPublicRentalItems(filters),
+ listPublicProviders(),
+ listPublicRivers(),
+ ]);
+
+ return (
+
+
+
+ Matériel à louer
+
+
+ Hamac, moustiquaire, pirogue, kayak, barque, réchaud, gilet de sauvetage…
+ Tout le matériel pour réussir votre séjour, mis à disposition par
+ l'association System D ou par des prestataires
+ locaux validés.
+
+
+
+
+
+
+
+ {items.length} item{items.length > 1 ? "s" : ""} disponible
+ {items.length > 1 ? "s" : ""}
+
+ {items.length === 0 ? (
+
+ Aucun item ne correspond à votre recherche. Essayez d'élargir
+ les filtres.
+
+ ) : (
+
+ {items.map((item) => (
+
+
+
+ ))}
+
+ )}
+
+
+ {providers.length > 0 ? (
+
+
+ Nos prestataires partenaires
+
+
+ {providers.length} prestataire{providers.length > 1 ? "s" : ""} valid
+ {providers.length > 1 ? "és" : "é"} sur Karbé.
+
+
+ {providers.map((p) => (
+
+
+
{p.name}
+ {p.isSystemD ? (
+
+ Karbé
+
+ ) : null}
+
+
+ Fleuves : {p.rivers.join(", ") || "—"} · {p.itemsCount} item
+ {p.itemsCount > 1 ? "s" : ""}
+
+ {p.description ? (
+
+ {p.description}
+
+ ) : null}
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/app/mentions-legales/page.tsx b/src/app/mentions-legales/page.tsx
new file mode 100644
index 0000000..1eeb699
--- /dev/null
+++ b/src/app/mentions-legales/page.tsx
@@ -0,0 +1,19 @@
+import { notFound } from "next/navigation";
+import { getContentPage } from "@/lib/content-pages";
+import { getLocale } from "@/lib/i18n/server";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { ContentPageRenderer } from "@/components/ContentPageRenderer";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ const page = await getContentPage("mentions-legales", await getLocale());
+ return { title: page?.title ?? "Mentions légales" };
+}
+
+export default async function MentionsPage() {
+ if (!(await isPluginEnabled("legal-pages"))) notFound();
+ const page = await getContentPage("mentions-legales", await getLocale());
+ if (!page) notFound();
+ return ;
+}
diff --git a/src/app/mes-favoris/page.tsx b/src/app/mes-favoris/page.tsx
new file mode 100644
index 0000000..5887400
--- /dev/null
+++ b/src/app/mes-favoris/page.tsx
@@ -0,0 +1,68 @@
+import { redirect } from "next/navigation";
+import Link from "next/link";
+
+import { auth } from "@/auth";
+import { listFavoriteCarbets } from "@/lib/reels";
+import { buildSrcSet } from "@/lib/image-variants";
+
+export const dynamic = "force-dynamic";
+
+export const metadata = { title: "Mes favoris" };
+
+export default async function MyFavoritesPage() {
+ const session = await auth();
+ if (!session?.user?.id) redirect("/connexion?next=/mes-favoris");
+
+ const carbets = await listFavoriteCarbets(session.user.id);
+
+ return (
+
+ Mes favoris
+
+ {carbets.length === 0
+ ? "Aucun favori pour l'instant — ajoutez des carbets depuis le mode Au fil de l'eau ou les fiches."
+ : `${carbets.length} carbet${carbets.length > 1 ? "s" : ""} sauvegardé${carbets.length > 1 ? "s" : ""}.`}
+
+
+ {carbets.length === 0 ? (
+
+
+ Découvrir des carbets
+
+
+ ) : (
+
+ {carbets.map((c) => (
+
+
+ {c.media[0] ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+
+
{c.title}
+
+ {c.river} · {Number(c.nightlyPrice).toFixed(0)} € / nuit
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/mes-locations/page.tsx b/src/app/mes-locations/page.tsx
new file mode 100644
index 0000000..53f7eb6
--- /dev/null
+++ b/src/app/mes-locations/page.tsx
@@ -0,0 +1,150 @@
+import Link from "next/link";
+
+import { CancelRentalButton } from "@/components/CancelRentalButton";
+import { requireAuth } from "@/lib/authorization";
+import { requirePluginOr404 } from "@/lib/plugins/guard";
+import { prisma } from "@/lib/prisma";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
+
+export const dynamic = "force-dynamic";
+
+export const metadata = { title: "Mes locations matériel" };
+
+const STATUS_LABEL: Record = {
+ PENDING: "En attente",
+ CONFIRMED: "Confirmée",
+ HANDED_OVER: "Remis",
+ RETURNED: "Retourné",
+ CANCELLED: "Annulée",
+};
+
+const PAYMENT_LABEL: Record = {
+ PENDING: "Paiement en attente",
+ AUTHORIZED: "Paiement autorisé",
+ SUCCEEDED: "Paiement reçu",
+ FAILED: "Paiement échoué",
+ REFUNDED: "Remboursé",
+};
+
+type SearchParams = Promise<{ payment?: string; ids?: string; ok?: string }>;
+
+export default async function MyRentalsPage({ searchParams }: { searchParams: SearchParams }) {
+ await requirePluginOr404("gear-rental");
+ const session = await requireAuth();
+ const sp = await searchParams;
+
+ const rentals = await prisma.rentalBooking.findMany({
+ where: { tenantId: session.user.id },
+ orderBy: [{ startDate: "desc" }],
+ include: {
+ provider: { select: { id: true, name: true, isSystemD: true, contactPhone: true, contactEmail: true } },
+ lines: { include: { item: { select: { id: true, name: true, category: true, imageUrl: true } } } },
+ booking: { select: { id: true, carbet: { select: { slug: true, title: true } } } },
+ },
+ });
+
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
+
+ const showSuccess = sp.payment === "success" || sp.ok;
+
+ return (
+
+
+
+ {showSuccess ? (
+
+ ✓ Votre commande de matériel a bien été enregistrée. Vous recevrez un email de confirmation.
+
+ ) : null}
+
+ {rentals.length === 0 ? (
+
+ Vous n'avez pas encore loué de matériel.{" "}
+
+ Découvrir le matériel disponible
+
+ .
+
+ ) : (
+
+ {rentals.map((rb) => (
+
+
+
+
{rb.provider.name}
+ {rb.booking?.carbet ? (
+
+ Pour le séjour{" "}
+
+ {rb.booking.carbet.title}
+
+
+ ) : (
+
Location indépendante
+ )}
+
+
+
+ {STATUS_LABEL[rb.status] ?? rb.status}
+
+
+ {PAYMENT_LABEL[rb.paymentStatus] ?? rb.paymentStatus}
+
+
+
+
+
+ Du {dateFmt.format(rb.startDate)} au {dateFmt.format(rb.endDate)}
+
+
+
+ {rb.lines.map((line) => (
+
+
+ {line.qty}× {" "}
+
+ {line.item.name}
+
+
+ {RENTAL_CATEGORY_LABEL[line.item.category]}
+
+
+
+ {Number(line.lineTotal).toFixed(2)} €
+
+
+ ))}
+
+
+
+ Total
+
+ {Number(rb.amount).toFixed(2)} {rb.currency}
+
+
+
+ {(rb.provider.contactPhone || rb.provider.contactEmail) && rb.status !== "CANCELLED" ? (
+
+ Contact prestataire :{" "}
+ {rb.provider.contactPhone ? 📞 {rb.provider.contactPhone} : null}
+ {rb.provider.contactEmail ? ✉ {rb.provider.contactEmail} : null}
+
+ ) : null}
+
+ {(rb.status === "PENDING" || rb.status === "CONFIRMED") ? (
+
+
+
+ ) : null}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/mon-compte/_components/DangerZone.tsx b/src/app/mon-compte/_components/DangerZone.tsx
new file mode 100644
index 0000000..0671134
--- /dev/null
+++ b/src/app/mon-compte/_components/DangerZone.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { useState, useTransition } from "react";
+
+import { deleteAccountAction } from "../actions";
+
+export function DangerZone() {
+ const [pending, startTransition] = useTransition();
+ const [step, setStep] = useState<"idle" | "confirm">("idle");
+ const [typed, setTyped] = useState("");
+
+ function deleteAccount() {
+ startTransition(async () => {
+ await deleteAccountAction();
+ });
+ }
+
+ return (
+
+
+ La suppression anonymise votre compte (nom, email, téléphone effacés). Vos réservations
+ passées restent en base pour les obligations comptables, mais ne sont plus rattachées à
+ des données personnelles identifiantes.
+
+ {step === "idle" ? (
+
setStep("confirm")}
+ className="rounded-md border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-50"
+ >
+ Supprimer mon compte
+
+ ) : (
+
+
+ Pour confirmer, saisissez SUPPRIMER ci-dessous.
+
+
setTyped(e.target.value)}
+ className="w-full rounded-md border border-rose-300 px-3 py-1.5 text-sm focus:border-rose-500 focus:outline-none"
+ disabled={pending}
+ />
+
+ setStep("idle")}
+ disabled={pending}
+ className="text-xs text-zinc-600 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ {pending ? "Suppression…" : "Confirmer la suppression"}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/mon-compte/_components/PasswordForm.tsx b/src/app/mon-compte/_components/PasswordForm.tsx
new file mode 100644
index 0000000..b809b87
--- /dev/null
+++ b/src/app/mon-compte/_components/PasswordForm.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useRef, useState, useTransition } from "react";
+
+import { changePasswordAction } from "../actions";
+
+export function PasswordForm() {
+ const formRef = useRef(null);
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ function onSubmit(fd: FormData) {
+ setError(null);
+ setSuccess(null);
+ const next = (fd.get("next") as string | null) ?? "";
+ const confirm = (fd.get("confirm") as string | null) ?? "";
+ if (next !== confirm) {
+ setError("Les deux nouveaux mots de passe ne correspondent pas.");
+ return;
+ }
+ startTransition(async () => {
+ const res = await changePasswordAction(fd);
+ if (res && res.ok === false) setError(res.error);
+ else {
+ setSuccess("Mot de passe mis à jour.");
+ formRef.current?.reset();
+ }
+ });
+ }
+
+ const inputCls =
+ "mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none";
+
+ return (
+
+
+
+ Mot de passe actuel
+
+
+
+
+ Nouveau mot de passe
+
+
+
+ Confirmer
+
+
+
+
+ {error ? (
+ {error}
+ ) : null}
+ {success ? (
+ {success}
+ ) : null}
+
+
+ {pending ? "Mise à jour…" : "Changer le mot de passe"}
+
+
+
+ );
+}
diff --git a/src/app/mon-compte/_components/ProfileForm.tsx b/src/app/mon-compte/_components/ProfileForm.tsx
new file mode 100644
index 0000000..23eac80
--- /dev/null
+++ b/src/app/mon-compte/_components/ProfileForm.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+import { updateProfileAction } from "../actions";
+
+type Props = {
+ initial: { firstName: string; lastName: string; phone: string | null };
+};
+
+export function ProfileForm({ initial }: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ function onSubmit(fd: FormData) {
+ setError(null);
+ setSuccess(null);
+ startTransition(async () => {
+ const res = await updateProfileAction(fd);
+ if (res && res.ok === false) setError(res.error);
+ else {
+ setSuccess("Profil enregistré.");
+ router.refresh();
+ }
+ });
+ }
+
+ const inputCls =
+ "mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none";
+
+ return (
+
+
+
+
+ Prénom
+
+
+
+ Nom
+
+
+
+
+ Téléphone (optionnel)
+
+
+
+ {error ? (
+ {error}
+ ) : null}
+ {success ? (
+ {success}
+ ) : null}
+
+
+ {pending ? "Enregistrement…" : "Enregistrer"}
+
+
+
+ );
+}
diff --git a/src/app/mon-compte/actions.ts b/src/app/mon-compte/actions.ts
new file mode 100644
index 0000000..c002a49
--- /dev/null
+++ b/src/app/mon-compte/actions.ts
@@ -0,0 +1,115 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+
+import { auth, signOut } from "@/auth";
+import { prisma } from "@/lib/prisma";
+import { hashPassword, verifyPassword } from "@/lib/password";
+import { recordAudit } from "@/lib/admin/audit";
+
+const profileSchema = z.object({
+ firstName: z.string().trim().min(1).max(100),
+ lastName: z.string().trim().min(1).max(100),
+ phone: z.string().trim().max(40).optional().nullable(),
+});
+
+const passwordSchema = z
+ .object({
+ current: z.string().min(1),
+ next: z.string().min(8).max(200),
+ })
+ .refine((d) => d.current !== d.next, { message: "Le nouveau mdp doit être différent de l'ancien." });
+
+async function requireSelf() {
+ const session = await auth();
+ if (!session?.user?.id) throw new Error("Non authentifié");
+ return session;
+}
+
+export async function updateProfileAction(fd: FormData) {
+ const session = await requireSelf();
+ const parsed = profileSchema.safeParse({
+ firstName: fd.get("firstName"),
+ lastName: fd.get("lastName"),
+ phone: ((fd.get("phone") as string | null) ?? "").trim() || null,
+ });
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => i.message).join(" · ") };
+ }
+ await prisma.user.update({
+ where: { id: session.user.id },
+ data: parsed.data,
+ });
+ await recordAudit({
+ scope: "public.profile",
+ event: "profile.update",
+ target: session.user.id,
+ actorEmail: session.user.email ?? null,
+ details: parsed.data,
+ });
+ revalidatePath("/mon-compte");
+ return { ok: true as const };
+}
+
+export async function changePasswordAction(fd: FormData) {
+ const session = await requireSelf();
+ const parsed = passwordSchema.safeParse({
+ current: fd.get("current"),
+ next: fd.get("next"),
+ });
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => i.message).join(" · ") };
+ }
+ const user = await prisma.user.findUnique({
+ where: { id: session.user.id },
+ select: { passwordHash: true },
+ });
+ if (!user) return { ok: false as const, error: "Utilisateur introuvable." };
+ const ok = await verifyPassword(parsed.data.current, user.passwordHash);
+ if (!ok) return { ok: false as const, error: "Mot de passe actuel incorrect." };
+ await prisma.user.update({
+ where: { id: session.user.id },
+ data: { passwordHash: await hashPassword(parsed.data.next) },
+ });
+ await recordAudit({
+ scope: "public.profile",
+ event: "password.change",
+ target: session.user.id,
+ actorEmail: session.user.email ?? null,
+ details: {},
+ });
+ return { ok: true as const };
+}
+
+/** Supprime le compte + cascade : bookings restent en DB (anonymisées) pour les obligations comptables. */
+export async function deleteAccountAction() {
+ const session = await requireSelf();
+ const userId = session.user.id;
+ const email = session.user.email ?? "";
+
+ // Anonymise plutôt que supprimer pour préserver l'historique comptable des bookings.
+ const anon = `anonymise-${userId}@karbe.invalid`;
+ await prisma.user.update({
+ where: { id: userId },
+ data: {
+ email: anon,
+ firstName: "Compte",
+ lastName: "supprimé",
+ phone: null,
+ passwordHash: "", // verrouille le login (bcrypt.compare retournera toujours false)
+ isActive: false,
+ },
+ });
+ await prisma.passwordResetToken.deleteMany({ where: { userId } });
+ await recordAudit({
+ scope: "public.profile",
+ event: "account.delete",
+ target: userId,
+ actorEmail: email,
+ details: { anonymisedTo: anon },
+ });
+ await signOut({ redirect: false });
+ redirect("/?account=deleted");
+}
diff --git a/src/app/mon-compte/page.tsx b/src/app/mon-compte/page.tsx
new file mode 100644
index 0000000..982b78a
--- /dev/null
+++ b/src/app/mon-compte/page.tsx
@@ -0,0 +1,71 @@
+import { redirect } from "next/navigation";
+
+import { auth } from "@/auth";
+import { prisma } from "@/lib/prisma";
+
+import { ProfileForm } from "./_components/ProfileForm";
+import { PasswordForm } from "./_components/PasswordForm";
+import { DangerZone } from "./_components/DangerZone";
+
+export const dynamic = "force-dynamic";
+
+export default async function MyAccountPage() {
+ const session = await auth();
+ if (!session?.user?.id) redirect("/connexion?next=/mon-compte");
+
+ const user = await prisma.user.findUnique({
+ where: { id: session.user.id },
+ select: { email: true, firstName: true, lastName: true, phone: true, role: true, createdAt: true },
+ });
+ if (!user) redirect("/connexion");
+
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
+
+ return (
+
+
+
+
+
+
+
+
+
+ Mes données (RGPD)
+
+
+ Téléchargez l'intégralité des données associées à votre compte au format JSON,
+ conformément à l'article 20 du RGPD (droit à la portabilité).
+
+
+ Télécharger mes données
+
+
+
+
+
+ Zone dangereuse
+
+
+
+
+ );
+}
diff --git a/src/app/mot-de-passe-oublie/[token]/_components/ResetForm.tsx b/src/app/mot-de-passe-oublie/[token]/_components/ResetForm.tsx
new file mode 100644
index 0000000..e77c3e7
--- /dev/null
+++ b/src/app/mot-de-passe-oublie/[token]/_components/ResetForm.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+export function ResetForm({ token }: { token: string }) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+
+ function onSubmit(fd: FormData) {
+ setError(null);
+ const password = (fd.get("password") as string | null) ?? "";
+ const confirm = (fd.get("confirm") as string | null) ?? "";
+ if (password.length < 8) {
+ setError("Mot de passe trop court (8 caractères min).");
+ return;
+ }
+ if (password !== confirm) {
+ setError("Les deux mots de passe ne correspondent pas.");
+ return;
+ }
+ startTransition(async () => {
+ const res = await fetch("/api/password/reset", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token, password }),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError(json?.error || `Erreur ${res.status}`);
+ return;
+ }
+ router.push("/connexion?reset=ok");
+ });
+ }
+
+ return (
+
+
+
+ Nouveau mot de passe
+
+
+
+ Confirmer le mot de passe
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {pending ? "Enregistrement…" : "Définir le nouveau mot de passe"}
+
+
+
+ );
+}
diff --git a/src/app/mot-de-passe-oublie/[token]/page.tsx b/src/app/mot-de-passe-oublie/[token]/page.tsx
new file mode 100644
index 0000000..80893ac
--- /dev/null
+++ b/src/app/mot-de-passe-oublie/[token]/page.tsx
@@ -0,0 +1,33 @@
+import Link from "next/link";
+
+import { ResetForm } from "./_components/ResetForm";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ token: string }> };
+
+export default async function ResetPage({ params }: PageProps) {
+ const { token } = await params;
+
+ return (
+
+
+
+
+
+
+
+
+ Retour à la connexion
+
+
+
+
+ );
+}
diff --git a/src/app/mot-de-passe-oublie/_components/ResetRequestForm.tsx b/src/app/mot-de-passe-oublie/_components/ResetRequestForm.tsx
new file mode 100644
index 0000000..563622a
--- /dev/null
+++ b/src/app/mot-de-passe-oublie/_components/ResetRequestForm.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { useState, useTransition } from "react";
+
+export function ResetRequestForm() {
+ const [pending, startTransition] = useTransition();
+ const [done, setDone] = useState(false);
+
+ function onSubmit(fd: FormData) {
+ const email = (fd.get("email") as string | null)?.trim() ?? "";
+ if (!email) return;
+ startTransition(async () => {
+ await fetch("/api/password/reset-request", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email }),
+ }).catch(() => {});
+ setDone(true);
+ });
+ }
+
+ if (done) {
+ return (
+
+ Si un compte existe pour cet email, vous recevrez un lien dans quelques instants. Pensez à
+ vérifier vos spams.
+
+ );
+ }
+
+ return (
+
+
+
+ Email
+
+
+
+ {pending ? "Envoi…" : "Envoyer le lien"}
+
+
+
+ );
+}
diff --git a/src/app/mot-de-passe-oublie/page.tsx b/src/app/mot-de-passe-oublie/page.tsx
new file mode 100644
index 0000000..1ac4a60
--- /dev/null
+++ b/src/app/mot-de-passe-oublie/page.tsx
@@ -0,0 +1,34 @@
+import { redirect } from "next/navigation";
+import Link from "next/link";
+
+import { auth } from "@/auth";
+import { ResetRequestForm } from "./_components/ResetRequestForm";
+
+export const dynamic = "force-dynamic";
+
+export default async function ForgotPasswordPage() {
+ const session = await auth();
+ if (session?.user?.id) redirect("/");
+
+ return (
+
+
+
+
+
+
+
+
+ Retour à la connexion
+
+
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 5d0099b..ad5f2bd 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,63 +1,9 @@
-import Link from "next/link";
-import { IfPluginEnabled } from "@/components/IfPluginEnabled";
-import { HeroSection } from "@/components/landing/HeroSection";
-import { ExperiencesSection } from "@/components/landing/ExperiencesSection";
-import { HowItWorksSection } from "@/components/landing/HowItWorksSection";
-import { CESection } from "@/components/landing/CESection";
-import { TestimonialsSection } from "@/components/landing/TestimonialsSection";
-import { LandingFooter } from "@/components/landing/Footer";
+import { redirect } from "next/navigation";
/**
- * Page d'accueil — la majorité du contenu est conditionnée par les plugins :
- * - `landing-hero` → hero plein écran
- * - `landing-sections` → 2 expériences + comment ça marche + CE + témoignages + footer riche
- *
- * Si aucun de ces plugins n'est activé, on retombe sur la home historique
- * minimaliste (fallback). Activable depuis /admin/plugins.
+ * Home redirige vers le mode immersif « Au fil de l'eau » par défaut.
+ * L'ancien hero/landing reste accessible via /accueil.
*/
export default function Home() {
- return (
- <>
-
-
-
- Karbé — carbets fluviaux de Guyane
-
-
- La marketplace pour louer des carbets le long des fleuves de Guyane.
-
-
-
- Découvrir les carbets
-
-
- Espace hôte
-
-
-
-
- }
- >
-
-
-
-
-
-
-
-
-
-
- >
- );
+ redirect("/decouvrir");
}
diff --git a/src/app/panier/_components/CartReview.tsx b/src/app/panier/_components/CartReview.tsx
new file mode 100644
index 0000000..2eb4023
--- /dev/null
+++ b/src/app/panier/_components/CartReview.tsx
@@ -0,0 +1,256 @@
+"use client";
+
+import { useMemo, useState, useTransition } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+
+import { useCart } from "@/components/RentalCartProvider";
+import { diffDays } from "@/lib/rental-cart";
+
+type ItemSnapshot = {
+ id: string;
+ name: string;
+ category: string;
+ imageUrl: string | null;
+ pricePerDay: string;
+ deposit: string;
+ totalQty: number;
+ provider: { id: string; name: string; isSystemD: boolean };
+};
+
+type Line = {
+ idx: number;
+ entry: { itemId: string; qty: number; startDate: string; endDate: string };
+ item: ItemSnapshot;
+};
+
+export function CartReview({ lines }: { lines: Line[] }) {
+ const router = useRouter();
+ const { removeEntry, updateEntry, clear } = useCart();
+ const [busy, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+
+ // Groupe par prestataire
+ const groups = useMemo(() => {
+ const map = new Map();
+ for (const l of lines) {
+ const nights = Math.max(1, diffDays(l.entry.startDate, l.entry.endDate));
+ const lineSub = nights * l.entry.qty * Number(l.item.pricePerDay);
+ const lineDeposit = l.entry.qty * Number(l.item.deposit);
+ const existing = map.get(l.item.provider.id);
+ if (existing) {
+ existing.lines.push(l);
+ existing.subtotal += lineSub;
+ existing.deposit += lineDeposit;
+ } else {
+ map.set(l.item.provider.id, {
+ providerName: l.item.provider.name,
+ isSystemD: l.item.provider.isSystemD,
+ lines: [l],
+ subtotal: lineSub,
+ deposit: lineDeposit,
+ });
+ }
+ }
+ return Array.from(map.values());
+ }, [lines]);
+
+ const grandTotal = groups.reduce((acc, g) => acc + g.subtotal, 0);
+ const grandDeposit = groups.reduce((acc, g) => acc + g.deposit, 0);
+
+ function checkout() {
+ setError(null);
+ startTransition(async () => {
+ const res = await fetch("/api/rentals/checkout", { method: "POST" });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError(json?.error || `Erreur ${res.status}`);
+ return;
+ }
+ if (json.checkoutUrl) {
+ window.location.assign(json.checkoutUrl);
+ return;
+ }
+ if (json.rentalBookingIds?.length) {
+ clear();
+ router.push(`/mes-locations?ok=${json.rentalBookingIds[0]}`);
+ return;
+ }
+ router.push("/mes-locations");
+ });
+ }
+
+ return (
+
+ {groups.map((g) => (
+
+
+
+ {g.providerName}
+ {g.isSystemD ? (
+
+ Karbé
+
+ ) : null}
+
+
+
+ {g.lines.map((l) => (
+ removeEntry(l.idx)}
+ onChangeQty={(qty) => updateEntry(l.idx, { qty })}
+ onChangeDates={(startDate, endDate) => updateEntry(l.idx, { startDate, endDate })}
+ disabled={busy}
+ />
+ ))}
+
+
+ Sous-total prestataire
+ {g.subtotal.toFixed(2)} €
+
+
+ ))}
+
+
+
+
+
Total location
+ {grandTotal.toFixed(2)} €
+
+ {grandDeposit > 0 ? (
+
+
+ Caution récupérable
+ {grandDeposit.toFixed(2)} €
+
+ ) : null}
+
+
À régler
+ {(grandTotal + grandDeposit).toFixed(2)} €
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ Vider le panier
+
+
+ {busy ? "Envoi…" : "Valider et payer"}
+
+
+
+ Vous devez être connecté pour finaliser.
+
+
+
+ );
+}
+
+function CartLineItem({
+ line,
+ onRemove,
+ onChangeQty,
+ onChangeDates,
+ disabled,
+}: {
+ line: Line;
+ onRemove: () => void;
+ onChangeQty: (qty: number) => void;
+ onChangeDates: (start: string, end: string) => void;
+ disabled?: boolean;
+}) {
+ const nights = Math.max(1, diffDays(line.entry.startDate, line.entry.endDate));
+ const lineTotal = nights * line.entry.qty * Number(line.item.pricePerDay);
+ return (
+
+
+ {line.item.imageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ {line.item.category === "SLEEP" ? "💤" :
+ line.item.category === "NAVIGATION" ? "🛶" :
+ line.item.category === "FISHING" ? "🎣" :
+ line.item.category === "COOKING" ? "🍳" : "🦺"}
+
+ )}
+
+
+
+ {line.item.name}
+
+
+
+
+ Retirer
+
+
+ );
+}
diff --git a/src/app/panier/page.tsx b/src/app/panier/page.tsx
new file mode 100644
index 0000000..04f13c6
--- /dev/null
+++ b/src/app/panier/page.tsx
@@ -0,0 +1,83 @@
+import Link from "next/link";
+
+import { requirePluginOr404 } from "@/lib/plugins/guard";
+import { prisma } from "@/lib/prisma";
+import { readCartFromCookies } from "@/lib/rental-cart-server";
+
+import { CartReview } from "./_components/CartReview";
+
+export const dynamic = "force-dynamic";
+
+export const metadata = { title: "Mon panier matériel" };
+
+export default async function CartPage() {
+ await requirePluginOr404("gear-rental");
+ const cart = await readCartFromCookies();
+
+ // Charge les items du panier en bulk pour rendu
+ const ids = Array.from(new Set(cart.items.map((e) => e.itemId)));
+ const items = ids.length
+ ? await prisma.rentalItem.findMany({
+ where: { id: { in: ids } },
+ include: {
+ provider: { select: { id: true, name: true, isSystemD: true, commissionPct: true } },
+ },
+ })
+ : [];
+
+ const itemById = new Map(items.map((i) => [i.id, i]));
+ const lines = cart.items
+ .map((entry, idx) => {
+ const item = itemById.get(entry.itemId);
+ if (!item) return null;
+ return {
+ idx,
+ entry,
+ item: {
+ id: item.id,
+ name: item.name,
+ category: item.category,
+ imageUrl: item.imageUrl,
+ pricePerDay: item.pricePerDay.toString(),
+ deposit: item.deposit.toString(),
+ totalQty: item.totalQty,
+ provider: {
+ id: item.provider.id,
+ name: item.provider.name,
+ isSystemD: item.provider.isSystemD,
+ },
+ },
+ };
+ })
+ .filter((l): l is NonNullable => l !== null);
+
+ return (
+
+
+
+ {lines.length === 0 ? (
+
+
Pas encore d'item dans votre panier.
+
+ Découvrir le matériel
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/app/partenaires-pirogue/page.tsx b/src/app/partenaires-pirogue/page.tsx
new file mode 100644
index 0000000..812be29
--- /dev/null
+++ b/src/app/partenaires-pirogue/page.tsx
@@ -0,0 +1,88 @@
+import { notFound } from "next/navigation";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { listActiveProviders } from "@/lib/pirogue-providers";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ return { title: "Partenaires pirogue" };
+}
+
+export default async function ProvidersPage() {
+ if (!(await isPluginEnabled("pirogue-providers"))) notFound();
+ const providers = await listActiveProviders();
+
+ return (
+
+
+
+ Transport
+
+
+ Nos partenaires pirogue
+
+
+ Pour les carbets accessibles uniquement par le fleuve, on travaille avec des piroguiers
+ locaux référencés. Tarifs estimatifs ci-dessous ; le détail de votre trajet est calé
+ directement avec le partenaire après réservation.
+
+
+
+ {providers.length === 0 ? (
+
+ Aucun partenaire référencé pour le moment.
+
+ ) : (
+
+ {providers.map((p) => (
+
+
+
{p.name}
+
+ {p.rivers.map((r) => (
+
+ {r}
+
+ ))}
+
+
+ {p.description ? (
+ {p.description}
+ ) : null}
+ {p.pricingNote ? (
+ {p.pricingNote}
+ ) : null}
+
+ {p.contactEmail ? (
+
+ ) : null}
+ {p.contactPhone ? (
+
+
Tél. ·
+ {p.contactPhone}
+
+ ) : null}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/politique-de-confidentialite/page.tsx b/src/app/politique-de-confidentialite/page.tsx
new file mode 100644
index 0000000..8db657d
--- /dev/null
+++ b/src/app/politique-de-confidentialite/page.tsx
@@ -0,0 +1,19 @@
+import { notFound } from "next/navigation";
+import { getContentPage } from "@/lib/content-pages";
+import { getLocale } from "@/lib/i18n/server";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { ContentPageRenderer } from "@/components/ContentPageRenderer";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ const page = await getContentPage("politique-de-confidentialite", await getLocale());
+ return { title: page?.title ?? "Politique de confidentialité" };
+}
+
+export default async function PrivacyPage() {
+ if (!(await isPluginEnabled("legal-pages"))) notFound();
+ const page = await getContentPage("politique-de-confidentialite", await getLocale());
+ if (!page) notFound();
+ return ;
+}
diff --git a/src/app/pour-comites-entreprise/page.tsx b/src/app/pour-comites-entreprise/page.tsx
new file mode 100644
index 0000000..fa83f77
--- /dev/null
+++ b/src/app/pour-comites-entreprise/page.tsx
@@ -0,0 +1,44 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+
+import { ContentPageRenderer } from "@/components/ContentPageRenderer";
+import { getContentPage } from "@/lib/content-pages";
+import { getLocale } from "@/lib/i18n/server";
+import { isPluginEnabled } from "@/lib/plugins/server";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ const page = await getContentPage("pour-comites-entreprise", await getLocale());
+ return { title: page?.title ?? "Pour comités d'entreprise" };
+}
+
+export default async function CEPage() {
+ if (!(await isPluginEnabled("content-pages"))) notFound();
+ const page = await getContentPage("pour-comites-entreprise", await getLocale());
+ if (!page) notFound();
+ const ceEnabled = await isPluginEnabled("ce-management");
+
+ return (
+ <>
+
+ {ceEnabled ? (
+
+
+ Vous êtes un Comité d'Entreprise ?
+
+
+ Créez votre espace CE sur Karbé pour proposer vos carbets à vos membres et au public
+ touriste, et activer la location de matériel.
+
+
+ Créer mon espace CE
+
+
+ ) : null}
+ >
+ );
+}
diff --git a/src/app/reservations/[id]/page.tsx b/src/app/reservations/[id]/page.tsx
new file mode 100644
index 0000000..69e1607
--- /dev/null
+++ b/src/app/reservations/[id]/page.tsx
@@ -0,0 +1,148 @@
+import { notFound, redirect } from "next/navigation";
+import Link from "next/link";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ id: string }> };
+
+const STATUS_LABEL: Record = {
+ PENDING: "En attente de confirmation",
+ CONFIRMED: "Confirmée",
+ CANCELLED: "Annulée",
+ COMPLETED: "Terminée",
+};
+
+const PAYMENT_LABEL: Record = {
+ PENDING: "Paiement en attente",
+ AUTHORIZED: "Paiement autorisé",
+ SUCCEEDED: "Paiement reçu",
+ FAILED: "Paiement échoué",
+ REFUNDED: "Remboursé",
+};
+
+export default async function ReservationPage({ params }: PageProps) {
+ const { id } = await params;
+ const session = await auth();
+ if (!session?.user?.id) redirect(`/connexion?next=/reservations/${id}`);
+
+ const booking = await prisma.booking.findUnique({
+ where: { id },
+ include: {
+ carbet: { select: { title: true, slug: true, river: true } },
+ tenant: { select: { id: true, email: true } },
+ rentalBookings: {
+ select: {
+ id: true,
+ status: true,
+ amount: true,
+ currency: true,
+ provider: { select: { name: true } },
+ lines: { select: { qty: true, item: { select: { id: true, name: true } } } },
+ },
+ },
+ },
+ });
+ if (!booking) notFound();
+
+ const isOwner = booking.tenant.id === session.user.id;
+ const isAdmin = session.user.role === UserRole.ADMIN;
+ if (!isOwner && !isAdmin) notFound();
+
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
+ const nights = Math.max(1, Math.round((booking.endDate.getTime() - booking.startDate.getTime()) / 86400000));
+
+ return (
+
+ Demande de réservation envoyée
+
+ Votre demande pour {booking.carbet.title} a bien été enregistrée. Vous recevrez
+ un email dès que l'hôte ou l'équipe Karbé l'aura confirmée.
+
+
+
+
+ Référence
+ {booking.id}
+
+
+
+
Carbet
+
+ {booking.carbet.title}
+
+
{booking.carbet.river}
+
+
+
Voyageurs
+
+ {booking.guestCount} personne{booking.guestCount > 1 ? "s" : ""}
+
+
+
+
Arrivée
+
{dateFmt.format(booking.startDate)}
+
+
+
Départ
+
{dateFmt.format(booking.endDate)}
+
+
+
Total ({nights} nuit{nights > 1 ? "s" : ""})
+
+ {Number(booking.amount).toFixed(2)} {booking.currency}
+
+
+
+
+
+ {STATUS_LABEL[booking.status] ?? booking.status}
+
+
+ {PAYMENT_LABEL[booking.paymentStatus] ?? booking.paymentStatus}
+
+
+
+
+ {booking.rentalBookings.length > 0 ? (
+
+ Matériel associé
+
+
+ Voir toutes mes locations →
+
+
+ ) : null}
+
+
+
+ ← Retour au carbet
+
+
+ Accueil
+
+
+
+ );
+}
diff --git a/src/auth.ts b/src/auth.ts
index 4071c3d..edbd27d 100644
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -31,6 +31,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
firstName: true,
lastName: true,
role: true,
+ organizationId: true,
isActive: true,
passwordHash: true,
},
@@ -50,6 +51,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: user.email,
name: `${user.firstName} ${user.lastName}`.trim(),
role: user.role,
+ organizationId: user.organizationId,
};
},
}),
@@ -59,12 +61,16 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (user?.role) {
token.role = user.role;
}
+ if (user && "organizationId" in user) {
+ token.organizationId = (user as { organizationId?: string | null }).organizationId ?? null;
+ }
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.sub ?? "";
session.user.role = token.role;
+ session.user.organizationId = token.organizationId ?? null;
}
return session;
},
diff --git a/src/components/AccessTypeBadge.tsx b/src/components/AccessTypeBadge.tsx
index 27b8d84..758486a 100644
--- a/src/components/AccessTypeBadge.tsx
+++ b/src/components/AccessTypeBadge.tsx
@@ -1,12 +1,12 @@
"use client";
import { useIsPluginEnabled } from "@/lib/plugins/client";
+import { useT } from "@/lib/i18n/client";
import type { AccessType } from "@/generated/prisma/enums";
/**
* Badge route+fleuve vs fleuve only. Gated par le plugin `access-type`.
- * Si le plugin est désactivé, rien n'est rendu — la fiche tombe sur le
- * comportement legacy (pirogue toujours mentionnée).
+ * Si le plugin est désactivé, rien n'est rendu. Label i18n via useT().
*/
export function AccessTypeBadge({
accessType,
@@ -16,10 +16,11 @@ export function AccessTypeBadge({
size?: "sm" | "md";
}) {
const enabled = useIsPluginEnabled("access-type");
+ const t = useT();
if (!enabled) return null;
const isExpedition = accessType === "RIVER_ONLY";
- const label = isExpedition ? "🛶 Expédition fleuve" : "🛣️ Route + fleuve";
+ const label = isExpedition ? t("access.riverOnly") : t("access.roadAndRiver");
const styles = isExpedition
? "bg-[var(--color-karbe-laterite-300)]/25 text-[var(--color-karbe-laterite-700)] ring-[var(--color-karbe-laterite-500)]/30"
: "bg-[var(--color-karbe-canopy-50)] text-[var(--color-karbe-canopy-700)] ring-[var(--color-karbe-canopy-500)]/30";
@@ -31,11 +32,7 @@ export function AccessTypeBadge({
return (
{label}
diff --git a/src/components/CancelRentalButton.tsx b/src/components/CancelRentalButton.tsx
new file mode 100644
index 0000000..963f9d8
--- /dev/null
+++ b/src/components/CancelRentalButton.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+type Props = {
+ rentalBookingId: string;
+ /** Label adapté au contexte d'appel : « Annuler ma location » côté tenant, etc. */
+ label?: string;
+ /** Affichage compact dans une grille d'actions (pas de margin auto). */
+ compact?: boolean;
+};
+
+export function CancelRentalButton({
+ rentalBookingId,
+ label = "Annuler",
+ compact = false,
+}: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [reason, setReason] = useState("");
+
+ function submit() {
+ setError(null);
+ startTransition(async () => {
+ const res = await fetch(`/api/rentals/${rentalBookingId}/cancel`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ reason }),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError(json?.error || `Erreur ${res.status}`);
+ return;
+ }
+ setConfirmOpen(false);
+ router.refresh();
+ });
+ }
+
+ if (!confirmOpen) {
+ return (
+ setConfirmOpen(true)}
+ className={
+ "rounded-md border border-rose-200 px-3 py-1.5 text-sm text-rose-700 hover:bg-rose-50 " +
+ (compact ? "" : "")
+ }
+ >
+ {label}
+
+ );
+ }
+
+ return (
+
+
Confirmer l'annulation
+
+ Le remboursement est calculé selon la politique : 100 % si annulation à plus de 7 jours,
+ 50 % entre 1 et 7 jours, caution seulement à moins de 24h.
+
+
+ Motif (optionnel)
+ setReason(e.target.value)}
+ maxLength={500}
+ rows={2}
+ className="mt-1 w-full rounded border border-rose-200 bg-white px-2 py-1 text-xs"
+ placeholder="Ex. changement de date, indisponibilité…"
+ />
+
+ {error ?
{error}
: null}
+
+ {
+ setConfirmOpen(false);
+ setError(null);
+ }}
+ disabled={pending}
+ className="rounded-md border border-zinc-300 bg-white px-3 py-1 text-xs text-zinc-700 hover:bg-zinc-50"
+ >
+ Garder la résa
+
+
+ {pending ? "Annulation…" : "Confirmer"}
+
+
+
+ );
+}
diff --git a/src/components/CartBadge.tsx b/src/components/CartBadge.tsx
new file mode 100644
index 0000000..ab18f84
--- /dev/null
+++ b/src/components/CartBadge.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import Link from "next/link";
+
+import { useCart } from "./RentalCartProvider";
+
+export function CartBadge() {
+ const { totalItems } = useCart();
+ if (totalItems === 0) return null;
+ return (
+
+ 🛒
+
+ {totalItems}
+
+
+ );
+}
diff --git a/src/components/ContentPageRenderer.tsx b/src/components/ContentPageRenderer.tsx
new file mode 100644
index 0000000..142fe62
--- /dev/null
+++ b/src/components/ContentPageRenderer.tsx
@@ -0,0 +1,31 @@
+import { renderMarkdown } from "@/lib/markdown";
+import type { ContentPage } from "@/lib/content-pages";
+
+/**
+ * Rend une ContentPage en HTML markdown. Server component pur.
+ * Pas de "use client" — le markdown est rendu côté serveur, pas de hydration.
+ */
+export function ContentPageRenderer({ page }: { page: ContentPage }) {
+ const html = renderMarkdown(page.body);
+ return (
+
+
+
+ {page.title}
+
+
+ Mis à jour le{" "}
+ {new Date(page.updatedAt).toLocaleDateString("fr-FR", {
+ day: "2-digit",
+ month: "long",
+ year: "numeric",
+ })}
+
+
+
+
+ );
+}
diff --git a/src/components/LocaleSwitcher.tsx b/src/components/LocaleSwitcher.tsx
new file mode 100644
index 0000000..7472539
--- /dev/null
+++ b/src/components/LocaleSwitcher.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { useLocale, useT } from "@/lib/i18n/client";
+import { LOCALE_COOKIE, type Locale } from "@/lib/i18n/types";
+
+/**
+ * Switcher de langue (FR / EN). Pose le cookie karbe-locale et refresh la page
+ * pour que le server re-render avec la nouvelle locale.
+ */
+export function LocaleSwitcher() {
+ const router = useRouter();
+ const current = useLocale();
+ const t = useT();
+ const [pending, startTransition] = useTransition();
+
+ function setLocale(next: Locale) {
+ if (next === current) return;
+ // 1 an, scope au site entier
+ document.cookie = `${LOCALE_COOKIE}=${next}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
+ startTransition(() => {
+ router.refresh();
+ });
+ }
+
+ return (
+
+ {t("language.switch")}
+ setLocale("fr")}
+ disabled={pending}
+ aria-pressed={current === "fr"}
+ className={`rounded-full px-2.5 py-0.5 uppercase tracking-wider transition ${
+ current === "fr"
+ ? "bg-[var(--color-karbe-bone)] text-[var(--color-karbe-canopy-900)]"
+ : "text-[var(--color-karbe-bone)] hover:bg-[var(--color-karbe-bone)]/15"
+ } disabled:opacity-50`}
+ >
+ FR
+
+ setLocale("en")}
+ disabled={pending}
+ aria-pressed={current === "en"}
+ className={`rounded-full px-2.5 py-0.5 uppercase tracking-wider transition ${
+ current === "en"
+ ? "bg-[var(--color-karbe-bone)] text-[var(--color-karbe-canopy-900)]"
+ : "text-[var(--color-karbe-bone)] hover:bg-[var(--color-karbe-bone)]/15"
+ } disabled:opacity-50`}
+ >
+ EN
+
+
+ );
+}
diff --git a/src/components/MediaUploader.tsx b/src/components/MediaUploader.tsx
new file mode 100644
index 0000000..1eb1cdd
--- /dev/null
+++ b/src/components/MediaUploader.tsx
@@ -0,0 +1,430 @@
+"use client";
+
+import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
+import {
+ DndContext,
+ PointerSensor,
+ TouchSensor,
+ KeyboardSensor,
+ closestCenter,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ arrayMove,
+ rectSortingStrategy,
+ useSortable,
+ sortableKeyboardCoordinates,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+
+export type MediaItem = {
+ id: string;
+ type: "PHOTO" | "VIDEO";
+ s3Url: string;
+ s3Key: string;
+ sortOrder: number;
+};
+
+/**
+ * Le composant gère deux périmètres : carbet (par défaut) et item de location.
+ * Les endpoints sont alors `/api/uploads/{rental-}{presign,finalize}`,
+ * `/api/{rental-}media/{id}` et `/api/{rental-}media/reorder` ; la clé de
+ * scope dans les payloads passe de `carbetId` à `itemId`.
+ */
+export type UploaderScope =
+ | { kind: "carbet"; carbetId: string }
+ | { kind: "rental-item"; itemId: string };
+
+type Props = {
+ scope?: UploaderScope;
+ /** @deprecated — passer `scope={{kind:"carbet", carbetId}}` à la place. */
+ carbetId?: string;
+ initialMedia: MediaItem[];
+};
+
+type Endpoints = {
+ presign: string;
+ finalize: string;
+ reorder: string;
+ remove: (mediaId: string) => string;
+ idKey: "carbetId" | "itemId";
+ idValue: string;
+};
+
+function endpointsFor(scope: UploaderScope): Endpoints {
+ if (scope.kind === "carbet") {
+ return {
+ presign: "/api/uploads/presign",
+ finalize: "/api/uploads/finalize",
+ reorder: "/api/media/reorder",
+ remove: (id) => `/api/media/${id}`,
+ idKey: "carbetId",
+ idValue: scope.carbetId,
+ };
+ }
+ return {
+ presign: "/api/uploads/rental-presign",
+ finalize: "/api/uploads/rental-finalize",
+ reorder: "/api/rental-media/reorder",
+ remove: (id) => `/api/rental-media/${id}`,
+ idKey: "itemId",
+ idValue: scope.itemId,
+ };
+}
+
+type UploadEntry = {
+ tempId: string;
+ name: string;
+ sizeBytes: number;
+ mime: string;
+ progress: number;
+ error?: string;
+ done: boolean;
+};
+
+const MAX_PARALLEL = 3;
+
+export function MediaUploader({ scope, carbetId, initialMedia }: Props) {
+ const endpoints = useMemo(
+ () => endpointsFor(scope ?? { kind: "carbet", carbetId: carbetId ?? "" }),
+ [scope, carbetId],
+ );
+ const [items, setItems] = useState(
+ [...initialMedia].sort((a, b) => a.sortOrder - b.sortOrder),
+ );
+ const [uploads, setUploads] = useState([]);
+ const [dragging, setDragging] = useState(false);
+ const inputId = useId();
+ const fileInput = useRef(null);
+ const queueRef = useRef([]);
+ const activeRef = useRef(0);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
+ useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 6 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+ );
+
+ const allIds = useMemo(() => items.map((i) => i.id), [items]);
+
+ const reorderOnServer = useCallback(
+ async (orderedIds: string[]) => {
+ await fetch(endpoints.reorder, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ [endpoints.idKey]: endpoints.idValue, orderedIds }),
+ }).catch(() => {});
+ },
+ [endpoints],
+ );
+
+ function onDragEnd(e: DragEndEvent) {
+ const { active, over } = e;
+ if (!over || active.id === over.id) return;
+ setItems((prev) => {
+ const oldIdx = prev.findIndex((p) => p.id === active.id);
+ const newIdx = prev.findIndex((p) => p.id === over.id);
+ if (oldIdx < 0 || newIdx < 0) return prev;
+ const next = arrayMove(prev, oldIdx, newIdx);
+ reorderOnServer(next.map((m) => m.id));
+ return next;
+ });
+ }
+
+ const setCover = useCallback(
+ (id: string) => {
+ setItems((prev) => {
+ const idx = prev.findIndex((p) => p.id === id);
+ if (idx <= 0) return prev;
+ const next = arrayMove(prev, idx, 0);
+ reorderOnServer(next.map((m) => m.id));
+ return next;
+ });
+ },
+ [reorderOnServer],
+ );
+
+ const removeItem = useCallback(async (id: string) => {
+ if (!confirm("Supprimer ce média ?")) return;
+ const res = await fetch(endpoints.remove(id), { method: "DELETE" });
+ if (res.ok) setItems((prev) => prev.filter((p) => p.id !== id));
+ }, [endpoints]);
+
+ const processFile = useCallback(async function processFile(file: File): Promise {
+ const tempId = crypto.randomUUID();
+ setUploads((u) => [
+ ...u,
+ { tempId, name: file.name, sizeBytes: file.size, mime: file.type, progress: 0, done: false },
+ ]);
+ try {
+ const presignRes = await fetch(endpoints.presign, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ [endpoints.idKey]: endpoints.idValue,
+ mime: file.type,
+ sizeBytes: file.size,
+ }),
+ });
+ const presign = await presignRes.json();
+ if (!presignRes.ok) throw new Error(presign?.error || "presign refusé");
+
+ await new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.upload.addEventListener("progress", (ev) => {
+ if (!ev.lengthComputable) return;
+ const pct = Math.round((ev.loaded / ev.total) * 100);
+ setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, progress: pct } : x)));
+ });
+ xhr.addEventListener("load", () =>
+ xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`HTTP ${xhr.status}`)),
+ );
+ xhr.addEventListener("error", () => reject(new Error("Réseau coupé")));
+ xhr.open("PUT", presign.uploadUrl);
+ xhr.setRequestHeader("Content-Type", file.type);
+ xhr.send(file);
+ });
+
+ const finalizeRes = await fetch(endpoints.finalize, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ [endpoints.idKey]: endpoints.idValue,
+ s3Key: presign.s3Key,
+ s3Url: presign.publicUrl,
+ mime: file.type,
+ }),
+ });
+ const finalize = await finalizeRes.json();
+ if (!finalizeRes.ok) throw new Error(finalize?.error || "finalize refusé");
+ setItems((prev) => [...prev, finalize.media]);
+ setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, progress: 100, done: true } : x)));
+ // Cleanup après 2s
+ setTimeout(() => {
+ setUploads((u) => u.filter((x) => x.tempId !== tempId));
+ }, 2000);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, error: msg } : x)));
+ }
+ }, [endpoints]);
+
+ const popQueueRef = useRef<() => void>(() => {});
+ const popQueue = useCallback(() => {
+ while (activeRef.current < MAX_PARALLEL && queueRef.current.length > 0) {
+ const file = queueRef.current.shift()!;
+ activeRef.current++;
+ processFile(file).finally(() => {
+ activeRef.current--;
+ popQueueRef.current();
+ });
+ }
+ }, [processFile]);
+ useEffect(() => {
+ popQueueRef.current = popQueue;
+ }, [popQueue]);
+
+ function addFiles(files: FileList | File[]) {
+ const arr = Array.from(files);
+ queueRef.current.push(...arr);
+ popQueue();
+ }
+
+ function onChange(e: React.ChangeEvent) {
+ if (e.target.files) addFiles(e.target.files);
+ if (fileInput.current) fileInput.current.value = "";
+ }
+
+ function onDrop(e: React.DragEvent) {
+ e.preventDefault();
+ setDragging(false);
+ if (e.dataTransfer.files) addFiles(e.dataTransfer.files);
+ }
+
+ // Permet le coller depuis presse-papier
+ useEffect(() => {
+ function onPaste(e: ClipboardEvent) {
+ if (!e.clipboardData?.files?.length) return;
+ addFiles(e.clipboardData.files);
+ }
+ window.addEventListener("paste", onPaste);
+ return () => window.removeEventListener("paste", onPaste);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
{
+ e.preventDefault();
+ setDragging(true);
+ }}
+ onDragLeave={() => setDragging(false)}
+ onDrop={onDrop}
+ className={
+ "rounded-lg border-2 border-dashed p-4 text-center transition " +
+ (dragging
+ ? "border-emerald-500 bg-emerald-50"
+ : "border-zinc-300 bg-zinc-50 hover:border-zinc-400")
+ }
+ >
+
+
+ Déposez vos photos ou vidéos ici, ou cliquez pour parcourir
+
+
+ JPG / PNG / WebP / AVIF (max 10 Mo) · MP4 / MOV / WebM (max 200 Mo) · plusieurs fichiers OK
+
+
+
+
+
+ {uploads.length > 0 ? (
+
+ ) : null}
+
+ {items.length > 0 ? (
+
+
+
+ {items.map((item, idx) => (
+ setCover(item.id)}
+ onDelete={() => removeItem(item.id)}
+ />
+ ))}
+
+
+
+ ) : (
+
+ Pas encore de média. Ajoutez votre premier ci-dessus.
+
+ )}
+
+
+ Glissez-déposez pour réordonner · Étoile = cover (image principale sur le catalogue)
+
+
+ );
+}
+
+function SortableTile({
+ item,
+ isCover,
+ onSetCover,
+ onDelete,
+}: {
+ item: MediaItem;
+ isCover: boolean;
+ onSetCover: () => void;
+ onDelete: () => void;
+}) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: item.id,
+ });
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+ return (
+
+
+ {item.type === "VIDEO" ? (
+
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+ {isCover ? (
+
+ Cover
+
+ ) : null}
+
+ {item.type}
+
+
+ {!isCover ? (
+
+ ★ Cover
+
+ ) : null}
+
+ ✕
+
+
+
+ );
+}
diff --git a/src/components/MobileMenuButton.tsx b/src/components/MobileMenuButton.tsx
new file mode 100644
index 0000000..58e00eb
--- /dev/null
+++ b/src/components/MobileMenuButton.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { signOut } from "next-auth/react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+type LinkItem = { href: string; label: string };
+
+type Props = {
+ isAuthenticated: boolean;
+ isOwner: boolean;
+ isRentalProvider: boolean;
+ isCeManager: boolean;
+ isAdmin: boolean;
+ rentalEnabled: boolean;
+ ceEnabled: boolean;
+};
+
+/**
+ * Bouton hamburger visible uniquement sur mobile (sm:hidden).
+ * Ouvre un drawer qui rassemble tous les liens de navigation, car en
+ * mobile les liens du SiteHeader sont masqués pour rester sur 1 ligne.
+ */
+export function MobileMenuButton({
+ isAuthenticated,
+ isOwner,
+ isRentalProvider,
+ isCeManager,
+ isAdmin,
+ rentalEnabled,
+ ceEnabled,
+}: Props) {
+ const [open, setOpen] = useState(false);
+ const pathname = usePathname();
+ // Ferme le menu si on change de page — pathname comparé à la valeur précédente
+ // dans un effect avec setState, façon "useRef + condition" pour éviter le
+ // warning react-hooks/set-state-in-effect (setState dans un effect sans
+ // dépendance externe = anti-pattern).
+ const lastPathnameRef = useRef(pathname);
+ useEffect(() => {
+ if (lastPathnameRef.current !== pathname) {
+ lastPathnameRef.current = pathname;
+ // closure ref → reflète bien la dernière valeur ; setOpen est stable
+ // (renvoyé par useState) donc OK dans deps.
+ setOpen(false);
+ }
+ }, [pathname]);
+
+ // Empêche le scroll sous-jacent quand ouvert
+ useEffect(() => {
+ if (!open) return;
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = prev;
+ };
+ }, [open]);
+
+ const publicLinks: LinkItem[] = [
+ { href: "/decouvrir", label: "Au fil de l'eau" },
+ { href: "/carbets", label: "Catalogue" },
+ ...(rentalEnabled ? [{ href: "/materiel", label: "Matériel" }] : []),
+ ];
+
+ const userLinks: LinkItem[] = isAuthenticated
+ ? [
+ { href: "/mes-favoris", label: "Favoris" },
+ { href: "/mes-reservations", label: "Mes réservations" },
+ ...(rentalEnabled ? [{ href: "/mes-locations", label: "Mes locations" }] : []),
+ { href: "/mon-compte", label: "Mon compte" },
+ ]
+ : [];
+
+ const proLinks: LinkItem[] = isAuthenticated
+ ? [
+ ...(isOwner ? [{ href: "/espace-hote", label: "Espace hôte" }] : []),
+ ...(isRentalProvider && rentalEnabled
+ ? [{ href: "/espace-prestataire", label: "Espace prestataire" }]
+ : []),
+ ...(isCeManager && ceEnabled ? [{ href: "/espace-ce", label: "Espace CE" }] : []),
+ ...(isAdmin ? [{ href: "/admin", label: "Admin" }] : []),
+ ]
+ : [];
+
+ return (
+ <>
+ setOpen(true)}
+ aria-label="Ouvrir le menu"
+ aria-expanded={open}
+ className="inline-flex h-9 w-9 items-center justify-center rounded-md text-zinc-700 hover:bg-zinc-100 sm:hidden"
+ >
+
+
+
+
+
+ {open ? (
+
+
setOpen(false)}
+ className="absolute inset-0 bg-zinc-900/40"
+ />
+
+
+
Menu
+
setOpen(false)}
+ aria-label="Fermer"
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 hover:bg-zinc-100"
+ >
+
+
+
+
+
+
+
+ {publicLinks.map((l) => (
+
+ {l.label}
+
+ ))}
+
+ {userLinks.length > 0 ? (
+
+ {userLinks.map((l) => (
+
+ {l.label}
+
+ ))}
+
+ ) : null}
+ {proLinks.length > 0 ? (
+
+ {proLinks.map((l) => (
+
+ {l.label}
+
+ ))}
+
+ ) : null}
+
+
+ {isAuthenticated ? (
+
signOut({ callbackUrl: "/" })}
+ className="w-full rounded-md border border-zinc-300 px-4 py-2 text-center text-sm font-semibold text-zinc-700 hover:bg-zinc-50"
+ >
+ Se déconnecter
+
+ ) : (
+
+
+ Connexion
+
+
+ Créer un compte
+
+
+ )}
+
+
+
+ ) : null}
+ >
+ );
+}
+
+function MenuSection({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+function MenuLink({
+ href,
+ pathname,
+ children,
+}: {
+ href: string;
+ pathname: string;
+ children: React.ReactNode;
+}) {
+ const active = pathname === href || (href !== "/" && pathname.startsWith(href));
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/OperationalBadges.tsx b/src/components/OperationalBadges.tsx
new file mode 100644
index 0000000..e2f3ea0
--- /dev/null
+++ b/src/components/OperationalBadges.tsx
@@ -0,0 +1,120 @@
+/**
+ * Badges opérationnels Karbé : 4 critères dealbreakers affichés en compact
+ * sur les cards catalog + en gros sur la fiche carbet.
+ *
+ * - Route (NONE / DRY_SEASON_ONLY / ALL_YEAR)
+ * - Capacité (X voyageurs max)
+ * - Électricité (NONE / SOLAR / GENERATOR_READY / EDF)
+ * - GSM (au carbet OUI / à X km / zone blanche)
+ */
+
+import { Electricity, RoadAccess } from "@/generated/prisma/enums";
+
+type Props = {
+ roadAccess: RoadAccess | null;
+ capacity: number;
+ electricity: Electricity | null;
+ gsmAtCarbet: boolean;
+ gsmExitDistanceKm: number | null;
+ /** "compact" pour les cards, "full" pour la fiche détail. */
+ variant?: "compact" | "full";
+};
+
+type Badge = {
+ emoji: string;
+ label: string;
+ tone: "good" | "neutral" | "warn";
+};
+
+function roadBadge(r: RoadAccess | null): Badge {
+ if (r === RoadAccess.ALL_YEAR) return { emoji: "🛣️", label: "Route toute saison", tone: "good" };
+ if (r === RoadAccess.DRY_SEASON_ONLY) return { emoji: "🛣️", label: "Route saison sèche", tone: "warn" };
+ if (r === RoadAccess.NONE) return { emoji: "🛶", label: "Pirogue uniquement", tone: "neutral" };
+ return { emoji: "🛣️", label: "Accès non précisé", tone: "neutral" };
+}
+
+function capacityBadge(c: number): Badge {
+ return { emoji: "👥", label: `${c} voyageur${c > 1 ? "s" : ""}`, tone: "neutral" };
+}
+
+function electricityBadge(e: Electricity | null): Badge {
+ if (e === Electricity.EDF) return { emoji: "⚡", label: "EDF / raccordé", tone: "good" };
+ if (e === Electricity.GENERATOR_READY) return { emoji: "🔌", label: "Préinstall groupe", tone: "good" };
+ if (e === Electricity.SOLAR) return { emoji: "☀️", label: "Solaire", tone: "neutral" };
+ if (e === Electricity.NONE) return { emoji: "🕯️", label: "Aucune électricité", tone: "warn" };
+ return { emoji: "⚡", label: "Électricité non précisée", tone: "neutral" };
+}
+
+function gsmBadge(atCarbet: boolean, exitKm: number | null): Badge {
+ if (atCarbet) return { emoji: "📶", label: "Réseau au carbet", tone: "good" };
+ if (exitKm !== null) {
+ const tone: Badge["tone"] = exitKm <= 1 ? "neutral" : "warn";
+ return { emoji: "📵", label: `Réseau à ${exitKm.toFixed(exitKm < 1 ? 1 : 0)} km`, tone };
+ }
+ return { emoji: "📵", label: "Zone blanche", tone: "warn" };
+}
+
+const TONE_CLASSES_COMPACT: Record = {
+ good: "bg-emerald-50 text-emerald-800 ring-emerald-200",
+ neutral: "bg-zinc-100 text-zinc-700 ring-zinc-200",
+ warn: "bg-amber-50 text-amber-800 ring-amber-200",
+};
+
+const TONE_CLASSES_FULL: Record = {
+ good: "bg-emerald-50 text-emerald-900 ring-emerald-300 border-emerald-200",
+ neutral: "bg-white text-zinc-900 ring-zinc-300 border-zinc-200",
+ warn: "bg-amber-50 text-amber-900 ring-amber-300 border-amber-200",
+};
+
+export function OperationalBadges({
+ roadAccess,
+ capacity,
+ electricity,
+ gsmAtCarbet,
+ gsmExitDistanceKm,
+ variant = "compact",
+}: Props) {
+ const badges: Badge[] = [
+ roadBadge(roadAccess),
+ capacityBadge(capacity),
+ electricityBadge(electricity),
+ gsmBadge(gsmAtCarbet, gsmExitDistanceKm),
+ ];
+
+ if (variant === "compact") {
+ return (
+
+ {badges.map((b, i) => (
+
+ {b.emoji}
+ {b.label}
+
+ ))}
+
+ );
+ }
+
+ // full : grille 2×2 pour la fiche
+ return (
+
+ {badges.map((b, i) => (
+
+ {b.emoji}
+ {b.label}
+
+ ))}
+
+ );
+}
diff --git a/src/components/PirogueTransportBlock.tsx b/src/components/PirogueTransportBlock.tsx
new file mode 100644
index 0000000..149d75e
--- /dev/null
+++ b/src/components/PirogueTransportBlock.tsx
@@ -0,0 +1,80 @@
+import { isPluginEnabled } from "@/lib/plugins/server";
+import {
+ TRANSPORT_MODE_EMOJI,
+ TRANSPORT_MODE_LABEL,
+ type PirogueProvider,
+} from "@/lib/pirogue-providers";
+
+/**
+ * Bloc transport pirogue sur la fiche carbet (server component).
+ * Gated par le plugin `pirogue-providers`. Sans le plugin, retourne null.
+ */
+export async function PirogueTransportBlock({
+ transportMode,
+ provider,
+}: {
+ transportMode: string | null;
+ provider: PirogueProvider | null;
+}) {
+ if (!(await isPluginEnabled("pirogue-providers"))) return null;
+ if (!transportMode) return null;
+
+ const emoji = TRANSPORT_MODE_EMOJI[transportMode] ?? "🛶";
+ const label = TRANSPORT_MODE_LABEL[transportMode] ?? "Transport pirogue";
+
+ return (
+
+
+ {emoji}
+
+ Transport pirogue — {label}
+
+
+
+ {transportMode === "PARTNER_PROVIDER" && provider ? (
+
+
+ Ce carbet travaille avec un partenaire référencé :{" "}
+ {provider.name}
+
+ {provider.description ?
{provider.description}
: null}
+ {provider.pricingNote ? (
+
{provider.pricingNote}
+ ) : null}
+
+ {provider.contactEmail ? (
+
+ ) : null}
+ {provider.contactPhone ? (
+
+
Tél. ·
+ {provider.contactPhone}
+
+ ) : null}
+
+
+ ) : transportMode === "OWNER_PROVIDES" ? (
+
+ Le loueur s'occupe du transport : il vous récupère au point d'embarquement et
+ vous ramène en fin de séjour. Détails de l'heure et du point de rendez-vous transmis
+ par e-mail après réservation.
+
+ ) : (
+
+ Le transport est à votre charge. Renseignez-vous auprès des piroguiers locaux du dégrad
+ d'embarquement, ou prévenez-nous : on peut vous orienter vers un partenaire.
+
+ )}
+
+ );
+}
diff --git a/src/components/RentalCartProvider.tsx b/src/components/RentalCartProvider.tsx
new file mode 100644
index 0000000..2dd8738
--- /dev/null
+++ b/src/components/RentalCartProvider.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+
+import {
+ CART_COOKIE,
+ EMPTY_CART,
+ parseCart,
+ serializeCart,
+ type Cart,
+ type CartEntry,
+} from "@/lib/rental-cart";
+
+type CartContextValue = {
+ cart: Cart;
+ addEntry: (entry: CartEntry) => void;
+ removeEntry: (index: number) => void;
+ updateEntry: (index: number, patch: Partial) => void;
+ clear: () => void;
+ totalItems: number;
+};
+
+const Ctx = createContext(null);
+
+function readCookieClient(): Cart {
+ if (typeof document === "undefined") return EMPTY_CART;
+ const match = document.cookie.split(/;\s*/).find((c) => c.startsWith(`${CART_COOKIE}=`));
+ if (!match) return EMPTY_CART;
+ const value = decodeURIComponent(match.slice(CART_COOKIE.length + 1));
+ return parseCart(value);
+}
+
+function writeCookieClient(cart: Cart): void {
+ if (typeof document === "undefined") return;
+ document.cookie = `${CART_COOKIE}=${encodeURIComponent(serializeCart(cart))}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax`;
+}
+
+export function RentalCartProvider({ children, initial }: { children: ReactNode; initial?: Cart }) {
+ // Initial vient du serveur (cookie lu côté serveur). Sur le client on relit le
+ // cookie une seule fois via lazy initializer pour rester cohérent si un autre
+ // onglet a modifié le panier entre le render serveur et l'hydration.
+ const [cart, setCart] = useState(() => {
+ if (typeof document === "undefined") return initial ?? EMPTY_CART;
+ const fromCookie = readCookieClient();
+ return fromCookie.items.length > 0 ? fromCookie : initial ?? EMPTY_CART;
+ });
+
+ const persist = useCallback((next: Cart) => {
+ setCart(next);
+ writeCookieClient(next);
+ }, []);
+
+ const addEntry = useCallback(
+ (entry: CartEntry) => {
+ const next = { ...cart, items: [...cart.items, entry] };
+ persist(next);
+ },
+ [cart, persist],
+ );
+
+ const removeEntry = useCallback(
+ (index: number) => {
+ const next = { ...cart, items: cart.items.filter((_, i) => i !== index) };
+ persist(next);
+ },
+ [cart, persist],
+ );
+
+ const updateEntry = useCallback(
+ (index: number, patch: Partial) => {
+ const next = {
+ ...cart,
+ items: cart.items.map((e, i) => (i === index ? { ...e, ...patch } : e)),
+ };
+ persist(next);
+ },
+ [cart, persist],
+ );
+
+ const clear = useCallback(() => {
+ persist({ v: 1, items: [] });
+ }, [persist]);
+
+ const value = useMemo(
+ () => ({
+ cart,
+ addEntry,
+ removeEntry,
+ updateEntry,
+ clear,
+ totalItems: cart.items.reduce((acc, e) => acc + e.qty, 0),
+ }),
+ [cart, addEntry, removeEntry, updateEntry, clear],
+ );
+
+ return {children} ;
+}
+
+export function useCart(): CartContextValue {
+ const ctx = useContext(Ctx);
+ if (!ctx) throw new Error("useCart must be used inside ");
+ return ctx;
+}
diff --git a/src/components/ResponsiveImage.tsx b/src/components/ResponsiveImage.tsx
new file mode 100644
index 0000000..61bfaa6
--- /dev/null
+++ b/src/components/ResponsiveImage.tsx
@@ -0,0 +1,56 @@
+/**
+ * avec srcset/sizes pré-rempli sur les variantes Karbé.
+ * Drop-in remplacement pour les balises ` ` côté front.
+ */
+
+import { buildSrcSet } from "@/lib/image-variants";
+
+type Props = {
+ src: string;
+ alt: string;
+ /** Indication CSS pour le browser. Ex: "(min-width: 768px) 800px, 100vw" */
+ sizes?: string;
+ className?: string;
+ loading?: "lazy" | "eager";
+ fetchPriority?: "high" | "low" | "auto";
+ width?: number;
+ height?: number;
+ decoding?: "async" | "sync" | "auto";
+ draggable?: boolean;
+ style?: React.CSSProperties;
+ onClick?: () => void;
+};
+
+export function ResponsiveImage({
+ src,
+ alt,
+ sizes = "(min-width: 768px) 800px, 100vw",
+ className,
+ loading = "lazy",
+ fetchPriority = "auto",
+ width,
+ height,
+ decoding = "async",
+ draggable,
+ style,
+ onClick,
+}: Props) {
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ );
+}
diff --git a/src/components/SeasonBanner.tsx b/src/components/SeasonBanner.tsx
new file mode 100644
index 0000000..2d1a6ab
--- /dev/null
+++ b/src/components/SeasonBanner.tsx
@@ -0,0 +1,43 @@
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { currentSeason, SEASON_META } from "@/lib/seasonality";
+import { t } from "@/lib/i18n/server";
+
+const TONES = {
+ ok: "bg-[var(--color-karbe-canopy-700)] text-[var(--color-karbe-bone)]",
+ warn: "bg-[var(--color-karbe-laterite-500)] text-[var(--color-karbe-bone)]",
+ info: "bg-[var(--color-karbe-maroni-700)] text-[var(--color-karbe-bone)]",
+} as const;
+
+const SEASON_KEYS = {
+ DRY: { label: "season.dry", message: "season.dry.message" },
+ LOW_WATER: { label: "season.lowWater", message: "season.lowWater.message" },
+ WET: { label: "season.wet", message: "season.wet.message" },
+} as const;
+
+/**
+ * Bandeau saison — affiché en haut de la home et de /carbets si le plugin
+ * `seasonality` est activé. Server component pur, pas de fetch DB.
+ * Texte i18n via t() server-side.
+ */
+export async function SeasonBanner() {
+ if (!(await isPluginEnabled("seasonality"))) return null;
+ const season = currentSeason();
+ const meta = SEASON_META[season];
+ const keys = SEASON_KEYS[season];
+ const label = await t(keys.label);
+ const message = await t(keys.message);
+
+ return (
+
+ );
+}
diff --git a/src/components/SignOutButton.tsx b/src/components/SignOutButton.tsx
new file mode 100644
index 0000000..9837157
--- /dev/null
+++ b/src/components/SignOutButton.tsx
@@ -0,0 +1,19 @@
+import { signOut } from "@/auth";
+
+export function SignOutButton() {
+ return (
+ {
+ "use server";
+ await signOut({ redirectTo: "/" });
+ }}
+ >
+
+ Se déconnecter
+
+
+ );
+}
diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx
new file mode 100644
index 0000000..64faae8
--- /dev/null
+++ b/src/components/SiteHeader.tsx
@@ -0,0 +1,125 @@
+/**
+ * Header global affiché sur toutes les pages PUBLIQUES (hors /admin qui a son
+ * propre shell). Charge la session côté serveur pour adapter les liens.
+ */
+
+import Link from "next/link";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { isPluginEnabled } from "@/lib/plugins/server";
+
+import { CartBadge } from "./CartBadge";
+import { MobileMenuButton } from "./MobileMenuButton";
+import { SignOutButton } from "./SignOutButton";
+
+export async function SiteHeader() {
+ const session = await auth();
+ const u = session?.user;
+ const isAdmin = u?.role === UserRole.ADMIN;
+ const isOwner = u?.role === UserRole.OWNER || isAdmin;
+ const isRentalProvider = u?.role === UserRole.RENTAL_PROVIDER || isAdmin;
+ const isCeManager = u?.role === UserRole.CE_MANAGER || isAdmin;
+ const [rentalEnabled, ceEnabled] = await Promise.all([
+ isPluginEnabled("gear-rental"),
+ isPluginEnabled("ce-management"),
+ ]);
+
+ return (
+
+ );
+}
diff --git a/src/components/SiteHeaderGuard.tsx b/src/components/SiteHeaderGuard.tsx
new file mode 100644
index 0000000..b1530d1
--- /dev/null
+++ b/src/components/SiteHeaderGuard.tsx
@@ -0,0 +1,28 @@
+/**
+ * N'affiche le SiteHeader QUE sur les pages publiques.
+ * Sur /admin, le shell admin a déjà sa propre TopBar + Sidebar.
+ * Sur /connexion et /inscription, on garde la page nue.
+ */
+
+import { headers } from "next/headers";
+
+import { SiteHeader } from "./SiteHeader";
+
+export async function SiteHeaderGuard() {
+ const h = await headers();
+ // Next.js 16 expose le pathname via le header x-pathname si on l'a posé,
+ // sinon on retombe sur next-url ou referer. On utilise une heuristique simple :
+ // pathname depuis x-invoke-path (Next internal) ou x-next-url-path-prefix.
+ const pathname =
+ h.get("x-pathname") ??
+ h.get("x-invoke-path") ??
+ h.get("next-url") ??
+ "";
+
+ if (pathname.startsWith("/admin")) return null;
+ if (pathname === "/connexion" || pathname === "/inscription") return null;
+ // Mode immersif : on cache le header pour donner 100dvh aux Reels
+ if (pathname === "/decouvrir" || pathname.startsWith("/decouvrir/")) return null;
+
+ return ;
+}
diff --git a/src/components/StayConstraints.tsx b/src/components/StayConstraints.tsx
new file mode 100644
index 0000000..8026bf1
--- /dev/null
+++ b/src/components/StayConstraints.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { useIsPluginEnabled } from "@/lib/plugins/client";
+
+/**
+ * Composant client qui affiche les contraintes de séjour si le plugin
+ * `min-stay` est activé. Sinon, retourne null (legacy = pas de contraintes).
+ */
+export function StayConstraints({
+ minNights,
+ maxNights,
+ minCapacity,
+ className = "",
+}: {
+ minNights?: number | null;
+ maxNights?: number | null;
+ minCapacity?: number | null;
+ className?: string;
+}) {
+ const enabled = useIsPluginEnabled("min-stay");
+ if (!enabled) return null;
+ if (!minNights && !maxNights && !minCapacity) return null;
+
+ const parts: string[] = [];
+ if (minNights && maxNights && minNights !== maxNights) {
+ parts.push(`${minNights}–${maxNights} nuits`);
+ } else if (minNights) {
+ parts.push(
+ minNights === 1 ? "1 nuit minimum" : `${minNights} nuits minimum`,
+ );
+ } else if (maxNights) {
+ parts.push(`Max ${maxNights} nuits`);
+ }
+ if (minCapacity && minCapacity > 1) {
+ parts.push(`groupe de ${minCapacity}+ recommandé`);
+ }
+ if (!parts.length) return null;
+
+ return (
+
+ 🌙 {parts.join(" · ")}
+
+ );
+}
diff --git a/src/components/admin/Breadcrumbs.tsx b/src/components/admin/Breadcrumbs.tsx
new file mode 100644
index 0000000..1206bf7
--- /dev/null
+++ b/src/components/admin/Breadcrumbs.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+const LABELS: Record = {
+ admin: "Admin",
+ carbets: "Carbets",
+ bookings: "Réservations",
+ reviews: "Avis",
+ users: "Utilisateurs",
+ organizations: "Organisations",
+ "pirogue-providers": "Prestataires",
+ media: "Médias",
+ "content-pages": "Pages",
+ plugins: "Plugins",
+ settings: "Paramètres",
+ audit: "Audit log",
+};
+
+export function Breadcrumbs() {
+ const pathname = usePathname();
+ if (!pathname.startsWith("/admin")) return null;
+ const parts = pathname.split("/").filter(Boolean);
+ // skip if just /admin
+ if (parts.length <= 1) return null;
+ return (
+
+ {parts.map((p, i) => {
+ const href = "/" + parts.slice(0, i + 1).join("/");
+ const isLast = i === parts.length - 1;
+ const label = LABELS[p] ?? decodeURIComponent(p);
+ return (
+
+ {i > 0 ? / : null}
+ {isLast ? (
+ {label}
+ ) : (
+ {label}
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/admin/CommandPalette.tsx b/src/components/admin/CommandPalette.tsx
new file mode 100644
index 0000000..2d395da
--- /dev/null
+++ b/src/components/admin/CommandPalette.tsx
@@ -0,0 +1,180 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { SearchHit } from "@/lib/admin/search";
+
+const TYPE_LABEL: Record = {
+ carbet: "Carbet",
+ user: "Utilisateur",
+ booking: "Réservation",
+ page: "Page",
+ provider: "Prestataire",
+};
+const TYPE_ACCENT: Record = {
+ carbet: "bg-emerald-100 text-emerald-800",
+ user: "bg-sky-100 text-sky-800",
+ booking: "bg-amber-100 text-amber-800",
+ page: "bg-violet-100 text-violet-800",
+ provider: "bg-rose-100 text-rose-800",
+};
+
+/**
+ * Palette ⌘K minimaliste, sans dépendance externe. Server search via
+ * /api/admin/search?q=…, navigation au clavier (↑/↓/Enter/Esc).
+ */
+export function CommandPalette() {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const [hits, setHits] = useState([]);
+ const [selected, setSelected] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const inputRef = useRef(null);
+ const abortRef = useRef(null);
+
+ // Ouvre la palette sur ⌘K / Ctrl+K. Esc ferme.
+ useEffect(() => {
+ function onKey(e: KeyboardEvent) {
+ const cmd = e.metaKey || e.ctrlKey;
+ if (cmd && e.key.toLowerCase() === "k") {
+ e.preventDefault();
+ setOpen((v) => !v);
+ } else if (e.key === "Escape") {
+ setOpen(false);
+ }
+ }
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, []);
+
+ useEffect(() => {
+ if (!open) return;
+ // Différé via microtask pour éviter le warning "Calling setState synchronously
+ // within an effect can trigger cascading renders" (react-hooks/purity).
+ queueMicrotask(() => {
+ setQuery("");
+ setHits([]);
+ setSelected(0);
+ setTimeout(() => inputRef.current?.focus(), 50);
+ });
+ }, [open]);
+
+ const runSearch = useCallback(async (q: string) => {
+ if (q.trim().length < 2) {
+ setHits([]);
+ return;
+ }
+ abortRef.current?.abort();
+ const ac = new AbortController();
+ abortRef.current = ac;
+ setLoading(true);
+ try {
+ const r = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, { signal: ac.signal });
+ if (r.ok) {
+ const j = await r.json();
+ setHits(j.hits ?? []);
+ setSelected(0);
+ }
+ } catch {
+ // aborted ou erreur silencieuse
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ const id = setTimeout(() => runSearch(query), 150);
+ return () => clearTimeout(id);
+ }, [query, runSearch]);
+
+ function onListKey(e: React.KeyboardEvent) {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setSelected((s) => Math.min(s + 1, hits.length - 1));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setSelected((s) => Math.max(s - 1, 0));
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ const hit = hits[selected];
+ if (hit) {
+ setOpen(false);
+ router.push(hit.href);
+ }
+ }
+ }
+
+ if (!open) return null;
+
+ return (
+ setOpen(false)}
+ >
+
e.stopPropagation()}
+ >
+
+
+
+
+
+
setQuery(e.target.value)}
+ onKeyDown={onListKey}
+ className="flex-1 bg-transparent text-sm text-zinc-900 placeholder-zinc-400 focus:outline-none"
+ />
+
+ ESC
+
+
+
+
+ {loading ? (
+
…
+ ) : query.length >= 2 && hits.length === 0 ? (
+
Aucun résultat.
+ ) : hits.length === 0 ? (
+
+ Tape au moins 2 caractères. Navigation : ↑ ↓ / Entrée.
+
+ ) : (
+
+ {hits.map((h, i) => (
+
+ setOpen(false)}
+ onMouseEnter={() => setSelected(i)}
+ className={`flex items-center justify-between gap-3 px-3 py-2 text-sm ${
+ i === selected ? "bg-zinc-100" : "hover:bg-zinc-50"
+ }`}
+ >
+
+
+ {TYPE_LABEL[h.type]}
+
+ {h.title}
+
+ {h.subtitle ? (
+ {h.subtitle}
+ ) : null}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/admin/FormField.tsx b/src/components/admin/FormField.tsx
new file mode 100644
index 0000000..4425bb9
--- /dev/null
+++ b/src/components/admin/FormField.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from "react";
+
+type Props = {
+ label: string;
+ htmlFor?: string;
+ hint?: string;
+ error?: string;
+ required?: boolean;
+ children: ReactNode;
+ className?: string;
+};
+
+export function FormField({ label, htmlFor, hint, error, required, children, className = "" }: Props) {
+ return (
+
+
+ {label}
+ {required ? * : null}
+
+ {children}
+ {hint && !error ? {hint} : null}
+ {error ? {error} : null}
+
+ );
+}
+
+export const inputCls =
+ "w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900 disabled:opacity-50";
+
+export const selectCls = inputCls + " cursor-pointer";
+
+export const textareaCls = inputCls + " font-mono leading-relaxed";
diff --git a/src/components/admin/KPICard.tsx b/src/components/admin/KPICard.tsx
new file mode 100644
index 0000000..0b83f29
--- /dev/null
+++ b/src/components/admin/KPICard.tsx
@@ -0,0 +1,44 @@
+import type { ReactNode } from "react";
+
+type Tone = "neutral" | "ok" | "warn" | "info";
+
+const toneStyles: Record = {
+ neutral: "border-zinc-200 bg-white",
+ ok: "border-emerald-200 bg-emerald-50",
+ warn: "border-amber-200 bg-amber-50",
+ info: "border-sky-200 bg-sky-50",
+};
+
+const toneText: Record = {
+ neutral: "text-zinc-900",
+ ok: "text-emerald-900",
+ warn: "text-amber-900",
+ info: "text-sky-900",
+};
+
+export function KPICard({
+ label,
+ value,
+ hint,
+ tone = "neutral",
+ icon,
+}: {
+ label: string;
+ value: string | number;
+ hint?: string;
+ tone?: Tone;
+ icon?: ReactNode;
+}) {
+ return (
+
+
+ {label}
+ {icon ? {icon} : null}
+
+
+ {value}
+
+ {hint ?
{hint}
: null}
+
+ );
+}
diff --git a/src/components/admin/Sidebar.tsx b/src/components/admin/Sidebar.tsx
new file mode 100644
index 0000000..6eae7d0
--- /dev/null
+++ b/src/components/admin/Sidebar.tsx
@@ -0,0 +1,208 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import type { ReactNode } from "react";
+
+type NavItem = {
+ href: string;
+ label: string;
+ icon: ReactNode;
+ badge?: number;
+};
+
+type NavGroup = {
+ label: string;
+ items: NavItem[];
+};
+
+const ICONS = {
+ dashboard: (
+
+
+
+
+
+
+ ),
+ carbets: (
+
+
+
+
+ ),
+ bookings: (
+
+
+
+
+
+
+ ),
+ users: (
+
+
+
+
+
+
+ ),
+ organizations: (
+
+
+
+
+
+
+ ),
+ pirogue: (
+
+
+
+
+
+
+ ),
+ reviews: (
+
+
+
+ ),
+ media: (
+
+
+
+
+
+ ),
+ pages: (
+
+
+
+
+
+
+ ),
+ plugins: (
+
+
+
+
+
+
+ ),
+ settings: (
+
+
+
+
+ ),
+ audit: (
+
+
+
+
+ ),
+};
+
+const GROUPS: NavGroup[] = [
+ {
+ label: "Vue d'ensemble",
+ items: [
+ { href: "/admin", label: "Dashboard", icon: ICONS.dashboard },
+ { href: "/admin/analytics", label: "Analytics", icon: ICONS.dashboard },
+ ],
+ },
+ {
+ label: "Catalogue",
+ items: [
+ { href: "/admin/carbets", label: "Carbets", icon: ICONS.carbets },
+ { href: "/admin/pirogue-providers", label: "Prestataires pirogue", icon: ICONS.pirogue },
+ { href: "/admin/rental-providers", label: "Prestataires matériel", icon: ICONS.pirogue },
+ { href: "/admin/rental-items", label: "Items locables", icon: ICONS.media },
+ { href: "/admin/media", label: "Médias", icon: ICONS.media },
+ ],
+ },
+ {
+ label: "Activité",
+ items: [
+ { href: "/admin/bookings", label: "Réservations", icon: ICONS.bookings },
+ { href: "/admin/rentals", label: "Locations matériel", icon: ICONS.bookings },
+ { href: "/admin/payouts", label: "Reversements", icon: ICONS.bookings },
+ { href: "/admin/reviews", label: "Avis & modération", icon: ICONS.reviews },
+ ],
+ },
+ {
+ label: "Membres",
+ items: [
+ { href: "/admin/users", label: "Utilisateurs", icon: ICONS.users },
+ { href: "/admin/organizations", label: "Organisations CE", icon: ICONS.organizations },
+ ],
+ },
+ {
+ label: "Contenu",
+ items: [
+ { href: "/admin/home", label: "Page d'accueil", icon: ICONS.dashboard },
+ { href: "/admin/content-pages", label: "Pages éditoriales", icon: ICONS.pages },
+ ],
+ },
+ {
+ label: "Système",
+ items: [
+ { href: "/admin/plugins", label: "Plugins", icon: ICONS.plugins },
+ { href: "/admin/settings", label: "Paramètres", icon: ICONS.settings },
+ { href: "/admin/audit", label: "Audit log", icon: ICONS.audit },
+ ],
+ },
+];
+
+export function Sidebar() {
+ const pathname = usePathname();
+
+ return (
+
+
+
+ Karbé · Admin
+
+
+ {GROUPS.map((group) => (
+
+
+ {group.label}
+
+
+ {group.items.map((item) => {
+ const active = pathname === item.href || (item.href !== "/admin" && pathname.startsWith(item.href));
+ return (
+
+
+
+ {item.icon}
+ {item.label}
+
+ {item.badge !== undefined ? (
+
+ {item.badge}
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ ))}
+
+ );
+}
diff --git a/src/components/admin/StatusBadge.tsx b/src/components/admin/StatusBadge.tsx
new file mode 100644
index 0000000..0bd7cce
--- /dev/null
+++ b/src/components/admin/StatusBadge.tsx
@@ -0,0 +1,43 @@
+const TONES = {
+ draft: "bg-zinc-100 text-zinc-700 ring-zinc-300",
+ published: "bg-emerald-100 text-emerald-800 ring-emerald-300",
+ archived: "bg-amber-100 text-amber-800 ring-amber-300",
+ pending: "bg-sky-100 text-sky-800 ring-sky-300",
+ confirmed: "bg-emerald-100 text-emerald-800 ring-emerald-300",
+ cancelled: "bg-rose-100 text-rose-700 ring-rose-300",
+ completed: "bg-zinc-100 text-zinc-700 ring-zinc-300",
+ authorized: "bg-indigo-100 text-indigo-800 ring-indigo-300",
+ succeeded: "bg-emerald-100 text-emerald-800 ring-emerald-300",
+ failed: "bg-rose-100 text-rose-700 ring-rose-300",
+ refunded: "bg-amber-100 text-amber-800 ring-amber-300",
+ active: "bg-emerald-100 text-emerald-800 ring-emerald-300",
+ inactive: "bg-zinc-100 text-zinc-500 ring-zinc-300",
+} as const;
+
+const LABELS: Record = {
+ DRAFT: "Brouillon",
+ PUBLISHED: "Publié",
+ ARCHIVED: "Archivé",
+ PENDING: "En attente",
+ CONFIRMED: "Confirmé",
+ CANCELLED: "Annulé",
+ COMPLETED: "Terminé",
+ AUTHORIZED: "Autorisé",
+ SUCCEEDED: "Payé",
+ FAILED: "Échec",
+ REFUNDED: "Remboursé",
+ ACTIVE: "Actif",
+ INACTIVE: "Inactif",
+};
+
+export function StatusBadge({ status }: { status: string }) {
+ const key = status.toLowerCase() as keyof typeof TONES;
+ const tone = TONES[key] ?? TONES.draft;
+ return (
+
+ {LABELS[status] ?? status}
+
+ );
+}
diff --git a/src/components/admin/TopBar.tsx b/src/components/admin/TopBar.tsx
new file mode 100644
index 0000000..339ef48
--- /dev/null
+++ b/src/components/admin/TopBar.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+
+function subscribe() {
+ // navigator.userAgent ne change pas durant la session, pas d'abonnement réel.
+ return () => {};
+}
+
+function getSnapshot(): boolean {
+ return navigator.userAgent.includes("Mac");
+}
+
+function getServerSnapshot(): boolean {
+ return false;
+}
+
+export function TopBar({ userEmail }: { userEmail: string }) {
+ const isMac = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+
+ return (
+
+
+ Cmd K pour rechercher
+
+
+
{
+ const ev = new KeyboardEvent("keydown", {
+ key: "k",
+ metaKey: isMac,
+ ctrlKey: !isMac,
+ bubbles: true,
+ });
+ window.dispatchEvent(ev);
+ }}
+ className="hidden items-center gap-2 rounded border border-zinc-200 bg-zinc-50 px-2.5 py-1 text-xs text-zinc-600 hover:border-zinc-300 hover:bg-white sm:inline-flex"
+ >
+
+
+
+
+ Rechercher…
+
+ {isMac ? "⌘K" : "Ctrl K"}
+
+
+
+ ↗ Voir le site
+
+
{userEmail}
+
+
+ );
+}
diff --git a/src/components/analytics/MonthlyRevenueChart.tsx b/src/components/analytics/MonthlyRevenueChart.tsx
new file mode 100644
index 0000000..b65f6b9
--- /dev/null
+++ b/src/components/analytics/MonthlyRevenueChart.tsx
@@ -0,0 +1,113 @@
+/**
+ * Bar chart SVG simple — pas de lib externe. Stack carbetRevenue + rentalRevenue.
+ * Affiche les 12 derniers mois en barres verticales.
+ */
+type Point = {
+ month: string;
+ carbetRevenue: number;
+ rentalRevenue: number;
+ total: number;
+};
+
+const MONTH_LABEL = ["jan", "fév", "mar", "avr", "mai", "jun", "jul", "aoû", "sep", "oct", "nov", "déc"];
+
+function shortMonth(ym: string): string {
+ const [, m] = ym.split("-");
+ return MONTH_LABEL[parseInt(m, 10) - 1] ?? ym;
+}
+
+function fmtEur(n: number): string {
+ return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
+}
+
+export function MonthlyRevenueChart({ data }: { data: Point[] }) {
+ const max = Math.max(1, ...data.map((d) => d.total));
+ const width = Math.max(360, data.length * 40);
+ const height = 180;
+ const padBottom = 24;
+ const padTop = 8;
+ const barWidth = width / data.length - 8;
+ const usableHeight = height - padTop - padBottom;
+
+ return (
+
+
+ {/* Y-axis grid */}
+ {[0, 0.25, 0.5, 0.75, 1].map((p) => {
+ const y = padTop + usableHeight * (1 - p);
+ return (
+
+
+
+ {fmtEur(max * p)}
+
+
+ );
+ })}
+
+ {data.map((d, i) => {
+ const x = 40 + i * (width / data.length) + 4;
+ const carbetH = (d.carbetRevenue / max) * usableHeight;
+ const rentalH = (d.rentalRevenue / max) * usableHeight;
+ const baseY = padTop + usableHeight;
+ return (
+
+ {/* Carbet revenue (bas) */}
+ {carbetH > 0 ? (
+
+
+ Carbet {d.month} : {fmtEur(d.carbetRevenue)}
+
+
+ ) : null}
+ {/* Rental revenue (top de la stack) */}
+ {rentalH > 0 ? (
+
+
+ Matériel {d.month} : {fmtEur(d.rentalRevenue)}
+
+
+ ) : null}
+
+ {shortMonth(d.month)}
+
+
+ );
+ })}
+
+
+
+ Carbet
+
+
+ Matériel rental
+
+
+
+ );
+}
diff --git a/src/components/landing/CESection.tsx b/src/components/landing/CESection.tsx
index 136c8aa..88c148e 100644
--- a/src/components/landing/CESection.tsx
+++ b/src/components/landing/CESection.tsx
@@ -1,36 +1,36 @@
import Link from "next/link";
+import { t } from "@/lib/i18n/server";
/**
- * Section dédiée aux Comités d'Entreprise (CE). Registre coop/solidaire,
- * voix différente du reste de la home (qui parle au touriste aventurier).
+ * Section dédiée aux Comités d'Entreprise (CE). Registre coop/solidaire, i18n.
*/
-export function CESection() {
+export async function CESection() {
+ const kpis = [
+ { k: "0 %", v: await t("ce.kpi.commission") },
+ { k: "CE first", v: await t("ce.kpi.ceFirst") },
+ { k: "Public open", v: await t("ce.kpi.publicOpen") },
+ { k: "Stripe", v: await t("ce.kpi.noPaperwork") },
+ ];
+
return (
- Pour comités d'entreprise
+ {await t("ce.eyebrow")}
- Les carbets dorment quand vous n'y êtes pas.
+ {await t("ce.title")}
- Partageons-les.
+ {await t("ce.titleAccent")}
- Karbé est conçu pour que les comités sociaux possédant déjà un carbet le réservent à
- leurs membres certains week-ends, et l'ouvrent au public touriste le reste de
- l'année. Sans commission sur le séjour : le paiement revient intégralement au CE.
+ {await t("ce.body")}
- {[
- { k: "0 %", v: "de commission sur le séjour" },
- { k: "CE first", v: "vos membres réservent en priorité" },
- { k: "Public ouvert", v: "le reste des dates rentre dans le pot" },
- { k: "Sans paperasse", v: "Stripe encaisse et reverse direct" },
- ].map(({ k, v }) => (
+ {kpis.map(({ k, v }) => (
- En savoir plus pour votre CE
+ {await t("ce.cta")}
→
diff --git a/src/components/landing/ExperiencesSection.tsx b/src/components/landing/ExperiencesSection.tsx
index 6b2091e..f432df5 100644
--- a/src/components/landing/ExperiencesSection.tsx
+++ b/src/components/landing/ExperiencesSection.tsx
@@ -1,24 +1,23 @@
import { RoadIcon, PirogueIcon } from "@/components/illustrations/Icons";
+import { t } from "@/lib/i18n/server";
/**
* Section « 2 expériences » — route+fleuve vs expédition fleuve.
- * Reflète la distinction métier qu'on appliquera côté schema dans le plugin
- * `access-type`. Ici on ne fait que l'éditorialiser pour la home.
+ * Texte i18n via t() server-side.
*/
-export function ExperiencesSection() {
+export async function ExperiencesSection() {
return (
- Deux façons de vivre Karbé
+ {await t("experiences.eyebrow")}
- Du bord du fleuve à l'expédition pirogue.
+ {await t("experiences.title")}
- Selon l'envie, on choisit le carbet qui se rejoint en voiture pour un week-end facile,
- ou celui qu'on n'atteint qu'en pirogue, à plusieurs heures du dernier village.
+ {await t("experiences.subtitle")}
@@ -29,20 +28,18 @@ export function ExperiencesSection() {
- 🛣️ Route + fleuve
+ {await t("experiences.roadFluve.tag")}
- Le carbet du week-end
+ {await t("experiences.roadFluve.title")}
- Accessible par la piste depuis Kourou, Saint-Laurent ou Régina. Garez la voiture,
- prenez vos affaires et vous y êtes. Pour les familles, les couples qui veulent du calme
- sans logistique, les CE qui réservent des séjours courts.
+ {await t("experiences.roadFluve.body")}
- 1 à 3 nuits typiques
- Voiture ou 4×4 selon la piste
- Carbets équipés, baignade possible
+ {await t("experiences.roadFluve.b1")}
+ {await t("experiences.roadFluve.b2")}
+ {await t("experiences.roadFluve.b3")}
@@ -52,20 +49,18 @@ export function ExperiencesSection() {
- 🛶 Expédition fleuve
+ {await t("experiences.riverOnly.tag")}
- Le carbet qu'on mérite
+ {await t("experiences.riverOnly.title")}
- Aucune route n'y mène. On embarque en pirogue depuis un dégrad, parfois deux ou
- trois heures de remontée. Pour ceux qui veulent vraiment dormir loin — singes hurleurs,
- ciel sans halo, l'eau du fleuve à 5 mètres du hamac.
+ {await t("experiences.riverOnly.body")}
- 2 nuits minimum recommandées
- Pirogue avec passeur (loueur ou partenaire)
- Saison sèche conseillée (juillet-novembre)
+ {await t("experiences.riverOnly.b1")}
+ {await t("experiences.riverOnly.b2")}
+ {await t("experiences.riverOnly.b3")}
diff --git a/src/components/landing/Footer.tsx b/src/components/landing/Footer.tsx
index cf907d8..ce1e5df 100644
--- a/src/components/landing/Footer.tsx
+++ b/src/components/landing/Footer.tsx
@@ -1,7 +1,8 @@
import Link from "next/link";
import { PalmIcon, WaveIcon } from "@/components/illustrations/Icons";
+import { t } from "@/lib/i18n/server";
-export function LandingFooter() {
+export async function LandingFooter() {
const year = new Date().getFullYear();
return (
@@ -14,42 +15,47 @@ export function LandingFooter() {
Karbé
- Marketplace des carbets fluviaux de Guyane. Solidaire avec les CE locaux. Sans
- commission sur le séjour.
+ {await t("footer.tagline")}
-
Découvrir
+
+ {await t("footer.col.discover")}
+
- Tous les carbets
- Comment ça marche
- À propos de Karbé
+ {await t("hero.ctaDiscover")}
+ {await t("howItWorks.eyebrow")}
+ Karbé
-
Proposer
+
+ {await t("footer.col.propose")}
+
- Devenir loueur
- Pour comités d'entreprise
- Espace membre
+ {await t("hero.ctaPropose")}
+ {await t("ce.eyebrow")}
+ Stripe
-
Légal
+
+ {await t("footer.col.legal")}
+
CGV
- Mentions légales
- Confidentialité
+ Mentions
+ RGPD
- © {year} Karbé — projet associatif numérique en Guyane.
+ © {year} Karbé — {await t("footer.copyright")}
karbe.cosmolan.fr
diff --git a/src/components/landing/HeroSection.tsx b/src/components/landing/HeroSection.tsx
index 6ee1165..49ab4db 100644
--- a/src/components/landing/HeroSection.tsx
+++ b/src/components/landing/HeroSection.tsx
@@ -1,36 +1,120 @@
import Link from "next/link";
import { CarbetRiver } from "@/components/illustrations/CarbetRiver";
+import { LocaleSwitcher } from "@/components/LocaleSwitcher";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { t } from "@/lib/i18n/server";
+import { aquarelleUrl, getActiveTheme } from "@/lib/theme";
/**
- * Hero plein écran. Plugin `landing-hero`.
- * Pas de dépendance image externe — illustration vectorielle inline.
+ * Hero plein écran. Plugin `landing-hero`. Texte i18n via t() server.
+ * Selon le theme actif :
+ * - aquarelle : illustration MinIO `01-hero-fleuve-maroni` en fond, ambiance
+ * carnet de voyage, texte sépia sur papier teinté, ornement palmier en
+ * coin et bordure hairline sépia
+ * - guyane : SVG vectoriel CarbetRiver, palette tropicale moderne
+ * - none : retombe sur le SVG
*/
-export function HeroSection() {
+export async function HeroSection() {
+ const i18nOn = await isPluginEnabled("i18n-fr-en");
+ const theme = await getActiveTheme();
+ const eyebrow = await t("hero.eyebrow");
+ const titleLine1 = await t("hero.titleLine1");
+ const titleAccent = await t("hero.titleAccent");
+ const subtitle = await t("hero.subtitle");
+ const ctaDiscover = await t("hero.ctaDiscover");
+ const ctaPropose = await t("hero.ctaPropose");
+
+ if (theme === "aquarelle") {
+ return (
+
+
+ {/* voile crème pour lisibilité texte sépia sur l'aquarelle */}
+
+
+
+ {i18nOn ? (
+
+
+
+ ) : null}
+
+
+
+ ~
+ {eyebrow}
+ ~
+
+
+
+ {titleLine1}
+
+ {titleAccent} .
+
+
+
+ {subtitle}
+
+
+
+
+ {ctaDiscover}
+
+
+ {ctaPropose}
+
+
+
+
+ — planche I, carnet d'expédition Karbé —
+
+
+
+ );
+ }
+
+ // Theme guyane (default actuel) ou pas de theme
return (
- {/* fond illustration */}
- {/* voile sombre pour lisibilité texte */}
+ {i18nOn ? (
+
+
+
+ ) : null}
+
- Marketplace solidaire — sans commission sur le séjour
+ {eyebrow}
- Le karbé qui dort
+ {titleLine1}
- vous attend .
+ {titleAccent} .
- Louez un carbet le long du Maroni, de l'Approuague ou de l'Oyapock. Le hamac est tendu,
- la pirogue glisse, le silence est vrai. Pour quelques nuits, le fleuve vous appartient.
+ {subtitle}
@@ -38,13 +122,13 @@ export function HeroSection() {
href="/carbets"
className="rounded-full bg-[var(--color-karbe-laterite-500)] px-6 py-3 text-sm font-semibold text-[var(--color-karbe-bone)] shadow-lg shadow-black/30 transition hover:bg-[var(--color-karbe-laterite-700)]"
>
- Découvrir un carbet
+ {ctaDiscover}
- Proposer le mien
+ {ctaPropose}
diff --git a/src/components/landing/HowItWorksSection.tsx b/src/components/landing/HowItWorksSection.tsx
index 59ef653..554aa95 100644
--- a/src/components/landing/HowItWorksSection.tsx
+++ b/src/components/landing/HowItWorksSection.tsx
@@ -1,42 +1,40 @@
import { CompassIcon, HammockIcon, HeartHandIcon } from "@/components/illustrations/Icons";
+import { t } from "@/lib/i18n/server";
/**
- * Section « Comment ça marche » — 3 étapes côté voyageur.
+ * Section « Comment ça marche » — 3 étapes côté voyageur, i18n via t() server.
*/
-export function HowItWorksSection() {
- const steps = [
+export async function HowItWorksSection() {
+ const steps = await Promise.all([
{
icon: CompassIcon,
step: "01",
- title: "Choisissez le fleuve",
- body:
- "Maroni, Approuague, Comté, Oyapock — chaque fleuve a son ambiance, son embarquement, ses carbets. Filtrez selon votre niveau d'aventure.",
+ title: await t("howItWorks.step1.title"),
+ body: await t("howItWorks.step1.body"),
},
{
icon: HammockIcon,
step: "02",
- title: "Réservez le carbet",
- body:
- "Dates, capacité, durée de pirogue le cas échéant. Paiement sécurisé Stripe, reversé au loueur sans commission sur le séjour.",
+ title: await t("howItWorks.step2.title"),
+ body: await t("howItWorks.step2.body"),
},
{
icon: HeartHandIcon,
step: "03",
- title: "Dormez vrai",
- body:
- "Le loueur (ou son partenaire) vous récupère au dégrad si besoin. Vous récupérez les clés du karbé, tendez le hamac, écoutez. Plus rien à faire.",
+ title: await t("howItWorks.step3.title"),
+ body: await t("howItWorks.step3.body"),
},
- ];
+ ]);
return (
- Comment ça marche
+ {await t("howItWorks.eyebrow")}
- Trois étapes pour s'échapper.
+ {await t("howItWorks.title")}
diff --git a/src/components/landing/TestimonialsSection.tsx b/src/components/landing/TestimonialsSection.tsx
index 5771d7e..3a2795c 100644
--- a/src/components/landing/TestimonialsSection.tsx
+++ b/src/components/landing/TestimonialsSection.tsx
@@ -1,9 +1,10 @@
+import { t } from "@/lib/i18n/server";
+
/**
- * Section témoignages — 3 stubs avec noms/contextes plausibles Guyane.
- * Les contenus sont éditorialisés par défaut, remplaçables via le plugin
- * `content-pages` (Phase 4) qui fournira un store éditable depuis l'admin.
+ * Section témoignages — i18n pour les headers, contenu des citations conservé
+ * tel quel (les vraies paroles restent en VO).
*/
-export function TestimonialsSection() {
+export async function TestimonialsSection() {
const items = [
{
name: "Émilie · CE Hôpital Cayenne",
@@ -30,10 +31,10 @@ export function TestimonialsSection() {
- Pas de marketing
+ {await t("testimonials.eyebrow")}
- Ils nous l'ont dit comme ça.
+ {await t("testimonials.title")}
diff --git a/src/lib/admin/audit.ts b/src/lib/admin/audit.ts
new file mode 100644
index 0000000..c39f46e
--- /dev/null
+++ b/src/lib/admin/audit.ts
@@ -0,0 +1,91 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { prisma } from "@/lib/prisma";
+
+export type AuditEntry = {
+ scope: string;
+ event: string;
+ target?: string | null;
+ actorEmail?: string | null;
+ details?: Record
| null;
+};
+
+export async function recordAudit(entry: AuditEntry): Promise {
+ const safeDetails = (entry.details ?? {}) as Prisma.InputJsonValue;
+ try {
+ await prisma.auditLog.create({
+ data: {
+ scope: entry.scope,
+ event: entry.event,
+ target: entry.target ?? null,
+ actorEmail: entry.actorEmail ?? null,
+ details: safeDetails,
+ },
+ });
+ } catch (e) {
+ console.error(
+ JSON.stringify({
+ warn: "audit.persist.failed",
+ scope: entry.scope,
+ event: entry.event,
+ target: entry.target ?? null,
+ actorEmail: entry.actorEmail ?? null,
+ details: entry.details ?? {},
+ error: e instanceof Error ? e.message : String(e),
+ }),
+ );
+ }
+}
+
+export type AuditFilters = {
+ q?: string;
+ scope?: string;
+ event?: string;
+ actor?: string;
+ from?: Date;
+ to?: Date;
+};
+
+export type AuditListItem = {
+ id: string;
+ scope: string;
+ event: string;
+ target: string | null;
+ actorEmail: string | null;
+ details: unknown;
+ createdAt: Date;
+};
+
+export async function listAuditAdmin(filters: AuditFilters = {}): Promise {
+ const where: Prisma.AuditLogWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { event: { contains: filters.q, mode: "insensitive" } },
+ { target: { contains: filters.q, mode: "insensitive" } },
+ { actorEmail: { contains: filters.q, mode: "insensitive" } },
+ ];
+ }
+ if (filters.scope) where.scope = filters.scope;
+ if (filters.event) where.event = filters.event;
+ if (filters.actor) where.actorEmail = filters.actor;
+ if (filters.from || filters.to) {
+ where.createdAt = {};
+ if (filters.from) where.createdAt.gte = filters.from;
+ if (filters.to) where.createdAt.lte = filters.to;
+ }
+ return prisma.auditLog.findMany({
+ where,
+ orderBy: { createdAt: "desc" },
+ take: 300,
+ });
+}
+
+export async function listAuditScopes(): Promise {
+ const rows = await prisma.auditLog.findMany({
+ distinct: ["scope"],
+ select: { scope: true },
+ orderBy: { scope: "asc" },
+ });
+ return rows.map((r) => r.scope);
+}
diff --git a/src/lib/admin/bookings.ts b/src/lib/admin/bookings.ts
new file mode 100644
index 0000000..026497c
--- /dev/null
+++ b/src/lib/admin/bookings.ts
@@ -0,0 +1,100 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { BookingStatus, PaymentStatus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export type AdminBookingFilters = {
+ q?: string;
+ status?: BookingStatus;
+ paymentStatus?: PaymentStatus;
+ carbetId?: string;
+ tenantId?: string;
+ from?: Date;
+ to?: Date;
+};
+
+export type AdminBookingListItem = {
+ id: string;
+ startDate: Date;
+ endDate: Date;
+ guestCount: number;
+ status: BookingStatus;
+ paymentStatus: PaymentStatus;
+ amount: string;
+ currency: string;
+ createdAt: Date;
+ carbet: { id: string; title: string; slug: string };
+ tenant: { id: string; firstName: string; lastName: string; email: string };
+};
+
+export async function listBookingsAdmin(
+ filters: AdminBookingFilters = {},
+): Promise {
+ const where: Prisma.BookingWhereInput = {};
+
+ if (filters.q) {
+ where.OR = [
+ { id: { contains: filters.q, mode: "insensitive" } },
+ { tenant: { email: { contains: filters.q, mode: "insensitive" } } },
+ { tenant: { firstName: { contains: filters.q, mode: "insensitive" } } },
+ { tenant: { lastName: { contains: filters.q, mode: "insensitive" } } },
+ { carbet: { title: { contains: filters.q, mode: "insensitive" } } },
+ { carbet: { slug: { contains: filters.q, mode: "insensitive" } } },
+ ];
+ }
+ if (filters.status) where.status = filters.status;
+ if (filters.paymentStatus) where.paymentStatus = filters.paymentStatus;
+ if (filters.carbetId) where.carbetId = filters.carbetId;
+ if (filters.tenantId) where.tenantId = filters.tenantId;
+ if (filters.from || filters.to) {
+ where.startDate = {};
+ if (filters.from) where.startDate.gte = filters.from;
+ if (filters.to) where.startDate.lte = filters.to;
+ }
+
+ const rows = await prisma.booking.findMany({
+ where,
+ orderBy: [{ createdAt: "desc" }],
+ take: 200,
+ select: {
+ id: true,
+ startDate: true,
+ endDate: true,
+ guestCount: true,
+ status: true,
+ paymentStatus: true,
+ amount: true,
+ currency: true,
+ createdAt: true,
+ carbet: { select: { id: true, title: true, slug: true } },
+ tenant: { select: { id: true, firstName: true, lastName: true, email: true } },
+ },
+ });
+
+ return rows.map((r) => ({
+ ...r,
+ amount: r.amount.toString(),
+ }));
+}
+
+export async function getBookingForAdmin(id: string) {
+ return prisma.booking.findUnique({
+ where: { id },
+ include: {
+ carbet: {
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ river: true,
+ owner: { select: { id: true, firstName: true, lastName: true, email: true } },
+ },
+ },
+ tenant: {
+ select: { id: true, firstName: true, lastName: true, email: true, phone: true, role: true },
+ },
+ review: { select: { id: true, rating: true, createdAt: true } },
+ },
+ });
+}
diff --git a/src/lib/admin/carbet-options.ts b/src/lib/admin/carbet-options.ts
new file mode 100644
index 0000000..b6271e7
--- /dev/null
+++ b/src/lib/admin/carbet-options.ts
@@ -0,0 +1,23 @@
+/**
+ * Options enum pour les selects du formulaire Carbet — fichier neutre
+ * (pas server-only, importable depuis les composants client).
+ */
+
+import { AccessType, CarbetStatus, TransportMode } from "@/generated/prisma/enums";
+
+export const ACCESS_TYPE_OPTIONS: { value: AccessType; label: string }[] = [
+ { value: AccessType.ROAD_AND_RIVER, label: "🛣️ Route + fleuve" },
+ { value: AccessType.RIVER_ONLY, label: "🛶 Expédition fleuve" },
+];
+
+export const TRANSPORT_MODE_OPTIONS: { value: TransportMode; label: string }[] = [
+ { value: TransportMode.SELF_ARRANGE, label: "🗺️ À organiser par le voyageur" },
+ { value: TransportMode.OWNER_PROVIDES, label: "👤 Le loueur fournit" },
+ { value: TransportMode.PARTNER_PROVIDER, label: "🤝 Partenaire référencé" },
+];
+
+export const STATUS_OPTIONS: { value: CarbetStatus; label: string }[] = [
+ { value: CarbetStatus.DRAFT, label: "Brouillon" },
+ { value: CarbetStatus.PUBLISHED, label: "Publié" },
+ { value: CarbetStatus.ARCHIVED, label: "Archivé" },
+];
diff --git a/src/lib/admin/carbets.ts b/src/lib/admin/carbets.ts
new file mode 100644
index 0000000..d9e7d36
--- /dev/null
+++ b/src/lib/admin/carbets.ts
@@ -0,0 +1,134 @@
+/**
+ * Helpers admin Carbets — listing avec filtres, lookup pour formulaires.
+ */
+
+import "server-only";
+import { Prisma } from "@/generated/prisma/client";
+import { prisma } from "@/lib/prisma";
+import { AccessType, CarbetStatus, UserRole } from "@/generated/prisma/enums";
+
+export type AdminCarbetListItem = {
+ id: string;
+ slug: string;
+ title: string;
+ river: string;
+ capacity: number;
+ nightlyPrice: string;
+ status: CarbetStatus;
+ accessType: AccessType;
+ ownerName: string;
+ ownerEmail: string;
+ mediaCount: number;
+ bookingsCount: number;
+ updatedAt: Date;
+};
+
+export type AdminCarbetFilters = {
+ q?: string;
+ river?: string;
+ status?: CarbetStatus;
+ accessType?: AccessType;
+};
+
+export async function listCarbetsAdmin(filters: AdminCarbetFilters = {}): Promise {
+ const where: Prisma.CarbetWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { title: { contains: filters.q, mode: "insensitive" } },
+ { slug: { contains: filters.q, mode: "insensitive" } },
+ { river: { contains: filters.q, mode: "insensitive" } },
+ ];
+ }
+ if (filters.river) where.river = filters.river;
+ if (filters.status) where.status = filters.status;
+ if (filters.accessType) where.accessType = filters.accessType;
+
+ const rows = await prisma.carbet.findMany({
+ where,
+ orderBy: { updatedAt: "desc" },
+ take: 100,
+ select: {
+ id: true,
+ slug: true,
+ title: true,
+ river: true,
+ capacity: true,
+ nightlyPrice: true,
+ status: true,
+ accessType: true,
+ updatedAt: true,
+ owner: { select: { firstName: true, lastName: true, email: true } },
+ _count: { select: { media: true, bookings: true } },
+ },
+ });
+
+ return rows.map((r) => ({
+ id: r.id,
+ slug: r.slug,
+ title: r.title,
+ river: r.river,
+ capacity: r.capacity,
+ nightlyPrice: r.nightlyPrice.toString(),
+ status: r.status,
+ accessType: r.accessType,
+ ownerName: `${r.owner.firstName} ${r.owner.lastName}`.trim() || r.owner.email,
+ ownerEmail: r.owner.email,
+ mediaCount: r._count.media,
+ bookingsCount: r._count.bookings,
+ updatedAt: r.updatedAt,
+ }));
+}
+
+export async function listDistinctRivers(): Promise {
+ const rows = await prisma.carbet.findMany({
+ distinct: ["river"],
+ orderBy: { river: "asc" },
+ select: { river: true },
+ });
+ return rows.map((r) => r.river);
+}
+
+export async function listOwners() {
+ return await prisma.user.findMany({
+ where: { role: UserRole.OWNER, isActive: true },
+ orderBy: [{ firstName: "asc" }, { lastName: "asc" }],
+ select: { id: true, firstName: true, lastName: true, email: true },
+ });
+}
+
+export async function listPirogueProviders() {
+ return await prisma.pirogueProvider.findMany({
+ where: { active: true },
+ orderBy: { name: "asc" },
+ select: { id: true, name: true, rivers: true },
+ });
+}
+
+export async function getCarbetForEdit(id: string) {
+ return await prisma.carbet.findUnique({
+ where: { id },
+ include: {
+ owner: { select: { id: true, firstName: true, lastName: true, email: true } },
+ pirogueProvider: { select: { id: true, name: true } },
+ media: { orderBy: { sortOrder: "asc" } },
+ organizations: {
+ orderBy: { addedAt: "asc" },
+ include: {
+ organization: { select: { id: true, name: true, slug: true, approved: true } },
+ },
+ },
+ _count: { select: { bookings: true, reviews: true } },
+ },
+ });
+}
+
+/** Liste les orgs disponibles pour link sur un carbet — toutes orgs (approuvées et pending). */
+export async function listOrganizationsForLink() {
+ return prisma.organization.findMany({
+ orderBy: [{ approved: "desc" }, { name: "asc" }],
+ select: { id: true, name: true, slug: true, approved: true },
+ });
+}
+
+// Options enum déplacées dans `./carbet-options.ts` pour être importables
+// depuis les composants client (ce fichier-ci est server-only).
diff --git a/src/lib/admin/home-keys.ts b/src/lib/admin/home-keys.ts
new file mode 100644
index 0000000..409b51f
--- /dev/null
+++ b/src/lib/admin/home-keys.ts
@@ -0,0 +1,51 @@
+/**
+ * Sections éditables depuis /admin/home.
+ *
+ * Liste curatée des préfixes de clés qui apparaissent réellement sur la
+ * page d'accueil. Le reste (season, language, access, site) est éditable
+ * via /admin/translations (toutes les clés) une fois construit.
+ */
+export const HOME_SECTIONS: { id: string; label: string; description: string; prefixes: string[] }[] = [
+ {
+ id: "hero",
+ label: "Bandeau d'accueil (hero)",
+ description: "Le visuel plein écran tout en haut — accroche + sous-titre + boutons.",
+ prefixes: ["hero."],
+ },
+ {
+ id: "experiences",
+ label: "Deux expériences",
+ description: "Section présentant les 2 modes (route + fleuve / expédition fleuve).",
+ prefixes: ["experiences."],
+ },
+ {
+ id: "howItWorks",
+ label: "Comment ça marche",
+ description: "Les étapes pour réserver un séjour.",
+ prefixes: ["howItWorks."],
+ },
+ {
+ id: "ce",
+ label: "Comités d'entreprise",
+ description: "Section dédiée aux CE et leurs membres.",
+ prefixes: ["ce."],
+ },
+ {
+ id: "testimonials",
+ label: "Témoignages",
+ description: "Bloc témoignages voyageurs.",
+ prefixes: ["testimonials."],
+ },
+ {
+ id: "footer",
+ label: "Pied de page",
+ description: "Liens et mentions en pied de page.",
+ prefixes: ["footer."],
+ },
+];
+
+export const HOME_PREFIXES: string[] = HOME_SECTIONS.flatMap((s) => s.prefixes);
+
+export function isHomeKey(key: string): boolean {
+ return HOME_PREFIXES.some((p) => key.startsWith(p));
+}
diff --git a/src/lib/admin/kpis.ts b/src/lib/admin/kpis.ts
new file mode 100644
index 0000000..6e593d8
--- /dev/null
+++ b/src/lib/admin/kpis.ts
@@ -0,0 +1,101 @@
+/**
+ * KPIs du dashboard admin Karbé.
+ * Toutes les queries sont scoppées à la company (mono-tenant pour l'instant)
+ * et calculent des chiffres simples mais utiles : activité récente, état du
+ * catalogue, modération à faire.
+ */
+
+import "server-only";
+import { prisma } from "@/lib/prisma";
+import { BookingStatus, CarbetStatus, PaymentStatus } from "@/generated/prisma/enums";
+
+export type AdminKpis = {
+ bookingsThisWeek: number;
+ bookingsConfirmed30d: number;
+ revenue30dCents: number;
+ occupancyPct: number; // 0..100
+ newUsers30d: number;
+ publishedCarbets: number;
+ reviewsToModerate: number;
+};
+
+function startOfWeek(d = new Date()): Date {
+ const x = new Date(d);
+ const day = (x.getDay() + 6) % 7; // 0 = lundi
+ x.setHours(0, 0, 0, 0);
+ x.setDate(x.getDate() - day);
+ return x;
+}
+
+function daysAgo(n: number): Date {
+ const x = new Date();
+ x.setHours(0, 0, 0, 0);
+ x.setDate(x.getDate() - n);
+ return x;
+}
+
+export async function getAdminKpis(): Promise {
+ const weekStart = startOfWeek();
+ const monthStart = daysAgo(30);
+
+ const [
+ bookingsThisWeek,
+ bookingsConfirmed30dList,
+ newUsers30d,
+ publishedCarbets,
+ reviewsToModerate,
+ ] = await Promise.all([
+ prisma.booking.count({
+ where: { startDate: { gte: weekStart } },
+ }),
+ prisma.booking.findMany({
+ where: {
+ status: BookingStatus.CONFIRMED,
+ paymentStatus: PaymentStatus.SUCCEEDED,
+ startDate: { gte: monthStart },
+ },
+ select: { amount: true, startDate: true, endDate: true },
+ }),
+ prisma.user.count({
+ where: { createdAt: { gte: monthStart } },
+ }),
+ prisma.carbet.count({
+ where: { status: CarbetStatus.PUBLISHED },
+ }),
+ prisma.review.count({
+ where: { hostResponse: null },
+ }),
+ ]);
+
+ const revenue30dCents = bookingsConfirmed30dList.reduce(
+ (acc, b) => acc + Math.round(Number(b.amount) * 100),
+ 0,
+ );
+
+ // Occupation = total bookings * jours moyens / (publishedCarbets * 30)
+ // Approximation simple, on raffine en sprint 3.
+ const bookedNights = bookingsConfirmed30dList.reduce((acc, b) => {
+ const diffMs = b.endDate.getTime() - b.startDate.getTime();
+ return acc + Math.max(0, Math.round(diffMs / (1000 * 60 * 60 * 24)));
+ }, 0);
+ const occupancyDen = publishedCarbets * 30;
+ const occupancyPct = occupancyDen > 0 ? Math.min(100, Math.round((bookedNights * 100) / occupancyDen)) : 0;
+
+ return {
+ bookingsThisWeek,
+ bookingsConfirmed30d: bookingsConfirmed30dList.length,
+ revenue30dCents,
+ occupancyPct,
+ newUsers30d,
+ publishedCarbets,
+ reviewsToModerate,
+ };
+}
+
+export function formatEur(cents: number): string {
+ return new Intl.NumberFormat("fr-FR", {
+ style: "currency",
+ currency: "EUR",
+ maximumFractionDigits: 0,
+ }).format(cents / 100);
+}
diff --git a/src/lib/admin/media.ts b/src/lib/admin/media.ts
new file mode 100644
index 0000000..f96654e
--- /dev/null
+++ b/src/lib/admin/media.ts
@@ -0,0 +1,60 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { MediaType } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export type AdminMediaFilters = {
+ q?: string;
+ type?: MediaType;
+ carbetId?: string;
+};
+
+export type AdminMediaListItem = {
+ id: string;
+ type: MediaType;
+ s3Key: string;
+ s3Url: string;
+ sortOrder: number;
+ createdAt: Date;
+ carbet: { id: string; title: string; slug: string; status: string };
+};
+
+export async function listMediaAdmin(filters: AdminMediaFilters = {}): Promise {
+ const where: Prisma.MediaWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { s3Key: { contains: filters.q, mode: "insensitive" } },
+ { carbet: { title: { contains: filters.q, mode: "insensitive" } } },
+ { carbet: { slug: { contains: filters.q, mode: "insensitive" } } },
+ ];
+ }
+ if (filters.type) where.type = filters.type;
+ if (filters.carbetId) where.carbetId = filters.carbetId;
+
+ return prisma.media.findMany({
+ where,
+ orderBy: [{ createdAt: "desc" }],
+ take: 200,
+ select: {
+ id: true,
+ type: true,
+ s3Key: true,
+ s3Url: true,
+ sortOrder: true,
+ createdAt: true,
+ carbet: { select: { id: true, title: true, slug: true, status: true } },
+ },
+ });
+}
+
+export async function getMediaStats() {
+ const [total, photo, video, carbetsWithMedia, carbetsWithoutMedia] = await Promise.all([
+ prisma.media.count(),
+ prisma.media.count({ where: { type: MediaType.PHOTO } }),
+ prisma.media.count({ where: { type: MediaType.VIDEO } }),
+ prisma.carbet.count({ where: { media: { some: {} } } }),
+ prisma.carbet.count({ where: { media: { none: {} } } }),
+ ]);
+ return { total, photo, video, carbetsWithMedia, carbetsWithoutMedia };
+}
diff --git a/src/lib/admin/organizations.ts b/src/lib/admin/organizations.ts
new file mode 100644
index 0000000..b2bc680
--- /dev/null
+++ b/src/lib/admin/organizations.ts
@@ -0,0 +1,93 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { prisma } from "@/lib/prisma";
+
+export type AdminOrgFilters = { q?: string; approved?: "all" | "pending" | "approved" };
+
+export type AdminOrgListItem = {
+ id: string;
+ name: string;
+ slug: string;
+ description: string | null;
+ contactEmail: string | null;
+ approved: boolean;
+ approvedAt: Date | null;
+ createdAt: Date;
+ membersCount: number;
+};
+
+export async function listOrganizationsAdmin(filters: AdminOrgFilters = {}): Promise {
+ const where: Prisma.OrganizationWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { name: { contains: filters.q, mode: "insensitive" } },
+ { slug: { contains: filters.q, mode: "insensitive" } },
+ { description: { contains: filters.q, mode: "insensitive" } },
+ ];
+ }
+ if (filters.approved === "pending") where.approved = false;
+ else if (filters.approved === "approved") where.approved = true;
+
+ const rows = await prisma.organization.findMany({
+ where,
+ orderBy: [{ approved: "asc" }, { name: "asc" }],
+ take: 200,
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ description: true,
+ contactEmail: true,
+ approved: true,
+ approvedAt: true,
+ createdAt: true,
+ _count: { select: { members: true } },
+ },
+ });
+ return rows.map((o) => ({
+ id: o.id,
+ name: o.name,
+ slug: o.slug,
+ description: o.description,
+ contactEmail: o.contactEmail,
+ approved: o.approved,
+ approvedAt: o.approvedAt,
+ createdAt: o.createdAt,
+ membersCount: o._count.members,
+ }));
+}
+
+export async function countPendingOrganizations(): Promise {
+ return prisma.organization.count({ where: { approved: false } });
+}
+
+export async function getOrganizationForAdmin(id: string) {
+ return prisma.organization.findUnique({
+ where: { id },
+ include: {
+ members: {
+ orderBy: [{ role: "asc" }, { lastName: "asc" }],
+ select: { id: true, firstName: true, lastName: true, email: true, role: true, isActive: true },
+ },
+ _count: { select: { carbetMemberships: true, rentalProviders: true } },
+ },
+ });
+}
+
+export async function approveOrganization(
+ id: string,
+ adminEmail: string,
+): Promise<{ ok: true; alreadyApproved: boolean } | { ok: false; error: string }> {
+ const org = await prisma.organization.findUnique({
+ where: { id },
+ select: { id: true, approved: true },
+ });
+ if (!org) return { ok: false, error: "Organisation introuvable" };
+ if (org.approved) return { ok: true, alreadyApproved: true };
+ await prisma.organization.update({
+ where: { id },
+ data: { approved: true, approvedAt: new Date(), approvedBy: adminEmail },
+ });
+ return { ok: true, alreadyApproved: false };
+}
diff --git a/src/lib/admin/pirogue-providers.ts b/src/lib/admin/pirogue-providers.ts
new file mode 100644
index 0000000..e29769d
--- /dev/null
+++ b/src/lib/admin/pirogue-providers.ts
@@ -0,0 +1,84 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { prisma } from "@/lib/prisma";
+
+export type AdminPirogueFilters = {
+ q?: string;
+ river?: string;
+ active?: "yes" | "no";
+};
+
+export type AdminPirogueListItem = {
+ id: string;
+ name: string;
+ contactEmail: string | null;
+ contactPhone: string | null;
+ rivers: string[];
+ active: boolean;
+ carbetsCount: number;
+ updatedAt: Date;
+};
+
+export async function listPirogueProvidersAdmin(filters: AdminPirogueFilters = {}): Promise {
+ const where: Prisma.PirogueProviderWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { name: { contains: filters.q, mode: "insensitive" } },
+ { contactEmail: { contains: filters.q, mode: "insensitive" } },
+ { description: { contains: filters.q, mode: "insensitive" } },
+ ];
+ }
+ if (filters.river) where.rivers = { has: filters.river };
+ if (filters.active === "yes") where.active = true;
+ if (filters.active === "no") where.active = false;
+
+ const rows = await prisma.pirogueProvider.findMany({
+ where,
+ orderBy: [{ active: "desc" }, { name: "asc" }],
+ take: 200,
+ select: {
+ id: true,
+ name: true,
+ contactEmail: true,
+ contactPhone: true,
+ rivers: true,
+ active: true,
+ updatedAt: true,
+ _count: { select: { carbets: true } },
+ },
+ });
+ return rows.map((p) => ({
+ id: p.id,
+ name: p.name,
+ contactEmail: p.contactEmail,
+ contactPhone: p.contactPhone,
+ rivers: p.rivers,
+ active: p.active,
+ carbetsCount: p._count.carbets,
+ updatedAt: p.updatedAt,
+ }));
+}
+
+export async function getPirogueProviderForAdmin(id: string) {
+ return prisma.pirogueProvider.findUnique({
+ where: { id },
+ include: {
+ carbets: {
+ orderBy: [{ updatedAt: "desc" }],
+ take: 20,
+ select: { id: true, title: true, slug: true, river: true, status: true, updatedAt: true },
+ },
+ },
+ });
+}
+
+export async function listPirogueRivers(): Promise {
+ const rows = await prisma.pirogueProvider.findMany({
+ where: { active: true },
+ select: { rivers: true },
+ });
+ const set = new Set();
+ for (const r of rows) for (const x of r.rivers) set.add(x);
+ return Array.from(set).sort();
+}
diff --git a/src/lib/admin/rental-bookings.ts b/src/lib/admin/rental-bookings.ts
new file mode 100644
index 0000000..21b35e5
--- /dev/null
+++ b/src/lib/admin/rental-bookings.ts
@@ -0,0 +1,60 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { RentalBookingStatus, PaymentStatus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export type AdminRentalBookingFilters = {
+ q?: string;
+ status?: RentalBookingStatus;
+ paymentStatus?: PaymentStatus;
+ providerId?: string;
+};
+
+export const RENTAL_STATUS_LABEL: Record = {
+ PENDING: "En attente",
+ CONFIRMED: "Confirmée",
+ HANDED_OVER: "Remis client",
+ RETURNED: "Retourné",
+ CANCELLED: "Annulée",
+};
+
+export async function listRentalBookingsAdmin(
+ filters: AdminRentalBookingFilters = {},
+) {
+ const where: Prisma.RentalBookingWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { id: { contains: filters.q, mode: "insensitive" } },
+ { tenant: { email: { contains: filters.q, mode: "insensitive" } } },
+ { provider: { name: { contains: filters.q, mode: "insensitive" } } },
+ ];
+ }
+ if (filters.status) where.status = filters.status;
+ if (filters.paymentStatus) where.paymentStatus = filters.paymentStatus;
+ if (filters.providerId) where.providerId = filters.providerId;
+
+ return prisma.rentalBooking.findMany({
+ where,
+ orderBy: [{ createdAt: "desc" }],
+ take: 200,
+ include: {
+ tenant: { select: { id: true, firstName: true, lastName: true, email: true } },
+ provider: { select: { id: true, name: true, isSystemD: true } },
+ booking: { select: { id: true, carbet: { select: { title: true, slug: true } } } },
+ lines: { include: { item: { select: { name: true, category: true } } } },
+ },
+ });
+}
+
+export async function getRentalBookingForAdmin(id: string) {
+ return prisma.rentalBooking.findUnique({
+ where: { id },
+ include: {
+ tenant: { select: { id: true, firstName: true, lastName: true, email: true, phone: true } },
+ provider: { select: { id: true, name: true, isSystemD: true, contactEmail: true, contactPhone: true } },
+ booking: { select: { id: true, carbet: { select: { id: true, title: true, slug: true } } } },
+ lines: { include: { item: { select: { id: true, name: true, category: true, imageUrl: true } } } },
+ },
+ });
+}
diff --git a/src/lib/admin/rental-items.ts b/src/lib/admin/rental-items.ts
new file mode 100644
index 0000000..114c6d4
--- /dev/null
+++ b/src/lib/admin/rental-items.ts
@@ -0,0 +1,97 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { RentalCategory } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES, isRentalCategory } from "@/lib/rental-category-labels";
+
+export type AdminRentalItemFilters = {
+ q?: string;
+ category?: RentalCategory;
+ providerId?: string;
+ active?: "yes" | "no";
+};
+
+export type AdminRentalItemListItem = {
+ id: string;
+ name: string;
+ category: RentalCategory;
+ providerId: string;
+ providerName: string;
+ providerIsSystemD: boolean;
+ pricePerDay: string;
+ pricePerWeek: string | null;
+ deposit: string;
+ totalQty: number;
+ withMotor: boolean;
+ fuelIncluded: boolean;
+ requiresLicense: boolean;
+ active: boolean;
+ imageUrl: string | null;
+ updatedAt: Date;
+};
+
+export async function listRentalItemsAdmin(
+ filters: AdminRentalItemFilters = {},
+): Promise {
+ const where: Prisma.RentalItemWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { name: { contains: filters.q, mode: "insensitive" } },
+ { description: { contains: filters.q, mode: "insensitive" } },
+ ];
+ }
+ if (filters.category) where.category = filters.category;
+ if (filters.providerId) where.providerId = filters.providerId;
+ if (filters.active === "yes") where.active = true;
+ if (filters.active === "no") where.active = false;
+
+ const rows = await prisma.rentalItem.findMany({
+ where,
+ orderBy: [{ category: "asc" }, { name: "asc" }],
+ take: 300,
+ include: {
+ provider: { select: { name: true, isSystemD: true } },
+ },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ category: r.category,
+ providerId: r.providerId,
+ providerName: r.provider.name,
+ providerIsSystemD: r.provider.isSystemD,
+ pricePerDay: r.pricePerDay.toString(),
+ pricePerWeek: r.pricePerWeek?.toString() ?? null,
+ deposit: r.deposit.toString(),
+ totalQty: r.totalQty,
+ withMotor: r.withMotor,
+ fuelIncluded: r.fuelIncluded,
+ requiresLicense: r.requiresLicense,
+ active: r.active,
+ imageUrl: r.imageUrl,
+ updatedAt: r.updatedAt,
+ }));
+}
+
+export async function getRentalItemForAdmin(id: string) {
+ return prisma.rentalItem.findUnique({
+ where: { id },
+ include: {
+ provider: { select: { id: true, name: true, isSystemD: true } },
+ media: {
+ orderBy: { sortOrder: "asc" },
+ select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
+ },
+ },
+ });
+}
+
+export async function listProvidersForSelect() {
+ return prisma.rentalProvider.findMany({
+ where: { active: true },
+ orderBy: [{ isSystemD: "desc" }, { name: "asc" }],
+ select: { id: true, name: true, isSystemD: true, approved: true },
+ });
+}
diff --git a/src/lib/admin/rental-providers.ts b/src/lib/admin/rental-providers.ts
new file mode 100644
index 0000000..b1e4d97
--- /dev/null
+++ b/src/lib/admin/rental-providers.ts
@@ -0,0 +1,106 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { prisma } from "@/lib/prisma";
+
+export type AdminRentalProviderFilters = {
+ q?: string;
+ approved?: "yes" | "no";
+ active?: "yes" | "no";
+ river?: string;
+};
+
+export type AdminRentalProviderListItem = {
+ id: string;
+ name: string;
+ isSystemD: boolean;
+ contactEmail: string | null;
+ contactPhone: string | null;
+ rivers: string[];
+ commissionPct: string;
+ active: boolean;
+ approved: boolean;
+ itemsCount: number;
+ updatedAt: Date;
+};
+
+export async function listRentalProvidersAdmin(
+ filters: AdminRentalProviderFilters = {},
+): Promise {
+ const where: Prisma.RentalProviderWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { name: { contains: filters.q, mode: "insensitive" } },
+ { contactEmail: { contains: filters.q, mode: "insensitive" } },
+ { description: { contains: filters.q, mode: "insensitive" } },
+ ];
+ }
+ if (filters.approved === "yes") where.approved = true;
+ if (filters.approved === "no") where.approved = false;
+ if (filters.active === "yes") where.active = true;
+ if (filters.active === "no") where.active = false;
+ if (filters.river) where.rivers = { has: filters.river };
+
+ const rows = await prisma.rentalProvider.findMany({
+ where,
+ orderBy: [{ approved: "asc" }, { isSystemD: "desc" }, { name: "asc" }],
+ take: 200,
+ select: {
+ id: true,
+ name: true,
+ isSystemD: true,
+ contactEmail: true,
+ contactPhone: true,
+ rivers: true,
+ commissionPct: true,
+ active: true,
+ approved: true,
+ updatedAt: true,
+ _count: { select: { items: true } },
+ },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ isSystemD: r.isSystemD,
+ contactEmail: r.contactEmail,
+ contactPhone: r.contactPhone,
+ rivers: r.rivers,
+ commissionPct: r.commissionPct.toString(),
+ active: r.active,
+ approved: r.approved,
+ itemsCount: r._count.items,
+ updatedAt: r.updatedAt,
+ }));
+}
+
+export async function getRentalProviderForAdmin(id: string) {
+ return prisma.rentalProvider.findUnique({
+ where: { id },
+ include: {
+ manager: { select: { id: true, firstName: true, lastName: true, email: true } },
+ items: {
+ orderBy: [{ category: "asc" }, { name: "asc" }],
+ select: {
+ id: true,
+ name: true,
+ category: true,
+ pricePerDay: true,
+ totalQty: true,
+ active: true,
+ },
+ },
+ _count: { select: { items: true, rentalBookings: true } },
+ },
+ });
+}
+
+export async function listRentalProviderRivers(): Promise {
+ const rows = await prisma.rentalProvider.findMany({
+ where: { active: true, approved: true },
+ select: { rivers: true },
+ });
+ const set = new Set();
+ for (const r of rows) for (const x of r.rivers) set.add(x);
+ return Array.from(set).sort();
+}
diff --git a/src/lib/admin/reviews.ts b/src/lib/admin/reviews.ts
new file mode 100644
index 0000000..18a31e1
--- /dev/null
+++ b/src/lib/admin/reviews.ts
@@ -0,0 +1,69 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { prisma } from "@/lib/prisma";
+
+export type AdminReviewFilters = {
+ q?: string;
+ carbetId?: string;
+ rating?: number;
+ withResponse?: "yes" | "no";
+};
+
+export type AdminReviewListItem = {
+ id: string;
+ rating: number;
+ comment: string | null;
+ hostResponse: string | null;
+ hostRespondedAt: Date | null;
+ createdAt: Date;
+ carbet: { id: string; title: string; slug: string };
+ author: { id: string; firstName: string; lastName: string; email: string };
+ booking: { id: string };
+};
+
+export async function listReviewsAdmin(filters: AdminReviewFilters = {}): Promise {
+ const where: Prisma.ReviewWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { id: { contains: filters.q, mode: "insensitive" } },
+ { comment: { contains: filters.q, mode: "insensitive" } },
+ { author: { email: { contains: filters.q, mode: "insensitive" } } },
+ { author: { firstName: { contains: filters.q, mode: "insensitive" } } },
+ { author: { lastName: { contains: filters.q, mode: "insensitive" } } },
+ { carbet: { title: { contains: filters.q, mode: "insensitive" } } },
+ ];
+ }
+ if (filters.carbetId) where.carbetId = filters.carbetId;
+ if (filters.rating) where.rating = filters.rating;
+ if (filters.withResponse === "yes") where.hostResponse = { not: null };
+ if (filters.withResponse === "no") where.hostResponse = null;
+
+ return prisma.review.findMany({
+ where,
+ orderBy: [{ createdAt: "desc" }],
+ take: 200,
+ select: {
+ id: true,
+ rating: true,
+ comment: true,
+ hostResponse: true,
+ hostRespondedAt: true,
+ createdAt: true,
+ booking: { select: { id: true } },
+ carbet: { select: { id: true, title: true, slug: true } },
+ author: { select: { id: true, firstName: true, lastName: true, email: true } },
+ },
+ });
+}
+
+export async function getReviewForAdmin(id: string) {
+ return prisma.review.findUnique({
+ where: { id },
+ include: {
+ booking: { select: { id: true, startDate: true, endDate: true, amount: true, currency: true } },
+ carbet: { select: { id: true, title: true, slug: true } },
+ author: { select: { id: true, firstName: true, lastName: true, email: true } },
+ },
+ });
+}
diff --git a/src/lib/admin/search.ts b/src/lib/admin/search.ts
new file mode 100644
index 0000000..12b85cb
--- /dev/null
+++ b/src/lib/admin/search.ts
@@ -0,0 +1,109 @@
+/**
+ * Recherche globale ⌘K — server function.
+ *
+ * Recherche transversale sur carbets / utilisateurs / réservations /
+ * pages éditoriales / prestataires pirogue. Renvoie au max 5 résultats
+ * par catégorie pour garder la palette lisible.
+ */
+
+import "server-only";
+import { prisma } from "@/lib/prisma";
+
+export type SearchHit = {
+ type: "carbet" | "user" | "booking" | "page" | "provider";
+ id: string;
+ title: string;
+ subtitle?: string;
+ href: string;
+};
+
+export async function adminSearch(query: string): Promise {
+ const q = query.trim();
+ if (q.length < 2) return [];
+ const ci = { contains: q, mode: "insensitive" as const };
+
+ const [carbets, users, bookings, pages, providers] = await Promise.all([
+ prisma.carbet.findMany({
+ where: {
+ OR: [{ slug: ci }, { title: ci }, { river: ci }],
+ },
+ take: 5,
+ select: { id: true, slug: true, title: true, river: true, status: true },
+ }),
+ prisma.user.findMany({
+ where: {
+ OR: [{ email: ci }, { firstName: ci }, { lastName: ci }],
+ },
+ take: 5,
+ select: { id: true, email: true, firstName: true, lastName: true, role: true },
+ }),
+ prisma.booking.findMany({
+ where: { id: ci },
+ take: 5,
+ select: { id: true, status: true, startDate: true, endDate: true },
+ }),
+ prisma.contentPage.findMany({
+ where: {
+ OR: [{ slug: ci }, { title: ci }],
+ lang: "fr",
+ },
+ take: 5,
+ select: { slug: true, title: true, category: true, lang: true },
+ }),
+ prisma.pirogueProvider.findMany({
+ where: { OR: [{ name: ci }] },
+ take: 5,
+ select: { id: true, name: true, rivers: true },
+ }),
+ ]);
+
+ const hits: SearchHit[] = [];
+
+ for (const c of carbets) {
+ hits.push({
+ type: "carbet",
+ id: c.id,
+ title: c.title,
+ subtitle: `${c.river} · ${c.status}`,
+ href: `/admin/carbets/${c.id}`,
+ });
+ }
+ for (const u of users) {
+ hits.push({
+ type: "user",
+ id: u.id,
+ title: `${u.firstName} ${u.lastName}`.trim() || u.email,
+ subtitle: `${u.email} · ${u.role}`,
+ href: `/admin/users/${u.id}`,
+ });
+ }
+ for (const b of bookings) {
+ hits.push({
+ type: "booking",
+ id: b.id,
+ title: `Réservation ${b.id.slice(0, 8)}`,
+ subtitle: `${b.status} · ${b.startDate.toISOString().slice(0, 10)} → ${b.endDate.toISOString().slice(0, 10)}`,
+ href: `/admin/bookings/${b.id}`,
+ });
+ }
+ for (const p of pages) {
+ hits.push({
+ type: "page",
+ id: p.slug,
+ title: p.title,
+ subtitle: `/${p.slug} · ${p.category} · ${p.lang}`,
+ href: `/admin/content-pages/${encodeURIComponent(p.slug)}`,
+ });
+ }
+ for (const p of providers) {
+ hits.push({
+ type: "provider",
+ id: p.id,
+ title: p.name,
+ subtitle: p.rivers.join(" · "),
+ href: `/admin/pirogue-providers/${p.id}`,
+ });
+ }
+
+ return hits;
+}
diff --git a/src/lib/admin/settings.ts b/src/lib/admin/settings.ts
new file mode 100644
index 0000000..e4339cf
--- /dev/null
+++ b/src/lib/admin/settings.ts
@@ -0,0 +1,120 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { prisma } from "@/lib/prisma";
+
+export type PlatformSettings = {
+ name: string;
+ defaultLang: string;
+ activeLangs: string[];
+ currency: string;
+ commissionPercent: number;
+};
+
+export type ThemeSettings = {
+ active: "default" | "theme-aquarelle" | "theme-guyane";
+};
+
+export type StripeSettings = {
+ currency: string;
+ commissionMode: "none" | "owner-subscription" | "per-booking";
+ perBookingFeePercent: number;
+};
+
+export type AllSettings = {
+ platform: PlatformSettings;
+ theme: ThemeSettings;
+ stripe: StripeSettings;
+};
+
+export const DEFAULTS: AllSettings = {
+ platform: {
+ name: "Karbé",
+ defaultLang: "fr",
+ activeLangs: ["fr"],
+ currency: "EUR",
+ commissionPercent: 0,
+ },
+ theme: {
+ active: "default",
+ },
+ stripe: {
+ currency: "EUR",
+ commissionMode: "owner-subscription",
+ perBookingFeePercent: 0,
+ },
+};
+
+const KEYS = ["platform", "theme", "stripe"] as const;
+type SettingKey = (typeof KEYS)[number];
+
+export async function getAllSettings(): Promise {
+ const rows = await prisma.setting.findMany({ where: { key: { in: [...KEYS] } } });
+ const map = new Map(rows.map((r) => [r.key as SettingKey, r.value]));
+ return {
+ platform: { ...DEFAULTS.platform, ...((map.get("platform") as Partial) ?? {}) },
+ theme: { ...DEFAULTS.theme, ...((map.get("theme") as Partial) ?? {}) },
+ stripe: { ...DEFAULTS.stripe, ...((map.get("stripe") as Partial) ?? {}) },
+ };
+}
+
+export async function setSetting(
+ key: SettingKey,
+ value: Record,
+ updatedBy: string | null,
+): Promise {
+ await prisma.setting.upsert({
+ where: { key },
+ create: { key, value: value as Prisma.InputJsonValue, updatedBy: updatedBy ?? null },
+ update: { value: value as Prisma.InputJsonValue, updatedBy: updatedBy ?? null },
+ });
+}
+
+export type EnvSnapshot = {
+ stripe: {
+ secretKeyConfigured: boolean;
+ publishableKeyConfigured: boolean;
+ webhookSecretConfigured: boolean;
+ ownerPriceIdConfigured: boolean;
+ };
+ s3: {
+ endpoint: string | null;
+ region: string | null;
+ bucket: string | null;
+ publicUrl: string | null;
+ pathStyle: boolean;
+ rootUserConfigured: boolean;
+ };
+ app: {
+ publicUrl: string | null;
+ deploymentVersion: string | null;
+ authUrl: string | null;
+ };
+};
+
+export function readEnvSnapshot(): EnvSnapshot {
+ const has = (k: string) => Boolean((process.env[k] ?? "").trim());
+ return {
+ stripe: {
+ secretKeyConfigured: has("STRIPE_SECRET_KEY"),
+ publishableKeyConfigured: has("STRIPE_PUBLISHABLE_KEY") || has("NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY"),
+ webhookSecretConfigured: has("STRIPE_WEBHOOK_SECRET"),
+ ownerPriceIdConfigured:
+ has("STRIPE_OWNER_SUBSCRIPTION_PRICE_ID") &&
+ !(process.env.STRIPE_OWNER_SUBSCRIPTION_PRICE_ID ?? "").includes("REPLACE_ME"),
+ },
+ s3: {
+ endpoint: process.env.S3_ENDPOINT ?? null,
+ region: process.env.S3_REGION ?? null,
+ bucket: process.env.S3_BUCKET ?? null,
+ publicUrl: process.env.S3_PUBLIC_URL ?? null,
+ pathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true",
+ rootUserConfigured: has("MINIO_ROOT_USER"),
+ },
+ app: {
+ publicUrl: process.env.NEXT_PUBLIC_SITE_URL ?? process.env.APP_URL ?? null,
+ deploymentVersion: process.env.DEPLOYMENT_VERSION ?? null,
+ authUrl: process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? null,
+ },
+ };
+}
diff --git a/src/lib/admin/translations.ts b/src/lib/admin/translations.ts
new file mode 100644
index 0000000..a2ffbbc
--- /dev/null
+++ b/src/lib/admin/translations.ts
@@ -0,0 +1,72 @@
+import "server-only";
+
+import { prisma } from "@/lib/prisma";
+import frMessages from "@/messages/fr.json";
+import enMessages from "@/messages/en.json";
+
+const BASE: Record<"fr" | "en", Record> = {
+ fr: frMessages as Record,
+ en: enMessages as Record,
+};
+
+export type TranslationRow = {
+ key: string;
+ baseFr: string;
+ baseEn: string;
+ overrideFr: string | null;
+ overrideEn: string | null;
+ updatedAt: Date | null;
+ updatedBy: string | null;
+};
+
+export async function listTranslationsForKeys(prefixes: string[]): Promise {
+ // Toutes les clés du fichier FR (canonique) qui matchent un préfixe.
+ const allKeys = Object.keys(BASE.fr).filter((k) => prefixes.some((p) => k.startsWith(p)));
+ allKeys.sort();
+
+ const overrides = await prisma.translation.findMany({
+ where: { key: { in: allKeys } },
+ select: { key: true, lang: true, value: true, updatedAt: true, updatedBy: true },
+ });
+ type Override = (typeof overrides)[number];
+ const overrideMap = new Map>();
+ for (const o of overrides) {
+ if (!overrideMap.has(o.key)) overrideMap.set(o.key, new Map());
+ overrideMap.get(o.key)!.set(o.lang, o);
+ }
+
+ return allKeys.map((key) => {
+ const rowFr = overrideMap.get(key)?.get("fr");
+ const rowEn = overrideMap.get(key)?.get("en");
+ const lastTs = Math.max(rowFr?.updatedAt.getTime() ?? 0, rowEn?.updatedAt.getTime() ?? 0);
+ const lastEditor = (rowFr?.updatedAt ?? new Date(0)) > (rowEn?.updatedAt ?? new Date(0))
+ ? rowFr?.updatedBy ?? null
+ : rowEn?.updatedBy ?? null;
+ return {
+ key,
+ baseFr: BASE.fr[key] ?? "",
+ baseEn: BASE.en[key] ?? "",
+ overrideFr: rowFr?.value ?? null,
+ overrideEn: rowEn?.value ?? null,
+ updatedAt: lastTs > 0 ? new Date(lastTs) : null,
+ updatedBy: lastEditor,
+ };
+ });
+}
+
+export async function upsertTranslation(
+ key: string,
+ lang: "fr" | "en",
+ value: string,
+ actor: string | null,
+): Promise {
+ await prisma.translation.upsert({
+ where: { key_lang: { key, lang } },
+ create: { key, lang, value, updatedBy: actor },
+ update: { value, updatedBy: actor },
+ });
+}
+
+export async function deleteTranslationOverride(key: string, lang: "fr" | "en"): Promise {
+ await prisma.translation.delete({ where: { key_lang: { key, lang } } }).catch(() => {});
+}
diff --git a/src/lib/admin/users.ts b/src/lib/admin/users.ts
new file mode 100644
index 0000000..1ed82c1
--- /dev/null
+++ b/src/lib/admin/users.ts
@@ -0,0 +1,91 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export type AdminUserFilters = {
+ q?: string;
+ role?: UserRole;
+ active?: "yes" | "no";
+};
+
+export type AdminUserListItem = {
+ id: string;
+ email: string;
+ firstName: string;
+ lastName: string;
+ role: UserRole;
+ isActive: boolean;
+ createdAt: Date;
+ carbetsCount: number;
+ bookingsCount: number;
+ reviewsCount: number;
+};
+
+export async function listUsersAdmin(filters: AdminUserFilters = {}): Promise {
+ const where: Prisma.UserWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { email: { contains: filters.q, mode: "insensitive" } },
+ { firstName: { contains: filters.q, mode: "insensitive" } },
+ { lastName: { contains: filters.q, mode: "insensitive" } },
+ { phone: { contains: filters.q, mode: "insensitive" } },
+ ];
+ }
+ if (filters.role) where.role = filters.role;
+ if (filters.active === "yes") where.isActive = true;
+ if (filters.active === "no") where.isActive = false;
+
+ const rows = await prisma.user.findMany({
+ where,
+ orderBy: [{ createdAt: "desc" }],
+ take: 300,
+ select: {
+ id: true,
+ email: true,
+ firstName: true,
+ lastName: true,
+ role: true,
+ isActive: true,
+ createdAt: true,
+ _count: { select: { carbets: true, bookings: true, reviews: true } },
+ },
+ });
+
+ return rows.map((u) => ({
+ id: u.id,
+ email: u.email,
+ firstName: u.firstName,
+ lastName: u.lastName,
+ role: u.role,
+ isActive: u.isActive,
+ createdAt: u.createdAt,
+ carbetsCount: u._count.carbets,
+ bookingsCount: u._count.bookings,
+ reviewsCount: u._count.reviews,
+ }));
+}
+
+export async function getUserForAdmin(id: string) {
+ return prisma.user.findUnique({
+ where: { id },
+ include: {
+ organization: { select: { id: true, name: true } },
+ _count: { select: { carbets: true, bookings: true, reviews: true, subscriptions: true } },
+ bookings: {
+ take: 10,
+ orderBy: { createdAt: "desc" },
+ select: {
+ id: true, status: true, paymentStatus: true, startDate: true, endDate: true, amount: true, currency: true,
+ carbet: { select: { id: true, title: true } },
+ },
+ },
+ carbets: {
+ take: 10,
+ orderBy: { updatedAt: "desc" },
+ select: { id: true, title: true, slug: true, status: true, updatedAt: true },
+ },
+ },
+ });
+}
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
new file mode 100644
index 0000000..697e5d9
--- /dev/null
+++ b/src/lib/analytics.ts
@@ -0,0 +1,218 @@
+import "server-only";
+
+import {
+ BookingStatus,
+ RentalBookingStatus,
+} from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+const MONTH_MS = 30 * 24 * 60 * 60 * 1000;
+
+export type MonthlyRevenuePoint = {
+ /** "YYYY-MM" */
+ month: string;
+ carbetRevenue: number;
+ rentalRevenue: number;
+ total: number;
+};
+
+/**
+ * CA mensuel sur les 12 derniers mois calendaires.
+ * Scope optionnel par organisation CE — filtre via Carbet.organizations (memberships)
+ * et RentalProvider.organizationId.
+ */
+export async function getMonthlyRevenueSeries(opts: {
+ organizationId?: string;
+ monthsBack?: number;
+} = {}): Promise {
+ const monthsBack = opts.monthsBack ?? 12;
+ const since = new Date();
+ since.setMonth(since.getMonth() - monthsBack);
+ since.setDate(1);
+ since.setHours(0, 0, 0, 0);
+
+ const carbetWhere = {
+ status: BookingStatus.CONFIRMED,
+ createdAt: { gte: since },
+ ...(opts.organizationId
+ ? { carbet: { organizations: { some: { organizationId: opts.organizationId } } } }
+ : {}),
+ };
+ const rentalWhere = {
+ status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER, RentalBookingStatus.RETURNED] },
+ createdAt: { gte: since },
+ ...(opts.organizationId ? { provider: { organizationId: opts.organizationId } } : {}),
+ };
+
+ const [bookings, rentals] = await Promise.all([
+ prisma.booking.findMany({
+ where: carbetWhere,
+ select: { amount: true, createdAt: true },
+ }),
+ prisma.rentalBooking.findMany({
+ where: rentalWhere,
+ select: { amount: true, createdAt: true },
+ }),
+ ]);
+
+ const map = new Map();
+ for (let i = 0; i < monthsBack; i++) {
+ const d = new Date();
+ d.setMonth(d.getMonth() - (monthsBack - 1 - i));
+ const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
+ map.set(key, { month: key, carbetRevenue: 0, rentalRevenue: 0, total: 0 });
+ }
+ for (const b of bookings) {
+ const key = `${b.createdAt.getFullYear()}-${String(b.createdAt.getMonth() + 1).padStart(2, "0")}`;
+ const p = map.get(key);
+ if (p) p.carbetRevenue += Number(b.amount);
+ }
+ for (const r of rentals) {
+ const key = `${r.createdAt.getFullYear()}-${String(r.createdAt.getMonth() + 1).padStart(2, "0")}`;
+ const p = map.get(key);
+ if (p) p.rentalRevenue += Number(r.amount);
+ }
+ for (const p of map.values()) p.total = p.carbetRevenue + p.rentalRevenue;
+ return Array.from(map.values());
+}
+
+export type CarbetOccupancy = {
+ carbetId: string;
+ title: string;
+ slug: string;
+ totalNights: number;
+ bookedNights: number;
+ occupancyPct: number;
+};
+
+/**
+ * Taux d'occupation des carbets sur la fenêtre `monthsBack` (par défaut 3).
+ * Calcule (nuits réservées CONFIRMED ∩ fenêtre) / (totalNights de la fenêtre) en %.
+ */
+export async function getCarbetsOccupancy(opts: {
+ organizationId?: string;
+ monthsBack?: number;
+} = {}): Promise {
+ const monthsBack = opts.monthsBack ?? 3;
+ const since = new Date(Date.now() - monthsBack * MONTH_MS);
+ const now = new Date();
+ const totalNights = Math.max(1, Math.floor((now.getTime() - since.getTime()) / 86_400_000));
+
+ const carbets = await prisma.carbet.findMany({
+ where: {
+ status: "PUBLISHED",
+ ...(opts.organizationId
+ ? { organizations: { some: { organizationId: opts.organizationId } } }
+ : {}),
+ },
+ select: {
+ id: true,
+ title: true,
+ slug: true,
+ bookings: {
+ where: {
+ status: BookingStatus.CONFIRMED,
+ startDate: { lt: now },
+ endDate: { gt: since },
+ },
+ select: { startDate: true, endDate: true },
+ },
+ },
+ });
+
+ return carbets
+ .map((c) => {
+ const booked = c.bookings.reduce((sum, b) => {
+ const start = Math.max(b.startDate.getTime(), since.getTime());
+ const end = Math.min(b.endDate.getTime(), now.getTime());
+ return sum + Math.max(0, Math.floor((end - start) / 86_400_000));
+ }, 0);
+ const occupancyPct = Math.round((booked / totalNights) * 1000) / 10;
+ return {
+ carbetId: c.id,
+ title: c.title,
+ slug: c.slug,
+ totalNights,
+ bookedNights: booked,
+ occupancyPct,
+ };
+ })
+ .sort((a, b) => b.occupancyPct - a.occupancyPct);
+}
+
+export type AdminGlobalKpis = {
+ usersTotal: number;
+ usersByRole: Record;
+ carbetsPublished: number;
+ bookings30d: number;
+ rentals30d: number;
+ revenue30d: number;
+ topCarbets: { carbetId: string; title: string; slug: string; revenue: number }[];
+ topProviders: { providerId: string; name: string; revenue: number }[];
+};
+
+export async function getAdminGlobalKpis(): Promise {
+ const since = new Date(Date.now() - 30 * 86_400_000);
+
+ const [usersByRoleRows, carbetsPublished, bookings30d, rentals30d] = await Promise.all([
+ prisma.user.groupBy({
+ by: ["role"],
+ _count: { _all: true },
+ }),
+ prisma.carbet.count({ where: { status: "PUBLISHED" } }),
+ prisma.booking.findMany({
+ where: { status: BookingStatus.CONFIRMED, createdAt: { gte: since } },
+ select: { amount: true, carbetId: true, carbet: { select: { title: true, slug: true } } },
+ }),
+ prisma.rentalBooking.findMany({
+ where: {
+ status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER, RentalBookingStatus.RETURNED] },
+ createdAt: { gte: since },
+ },
+ select: { amount: true, providerId: true, provider: { select: { name: true } } },
+ }),
+ ]);
+
+ const usersByRole: Record = {};
+ let usersTotal = 0;
+ for (const row of usersByRoleRows) {
+ usersByRole[row.role] = row._count._all;
+ usersTotal += row._count._all;
+ }
+
+ const carbetAgg = new Map();
+ for (const b of bookings30d) {
+ const v = carbetAgg.get(b.carbetId) ?? { title: b.carbet.title, slug: b.carbet.slug, revenue: 0 };
+ v.revenue += Number(b.amount);
+ carbetAgg.set(b.carbetId, v);
+ }
+ const topCarbets = Array.from(carbetAgg.entries())
+ .map(([carbetId, v]) => ({ carbetId, ...v }))
+ .sort((a, b) => b.revenue - a.revenue)
+ .slice(0, 5);
+
+ const providerAgg = new Map();
+ for (const r of rentals30d) {
+ const v = providerAgg.get(r.providerId) ?? { name: r.provider.name, revenue: 0 };
+ v.revenue += Number(r.amount);
+ providerAgg.set(r.providerId, v);
+ }
+ const topProviders = Array.from(providerAgg.entries())
+ .map(([providerId, v]) => ({ providerId, ...v }))
+ .sort((a, b) => b.revenue - a.revenue)
+ .slice(0, 5);
+
+ const bookingsRevenue = bookings30d.reduce((s, b) => s + Number(b.amount), 0);
+ const rentalsRevenue = rentals30d.reduce((s, r) => s + Number(r.amount), 0);
+
+ return {
+ usersTotal,
+ usersByRole,
+ carbetsPublished,
+ bookings30d: bookings30d.length,
+ rentals30d: rentals30d.length,
+ revenue30d: bookingsRevenue + rentalsRevenue,
+ topCarbets,
+ topProviders,
+ };
+}
diff --git a/src/lib/carbet-access.ts b/src/lib/carbet-access.ts
index 420cd49..9822b24 100644
--- a/src/lib/carbet-access.ts
+++ b/src/lib/carbet-access.ts
@@ -3,19 +3,44 @@ 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];
+/**
+ * Espace hôte ET espace CE (les deux dashboards) : accessible à OWNER, CE_MANAGER, ADMIN.
+ * Chacun ne voit que ses propres carbets (own ou via membership). La page liste filtre
+ * par session.user.id / session.user.organizationId.
+ */
+const MANAGER_ROLES: UserRole[] = [
+ UserRole.OWNER,
+ UserRole.CE_MANAGER,
+ 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.
+/**
+ * Vrai si :
+ * - ADMIN
+ * - OWNER + session.user.id === carbetOwnerId
+ * - CE_MANAGER + son organizationId est dans `linkedOrgIds`
+ *
+ * Les callers DOIVENT charger `Carbet.organizations.map(m => m.organizationId)` quand le rôle
+ * peut être CE_MANAGER. Pour un caller historique qui n'a que l'ownerId, le CE_MANAGER ne
+ * pourra pas gérer le carbet — comportement sûr par défaut.
+ */
export function canManageCarbet(
session: Session,
carbetOwnerId: string,
+ linkedOrgIds: string[] = [],
): boolean {
- return (
- session.user.role === UserRole.ADMIN || session.user.id === carbetOwnerId
- );
+ if (session.user.role === UserRole.ADMIN) return true;
+ if (session.user.id === carbetOwnerId) return true;
+ if (
+ session.user.role === UserRole.CE_MANAGER &&
+ session.user.organizationId &&
+ linkedOrgIds.includes(session.user.organizationId)
+ ) {
+ return true;
+ }
+ return false;
}
diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts
index 233898c..3a947f8 100644
--- a/src/lib/carbet-public.ts
+++ b/src/lib/carbet-public.ts
@@ -2,12 +2,13 @@ import { cache } from "react";
import { prisma } from "@/lib/prisma";
import { amenityLabel } from "@/lib/amenities";
-import { AccessType, CarbetStatus, MediaType } from "@/generated/prisma/enums";
+import { AccessType, CarbetStatus, MediaType, TransportMode } from "@/generated/prisma/enums";
import type { CarbetReview, CarbetReviewStats } from "@/lib/reviews";
import {
getCarbetReviewStats,
listCarbetReviews,
} from "@/lib/reviews-server";
+import type { PirogueProvider } from "@/lib/pirogue-providers";
export type PublicCarbetMedia = {
id: string;
@@ -26,10 +27,23 @@ export type PublicCarbetDetail = {
accessType: AccessType;
roadAccessNote: string | null;
capacity: number;
+ nightlyPrice: string;
+ roadAccess: import("@/generated/prisma/enums").RoadAccess | null;
+ electricity: import("@/generated/prisma/enums").Electricity | null;
+ gsmAtCarbet: boolean;
+ gsmExitDistanceKm: number | null;
+ minStayNights: number | null;
+ maxStayNights: number | null;
+ minCapacity: number | null;
+ seasonalConstraints: unknown;
+ transportMode: TransportMode | null;
+ pirogueProvider: PirogueProvider | null;
latitude: string;
longitude: string;
ownerId: string;
ownerFirstName: string;
+ /** Comités d'Entreprise qui co-gèrent ce carbet (vide si hôte individuel). */
+ organizations: { id: string; name: string; slug: string }[];
media: PublicCarbetMedia[];
amenities: { key: string; label: string }[];
reviewStats: CarbetReviewStats;
@@ -53,10 +67,33 @@ export const getPublicCarbet = cache(
accessType: true,
roadAccessNote: true,
capacity: true,
+ nightlyPrice: true,
+ roadAccess: true,
+ electricity: true,
+ gsmAtCarbet: true,
+ gsmExitDistanceKm: true,
+ minStayNights: true,
+ maxStayNights: true,
+ minCapacity: true,
+ seasonalConstraints: true,
+ transportMode: true,
+ pirogueProviderId: true,
latitude: true,
longitude: true,
ownerId: true,
owner: { select: { firstName: true } },
+ pirogueProvider: {
+ select: {
+ id: true,
+ name: true,
+ contactEmail: true,
+ contactPhone: true,
+ rivers: true,
+ pricingNote: true,
+ description: true,
+ active: true,
+ },
+ },
media: {
orderBy: { sortOrder: "asc" },
select: { id: true, type: true, s3Url: true },
@@ -64,6 +101,12 @@ export const getPublicCarbet = cache(
amenities: {
select: { amenity: { select: { key: true, label: true } } },
},
+ organizations: {
+ where: { organization: { approved: true } },
+ select: {
+ organization: { select: { id: true, name: true, slug: true } },
+ },
+ },
},
});
@@ -85,10 +128,37 @@ export const getPublicCarbet = cache(
accessType: carbet.accessType,
roadAccessNote: carbet.roadAccessNote,
capacity: carbet.capacity,
+ nightlyPrice: carbet.nightlyPrice.toString(),
+ roadAccess: carbet.roadAccess,
+ electricity: carbet.electricity,
+ gsmAtCarbet: carbet.gsmAtCarbet,
+ gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? Number(carbet.gsmExitDistanceKm) : null,
+ minStayNights: carbet.minStayNights,
+ maxStayNights: carbet.maxStayNights,
+ minCapacity: carbet.minCapacity,
+ seasonalConstraints: carbet.seasonalConstraints,
+ transportMode: carbet.transportMode,
+ pirogueProvider: carbet.pirogueProvider
+ ? {
+ id: carbet.pirogueProvider.id,
+ name: carbet.pirogueProvider.name,
+ contactEmail: carbet.pirogueProvider.contactEmail,
+ contactPhone: carbet.pirogueProvider.contactPhone,
+ rivers: carbet.pirogueProvider.rivers,
+ pricingNote: carbet.pirogueProvider.pricingNote,
+ description: carbet.pirogueProvider.description,
+ active: carbet.pirogueProvider.active,
+ }
+ : null,
latitude: carbet.latitude.toString(),
longitude: carbet.longitude.toString(),
ownerId: carbet.ownerId,
ownerFirstName: carbet.owner.firstName,
+ organizations: carbet.organizations.map((m) => ({
+ id: m.organization.id,
+ name: m.organization.name,
+ slug: m.organization.slug,
+ })),
media: carbet.media.map((m) => ({
id: m.id,
type: m.type,
diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts
index aa8b4be..cd53126 100644
--- a/src/lib/carbet-search.ts
+++ b/src/lib/carbet-search.ts
@@ -5,6 +5,8 @@ import {
AvailabilityBlockReason,
AvailabilityScope,
CarbetStatus,
+ Electricity,
+ RoadAccess,
} from "@/generated/prisma/enums";
import { getCarbetReviewStatsMany } from "@/lib/reviews-server";
@@ -13,9 +15,16 @@ export type CarbetSearchFilters = {
startDate?: Date;
endDate?: Date;
capacity?: number;
- // Filtre plugin access-type : si "river-only" exclu, on garde uniquement
- // ROAD_AND_RIVER. Si "all" ou non spécifié, tout passe.
+ capacityMax?: number;
accessibility?: "road-only" | "all";
+ priceMax?: number;
+ amenities?: string[];
+ /** Niveaux d'accès route acceptés (multi). */
+ roadAccess?: RoadAccess[];
+ /** Niveaux d'électricité acceptés (multi). */
+ electricity?: Electricity[];
+ /** Distance max en km pour atteindre le réseau GSM. 0 = exige le réseau au carbet. */
+ gsmMaxKm?: number;
};
export type RawSearchParams = {
@@ -69,6 +78,63 @@ export function parseSearchFilters(
filters.accessibility = accessibility;
}
+ const capacityMaxRaw = pickString(searchParams.capacityMax);
+ if (capacityMaxRaw) {
+ const cmax = Number(capacityMaxRaw);
+ if (Number.isInteger(cmax) && cmax > 0 && cmax <= 100) filters.capacityMax = cmax;
+ }
+
+ const roadRaw = searchParams.roadAccess;
+ if (roadRaw) {
+ const arr = Array.isArray(roadRaw) ? roadRaw : [roadRaw];
+ const keys = arr
+ .flatMap((s) => s.split(","))
+ .map((s) => s.trim())
+ .filter((s): s is RoadAccess =>
+ s === RoadAccess.NONE || s === RoadAccess.DRY_SEASON_ONLY || s === RoadAccess.ALL_YEAR,
+ );
+ if (keys.length > 0) filters.roadAccess = Array.from(new Set(keys));
+ }
+
+ const elecRaw = searchParams.electricity;
+ if (elecRaw) {
+ const arr = Array.isArray(elecRaw) ? elecRaw : [elecRaw];
+ const keys = arr
+ .flatMap((s) => s.split(","))
+ .map((s) => s.trim())
+ .filter((s): s is Electricity =>
+ s === Electricity.NONE ||
+ s === Electricity.SOLAR ||
+ s === Electricity.GENERATOR_READY ||
+ s === Electricity.EDF,
+ );
+ if (keys.length > 0) filters.electricity = Array.from(new Set(keys));
+ }
+
+ const gsmMaxRaw = pickString(searchParams.gsmMaxKm);
+ if (gsmMaxRaw) {
+ const km = Number(gsmMaxRaw);
+ if (Number.isFinite(km) && km >= 0 && km <= 50) filters.gsmMaxKm = km;
+ }
+
+ const priceMaxRaw = pickString(searchParams.priceMax);
+ if (priceMaxRaw) {
+ const priceMax = Number(priceMaxRaw);
+ if (Number.isFinite(priceMax) && priceMax > 0 && priceMax <= 10000) {
+ filters.priceMax = priceMax;
+ }
+ }
+
+ const amenitiesRaw = searchParams.amenities;
+ if (amenitiesRaw) {
+ const arr = Array.isArray(amenitiesRaw) ? amenitiesRaw : [amenitiesRaw];
+ const keys = arr
+ .flatMap((s) => s.split(","))
+ .map((s) => s.trim())
+ .filter((s) => /^[a-z0-9-]{1,40}$/.test(s));
+ if (keys.length > 0) filters.amenities = keys.slice(0, 10);
+ }
+
return filters;
}
@@ -82,11 +148,21 @@ export type CarbetSearchResult = {
accessType: AccessType;
roadAccessNote: string | null;
capacity: number;
+ minStayNights: number | null;
+ maxStayNights: number | null;
+ minCapacity: number | null;
description: string;
coverUrl: string | null;
mediaCount: number;
reviewCount: number;
averageRating: number | null;
+ nightlyPrice: string;
+ latitude: number;
+ longitude: number;
+ roadAccess: RoadAccess | null;
+ electricity: Electricity | null;
+ gsmAtCarbet: boolean;
+ gsmExitDistanceKm: number | null;
};
// Build the Prisma where-clause for a public carbet search. A carbet is only
@@ -101,14 +177,46 @@ function buildWhere(filters: CarbetSearchFilters): Prisma.CarbetWhereInput {
where.river = { contains: filters.river, mode: "insensitive" };
}
- if (filters.capacity) {
- where.capacity = { gte: filters.capacity };
+ if (filters.capacity || filters.capacityMax) {
+ where.capacity = {};
+ if (filters.capacity) where.capacity.gte = filters.capacity;
+ if (filters.capacityMax) where.capacity.lte = filters.capacityMax;
+ }
+
+ if (filters.roadAccess && filters.roadAccess.length > 0) {
+ where.roadAccess = { in: filters.roadAccess };
+ }
+
+ if (filters.electricity && filters.electricity.length > 0) {
+ where.electricity = { in: filters.electricity };
+ }
+
+ if (filters.gsmMaxKm !== undefined) {
+ if (filters.gsmMaxKm === 0) {
+ where.gsmAtCarbet = true;
+ } else {
+ where.OR = [
+ ...(where.OR ?? []),
+ { gsmAtCarbet: true },
+ { gsmExitDistanceKm: { lte: filters.gsmMaxKm } },
+ ];
+ }
}
if (filters.accessibility === "road-only") {
where.accessType = AccessType.ROAD_AND_RIVER;
}
+ if (filters.priceMax !== undefined) {
+ where.nightlyPrice = { lte: filters.priceMax };
+ }
+
+ if (filters.amenities && filters.amenities.length > 0) {
+ where.AND = filters.amenities.map((key) => ({
+ amenities: { some: { amenity: { key } } },
+ }));
+ }
+
if (filters.startDate && filters.endDate) {
where.availabilities = {
some: {
@@ -142,7 +250,17 @@ export async function searchCarbets(
accessType: true,
roadAccessNote: true,
capacity: true,
+ minStayNights: true,
+ maxStayNights: true,
+ minCapacity: true,
description: true,
+ roadAccess: true,
+ electricity: true,
+ gsmAtCarbet: true,
+ gsmExitDistanceKm: true,
+ nightlyPrice: true,
+ latitude: true,
+ longitude: true,
media: {
orderBy: { sortOrder: "asc" },
take: 1,
@@ -169,11 +287,21 @@ export async function searchCarbets(
accessType: carbet.accessType,
roadAccessNote: carbet.roadAccessNote,
capacity: carbet.capacity,
+ minStayNights: carbet.minStayNights,
+ maxStayNights: carbet.maxStayNights,
+ minCapacity: carbet.minCapacity,
description: carbet.description,
coverUrl: carbet.media[0]?.s3Url ?? null,
mediaCount: carbet._count.media,
reviewCount: stats.count,
averageRating: stats.averageRating,
+ nightlyPrice: carbet.nightlyPrice.toString(),
+ latitude: Number(carbet.latitude),
+ longitude: Number(carbet.longitude),
+ roadAccess: carbet.roadAccess,
+ electricity: carbet.electricity,
+ gsmAtCarbet: carbet.gsmAtCarbet,
+ gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? Number(carbet.gsmExitDistanceKm) : null,
};
});
}
diff --git a/src/lib/ce-access.ts b/src/lib/ce-access.ts
new file mode 100644
index 0000000..c525d04
--- /dev/null
+++ b/src/lib/ce-access.ts
@@ -0,0 +1,92 @@
+import "server-only";
+
+import { redirect } from "next/navigation";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+/**
+ * Garde-fou commun pour /espace-ce/* : redirige vers /connexion si pas de session,
+ * vers / si le rôle n'est pas CE_MANAGER ni ADMIN.
+ */
+export async function requireCeManagerSession() {
+ const session = await auth();
+ if (!session?.user?.id) {
+ redirect("/connexion?next=/espace-ce");
+ }
+ const role = session.user.role;
+ if (role !== UserRole.CE_MANAGER && role !== UserRole.ADMIN) {
+ redirect("/");
+ }
+ return session;
+}
+
+/**
+ * Récupère l'Organization de l'utilisateur connecté (via User.organizationId).
+ * - CE_MANAGER → son org (toujours rattaché)
+ * - ADMIN → soit l'org ciblée par `organizationId`, soit null pour forcer le choix
+ */
+export async function getCurrentCeOrganization(opts: { organizationId?: string } = {}) {
+ const session = await auth();
+ if (!session?.user?.id) return null;
+ const role = session.user.role;
+
+ if (role === UserRole.ADMIN && opts.organizationId) {
+ return prisma.organization.findUnique({ where: { id: opts.organizationId } });
+ }
+ if (role === UserRole.ADMIN && !opts.organizationId) {
+ return null;
+ }
+ // CE_MANAGER : retourne son org via User.organizationId
+ const user = await prisma.user.findUnique({
+ where: { id: session.user.id },
+ select: { organization: true },
+ });
+ return user?.organization ?? null;
+}
+
+/**
+ * Un CE_MANAGER peut-il gérer ce carbet ?
+ * - vrai s'il en est l'owner direct (`Carbet.ownerId == userId`)
+ * - OU s'il est membre d'une org liée au carbet via OrganizationCarbetMembership
+ * - ADMIN passe toujours.
+ */
+export async function canManageCarbetForCe(
+ userId: string,
+ role: string | undefined,
+ carbetId: string,
+): Promise {
+ if (role === UserRole.ADMIN) return true;
+ if (role !== UserRole.CE_MANAGER) return false;
+
+ const [carbet, user] = await Promise.all([
+ prisma.carbet.findUnique({
+ where: { id: carbetId },
+ select: {
+ ownerId: true,
+ organizations: { select: { organizationId: true } },
+ },
+ }),
+ prisma.user.findUnique({
+ where: { id: userId },
+ select: { organizationId: true },
+ }),
+ ]);
+ if (!carbet || !user?.organizationId) return false;
+ if (carbet.ownerId === userId) return true;
+ return carbet.organizations.some((m) => m.organizationId === user.organizationId);
+}
+
+/**
+ * Garantit que l'org du user est `approved=true`. Sinon redirige vers le dashboard
+ * /espace-ce qui affiche une bannière « En attente de validation ».
+ * Utiliser sur les pages qui doivent publier du contenu (créer carbet/item).
+ */
+export async function requireApprovedOrg() {
+ const org = await getCurrentCeOrganization();
+ if (!org || !org.approved) {
+ redirect("/espace-ce?pending=1");
+ }
+ return org;
+}
diff --git a/src/lib/ce-dashboard.ts b/src/lib/ce-dashboard.ts
new file mode 100644
index 0000000..c5edb40
--- /dev/null
+++ b/src/lib/ce-dashboard.ts
@@ -0,0 +1,90 @@
+import "server-only";
+
+import {
+ BookingStatus,
+ RentalBookingStatus,
+} from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+/**
+ * KPIs agrégés à l'échelle d'une organisation CE.
+ * - carbets : nombre de carbets co-gérés via OrganizationCarbetMembership
+ * - rentalItems : items des providers liés à l'org
+ * - bookings30d : bookings confirmées sur les carbets de l'org (30 derniers jours)
+ * - rentalBookings30d : RentalBooking confirmées sur les providers de l'org
+ * - revenue30d : somme des amounts (booking + rental) sur 30j
+ */
+export async function getCeOrgKpis(organizationId: string) {
+ const since = new Date(Date.now() - 30 * 86_400_000);
+
+ const [carbetsCount, providers, bookings30d, rentalBookings30d] = await Promise.all([
+ prisma.organizationCarbetMembership.count({ where: { organizationId } }),
+ prisma.rentalProvider.findMany({
+ where: { organizationId },
+ select: {
+ id: true,
+ approved: true,
+ active: true,
+ _count: { select: { items: true } },
+ },
+ }),
+ prisma.booking.findMany({
+ where: {
+ status: BookingStatus.CONFIRMED,
+ createdAt: { gte: since },
+ carbet: { organizations: { some: { organizationId } } },
+ },
+ select: { amount: true, currency: true },
+ }),
+ prisma.rentalBooking.findMany({
+ where: {
+ status: RentalBookingStatus.CONFIRMED,
+ createdAt: { gte: since },
+ provider: { organizationId },
+ },
+ select: { amount: true, currency: true },
+ }),
+ ]);
+
+ const itemsCount = providers.reduce((s, p) => s + p._count.items, 0);
+ const revenue30d = [
+ ...bookings30d.map((b) => Number(b.amount)),
+ ...rentalBookings30d.map((r) => Number(r.amount)),
+ ].reduce((s, n) => s + n, 0);
+
+ return {
+ carbetsCount,
+ providersCount: providers.length,
+ rentalItemsCount: itemsCount,
+ rentalProviderApproved: providers.every((p) => p.approved),
+ bookings30dCount: bookings30d.length,
+ rentalBookings30dCount: rentalBookings30d.length,
+ revenue30d,
+ };
+}
+
+/**
+ * Liste les carbets co-gérés par une org (joinés via membership).
+ */
+export async function listCeCarbets(organizationId: string) {
+ const memberships = await prisma.organizationCarbetMembership.findMany({
+ where: { organizationId },
+ orderBy: { addedAt: "desc" },
+ select: {
+ carbet: {
+ select: {
+ id: true,
+ slug: true,
+ title: true,
+ river: true,
+ status: true,
+ capacity: true,
+ nightlyPrice: true,
+ ownerId: true,
+ owner: { select: { firstName: true, lastName: true } },
+ },
+ },
+ },
+ });
+ return memberships.map((m) => m.carbet);
+}
diff --git a/src/lib/ce-invites.ts b/src/lib/ce-invites.ts
new file mode 100644
index 0000000..ca8fb26
--- /dev/null
+++ b/src/lib/ce-invites.ts
@@ -0,0 +1,81 @@
+import "server-only";
+
+import crypto from "node:crypto";
+
+import { prisma } from "@/lib/prisma";
+
+const INVITE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 14 jours
+
+/** Hash sha256 d'un token plain → utilisé comme PK pour ne jamais persister le plain. */
+export function hashToken(token: string): string {
+ return crypto.createHash("sha256").update(token).digest("hex");
+}
+
+/**
+ * Vrai si une invitation est encore consommable : pas marquée `usedAt`
+ * et pas encore expirée. Helper extrait pour testabilité.
+ */
+export function isInviteValid(
+ row: { expiresAt: Date; usedAt: Date | null },
+ now: Date = new Date(),
+): boolean {
+ if (row.usedAt) return false;
+ if (row.expiresAt < now) return false;
+ return true;
+}
+
+export async function createOrgInviteToken(opts: {
+ organizationId: string;
+ createdByUserId: string;
+ email?: string | null;
+ ttlMs?: number;
+}): Promise {
+ const token = crypto.randomBytes(24).toString("base64url");
+ const tokenHash = hashToken(token);
+ const expiresAt = new Date(Date.now() + (opts.ttlMs ?? INVITE_TTL_MS));
+ await prisma.orgInviteToken.create({
+ data: {
+ tokenHash,
+ organizationId: opts.organizationId,
+ createdByUserId: opts.createdByUserId,
+ email: opts.email ?? null,
+ expiresAt,
+ },
+ });
+ return token;
+}
+
+export async function listOrgInviteTokens(organizationId: string) {
+ return prisma.orgInviteToken.findMany({
+ where: { organizationId },
+ orderBy: { createdAt: "desc" },
+ take: 50,
+ });
+}
+
+/** Renvoie l'invitation si elle existe, non expirée et non consommée. */
+export async function getOrgInviteByToken(plainToken: string) {
+ const tokenHash = hashToken(plainToken);
+ const row = await prisma.orgInviteToken.findUnique({
+ where: { tokenHash },
+ include: {
+ organization: { select: { id: true, name: true, slug: true, approved: true } },
+ },
+ });
+ if (!row) return null;
+ if (!isInviteValid(row)) return null;
+ return row;
+}
+
+/** Marque l'invitation comme consommée. À appeler dans la transaction de signup. */
+export async function markOrgInviteConsumed(plainToken: string): Promise {
+ const tokenHash = hashToken(plainToken);
+ await prisma.orgInviteToken.update({
+ where: { tokenHash },
+ data: { usedAt: new Date() },
+ });
+}
+
+export async function revokeOrgInviteToken(tokenHash: string): Promise {
+ await prisma.orgInviteToken.delete({ where: { tokenHash } }).catch(() => {});
+}
diff --git a/src/lib/content-pages.ts b/src/lib/content-pages.ts
new file mode 100644
index 0000000..e789382
--- /dev/null
+++ b/src/lib/content-pages.ts
@@ -0,0 +1,136 @@
+/**
+ * Helpers Plugin content-pages / legal-pages.
+ *
+ * Une `ContentPage` est une page éditable (markdown léger) servie depuis la
+ * table DB. Clé composite (slug, lang) : une page peut exister en plusieurs
+ * langues. getContentPage(slug, lang) fait un fallback sur 'fr' si la version
+ * dans la langue demandée n'existe pas ou n'est pas publiée.
+ */
+
+import { prisma } from "@/lib/prisma";
+
+export type ContentPage = {
+ slug: string;
+ title: string;
+ body: string;
+ lang: string;
+ category: string;
+ published: boolean;
+ updatedAt: Date;
+};
+
+export async function getContentPage(slug: string, lang: string = "fr"): Promise {
+ // Essai dans la langue demandée
+ const row = await prisma.contentPage.findUnique({
+ where: { slug_lang: { slug, lang } },
+ });
+ if (row && row.published) {
+ return {
+ slug: row.slug,
+ title: row.title,
+ body: row.body,
+ lang: row.lang,
+ category: row.category,
+ published: row.published,
+ updatedAt: row.updatedAt,
+ };
+ }
+ // Fallback FR si autre langue manquante
+ if (lang !== "fr") {
+ const fallback = await prisma.contentPage.findUnique({
+ where: { slug_lang: { slug, lang: "fr" } },
+ });
+ if (fallback && fallback.published) {
+ return {
+ slug: fallback.slug,
+ title: fallback.title,
+ body: fallback.body,
+ lang: fallback.lang,
+ category: fallback.category,
+ published: fallback.published,
+ updatedAt: fallback.updatedAt,
+ };
+ }
+ }
+ return null;
+}
+
+export async function listContentPages(category?: string, lang?: string): Promise {
+ const where: { category?: string; lang?: string } = {};
+ if (category) where.category = category;
+ if (lang) where.lang = lang;
+ const rows = await prisma.contentPage.findMany({
+ where,
+ orderBy: [{ category: "asc" }, { slug: "asc" }, { lang: "asc" }],
+ });
+ return rows.map((r) => ({
+ slug: r.slug,
+ title: r.title,
+ body: r.body,
+ lang: r.lang,
+ category: r.category,
+ published: r.published,
+ updatedAt: r.updatedAt,
+ }));
+}
+
+export async function upsertContentPage(input: {
+ slug: string;
+ title: string;
+ body: string;
+ category?: string;
+ lang?: string;
+ published?: boolean;
+ lastEditedBy?: string;
+}): Promise {
+ const lang = input.lang ?? "fr";
+ const row = await prisma.contentPage.upsert({
+ where: { slug_lang: { slug: input.slug, lang } },
+ update: {
+ title: input.title,
+ body: input.body,
+ category: input.category ?? "general",
+ published: input.published ?? true,
+ lastEditedBy: input.lastEditedBy ?? null,
+ },
+ create: {
+ slug: input.slug,
+ lang,
+ title: input.title,
+ body: input.body,
+ category: input.category ?? "general",
+ published: input.published ?? true,
+ lastEditedBy: input.lastEditedBy ?? null,
+ },
+ });
+ return {
+ slug: row.slug,
+ title: row.title,
+ body: row.body,
+ lang: row.lang,
+ category: row.category,
+ published: row.published,
+ updatedAt: row.updatedAt,
+ };
+}
+
+export async function setContentPagePublished(
+ slug: string,
+ category: string,
+ published: boolean,
+ lang?: string,
+): Promise {
+ const result = await prisma.contentPage.updateMany({
+ where: lang ? { slug, category, lang } : { slug, category },
+ data: { published },
+ });
+ return result.count;
+}
+
+export async function unpublishCategory(category: string): Promise {
+ const result = await prisma.contentPage.updateMany({
+ where: { category },
+ data: { published: false },
+ });
+ return result.count;
+}
diff --git a/src/lib/cron-auth.ts b/src/lib/cron-auth.ts
new file mode 100644
index 0000000..dd034ba
--- /dev/null
+++ b/src/lib/cron-auth.ts
@@ -0,0 +1,18 @@
+import "server-only";
+
+/**
+ * Auth Bearer pour les endpoints /api/cron/*. Le token est partagé entre le
+ * serveur et le cron caller externe (Hermes, cron host, etc.).
+ *
+ * Renvoie true si l'en-tête Authorization correspond exactement à
+ * `Bearer ${process.env.CRON_TOKEN}` (timing-safe via le comparateur natif —
+ * acceptable car le token n'est pas dérivable de la requête).
+ */
+export function isAuthorizedCronRequest(req: Request): boolean {
+ const expected = (process.env.CRON_TOKEN ?? "").trim();
+ if (!expected) return false;
+ const header = req.headers.get("authorization") ?? "";
+ if (!header.startsWith("Bearer ")) return false;
+ const token = header.slice("Bearer ".length).trim();
+ return token === expected;
+}
diff --git a/src/lib/email.ts b/src/lib/email.ts
new file mode 100644
index 0000000..47eb1d9
--- /dev/null
+++ b/src/lib/email.ts
@@ -0,0 +1,523 @@
+/**
+ * Service email — Resend si `RESEND_API_KEY` est configuré, sinon log console.
+ *
+ * Le code consommateur ne doit jamais bloquer ni jeter d'erreur sur un échec
+ * d'envoi (best-effort, le booking est l'action principale).
+ */
+
+import "server-only";
+
+let resendClient: import("resend").Resend | null | undefined;
+
+async function getResend(): Promise {
+ if (resendClient !== undefined) return resendClient;
+ const key = process.env.RESEND_API_KEY?.trim();
+ if (!key) {
+ resendClient = null;
+ return null;
+ }
+ try {
+ const { Resend } = await import("resend");
+ resendClient = new Resend(key);
+ return resendClient;
+ } catch (e) {
+ console.error("[email] resend init failed:", e instanceof Error ? e.message : e);
+ resendClient = null;
+ return null;
+ }
+}
+
+export type EmailOpts = {
+ to: string | string[];
+ subject: string;
+ html: string;
+ text?: string;
+ replyTo?: string;
+};
+
+const DEFAULT_FROM = process.env.RESEND_FROM ?? "Karbé ";
+
+export async function sendEmail(opts: EmailOpts): Promise<{ ok: boolean; id?: string; reason?: string }> {
+ const client = await getResend();
+ if (!client) {
+ console.log(
+ "[email] dry-run (no RESEND_API_KEY):",
+ JSON.stringify({ to: opts.to, subject: opts.subject }),
+ );
+ return { ok: true, reason: "dry-run" };
+ }
+ try {
+ const { data, error } = await client.emails.send({
+ from: DEFAULT_FROM,
+ to: Array.isArray(opts.to) ? opts.to : [opts.to],
+ subject: opts.subject,
+ html: opts.html,
+ text: opts.text,
+ replyTo: opts.replyTo,
+ });
+ if (error) {
+ console.error("[email] resend error:", error);
+ return { ok: false, reason: error.message };
+ }
+ return { ok: true, id: data?.id };
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ console.error("[email] send failed:", msg);
+ return { ok: false, reason: msg };
+ }
+}
+
+// ---------- Templates ----------
+
+const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
+
+const baseStyle = `
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ color: #18181b;
+ max-width: 580px;
+ margin: 0 auto;
+ padding: 24px;
+ line-height: 1.5;
+`;
+
+function wrap(title: string, content: string): string {
+ return `
+
+
${title}
+ ${content}
+
+
+ Karbé · ${SITE_URL}
+ Cet email a été envoyé suite à une action sur votre compte. Si ce n'est pas vous, ignorez-le.
+
+
+ `;
+}
+
+export async function sendSignupWelcome(to: string, firstName: string): Promise {
+ await sendEmail({
+ to,
+ subject: "Bienvenue sur Karbé",
+ html: wrap(
+ `Bienvenue ${firstName} !`,
+ `Votre compte Karbé est créé. Vous pouvez désormais réserver un séjour ou, si vous êtes hôte, publier votre carbet.
+ Découvrir les carbets
`,
+ ),
+ text: `Bienvenue ${firstName} ! Votre compte Karbé est créé. ${SITE_URL}/carbets`,
+ });
+}
+
+export async function sendBookingRequestToTenant(
+ to: string,
+ firstName: string,
+ bookingId: string,
+ carbetTitle: string,
+ startDate: Date,
+ endDate: Date,
+ amount: string,
+ currency: string,
+): Promise {
+ const fmt = (d: Date) =>
+ new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
+ await sendEmail({
+ to,
+ subject: `Demande de réservation enregistrée — ${carbetTitle}`,
+ html: wrap(
+ "Demande de réservation envoyée",
+ `Bonjour ${firstName},
+ Votre demande de réservation pour ${carbetTitle} a bien été enregistrée :
+
+ Arrivée : ${fmt(startDate)}
+ Départ : ${fmt(endDate)}
+ Montant : ${Number(amount).toFixed(2)} ${currency}
+
+ Vous recevrez un nouvel email dès que l'hôte ou l'équipe Karbé confirmera votre séjour.
+ Voir ma réservation
`,
+ ),
+ });
+}
+
+export async function sendBookingRequestToOwner(
+ to: string,
+ ownerFirstName: string,
+ bookingId: string,
+ carbetTitle: string,
+ tenantName: string,
+ startDate: Date,
+ endDate: Date,
+): Promise {
+ const fmt = (d: Date) =>
+ new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
+ await sendEmail({
+ to,
+ subject: `Nouvelle demande de réservation — ${carbetTitle}`,
+ html: wrap(
+ "Nouvelle demande à confirmer",
+ `Bonjour ${ownerFirstName},
+ ${tenantName} souhaite réserver ${carbetTitle} :
+
+ Du ${fmt(startDate)} au ${fmt(endDate)}
+
+ Connectez-vous à votre espace hôte pour confirmer ou refuser.
+ Mon espace hôte
`,
+ ),
+ });
+}
+
+export async function sendBookingConfirmed(
+ to: string,
+ firstName: string,
+ bookingId: string,
+ carbetTitle: string,
+ startDate: Date,
+ endDate: Date,
+): Promise {
+ const fmt = (d: Date) =>
+ new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
+ await sendEmail({
+ to,
+ subject: `Réservation confirmée — ${carbetTitle}`,
+ html: wrap(
+ "Votre séjour est confirmé",
+ `Bonjour ${firstName},
+ Votre réservation pour ${carbetTitle} du ${fmt(startDate)} au ${fmt(endDate)} est confirmée.
+ Voir ma réservation
`,
+ ),
+ });
+}
+
+export async function sendNewCeRequest(
+ orgName: string,
+ managerEmail: string,
+): Promise {
+ const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL ?? "contact@karbe.cosmolan.fr";
+ await sendEmail({
+ to: adminEmail,
+ subject: `Nouvelle demande CE — ${orgName}`,
+ html: wrap(
+ "Demande de Comité d'Entreprise à valider",
+ `Une organisation vient de s'inscrire en tant que Comité d'Entreprise.
+
+ Nom : ${orgName}
+ Email du manager : ${managerEmail}
+
+ Valider sur l'admin Karbé
+ Le CE_MANAGER peut accéder à son dashboard mais ne peut rien publier tant que Organization.approved=false.
`,
+ ),
+ });
+}
+
+export async function sendCeInviteEmail(
+ to: string,
+ orgName: string,
+ inviteUrl: string,
+ inviterName?: string | null,
+): Promise {
+ const intro = inviterName
+ ? `${inviterName} vous invite à rejoindre`
+ : "Vous êtes invité à rejoindre";
+ await sendEmail({
+ to,
+ subject: `Invitation à rejoindre « ${orgName} » sur Karbé`,
+ html: wrap(
+ `Invitation Karbé — ${orgName}`,
+ `${intro} le Comité d'Entreprise ${orgName} sur Karbé.
+ Cliquez sur le bouton ci-dessous pour créer votre compte CE_MEMBER et accéder aux carbets et matériel de votre CE :
+ Rejoindre ${orgName}
+ Lien valable 14 jours. Si vous n'êtes pas le destinataire attendu, ignorez cet email.
+ Lien direct : ${inviteUrl}
`,
+ ),
+ text: `${intro.replace(/<[^>]+>/g, "")} le CE ${orgName} sur Karbé : ${inviteUrl}`,
+ });
+}
+
+export async function sendCeApproved(
+ to: string,
+ firstName: string,
+ orgName: string,
+): Promise {
+ await sendEmail({
+ to,
+ subject: `Votre CE « ${orgName} » est validé sur Karbé`,
+ html: wrap(
+ "Organisation validée",
+ `Bonjour ${firstName},
+ Votre Comité d'Entreprise ${orgName} vient d'être validé. Vous pouvez désormais publier vos carbets et activer la location de matériel pour vos membres et le public touriste.
+ Accéder à mon espace CE
`,
+ ),
+ });
+}
+
+export async function sendNewRentalProviderRequest(
+ providerName: string,
+ userEmail: string,
+): Promise {
+ const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL ?? "contact@karbe.cosmolan.fr";
+ await sendEmail({
+ to: adminEmail,
+ subject: `Nouvelle demande prestataire matériel — ${providerName}`,
+ html: wrap(
+ "Demande de prestataire à valider",
+ `Une demande d'inscription en tant que prestataire de location matériel vient d'arriver.
+
+ Nom : ${providerName}
+ Email contact : ${userEmail}
+
+ Valider sur l'admin Karbé
+ Le prestataire reste en attente jusqu'à validation. Ses items ne sont pas publiés tant que approved=false.
`,
+ ),
+ });
+}
+
+export async function sendPasswordReset(
+ to: string,
+ resetUrl: string,
+): Promise {
+ await sendEmail({
+ to,
+ subject: "Réinitialisation de votre mot de passe Karbé",
+ html: wrap(
+ "Réinitialiser votre mot de passe",
+ `Vous avez demandé à réinitialiser votre mot de passe Karbé. Cliquez sur le lien ci-dessous pour choisir un nouveau mot de passe (valable 1 heure) :
+ Réinitialiser mon mot de passe
+ Si vous n'avez pas fait cette demande, ignorez simplement cet email — votre mot de passe ne change pas.
`,
+ ),
+ text: `Réinitialiser votre mot de passe Karbé : ${resetUrl} (valable 1h).`,
+ });
+}
+
+type RentalLineSummary = { qty: number; itemName: string };
+
+function renderLines(lines: RentalLineSummary[]): string {
+ return lines.map((l) => `${l.qty}× ${l.itemName} `).join("");
+}
+
+export async function sendRentalRequestedTenant(
+ to: string,
+ firstName: string,
+ rentalBookingId: string,
+ providerName: string,
+ startDate: Date,
+ endDate: Date,
+ amount: string,
+ currency: string,
+ lines: RentalLineSummary[],
+): Promise {
+ const fmt = (d: Date) =>
+ new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
+ await sendEmail({
+ to,
+ subject: `Demande de location matériel — ${providerName}`,
+ html: wrap(
+ "Votre demande de location est enregistrée",
+ `Bonjour ${firstName},
+ Votre demande de location auprès de ${providerName} est bien enregistrée :
+
+ Du ${fmt(startDate)} au ${fmt(endDate)}
+ Montant : ${Number(amount).toFixed(2)} ${currency}
+
+ Matériel demandé :
+
+ Vous recevrez un nouvel email dès que le paiement sera validé et le prestataire confirmera la préparation du matériel.
+ Mes locations
+ Référence : ${rentalBookingId}
`,
+ ),
+ });
+}
+
+export async function sendRentalRequestedProvider(
+ to: string,
+ providerName: string,
+ rentalBookingId: string,
+ tenantName: string,
+ startDate: Date,
+ endDate: Date,
+ lines: RentalLineSummary[],
+): Promise {
+ const fmt = (d: Date) =>
+ new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
+ await sendEmail({
+ to,
+ subject: `Nouvelle demande de location — ${tenantName}`,
+ html: wrap(
+ "Nouvelle demande à préparer",
+ `Bonjour ${providerName},
+ ${tenantName} vient de réserver du matériel :
+
+ Du ${fmt(startDate)} au ${fmt(endDate)}
+
+ Matériel :
+
+ Préparez le matériel pour la remise. Vous recevrez une confirmation paiement une fois le règlement validé.
+ Mes réservations
+ Référence : ${rentalBookingId}
`,
+ ),
+ });
+}
+
+export async function sendRentalCancelled(
+ to: string,
+ firstName: string,
+ rentalBookingId: string,
+ providerName: string,
+ refundAmount: string,
+ currency: string,
+ policyLabel: string,
+ cancelledBy: "tenant" | "provider" | "admin",
+): Promise {
+ const actor =
+ cancelledBy === "tenant"
+ ? "Vous avez annulé"
+ : cancelledBy === "provider"
+ ? `${providerName} a annulé`
+ : "L'équipe Karbé a annulé";
+ await sendEmail({
+ to,
+ subject: `Location annulée — ${providerName}`,
+ html: wrap(
+ "Location annulée",
+ `Bonjour ${firstName},
+ ${actor} votre location auprès de ${providerName} .
+ Politique appliquée : ${policyLabel}
+ Remboursement : ${Number(refundAmount).toFixed(2)} ${currency}
+ Si un paiement avait été reçu, le remboursement est traité par Stripe sous 3-5 jours ouvrés.
+ Mes locations
+ Référence : ${rentalBookingId}
`,
+ ),
+ });
+}
+
+export async function sendRentalConfirmed(
+ to: string,
+ firstName: string,
+ rentalBookingId: string,
+ providerName: string,
+ startDate: Date,
+ endDate: Date,
+): Promise {
+ const fmt = (d: Date) =>
+ new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
+ await sendEmail({
+ to,
+ subject: `Location confirmée — ${providerName}`,
+ html: wrap(
+ "Votre location est confirmée",
+ `Bonjour ${firstName},
+ Le paiement de votre location auprès de ${providerName} du ${fmt(startDate)} au ${fmt(endDate)} est validé.
+ Le prestataire vous contactera pour organiser la remise du matériel sur place.
+ Voir ma location
+ Référence : ${rentalBookingId}
`,
+ ),
+ });
+}
+
+export async function sendPayoutSent(
+ to: string,
+ providerName: string,
+ periodMonth: Date,
+ amount: string,
+ reference: string | null,
+): Promise {
+ const monthLabel = periodMonth.toLocaleDateString("fr-FR", {
+ timeZone: "UTC",
+ year: "numeric",
+ month: "long",
+ });
+ await sendEmail({
+ to,
+ subject: `Reversement Karbé — ${monthLabel}`,
+ html: wrap(
+ `Reversement ${monthLabel}`,
+ `Bonjour ${providerName},
+ Le reversement de vos locations matériel pour ${monthLabel} a été effectué :
+
+ Montant : ${Number(amount).toFixed(2)} EUR
+ ${reference ? `Référence virement : ${reference} ` : ""}
+
+ Vérifiez votre compte bancaire dans les 1 à 3 jours ouvrés. En cas de question, répondez à cet email.
+ Voir mes réservations
`,
+ ),
+ });
+}
+
+export async function sendBookingReminder(
+ to: string,
+ firstName: string,
+ bookingId: string,
+ carbetTitle: string,
+ startDate: Date,
+ carbetSlug: string,
+): Promise {
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", {
+ day: "2-digit",
+ month: "long",
+ year: "numeric",
+ weekday: "long",
+ }).format(startDate);
+ await sendEmail({
+ to,
+ subject: `Demain : votre séjour ${carbetTitle}`,
+ html: wrap(
+ "Votre séjour démarre demain",
+ `Bonjour ${firstName},
+ Votre séjour au carbet ${carbetTitle} commence ${dateFmt} .
+ Pensez à vérifier vos affaires : hamac, moustiquaire, frontale, eau, etc. Vérifiez aussi avec le loueur les détails d'arrivée (clés, dégrad, pirogue).
+ Voir ma réservation
+ Détails du carbet
`,
+ ),
+ });
+}
+
+export async function sendRentalReminder(
+ to: string,
+ firstName: string,
+ rentalBookingId: string,
+ providerName: string,
+ startDate: Date,
+ providerContact: { email: string | null; phone: string | null },
+): Promise {
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", {
+ day: "2-digit",
+ month: "long",
+ weekday: "long",
+ }).format(startDate);
+ const contact = [
+ providerContact.phone ? `📞 ${providerContact.phone}` : null,
+ providerContact.email ? `✉ ${providerContact.email}` : null,
+ ]
+ .filter(Boolean)
+ .join(" · ");
+ await sendEmail({
+ to,
+ subject: `Demain : récupération matériel ${providerName}`,
+ html: wrap(
+ "Récupération matériel demain",
+ `Bonjour ${firstName},
+ Votre location matériel auprès de ${providerName} démarre ${dateFmt} .
+ Contactez le prestataire pour convenir du créneau et du lieu de remise.
+ ${contact ? `${contact}
` : ""}
+ Mes locations
+ Référence : ${rentalBookingId}
`,
+ ),
+ });
+}
+
+export async function sendBookingRefunded(
+ to: string,
+ firstName: string,
+ bookingId: string,
+ carbetTitle: string,
+ amount: string,
+ currency: string,
+): Promise {
+ await sendEmail({
+ to,
+ subject: `Remboursement traité — ${carbetTitle}`,
+ html: wrap(
+ "Remboursement en cours",
+ `Bonjour ${firstName},
+ Votre réservation pour ${carbetTitle} a été annulée et le remboursement de ${Number(amount).toFixed(2)} ${currency} est en cours de traitement par Stripe (3 à 5 jours ouvrés).
+ Détails de la réservation
`,
+ ),
+ });
+}
diff --git a/src/lib/host-dashboard.ts b/src/lib/host-dashboard.ts
new file mode 100644
index 0000000..586ff65
--- /dev/null
+++ b/src/lib/host-dashboard.ts
@@ -0,0 +1,203 @@
+import "server-only";
+
+import { BookingStatus, PaymentStatus, UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export type HostKpis = {
+ revenueTotal: string;
+ revenue30d: string;
+ revenue365d: string;
+ bookingsPending: number;
+ bookingsConfirmedUpcoming: number;
+ bookingsTotal: number;
+ carbetsCount: number;
+ carbetsPublished: number;
+ occupancyRate30d: number; // 0..1
+ nextArrival: { id: string; carbetTitle: string; startDate: Date; tenantName: string } | null;
+};
+
+type Scope = { ownerId: string; isAdmin: boolean };
+
+function scopeWhere(scope: Scope) {
+ return scope.isAdmin ? {} : { carbet: { ownerId: scope.ownerId } };
+}
+
+function carbetWhere(scope: Scope) {
+ return scope.isAdmin ? {} : { ownerId: scope.ownerId };
+}
+
+export async function getHostKpis(scope: Scope): Promise {
+ const now = new Date();
+ const last30 = new Date(now.getTime() - 30 * 86_400_000);
+ const last365 = new Date(now.getTime() - 365 * 86_400_000);
+
+ const [revAll, rev30, rev365, pending, upcomingConfirmed, total, carbetsTotal, carbetsPub, nextArrival] =
+ await Promise.all([
+ prisma.booking.aggregate({
+ where: {
+ ...scopeWhere(scope),
+ status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] },
+ paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] },
+ },
+ _sum: { amount: true },
+ }),
+ prisma.booking.aggregate({
+ where: {
+ ...scopeWhere(scope),
+ status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] },
+ paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] },
+ createdAt: { gte: last30 },
+ },
+ _sum: { amount: true },
+ }),
+ prisma.booking.aggregate({
+ where: {
+ ...scopeWhere(scope),
+ status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] },
+ paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] },
+ createdAt: { gte: last365 },
+ },
+ _sum: { amount: true },
+ }),
+ prisma.booking.count({
+ where: { ...scopeWhere(scope), status: BookingStatus.PENDING },
+ }),
+ prisma.booking.count({
+ where: {
+ ...scopeWhere(scope),
+ status: BookingStatus.CONFIRMED,
+ startDate: { gte: now },
+ },
+ }),
+ prisma.booking.count({ where: scopeWhere(scope) }),
+ prisma.carbet.count({ where: carbetWhere(scope) }),
+ prisma.carbet.count({ where: { ...carbetWhere(scope), status: "PUBLISHED" } }),
+ prisma.booking.findFirst({
+ where: {
+ ...scopeWhere(scope),
+ status: BookingStatus.CONFIRMED,
+ startDate: { gte: now },
+ },
+ orderBy: { startDate: "asc" },
+ select: {
+ id: true,
+ startDate: true,
+ carbet: { select: { title: true } },
+ tenant: { select: { firstName: true, lastName: true } },
+ },
+ }),
+ ]);
+
+ // Taux d'occupation 30j : nuits réservées / (carbets publiés × 30)
+ const occupiedNights = await prisma.booking.findMany({
+ where: {
+ ...scopeWhere(scope),
+ status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] },
+ startDate: { lt: now },
+ endDate: { gte: last30 },
+ },
+ select: { startDate: true, endDate: true },
+ });
+ let totalNightsOccupied = 0;
+ for (const b of occupiedNights) {
+ const s = Math.max(b.startDate.getTime(), last30.getTime());
+ const e = Math.min(b.endDate.getTime(), now.getTime());
+ if (e > s) totalNightsOccupied += Math.floor((e - s) / 86_400_000);
+ }
+ const denom = Math.max(1, carbetsPub * 30);
+ const occupancyRate30d = Math.min(1, totalNightsOccupied / denom);
+
+ return {
+ revenueTotal: (revAll._sum.amount ?? 0).toString(),
+ revenue30d: (rev30._sum.amount ?? 0).toString(),
+ revenue365d: (rev365._sum.amount ?? 0).toString(),
+ bookingsPending: pending,
+ bookingsConfirmedUpcoming: upcomingConfirmed,
+ bookingsTotal: total,
+ carbetsCount: carbetsTotal,
+ carbetsPublished: carbetsPub,
+ occupancyRate30d,
+ nextArrival: nextArrival
+ ? {
+ id: nextArrival.id,
+ carbetTitle: nextArrival.carbet.title,
+ startDate: nextArrival.startDate,
+ tenantName: `${nextArrival.tenant.firstName} ${nextArrival.tenant.lastName}`.trim(),
+ }
+ : null,
+ };
+}
+
+export type HostRecentBooking = {
+ id: string;
+ carbetId: string;
+ carbetTitle: string;
+ carbetSlug: string;
+ tenantName: string;
+ startDate: Date;
+ endDate: Date;
+ guestCount: number;
+ status: BookingStatus;
+ paymentStatus: PaymentStatus;
+ amount: string;
+ currency: string;
+};
+
+export async function listHostRecentBookings(
+ scope: Scope,
+ limit = 10,
+): Promise {
+ const rows = await prisma.booking.findMany({
+ where: scopeWhere(scope),
+ orderBy: [{ status: "asc" }, { createdAt: "desc" }],
+ take: limit,
+ select: {
+ id: true,
+ startDate: true,
+ endDate: true,
+ guestCount: true,
+ status: true,
+ paymentStatus: true,
+ amount: true,
+ currency: true,
+ carbet: { select: { id: true, title: true, slug: true } },
+ tenant: { select: { firstName: true, lastName: true } },
+ },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ carbetId: r.carbet.id,
+ carbetTitle: r.carbet.title,
+ carbetSlug: r.carbet.slug,
+ tenantName: `${r.tenant.firstName} ${r.tenant.lastName}`.trim(),
+ startDate: r.startDate,
+ endDate: r.endDate,
+ guestCount: r.guestCount,
+ status: r.status,
+ paymentStatus: r.paymentStatus,
+ amount: r.amount.toString(),
+ currency: r.currency,
+ }));
+}
+
+export async function listHostCarbets(scope: Scope) {
+ const rows = await prisma.carbet.findMany({
+ where: carbetWhere(scope),
+ orderBy: [{ updatedAt: "desc" }],
+ select: {
+ id: true,
+ slug: true,
+ title: true,
+ status: true,
+ nightlyPrice: true,
+ capacity: true,
+ river: true,
+ _count: { select: { bookings: true, reviews: true, media: true } },
+ },
+ });
+ return rows.map((r) => ({ ...r, nightlyPrice: r.nightlyPrice.toString() }));
+}
+
+export function isScopeAdmin(role: UserRole | string | undefined): boolean {
+ return role === UserRole.ADMIN;
+}
diff --git a/src/lib/i18n/client.tsx b/src/lib/i18n/client.tsx
new file mode 100644
index 0000000..69a9be6
--- /dev/null
+++ b/src/lib/i18n/client.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
+import type { Locale } from "./types";
+
+type LocaleCtx = {
+ locale: Locale;
+ messages: Record;
+};
+
+const Ctx = createContext({ locale: "fr", messages: {} });
+
+export function LocaleProvider({
+ locale,
+ messages,
+ children,
+}: {
+ locale: Locale;
+ messages: Record;
+ children: ReactNode;
+}) {
+ const value = useMemo(() => ({ locale, messages }), [locale, messages]);
+ return {children} ;
+}
+
+export function useLocale(): Locale {
+ return useContext(Ctx).locale;
+}
+
+export function useT() {
+ const { messages } = useContext(Ctx);
+ return useCallback(
+ (key: string) => messages[key] ?? key,
+ [messages],
+ );
+}
diff --git a/src/lib/i18n/overrides.ts b/src/lib/i18n/overrides.ts
new file mode 100644
index 0000000..365b6d6
--- /dev/null
+++ b/src/lib/i18n/overrides.ts
@@ -0,0 +1,59 @@
+import "server-only";
+
+import { prisma } from "@/lib/prisma";
+import type { Locale } from "./types";
+
+type Cache = {
+ fr: Map;
+ en: Map;
+ loadedAt: number;
+};
+
+const TTL_MS = 10_000;
+let cache: Cache | null = null;
+let inflight: Promise | null = null;
+
+async function refresh(): Promise {
+ const rows = await prisma.translation.findMany({
+ select: { key: true, lang: true, value: true },
+ });
+ const fr = new Map();
+ const en = new Map();
+ for (const r of rows) {
+ if (r.lang === "fr") fr.set(r.key, r.value);
+ else if (r.lang === "en") en.set(r.key, r.value);
+ }
+ cache = { fr, en, loadedAt: Date.now() };
+ return cache;
+}
+
+async function loadCache(): Promise {
+ if (cache && Date.now() - cache.loadedAt < TTL_MS) return cache;
+ if (inflight) return inflight;
+ inflight = refresh().finally(() => {
+ inflight = null;
+ });
+ return inflight;
+}
+
+export async function getTranslationOverride(key: string, lang: Locale): Promise {
+ try {
+ const c = await loadCache();
+ return c[lang].get(key);
+ } catch {
+ return undefined;
+ }
+}
+
+export async function getTranslationOverridesMap(lang: Locale): Promise> {
+ try {
+ const c = await loadCache();
+ return c[lang];
+ } catch {
+ return new Map();
+ }
+}
+
+export function invalidateTranslationCache(): void {
+ cache = null;
+}
diff --git a/src/lib/i18n/server.ts b/src/lib/i18n/server.ts
new file mode 100644
index 0000000..347d6b2
--- /dev/null
+++ b/src/lib/i18n/server.ts
@@ -0,0 +1,79 @@
+/**
+ * Plugin i18n-fr-en — résolution serveur de la locale courante.
+ *
+ * Ordre de priorité :
+ * 1. cookie karbe-locale (posé par le LocaleSwitcher)
+ * 2. en-tête Accept-Language
+ * 3. DEFAULT_LOCALE (fr)
+ *
+ * Quand le plugin i18n est désactivé, le résolveur force FR.
+ */
+
+import "server-only";
+import { cookies, headers } from "next/headers";
+import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE, type Locale } from "./types";
+import { isPluginEnabled } from "@/lib/plugins/server";
+
+import frMessages from "@/messages/fr.json";
+import enMessages from "@/messages/en.json";
+import { getTranslationOverride, getTranslationOverridesMap } from "./overrides";
+
+const DICTS: Record> = {
+ fr: frMessages as Record,
+ en: enMessages as Record,
+};
+
+function parseAcceptLanguage(header: string | null): Locale | null {
+ if (!header) return null;
+ const items = header
+ .split(",")
+ .map((s) => s.trim().toLowerCase())
+ .filter(Boolean);
+ for (const item of items) {
+ const code = item.split(";")[0].slice(0, 2);
+ if (code === "fr") return "fr";
+ if (code === "en") return "en";
+ }
+ return null;
+}
+
+export async function getLocale(): Promise {
+ // Plugin éteint → toujours FR (comportement legacy).
+ if (!(await isPluginEnabled("i18n-fr-en"))) return "fr";
+
+ const cookieStore = await cookies();
+ const cookieValue = cookieStore.get(LOCALE_COOKIE)?.value;
+ if (isLocale(cookieValue)) return cookieValue;
+
+ const h = await headers();
+ const fromHeader = parseAcceptLanguage(h.get("accept-language"));
+ return fromHeader ?? DEFAULT_LOCALE;
+}
+
+/**
+ * t(key, locale?) : récupère un message du dico. Sans locale, on lit la locale
+ * courante. Fallback : retourne la valeur FR puis la clé brute si rien.
+ */
+export async function t(key: string, locale?: Locale): Promise {
+ const lang = locale ?? (await getLocale());
+ const override = await getTranslationOverride(key, lang);
+ if (override !== undefined) return override;
+ const dict = DICTS[lang];
+ const fallback = DICTS.fr;
+ return dict[key] ?? fallback[key] ?? key;
+}
+
+/**
+ * dict(locale) : retourne le dico complet pour passer en prop client-side.
+ * Garde le bundle léger (le client ne reçoit qu'une langue à la fois).
+ * Les overrides DB sont fusionnés par-dessus le fichier de base.
+ */
+export async function dict(locale?: Locale): Promise> {
+ const lang = locale ?? (await getLocale());
+ const base = DICTS[lang];
+ const overrides = await getTranslationOverridesMap(lang);
+ if (overrides.size === 0) return base;
+ const merged: Record = { ...base };
+ for (const [k, v] of overrides) merged[k] = v;
+ return merged;
+}
diff --git a/src/lib/i18n/types.ts b/src/lib/i18n/types.ts
new file mode 100644
index 0000000..3f537c4
--- /dev/null
+++ b/src/lib/i18n/types.ts
@@ -0,0 +1,13 @@
+/**
+ * Plugin i18n-fr-en — types et constantes partagés.
+ */
+
+export const LOCALES = ["fr", "en"] as const;
+export type Locale = (typeof LOCALES)[number];
+
+export const DEFAULT_LOCALE: Locale = "fr";
+export const LOCALE_COOKIE = "karbe-locale";
+
+export function isLocale(value: unknown): value is Locale {
+ return value === "fr" || value === "en";
+}
diff --git a/src/lib/image-variants.ts b/src/lib/image-variants.ts
new file mode 100644
index 0000000..5d0e22a
--- /dev/null
+++ b/src/lib/image-variants.ts
@@ -0,0 +1,41 @@
+/**
+ * Variantes responsive : génération + URL helpers.
+ *
+ * Convention de nommage : .jpg -> -320.jpg, -800.jpg, -1600.jpg.
+ * Le format est forcé à JPEG pour les variantes (compression efficace,
+ * supporté partout). L'original reste tel quel (PNG/WebP/AVIF préservés).
+ *
+ * Helper côté client : variantUrl(originalUrl, width) → URL de la variante.
+ * Le browser fait le fallback automatiquement via srcset si la variante 404.
+ */
+
+export const VARIANT_WIDTHS = [320, 800, 1600] as const;
+export type VariantWidth = (typeof VARIANT_WIDTHS)[number];
+
+/** Calcule l'URL d'une variante depuis l'URL originale. */
+export function variantUrl(originalUrl: string, width: VariantWidth): string {
+ const lastDot = originalUrl.lastIndexOf(".");
+ if (lastDot === -1) return originalUrl;
+ const base = originalUrl.slice(0, lastDot);
+ return `${base}-${width}.jpg`;
+}
+
+/** Calcule la s3Key d'une variante depuis la s3Key originale. */
+export function variantS3Key(originalKey: string, width: VariantWidth): string {
+ const lastDot = originalKey.lastIndexOf(".");
+ if (lastDot === -1) return originalKey;
+ const base = originalKey.slice(0, lastDot);
+ return `${base}-${width}.jpg`;
+}
+
+/**
+ * srcSet attribut pour un ` `. Le browser pick la meilleure variante
+ * selon viewport+DPR. Si une variante 404, srcset fallback en cascade ;
+ * on ajoute toujours l'original comme dernière entrée pour garantir
+ * qu'au moins UNE source fonctionne.
+ */
+export function buildSrcSet(originalUrl: string): string {
+ return VARIANT_WIDTHS.map((w) => `${variantUrl(originalUrl, w)} ${w}w`)
+ .concat([`${originalUrl} 2000w`])
+ .join(", ");
+}
diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts
new file mode 100644
index 0000000..2be5e6c
--- /dev/null
+++ b/src/lib/markdown.ts
@@ -0,0 +1,151 @@
+/**
+ * Mini-renderer markdown sans dépendance externe.
+ *
+ * Volontairement minimal et stable : pas de plugins, pas d'extension HTML
+ * arbitraire. Couvre les besoins des pages CMS Karbé (À propos, FAQ, CGV,
+ * etc.) sans introduire de surface d'attaque XSS.
+ *
+ * Supporte :
+ * # H1 / ## H2 / ### H3
+ * paragraphes (séparés par ligne vide)
+ * **gras** et *italique*
+ * [texte](https://lien)
+ * - liste à puces
+ * 1. liste numérotée
+ * --- (séparateur)
+ * > citation (blockquote)
+ *
+ * Toute autre balise HTML dans le markdown est échappée.
+ */
+
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function inline(text: string): string {
+ let out = escapeHtml(text);
+ // **bold**
+ out = out.replace(/\*\*([^*]+)\*\*/g, "$1 ");
+ // *italic*
+ out = out.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2 ");
+ // [text](url)
+ out = out.replace(
+ /\[([^\]]+)\]\(((?:https?:\/\/|\/|mailto:)[^)\s]+)\)/g,
+ (_m, label: string, href: string) => {
+ const safe = href.replace(/[&<>"']/g, (c) => `${c.charCodeAt(0)};`);
+ const isExternal = /^https?:/.test(href);
+ const extra = isExternal ? ' target="_blank" rel="noopener noreferrer"' : "";
+ return `${label} `;
+ },
+ );
+ return out;
+}
+
+export function renderMarkdown(md: string): string {
+ const lines = md.replace(/\r\n?/g, "\n").split("\n");
+ const out: string[] = [];
+ let paragraph: string[] = [];
+ let listType: "ul" | "ol" | null = null;
+ let inBlockquote = false;
+ const blockquote: string[] = [];
+
+ function flushParagraph() {
+ if (paragraph.length) {
+ out.push(`${inline(paragraph.join(" "))}
`);
+ paragraph = [];
+ }
+ }
+ function flushList() {
+ if (listType) {
+ out.push(`${listType}>`);
+ listType = null;
+ }
+ }
+ function flushBlockquote() {
+ if (inBlockquote) {
+ out.push(
+ `${blockquote
+ .map((l) => inline(l))
+ .join(" ")} `,
+ );
+ blockquote.length = 0;
+ inBlockquote = false;
+ }
+ }
+
+ for (const rawLine of lines) {
+ const line = rawLine.trimEnd();
+
+ if (!line.trim()) {
+ flushParagraph();
+ flushList();
+ flushBlockquote();
+ continue;
+ }
+
+ if (/^---+$/.test(line.trim())) {
+ flushParagraph();
+ flushList();
+ flushBlockquote();
+ out.push(` `);
+ continue;
+ }
+
+ let m: RegExpExecArray | null;
+ if ((m = /^(#{1,3})\s+(.+)$/.exec(line))) {
+ flushParagraph();
+ flushList();
+ flushBlockquote();
+ const level = m[1].length;
+ const sizes = ["", "text-3xl mt-8 mb-4 font-medium font-serif", "text-2xl mt-7 mb-3 font-medium font-serif", "text-xl mt-6 mb-2 font-semibold"];
+ out.push(`${inline(m[2])} `);
+ continue;
+ }
+
+ if ((m = /^[-*]\s+(.+)$/.exec(line))) {
+ flushParagraph();
+ flushBlockquote();
+ if (listType !== "ul") {
+ flushList();
+ out.push(``);
+ listType = "ul";
+ }
+ out.push(`${inline(m[1])} `);
+ continue;
+ }
+ if ((m = /^\d+\.\s+(.+)$/.exec(line))) {
+ flushParagraph();
+ flushBlockquote();
+ if (listType !== "ol") {
+ flushList();
+ out.push(``);
+ listType = "ol";
+ }
+ out.push(`${inline(m[1])} `);
+ continue;
+ }
+
+ if ((m = /^>\s?(.*)$/.exec(line))) {
+ flushParagraph();
+ flushList();
+ inBlockquote = true;
+ blockquote.push(m[1]);
+ continue;
+ }
+
+ flushList();
+ flushBlockquote();
+ paragraph.push(line);
+ }
+
+ flushParagraph();
+ flushList();
+ flushBlockquote();
+
+ return out.join("\n");
+}
diff --git a/src/lib/password-reset.ts b/src/lib/password-reset.ts
new file mode 100644
index 0000000..31959da
--- /dev/null
+++ b/src/lib/password-reset.ts
@@ -0,0 +1,51 @@
+import "server-only";
+
+import crypto from "node:crypto";
+
+import { prisma } from "@/lib/prisma";
+import { hashPassword } from "@/lib/password";
+
+const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
+
+function hashToken(token: string): string {
+ return crypto.createHash("sha256").update(token).digest("hex");
+}
+
+/** Crée un token (renvoie la version *plain* à mettre dans l'URL email). */
+export async function createPasswordResetToken(userId: string): Promise {
+ const token = crypto.randomBytes(32).toString("base64url");
+ const tokenHash = hashToken(token);
+ const expiresAt = new Date(Date.now() + TOKEN_TTL_MS);
+ await prisma.passwordResetToken.create({
+ data: { tokenHash, userId, expiresAt },
+ });
+ return token;
+}
+
+/** Vérifie un token plain → renvoie userId si valide & non expiré. */
+export async function consumePasswordResetToken(
+ plainToken: string,
+ newPassword: string,
+): Promise<{ ok: true; userId: string } | { ok: false; reason: string }> {
+ const tokenHash = hashToken(plainToken);
+ const row = await prisma.passwordResetToken.findUnique({ where: { tokenHash } });
+ if (!row) return { ok: false, reason: "Lien invalide." };
+ if (row.expiresAt < new Date()) {
+ await prisma.passwordResetToken.delete({ where: { tokenHash } }).catch(() => {});
+ return { ok: false, reason: "Lien expiré." };
+ }
+ const passwordHash = await hashPassword(newPassword);
+ await prisma.$transaction([
+ prisma.user.update({ where: { id: row.userId }, data: { passwordHash } }),
+ prisma.passwordResetToken.deleteMany({ where: { userId: row.userId } }),
+ ]);
+ return { ok: true, userId: row.userId };
+}
+
+/** Cleanup utility — peut être lancé par un cron. */
+export async function purgeExpiredResetTokens(): Promise {
+ const result = await prisma.passwordResetToken.deleteMany({
+ where: { expiresAt: { lt: new Date() } },
+ });
+ return result.count;
+}
diff --git a/src/lib/payouts.ts b/src/lib/payouts.ts
new file mode 100644
index 0000000..7e63e0a
--- /dev/null
+++ b/src/lib/payouts.ts
@@ -0,0 +1,218 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { RentalBookingStatus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+/**
+ * Politique de reversement v1 :
+ * Pour chaque RentalBooking CONFIRMED/HANDED_OVER/RETURNED créée pendant le mois M,
+ * le provider reçoit (itemsTotal + depositTotal - commissionAmount).
+ * Le marketplace garde la commission (commissionAmount) et la caution est restituée
+ * via le provider qui la collecte au remise (hors flux Stripe).
+ *
+ * En pratique : net du au provider = itemsTotal - commissionAmount.
+ * La caution n'est PAS comptée dans le reversement (le provider la collecte
+ * directement auprès du client).
+ */
+
+const COUNTED_STATUSES: RentalBookingStatus[] = [
+ RentalBookingStatus.CONFIRMED,
+ RentalBookingStatus.HANDED_OVER,
+ RentalBookingStatus.RETURNED,
+];
+
+export type ProviderPayout = {
+ providerId: string;
+ providerName: string;
+ isSystemD: boolean;
+ /** 1er du mois minuit UTC. */
+ periodMonth: Date;
+ bookingsCount: number;
+ /** Sum itemsTotal pour le provider × mois. */
+ grossAmount: number;
+ /** Sum commissionAmount. */
+ commission: number;
+ /** Net dû au provider = gross - commission. */
+ netAmount: number;
+ /** Mark déjà enregistrée si payé. */
+ paid: {
+ paidAt: Date;
+ amount: number;
+ reference: string | null;
+ paidByEmail: string | null;
+ } | null;
+};
+
+/**
+ * 1er jour du mois en UTC pour normalisation.
+ */
+export function monthKey(d: Date): Date {
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
+}
+
+export function formatMonth(d: Date): string {
+ return d.toLocaleDateString("fr-FR", {
+ timeZone: "UTC",
+ year: "numeric",
+ month: "long",
+ });
+}
+
+/**
+ * Calcule les reversements à effectuer sur les `monthsBack` derniers mois.
+ * - Exclut System D (commission 0 % et c'est l'asso qui gère).
+ * - Renvoie tous les providers actifs, même ceux à 0 € (pour visibilité).
+ * - Inclut le statut payé/non payé depuis RentalPayoutMark.
+ */
+export async function listProviderPayouts(opts: {
+ monthsBack?: number;
+} = {}): Promise {
+ const monthsBack = opts.monthsBack ?? 6;
+ const now = new Date();
+ const earliest = monthKey(
+ new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - (monthsBack - 1), 1)),
+ );
+
+ const [providers, bookings, marks] = await Promise.all([
+ prisma.rentalProvider.findMany({
+ where: { isSystemD: false },
+ select: { id: true, name: true, isSystemD: true },
+ }),
+ prisma.rentalBooking.findMany({
+ where: {
+ status: { in: COUNTED_STATUSES },
+ createdAt: { gte: earliest },
+ provider: { isSystemD: false },
+ },
+ select: {
+ providerId: true,
+ createdAt: true,
+ itemsTotal: true,
+ commissionAmount: true,
+ },
+ }),
+ prisma.rentalPayoutMark.findMany({
+ where: { periodMonth: { gte: earliest } },
+ }),
+ ]);
+
+ const result: ProviderPayout[] = [];
+ const monthsList: Date[] = [];
+ for (let i = 0; i < monthsBack; i++) {
+ monthsList.push(
+ monthKey(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1))),
+ );
+ }
+
+ // Init grid provider × month avec zéros
+ type Cell = {
+ bookingsCount: number;
+ grossAmount: Prisma.Decimal;
+ commission: Prisma.Decimal;
+ };
+ const grid = new Map();
+ const cellKey = (providerId: string, periodTs: number) => `${providerId}:${periodTs}`;
+
+ for (const p of providers) {
+ for (const m of monthsList) {
+ grid.set(cellKey(p.id, m.getTime()), {
+ bookingsCount: 0,
+ grossAmount: new Prisma.Decimal(0),
+ commission: new Prisma.Decimal(0),
+ });
+ }
+ }
+
+ // Aggrège les bookings
+ for (const b of bookings) {
+ const period = monthKey(b.createdAt);
+ const k = cellKey(b.providerId, period.getTime());
+ const cell = grid.get(k);
+ if (!cell) continue;
+ cell.bookingsCount++;
+ cell.grossAmount = cell.grossAmount.add(b.itemsTotal);
+ cell.commission = cell.commission.add(b.commissionAmount);
+ }
+
+ // Index des marks
+ const markIndex = new Map();
+ for (const m of marks) {
+ markIndex.set(cellKey(m.providerId, m.periodMonth.getTime()), m);
+ }
+
+ // Produit le résultat
+ for (const p of providers) {
+ for (const m of monthsList) {
+ const k = cellKey(p.id, m.getTime());
+ const cell = grid.get(k)!;
+ const net = cell.grossAmount.sub(cell.commission);
+ const mark = markIndex.get(k);
+ result.push({
+ providerId: p.id,
+ providerName: p.name,
+ isSystemD: p.isSystemD,
+ periodMonth: m,
+ bookingsCount: cell.bookingsCount,
+ grossAmount: cell.grossAmount.toDecimalPlaces(2).toNumber(),
+ commission: cell.commission.toDecimalPlaces(2).toNumber(),
+ netAmount: net.toDecimalPlaces(2).toNumber(),
+ paid: mark
+ ? {
+ paidAt: mark.paidAt,
+ amount: Number(mark.amount),
+ reference: mark.reference,
+ paidByEmail: mark.paidByEmail,
+ }
+ : null,
+ });
+ }
+ }
+
+ // Tri : mois décroissant puis provider
+ return result.sort(
+ (a, b) =>
+ b.periodMonth.getTime() - a.periodMonth.getTime() ||
+ a.providerName.localeCompare(b.providerName, "fr"),
+ );
+}
+
+/**
+ * Crée un RentalPayoutMark (idempotent via unique constraint provider+period).
+ */
+export async function createPayoutMark(opts: {
+ providerId: string;
+ periodMonth: Date;
+ amount: number;
+ reference?: string | null;
+ paidByEmail: string | null;
+}): Promise<{ ok: true; alreadyExists: boolean } | { ok: false; error: string }> {
+ const period = monthKey(opts.periodMonth);
+ const existing = await prisma.rentalPayoutMark.findUnique({
+ where: { providerId_periodMonth: { providerId: opts.providerId, periodMonth: period } },
+ select: { id: true },
+ });
+ if (existing) return { ok: true, alreadyExists: true };
+ await prisma.rentalPayoutMark.create({
+ data: {
+ providerId: opts.providerId,
+ periodMonth: period,
+ amount: new Prisma.Decimal(opts.amount),
+ reference: opts.reference ?? null,
+ paidByEmail: opts.paidByEmail,
+ },
+ });
+ return { ok: true, alreadyExists: false };
+}
+
+export async function deletePayoutMark(
+ providerId: string,
+ periodMonth: Date,
+): Promise {
+ const period = monthKey(periodMonth);
+ await prisma.rentalPayoutMark
+ .delete({
+ where: { providerId_periodMonth: { providerId, periodMonth: period } },
+ })
+ .catch(() => {});
+}
diff --git a/src/lib/pirogue-providers.ts b/src/lib/pirogue-providers.ts
new file mode 100644
index 0000000..923cdd0
--- /dev/null
+++ b/src/lib/pirogue-providers.ts
@@ -0,0 +1,50 @@
+/**
+ * Helpers Plugin pirogue-providers.
+ */
+
+import { prisma } from "@/lib/prisma";
+
+export type PirogueProvider = {
+ id: string;
+ name: string;
+ contactEmail: string | null;
+ contactPhone: string | null;
+ rivers: string[];
+ pricingNote: string | null;
+ description: string | null;
+ active: boolean;
+};
+
+export async function listActiveProviders(): Promise {
+ const rows = await prisma.pirogueProvider.findMany({
+ where: { active: true },
+ orderBy: { name: "asc" },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ contactEmail: r.contactEmail,
+ contactPhone: r.contactPhone,
+ rivers: r.rivers,
+ pricingNote: r.pricingNote,
+ description: r.description,
+ active: r.active,
+ }));
+}
+
+export async function listProvidersForRiver(river: string): Promise {
+ const all = await listActiveProviders();
+ return all.filter((p) => p.rivers.some((r) => r.toLowerCase() === river.toLowerCase()));
+}
+
+export const TRANSPORT_MODE_LABEL: Record = {
+ OWNER_PROVIDES: "Le loueur fournit le passeur",
+ SELF_ARRANGE: "À organiser par le voyageur",
+ PARTNER_PROVIDER: "Partenaire référencé",
+};
+
+export const TRANSPORT_MODE_EMOJI: Record = {
+ OWNER_PROVIDES: "👤",
+ SELF_ARRANGE: "🗺️",
+ PARTNER_PROVIDER: "🤝",
+};
diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts
index 452c5c8..f984522 100644
--- a/src/lib/plugins/hooks.ts
+++ b/src/lib/plugins/hooks.ts
@@ -15,6 +15,38 @@ export interface PluginHookSet {
}
import { archiveDemoCarbets, seedDemoCarbets } from "./seeds/demo-carbets";
+import { archiveDemoCe, seedDemoCe } from "./seeds/demo-ce";
+import {
+ republishContentPages,
+ seedContentPages,
+ unpublishContentPages,
+} from "./seeds/content-pages-default";
+import {
+ republishLegalPages,
+ seedLegalPages,
+ unpublishLegalPages,
+} from "./seeds/legal-pages-default";
+import {
+ deactivatePirogueProviders,
+ seedPirogueProviders,
+} from "./seeds/pirogue-providers-default";
+import { seedEnglishContentPages } from "./seeds/content-pages-en";
+import { detachAquarelleMedia, seedAquarelleMedia } from "./seeds/aquarelle-media";
+import { prisma } from "@/lib/prisma";
+
+// Mutuelle exclusion theme-guyane / theme-aquarelle : activer l'un
+// désactive automatiquement l'autre.
+async function disableOtherTheme(currentKey: string): Promise {
+ const other = currentKey === "theme-guyane" ? "theme-aquarelle" : "theme-guyane";
+ const row = await prisma.plugin.findUnique({ where: { key: other } });
+ if (row?.enabled) {
+ await prisma.plugin.update({
+ where: { key: other },
+ data: { enabled: false, lastDisabledAt: new Date() },
+ });
+ console.log(`[plugin ${currentKey}] désactive ${other} (mutual exclusion)`);
+ }
+}
export const pluginHooks: Record = {
"demo-carbets-seed": {
@@ -31,4 +63,90 @@ export const pluginHooks: Record = {
);
},
},
+ "content-pages": {
+ onEnable: async () => {
+ const seeded = await seedContentPages();
+ const republished = await republishContentPages();
+ console.log(
+ `[plugin content-pages] seed: ${seeded.created} pages, republished: ${republished}`,
+ );
+ },
+ onDisable: async () => {
+ const unpub = await unpublishContentPages();
+ console.log(`[plugin content-pages] disable: ${unpub} pages dépubliées`);
+ },
+ },
+ "legal-pages": {
+ onEnable: async () => {
+ const seeded = await seedLegalPages();
+ const republished = await republishLegalPages();
+ console.log(
+ `[plugin legal-pages] seed: ${seeded.created} pages, republished: ${republished}`,
+ );
+ },
+ onDisable: async () => {
+ const unpub = await unpublishLegalPages();
+ console.log(`[plugin legal-pages] disable: ${unpub} pages dépubliées`);
+ },
+ },
+ "pirogue-providers": {
+ onEnable: async () => {
+ const { providers, carbets } = await seedPirogueProviders();
+ console.log(
+ `[plugin pirogue-providers] seed: ${providers} partenaires, ${carbets} carbets attachés`,
+ );
+ },
+ onDisable: async () => {
+ const count = await deactivatePirogueProviders();
+ console.log(`[plugin pirogue-providers] disable: ${count} partenaires désactivés`);
+ },
+ },
+ // Quand i18n est activé, on seed les pages content + legal en EN.
+ // Désactiver n'efface pas les EN pages (elles dorment juste, fallback FR
+ // reprend la main au prochain getContentPage).
+ "i18n-fr-en": {
+ onEnable: async () => {
+ const count = await seedEnglishContentPages();
+ console.log(`[plugin i18n-fr-en] seed: ${count} pages EN`);
+ },
+ },
+ // Themes : mutuellement exclusifs (un seul actif à la fois).
+ "theme-guyane": {
+ onEnable: async () => {
+ await disableOtherTheme("theme-guyane");
+ },
+ },
+ "theme-aquarelle": {
+ onEnable: async () => {
+ await disableOtherTheme("theme-aquarelle");
+ },
+ },
+ "image-gallery-aquarelle-seed": {
+ onEnable: async () => {
+ const { attached } = await seedAquarelleMedia();
+ console.log(
+ `[plugin image-gallery-aquarelle-seed] ${attached} Media attachés aux carbets démo`,
+ );
+ },
+ onDisable: async () => {
+ const count = await detachAquarelleMedia();
+ console.log(
+ `[plugin image-gallery-aquarelle-seed] ${count} Media seedés détachés`,
+ );
+ },
+ },
+ "demo-ce-seed": {
+ onEnable: async () => {
+ const { created, orgId } = await seedDemoCe();
+ console.log(
+ `[plugin demo-ce-seed] ${created ? "créé" : "déjà présent"}: orgId=${orgId}`,
+ );
+ },
+ onDisable: async () => {
+ const { deletedUsers, deletedOrg } = await archiveDemoCe();
+ console.log(
+ `[plugin demo-ce-seed] disable: ${deletedUsers} users supprimés, ${deletedOrg} org supprimée(s)`,
+ );
+ },
+ },
};
diff --git a/src/lib/plugins/registry.ts b/src/lib/plugins/registry.ts
index 402ac92..ee5fe3b 100644
--- a/src/lib/plugins/registry.ts
+++ b/src/lib/plugins/registry.ts
@@ -27,6 +27,14 @@ export const PLUGINS: PluginDescriptor[] = [
category: "visual",
version: "0.1.0",
},
+ {
+ key: "theme-aquarelle",
+ name: "Thème Aquarelle (carnet naturaliste)",
+ description:
+ "Direction artistique « carnet de voyage XIXᵉ » : papier teinté crème, traits sépia fins, aquarelles ocres+verts délavés, typographie display PT Serif. Active automatiquement les illustrations aquarelle si présentes. Mutuellement exclusif avec theme-guyane.",
+ category: "visual",
+ version: "0.1.0",
+ },
{
key: "landing-hero",
name: "Hero d'accueil",
@@ -51,6 +59,14 @@ export const PLUGINS: PluginDescriptor[] = [
category: "visual",
version: "0.1.0",
},
+ {
+ key: "image-gallery-aquarelle-seed",
+ name: "Galerie aquarelles seed",
+ description:
+ "14 illustrations aquarelle (6 planches naturalistes carbets, 7 scènes carnet de voyage, 1 ornement palmier) stockées dans MinIO/karbe-medias/seed/aquarelle/. Création des Media liés aux 6 carbets démo. Désactivation : suppression des Media seedés (les fichiers MinIO restent).",
+ category: "visual",
+ version: "0.1.0",
+ },
{
key: "demo-carbets-seed",
name: "Carbets de démo",
@@ -93,6 +109,22 @@ export const PLUGINS: PluginDescriptor[] = [
category: "business",
version: "0.1.0",
},
+ {
+ key: "gear-rental",
+ name: "Location matériel (sous-marketplace)",
+ description:
+ "Catalogue matériel (hamac, moustiquaire, pirogue, kayak…) loué par System D et prestataires tiers. Inclut panier, checkout Stripe, espace prestataire, recommandations carbet. Si désactivé : /materiel, /espace-prestataire et /mes-locations renvoient 404; liens header masqués.",
+ category: "business",
+ version: "0.1.0",
+ },
+ {
+ key: "ce-management",
+ name: "Gestion des Comités d'Entreprise",
+ description:
+ "Permet à un CE de s'inscrire (validation admin), publier ses carbets en co-gestion (OrganizationCarbetMembership), et activer un RentalProvider org-scoped pour louer son matériel. Dashboard /espace-ce avec KPIs agrégés par organisation. Si désactivé : /espace-ce et le choix « Comité d'Entreprise » sur /inscription disparaissent.",
+ category: "business",
+ version: "0.1.0",
+ },
// Contenus / i18n
{
@@ -117,6 +149,14 @@ export const PLUGINS: PluginDescriptor[] = [
category: "content",
version: "0.1.0",
},
+ {
+ key: "demo-ce-seed",
+ name: "Démo Comité d'Entreprise",
+ description:
+ "Seed une organisation CE démo (Comité ESA Kourou) avec 2 managers, 3 membres, 2 carbets co-gérés et 1 provider rental org-scoped + 4 items. Utile pour visualiser le module CE sans signup manuel. Disable : carbets archivés, users + org supprimés. Dépend de `ce-management`.",
+ category: "visual",
+ version: "0.1.0",
+ },
];
export const PLUGIN_KEYS = PLUGINS.map((p) => p.key);
diff --git a/src/lib/plugins/seeds/aquarelle-media.ts b/src/lib/plugins/seeds/aquarelle-media.ts
new file mode 100644
index 0000000..75162b9
--- /dev/null
+++ b/src/lib/plugins/seeds/aquarelle-media.ts
@@ -0,0 +1,64 @@
+/**
+ * Seed du plugin `image-gallery-aquarelle-seed`.
+ *
+ * Crée des entrées Media qui pointent vers les illustrations aquarelle uploadées
+ * dans MinIO (bucket karbe-medias/seed/aquarelle/...). Une par carbet démo,
+ * + une « hero » et 4 « scènes » accessibles séparément via l'URL theme.
+ *
+ * Les fichiers MinIO doivent être uploadés AVANT activation du plugin
+ * (cf. scripts/upload-aquarelles.sh). Si les fichiers ne sont pas là, le seed
+ * crée quand même les Media (URLs publiques 404, mais le toggle reste réversible).
+ */
+
+import { prisma } from "@/lib/prisma";
+import { MediaType } from "@/generated/prisma/enums";
+import { aquarelleUrl } from "@/lib/theme";
+
+const CARBET_AQUARELLES: { slug: string; file: string }[] = [
+ { slug: "demo-karbe-awara-maroni", file: "02-planche-carbet-awara.png" },
+ { slug: "demo-karbe-wapa-comte", file: "03-planche-carbet-wapa.png" },
+ { slug: "demo-karbe-maripa-approuague", file: "04-planche-carbet-maripa.png" },
+ { slug: "demo-karbe-paripou-oyapock", file: "05-planche-carbet-paripou.png" },
+ { slug: "demo-karbe-mahury-ce-hopital", file: "06-planche-carbet-mahury.png" },
+ { slug: "demo-karbe-kourou-couleuvre", file: "07-planche-carbet-kourou.png" },
+];
+
+const SEED_PREFIX = "seed/aquarelle/";
+
+export async function seedAquarelleMedia(): Promise<{ attached: number }> {
+ let attached = 0;
+ for (const { slug, file } of CARBET_AQUARELLES) {
+ const carbet = await prisma.carbet.findUnique({ where: { slug } });
+ if (!carbet) continue;
+
+ const s3Key = `${SEED_PREFIX}${file}`;
+ const url = aquarelleUrl(file);
+
+ // Existe déjà ? upsert manuel via s3Key (pas d'unique sur s3Key, on filtre).
+ const existing = await prisma.media.findFirst({
+ where: { carbetId: carbet.id, s3Key },
+ });
+ if (existing) {
+ attached += 1;
+ continue;
+ }
+ await prisma.media.create({
+ data: {
+ carbetId: carbet.id,
+ type: MediaType.PHOTO,
+ s3Key,
+ s3Url: url,
+ sortOrder: 0,
+ },
+ });
+ attached += 1;
+ }
+ return { attached };
+}
+
+export async function detachAquarelleMedia(): Promise {
+ const result = await prisma.media.deleteMany({
+ where: { s3Key: { startsWith: SEED_PREFIX } },
+ });
+ return result.count;
+}
diff --git a/src/lib/plugins/seeds/content-pages-default.ts b/src/lib/plugins/seeds/content-pages-default.ts
new file mode 100644
index 0000000..5ab3cfb
--- /dev/null
+++ b/src/lib/plugins/seeds/content-pages-default.ts
@@ -0,0 +1,185 @@
+/**
+ * Seeds des pages de contenu (plugin content-pages, catégorie "general").
+ */
+
+import { setContentPagePublished, unpublishCategory, upsertContentPage } from "@/lib/content-pages";
+
+type SeedPage = { slug: string; title: string; body: string };
+
+const PAGES: SeedPage[] = [
+ {
+ slug: "a-propos",
+ title: "À propos de Karbé",
+ body: `## Une marketplace solidaire des fleuves de Guyane
+
+Karbé met en relation des propriétaires de carbets fluviaux — particuliers, comités d'entreprise, associations — avec des voyageurs qui cherchent autre chose qu'une chambre d'hôtel : un hamac, un fleuve, du silence.
+
+## Pourquoi Karbé existe
+
+En Guyane, des centaines de carbets dorment six mois par an. Pendant ce temps, des touristes cherchent l'aventure et des CE locaux peinent à proposer des séjours abordables à leurs membres. Karbé fait le pont entre les deux mondes — sans s'enrichir au passage.
+
+## Notre modèle
+
+Le paiement de la réservation transite par Stripe et **est reversé intégralement au propriétaire** : 0 % de commission. Karbé se finance par un abonnement annuel payé par les loueurs qui veulent référencer leur carbet — pas par une prise sur ce que vous payez.
+
+## L'équipe
+
+Karbé est porté par une association numérique guyanaise. Nous sommes basés à Cayenne et nous parlons français, créole, anglais, portugais.
+
+[Devenir loueur](/espace-hote) · [Pour comités d'entreprise](/pour-comites-entreprise) · [Contact](mailto:bonjour@karbe.cosmolan.fr)
+`,
+ },
+ {
+ slug: "comment-ca-marche",
+ title: "Comment ça marche",
+ body: `Karbé permet de réserver un carbet fluvial en trois étapes.
+
+## 1. Trouver le carbet qui vous ressemble
+
+Sur [la page de recherche](/carbets), filtrez par fleuve, dates et nombre de voyageurs. Chaque carbet est classé selon son type d'accès :
+
+- 🛣️ **Route + fleuve** — accessible en voiture, idéal pour un week-end facile
+- 🛶 **Expédition fleuve** — uniquement en pirogue, pour ceux qui veulent vraiment dormir loin
+
+## 2. Réserver et payer
+
+La réservation se fait en ligne, paiement sécurisé via Stripe. Vous recevez une confirmation par e-mail avec les détails d'accès, le point d'embarquement et, si applicable, le contact du passeur.
+
+## 3. Profiter
+
+Le loueur vous remet les clés du karbé (en personne ou via une boîte sécurisée selon le carbet). À votre arrivée, le hamac est là. Le fleuve aussi.
+
+## Et après ?
+
+Une fois rentré, vous pouvez laisser un avis sur votre séjour : il aidera les futurs voyageurs et donnera de la visibilité aux loueurs sérieux.
+
+[Voir tous les carbets disponibles](/carbets)
+`,
+ },
+ {
+ slug: "faq",
+ title: "Questions fréquentes",
+ body: `## Réservation
+
+### Quand mon paiement est-il prélevé ?
+
+Le paiement est autorisé sur votre carte au moment de la réservation et capturé lorsque le loueur confirme la dispo (ou automatiquement dans les 24 h). Si la réservation n'aboutit pas, l'autorisation est libérée sans frais.
+
+### Karbé prend-il une commission ?
+
+**Non.** Le séjour vous est facturé au prix exact fixé par le loueur, qui le reçoit intégralement (moins les frais Stripe). Karbé se finance par un abonnement annuel payé par les loueurs.
+
+### Et si je dois annuler ?
+
+Chaque loueur fixe sa politique d'annulation. Elle est affichée sur la fiche du carbet avant le paiement.
+
+## Sur place
+
+### Comment se passe le transport en pirogue ?
+
+Selon le carbet, le loueur fournit lui-même le passeur, vous oriente vers un prestataire partenaire, ou vous laisse organiser. L'info est précisée sur chaque fiche.
+
+### Les carbets ont-ils l'électricité ? L'eau courante ?
+
+Variable. Beaucoup fonctionnent au solaire et à l'eau de pluie. L'équipement précis figure sur chaque fiche.
+
+### Y a-t-il du réseau ?
+
+Sur les fleuves Maroni, Oyapock et Approuague en amont des bourgs, **non**. Téléchargez vos cartes hors-ligne avant de partir.
+
+## Pour les loueurs
+
+### Combien ça coûte de référencer mon carbet ?
+
+L'abonnement annuel loueur est facturé via Stripe. Le montant exact dépend du nombre de carbets référencés (un seul carbet : tarif standard). Voir [Devenir loueur](/espace-hote) pour les détails.
+
+### Mon CE peut-il référencer son carbet ?
+
+Oui — et c'est encouragé. Les CE peuvent ouvrir leur carbet au public les semaines où aucun membre n'a réservé, tout en gardant la priorité pour leurs adhérents. Voir [Pour comités d'entreprise](/pour-comites-entreprise).
+`,
+ },
+ {
+ slug: "pour-comites-entreprise",
+ title: "Pour comités d'entreprise",
+ body: `## Vos carbets dorment six mois par an. Partageons-les.
+
+De nombreux comités sociaux d'entreprise possèdent déjà un carbet — financé par les cotisations, réservé en priorité aux salariés, mais souvent vide entre deux week-ends. Karbé permet d'ouvrir ces périodes creuses au public, sans commission, sans gestion supplémentaire.
+
+## Comment ça fonctionne
+
+1. Vous référencez votre carbet sur Karbé. C'est gratuit pour les associations et CE conventionnés.
+2. Vous bloquez en priorité les créneaux réservés à vos membres (week-ends, congés scolaires…).
+3. Les créneaux restants sont proposés aux voyageurs publics sur la plateforme.
+4. Le paiement leur est facturé via Stripe et reversé directement au compte du CE.
+
+## Bénéfices
+
+- **Revenus complémentaires** : les locations publiques financent l'entretien.
+- **0 % de commission** : Karbé ne prend rien sur les séjours.
+- **Pas de paperasse** : Stripe encaisse et reverse, sans intermédiaire.
+- **Vos membres gardent la priorité** : un système de double calendrier sépare les créneaux CE des créneaux publics.
+
+## Prochaine étape
+
+Contactez-nous pour discuter de votre cas spécifique (statut du CE, modalités de paiement vers une association, gestion mutualisée de plusieurs carbets) : [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr).
+`,
+ },
+ {
+ slug: "devenir-loueur",
+ title: "Devenir loueur",
+ body: `Vous avez un carbet ? Vous pouvez le proposer sur Karbé en quelques minutes.
+
+## Ce qu'il faut
+
+- Un titre de propriété ou une autorisation explicite du propriétaire
+- Photos du carbet (intérieur, extérieur, point d'embarquement)
+- Description honnête : commodités, accès, contraintes saisonnières
+- Une **politique d'annulation** claire que les voyageurs verront avant de payer
+
+## Étapes
+
+1. [Créez un compte loueur](/connexion) avec votre email et votre numéro de téléphone.
+2. Ajoutez votre carbet : photos, description, coordonnées GPS, point d'embarquement, durée pirogue (si fleuve), équipements.
+3. Ouvrez votre calendrier : indiquez les semaines disponibles, vos contraintes saisonnières (étiage Oyapock ? saison sèche uniquement ?).
+4. Activez l'abonnement annuel via Stripe (paiement sécurisé, prélèvement automatique).
+5. Vous êtes en ligne. Les réservations arrivent par e-mail.
+
+## Le paiement
+
+Quand un voyageur réserve, Stripe encaisse le séjour et **vous le reverse intégralement** sur votre compte bancaire. Karbé ne prend rien sur les séjours.
+
+## Questions
+
+Pour les cas spécifiques (carbet en indivision, parcelle ZAD, accord avec une commune…), écrivez-nous : [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr).
+
+[Créer mon compte loueur](/connexion)
+`,
+ },
+];
+
+export async function seedContentPages(): Promise<{ created: number }> {
+ let created = 0;
+ for (const page of PAGES) {
+ await upsertContentPage({
+ slug: page.slug,
+ title: page.title,
+ body: page.body,
+ category: "general",
+ published: true,
+ });
+ created += 1;
+ }
+ return { created };
+}
+
+export async function unpublishContentPages(): Promise {
+ return await unpublishCategory("general");
+}
+
+export async function republishContentPages(): Promise {
+ let count = 0;
+ for (const page of PAGES) {
+ count += await setContentPagePublished(page.slug, "general", true);
+ }
+ return count;
+}
diff --git a/src/lib/plugins/seeds/content-pages-en.ts b/src/lib/plugins/seeds/content-pages-en.ts
new file mode 100644
index 0000000..fbe190c
--- /dev/null
+++ b/src/lib/plugins/seeds/content-pages-en.ts
@@ -0,0 +1,282 @@
+/**
+ * Seeds des pages content + legal en anglais.
+ * Activé conjointement avec le plugin `content-pages` / `legal-pages` quand
+ * `i18n-fr-en` est aussi enabled — sinon dort en DB sans être servi.
+ */
+
+import { upsertContentPage } from "@/lib/content-pages";
+
+const PAGES_EN = [
+ {
+ slug: "a-propos",
+ category: "general",
+ title: "About Karbé",
+ body: `## A solidarity marketplace for French Guiana's rivers
+
+Karbé connects owners of riverside carbets — individuals, social committees, associations — with travellers who are looking for something other than a hotel room: a hammock, a river, silence.
+
+## Why Karbé exists
+
+In French Guiana, hundreds of carbets sleep six months a year. Meanwhile, travellers look for adventure and local social committees struggle to offer affordable stays to their members. Karbé bridges those two worlds — without taking a cut along the way.
+
+## Our model
+
+Stay payments transit Stripe and **are paid in full to the owner**: 0 % commission. Karbé is funded by an annual subscription paid by hosts who want to list their carbet — not by taking a share of what you pay.
+
+## The team
+
+Karbé is run by a French Guianese digital association. We are based in Cayenne and speak French, Creole, English and Portuguese.
+
+[Become a host](/devenir-loueur) · [For social committees](/pour-comites-entreprise) · [Contact](mailto:bonjour@karbe.cosmolan.fr)
+`,
+ },
+ {
+ slug: "comment-ca-marche",
+ category: "general",
+ title: "How it works",
+ body: `Karbé lets you book a riverside carbet in three steps.
+
+## 1. Find the carbet that fits you
+
+On [the search page](/carbets), filter by river, dates and travellers. Each carbet is tagged by access type:
+
+- 🛣️ **Road + river** — reachable by car, ideal for an easy weekend
+- 🛶 **River expedition** — pirogue only, for those who really want to sleep far away
+
+## 2. Book and pay
+
+Booking is online, secure payment via Stripe. You receive an email confirmation with access details, embarkation point, and (if relevant) the skipper's contact.
+
+## 3. Enjoy
+
+The host hands you the karbé keys (in person or via a secure box, depending on the carbet). On arrival, the hammock is up. So is the river.
+
+## And then?
+
+Once you're back, you can leave a review — it helps future travellers and gives visibility to serious hosts.
+
+[See all available carbets](/carbets)
+`,
+ },
+ {
+ slug: "faq",
+ category: "general",
+ title: "Frequently asked questions",
+ body: `## Booking
+
+### When is my payment charged?
+
+The payment is authorised on your card at booking time and captured when the host confirms availability (or automatically within 24 h).
+
+### Does Karbé take a commission?
+
+**No.** The stay is billed at the exact price set by the host, who receives it in full (minus Stripe fees).
+
+### What if I have to cancel?
+
+Each host sets their own cancellation policy. It's shown on the carbet page before payment.
+
+## On site
+
+### How does pirogue transport work?
+
+Depending on the carbet, the host provides the skipper, refers you to a partner, or lets you arrange it yourself. The details are on each page.
+
+### Is there mobile coverage?
+
+On the upper Maroni, Oyapock and Approuague rivers: **no**. Download your offline maps before leaving.`,
+ },
+ {
+ slug: "pour-comites-entreprise",
+ category: "general",
+ title: "For social committees",
+ body: `## Your carbets sleep six months a year. Let's share them.
+
+Many social committees already own a carbet — funded by member dues, reserved as a priority for staff, but often empty between weekends. Karbé lets you open those quiet periods to public travellers, with zero commission and no extra admin.
+
+## How it works
+
+1. You list your carbet on Karbé. Free for associations and committees.
+2. You block, as a priority, the dates reserved for your members.
+3. The remaining dates are offered to public travellers.
+4. Payment is collected by Stripe and remitted directly to your account.
+
+## Benefits
+
+- **Extra revenue**: public bookings fund maintenance.
+- **0 % commission**: Karbé takes nothing on stays.
+- **No paperwork**: Stripe collects and remits, no middleman.
+- **Members keep priority**: a dual-calendar system separates committee and public slots.
+
+## Next step
+
+Contact us to discuss your specific case: [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr).
+`,
+ },
+ {
+ slug: "devenir-loueur",
+ category: "general",
+ title: "Become a host",
+ body: `Got a carbet? You can list it on Karbé in a few minutes.
+
+## What you'll need
+
+- Proof of ownership or written permission
+- Photos (interior, exterior, embarkation point)
+- Honest description: amenities, access, seasonal constraints
+- A clear **cancellation policy** travellers can see before paying
+
+## Steps
+
+1. [Create a host account](/connexion) with your email and phone.
+2. Add your carbet: photos, description, GPS, embarkation point, pirogue time (if relevant), amenities.
+3. Open your calendar: which weeks are available, what seasonal constraints apply (Oyapock low water? dry season only?).
+4. Activate the annual host subscription via Stripe.
+5. You're online. Bookings come in by email.
+
+## Payment
+
+When a traveller books, Stripe collects the stay and **pays it to you in full**. Karbé takes nothing.
+
+## Questions
+
+For edge cases (jointly owned carbet, ZAD plot, agreement with a town hall…), write to us: [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr).
+
+[Create my host account](/connexion)
+`,
+ },
+ {
+ slug: "cgv",
+ category: "legal",
+ title: "Terms of service",
+ body: `*Last updated: 2026-05-31*
+
+These terms govern the use of the Karbé platform (karbe.cosmolan.fr) operated by the project's umbrella association.
+
+## 1. Object
+
+Karbé is a platform connecting owners of riverside carbets located in French Guiana with travellers wishing to rent them for tourism stays. Karbé does not own the carbets and is not a party to the rental contract.
+
+## 2. Registration
+
+Registration is free for travellers. For hosts, it is conditional upon subscribing to an annual subscription billed via Stripe.
+
+## 3. Payment and remittance
+
+Stays are billed via Stripe at booking time. The amount is **paid in full to the host** (minus Stripe fees), with no Karbé commission.
+
+## 4. Host subscription
+
+The annual subscription is billed at the start of the period and tacitly renewed unless cancelled by the user.
+
+## 5. Cancellations
+
+Each host sets their own cancellation policy, shown before payment.
+
+## 6. Liability
+
+Karbé is not liable for the state of the carbet, the pirogue transport, river conditions, or any incident during the stay. The traveller is invited to take out a suitable travel insurance.
+
+## 7. Personal data
+
+See the [Privacy Policy](/politique-de-confidentialite).
+
+## 8. Applicable law
+
+French law. Court of Cayenne (French Guiana).
+
+## 9. Contact
+
+[bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr)
+`,
+ },
+ {
+ slug: "mentions-legales",
+ category: "legal",
+ title: "Legal notice",
+ body: `*Last updated: 2026-05-31*
+
+## Publisher
+
+**Karbé** is published by the project's umbrella association (under formation), based in Cayenne (French Guiana).
+
+- Email: [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr)
+- Site: [karbe.cosmolan.fr](https://karbe.cosmolan.fr)
+
+## Hosting
+
+Servers located in mainland France, operated by the Cosmolan infrastructure.
+
+## Source code
+
+Open source: [git.cosmolan.fr/tarzzan/karbe](https://git.cosmolan.fr/tarzzan/karbe).
+
+## Intellectual property
+
+Editorial content of carbet listings belongs to the hosts who published them. The Karbé brand and proprietary visual elements remain the property of the publisher.
+
+## Reporting
+
+[bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr)
+`,
+ },
+ {
+ slug: "politique-de-confidentialite",
+ category: "legal",
+ title: "Privacy policy",
+ body: `*Last updated: 2026-05-31*
+
+Karbé respects your privacy.
+
+## 1. Data collected
+
+- **Identification**: email, last name, first name, phone
+- **Booking**: dates, carbet, amount paid
+- **Technical**: IP, browser, pages visited (fraud prevention)
+
+We **do not collect** your bank card: payments are delegated to Stripe (PCI-DSS).
+
+## 2. Purposes
+
+- Traveller-host matching
+- Subscription billing
+- Fraud prevention
+- Account / booking communication
+
+## 3. Retention
+
+- Active account: as long as it's used
+- Accounting data: 10 years
+- Technical logs: 12 months max
+
+## 4. Recipients
+
+Karbé team, Stripe, host (for your stay). **No resale.**
+
+## 5. Your rights
+
+Right of access, rectification, erasure, portability, objection.
+
+Contact: [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr) · CNIL: [cnil.fr](https://www.cnil.fr)
+
+## 6. Cookies
+
+Technical cookies only (session, preferences). No advertising or third-party tracking cookies.`,
+ },
+];
+
+export async function seedEnglishContentPages(): Promise {
+ let count = 0;
+ for (const page of PAGES_EN) {
+ await upsertContentPage({
+ slug: page.slug,
+ title: page.title,
+ body: page.body,
+ category: page.category,
+ lang: "en",
+ published: true,
+ });
+ count += 1;
+ }
+ return count;
+}
diff --git a/src/lib/plugins/seeds/demo-ce.ts b/src/lib/plugins/seeds/demo-ce.ts
new file mode 100644
index 0000000..3f0ec74
--- /dev/null
+++ b/src/lib/plugins/seeds/demo-ce.ts
@@ -0,0 +1,234 @@
+/**
+ * Seed du plugin `demo-ce-seed`.
+ *
+ * Crée une organisation démo « Comité ESA Kourou » (approved=true) avec :
+ * - 2 CE_MANAGERs et 3 CE_MEMBERs (password "demo")
+ * - 2 carbets co-gérés (ownerId = manager#1) avec OrganizationCarbetMembership
+ * - 1 RentalProvider org-scoped avec 4 items
+ *
+ * Idempotent : check d'existence par email/slug stables avant chaque création.
+ * Disable : soft cleanup en cascade (les emails @karbe.demo sont supprimés,
+ * la cascade Prisma se charge des memberships, items, etc.).
+ */
+
+import {
+ CarbetStatus,
+ RentalCategory,
+ UserRole,
+} from "@/generated/prisma/enums";
+import { hashPassword } from "@/lib/password";
+import { prisma } from "@/lib/prisma";
+
+const DEMO_ORG_SLUG = "demo-comite-esa-kourou";
+const DEMO_PROVIDER_NAME = "Matériel — Comité ESA Kourou (démo)";
+
+const DEMO_USERS = [
+ { email: "demo-ce-mgr1@karbe.demo", firstName: "Aline", lastName: "Spaceport", role: UserRole.CE_MANAGER },
+ { email: "demo-ce-mgr2@karbe.demo", firstName: "Bruno", lastName: "Soyouz", role: UserRole.CE_MANAGER },
+ { email: "demo-ce-mbr1@karbe.demo", firstName: "Clara", lastName: "Ariane", role: UserRole.CE_MEMBER },
+ { email: "demo-ce-mbr2@karbe.demo", firstName: "David", lastName: "Vega", role: UserRole.CE_MEMBER },
+ { email: "demo-ce-mbr3@karbe.demo", firstName: "Élodie", lastName: "Falcon", role: UserRole.CE_MEMBER },
+] as const;
+
+const DEMO_CARBETS = [
+ {
+ slug: "demo-ce-karbe-sinnamary",
+ title: "Karbé CE Sinnamary",
+ river: "Sinnamary",
+ embarkPoint: "Dégrad Pointe Combi",
+ latitude: 5.39,
+ longitude: -53.0,
+ capacity: 6,
+ nightlyPrice: 95,
+ description:
+ "Carbet du Comité ESA Kourou sur la Sinnamary, accessible par 1h de pirogue depuis le dégrad. Réservé en priorité aux membres du CE le week-end, ouvert au public en semaine.",
+ },
+ {
+ slug: "demo-ce-karbe-kourou",
+ title: "Karbé CE Tonate",
+ river: "Kourou",
+ embarkPoint: "Embarcadère Tonate",
+ latitude: 5.25,
+ longitude: -52.65,
+ capacity: 8,
+ nightlyPrice: 110,
+ description:
+ "Carbet d'entreprise sur le fleuve Kourou, accessible en voiture jusqu'au dégrad. Équipé pour 8 voyageurs.",
+ },
+] as const;
+
+const DEMO_RENTAL_ITEMS = [
+ { name: "Hamac coton 2 places (démo CE)", category: RentalCategory.SLEEP, pricePerDay: 5, deposit: 15, totalQty: 12 },
+ { name: "Moustiquaire fleuve (démo CE)", category: RentalCategory.SLEEP, pricePerDay: 3, deposit: 10, totalQty: 12 },
+ { name: "Kayak monoplace (démo CE)", category: RentalCategory.NAVIGATION, pricePerDay: 35, deposit: 200, totalQty: 4 },
+ { name: "Réchaud gaz (démo CE)", category: RentalCategory.COOKING, pricePerDay: 6, deposit: 30, totalQty: 5 },
+] as const;
+
+export async function seedDemoCe(): Promise<{ created: boolean; orgId: string }> {
+ // 1. Organisation (idempotent par slug)
+ const existing = await prisma.organization.findUnique({
+ where: { slug: DEMO_ORG_SLUG },
+ select: { id: true },
+ });
+ if (existing) {
+ return { created: false, orgId: existing.id };
+ }
+
+ const passwordHash = await hashPassword("demo");
+
+ const org = await prisma.organization.create({
+ data: {
+ name: "Comité ESA Kourou (démo)",
+ slug: DEMO_ORG_SLUG,
+ description:
+ "Comité d'entreprise démo (fictif). Démontre la co-gestion de carbets et la location matériel par un CE sur Karbé.",
+ contactEmail: "demo-ce-mgr1@karbe.demo",
+ approved: true,
+ approvedAt: new Date(),
+ approvedBy: "demo-seed",
+ },
+ select: { id: true },
+ });
+
+ // 2. Membres
+ const users: { id: string; role: UserRole }[] = [];
+ for (const u of DEMO_USERS) {
+ const created = await prisma.user.upsert({
+ where: { email: u.email },
+ update: { organizationId: org.id, role: u.role },
+ create: {
+ email: u.email,
+ passwordHash,
+ firstName: u.firstName,
+ lastName: u.lastName,
+ role: u.role,
+ organizationId: org.id,
+ isActive: true,
+ },
+ select: { id: true, role: true },
+ });
+ users.push(created);
+ }
+ const mgr1 = users[0]!;
+
+ // 3. Carbets + memberships
+ for (const c of DEMO_CARBETS) {
+ const existingCarbet = await prisma.carbet.findUnique({
+ where: { slug: c.slug },
+ select: { id: true },
+ });
+ if (existingCarbet) {
+ // S'assure que la membership existe
+ await prisma.organizationCarbetMembership
+ .upsert({
+ where: { organizationId_carbetId: { organizationId: org.id, carbetId: existingCarbet.id } },
+ update: {},
+ create: { organizationId: org.id, carbetId: existingCarbet.id, addedByUserId: mgr1.id },
+ });
+ continue;
+ }
+ const carbet = await prisma.carbet.create({
+ data: {
+ ownerId: mgr1.id,
+ title: c.title,
+ slug: c.slug,
+ description: c.description,
+ river: c.river,
+ latitude: c.latitude,
+ longitude: c.longitude,
+ embarkPoint: c.embarkPoint,
+ pirogueDurationMin: 60,
+ capacity: c.capacity,
+ nightlyPrice: c.nightlyPrice,
+ status: CarbetStatus.PUBLISHED,
+ },
+ select: { id: true },
+ });
+ await prisma.organizationCarbetMembership.create({
+ data: { organizationId: org.id, carbetId: carbet.id, addedByUserId: mgr1.id },
+ });
+ }
+
+ // 4. RentalProvider org-scoped + items
+ let provider = await prisma.rentalProvider.findFirst({
+ where: { organizationId: org.id },
+ select: { id: true },
+ });
+ if (!provider) {
+ provider = await prisma.rentalProvider.create({
+ data: {
+ name: DEMO_PROVIDER_NAME,
+ isSystemD: false,
+ managedByUserId: mgr1.id,
+ organizationId: org.id,
+ contactEmail: "demo-ce-mgr1@karbe.demo",
+ rivers: ["Sinnamary", "Kourou"],
+ commissionPct: 10,
+ active: true,
+ approved: true,
+ approvedAt: new Date(),
+ approvedBy: "demo-seed",
+ },
+ select: { id: true },
+ });
+ }
+
+ for (const item of DEMO_RENTAL_ITEMS) {
+ const existingItem = await prisma.rentalItem.findFirst({
+ where: { providerId: provider.id, name: item.name },
+ select: { id: true },
+ });
+ if (existingItem) continue;
+ await prisma.rentalItem.create({
+ data: {
+ providerId: provider.id,
+ category: item.category,
+ name: item.name,
+ pricePerDay: item.pricePerDay,
+ deposit: item.deposit,
+ totalQty: item.totalQty,
+ active: true,
+ },
+ });
+ }
+
+ return { created: true, orgId: org.id };
+}
+
+export async function archiveDemoCe(): Promise<{ deletedUsers: number; deletedOrg: number }> {
+ // Cascade Prisma : Org delete → memberships + invites cascade ;
+ // RentalProvider.organizationId → SetNull (l'orga disparaît, le provider
+ // reste rattaché au manager nominal, on le supprime explicitement).
+ // Users → on supprime les emails @karbe.demo (cascade RentalProvider via
+ // managedByUserId=SetNull, mais on garde les carbets ; archive les carbets
+ // démo via status=ARCHIVED pour pas casser les bookings historiques).
+ const org = await prisma.organization.findUnique({
+ where: { slug: DEMO_ORG_SLUG },
+ select: { id: true },
+ });
+ if (!org) return { deletedUsers: 0, deletedOrg: 0 };
+
+ // Soft-archive les carbets démo
+ await prisma.carbet.updateMany({
+ where: { slug: { in: DEMO_CARBETS.map((c) => c.slug) } },
+ data: { status: CarbetStatus.ARCHIVED },
+ });
+
+ // Supprime le RentalProvider démo (cascade items + bookings → onDelete:Restrict
+ // sur bookings, donc skip si des bookings existent)
+ await prisma.rentalProvider
+ .deleteMany({ where: { organizationId: org.id, name: DEMO_PROVIDER_NAME } })
+ .catch(() => {});
+
+ // Supprime les users démo (cascade memberships)
+ const { count: deletedUsers } = await prisma.user.deleteMany({
+ where: { email: { endsWith: "@karbe.demo", in: DEMO_USERS.map((u) => u.email) } },
+ });
+
+ // Supprime l'org (cascade memberships restantes)
+ const { count: deletedOrg } = await prisma.organization.deleteMany({
+ where: { slug: DEMO_ORG_SLUG },
+ });
+
+ return { deletedUsers, deletedOrg };
+}
diff --git a/src/lib/plugins/seeds/legal-pages-default.ts b/src/lib/plugins/seeds/legal-pages-default.ts
new file mode 100644
index 0000000..56f8452
--- /dev/null
+++ b/src/lib/plugins/seeds/legal-pages-default.ts
@@ -0,0 +1,186 @@
+/**
+ * Seeds des pages légales (plugin legal-pages, catégorie "legal").
+ *
+ * Contenu de base, non-conseiller juridique. À faire réviser par un avocat
+ * avant la mise en production réelle.
+ */
+
+import { setContentPagePublished, unpublishCategory, upsertContentPage } from "@/lib/content-pages";
+
+const PAGES: { slug: string; title: string; body: string }[] = [
+ {
+ slug: "cgv",
+ title: "Conditions générales de vente",
+ body: `*Dernière mise à jour : 2026-05-31*
+
+Ces conditions générales régissent l'utilisation de la plateforme Karbé (karbe.cosmolan.fr) opérée par l'association porteuse du projet.
+
+## 1. Objet
+
+Karbé est une plateforme de mise en relation entre propriétaires de carbets fluviaux situés en Guyane française et voyageurs souhaitant louer ces carbets pour des séjours touristiques. Karbé n'est ni propriétaire des carbets ni partie au contrat de location.
+
+## 2. Inscription et comptes
+
+L'inscription est gratuite pour les voyageurs. Pour les loueurs, elle est conditionnée à la souscription d'un abonnement annuel facturé via Stripe.
+
+L'utilisateur s'engage à fournir des informations exactes et à maintenir à jour ses coordonnées.
+
+## 3. Paiement des séjours et reversement
+
+Les séjours sont facturés via Stripe au moment de la réservation. Le montant est **reversé intégralement au loueur** (moins les frais Stripe), sans prélèvement par Karbé.
+
+Karbé ne perçoit aucune commission sur les séjours. Le modèle économique repose exclusivement sur l'abonnement annuel des loueurs.
+
+## 4. Abonnement loueur
+
+L'abonnement annuel est facturé en début de période. Il est tacitement reconduit chaque année sauf résiliation par l'utilisateur depuis son espace.
+
+En cas de résiliation, le carbet est dépublié de la plateforme à la fin de la période en cours. Aucun remboursement prorata n'est effectué.
+
+## 5. Annulations
+
+Chaque loueur fixe sa propre politique d'annulation. Celle-ci est affichée sur la fiche du carbet avant tout paiement.
+
+En cas d'annulation par le voyageur, les conditions du loueur s'appliquent. Karbé peut, en cas de force majeure (fermeture administrative d'un fleuve, par exemple), procéder à un remboursement intégral après instruction.
+
+## 6. Responsabilité
+
+Karbé n'est pas responsable :
+
+- de l'état du carbet ou des équipements fournis par le loueur ;
+- du transport pirogue effectué par le loueur ou un prestataire ;
+- des conditions de navigation (étiage, crue, sécurité fleuve) ;
+- de tout incident survenant pendant le séjour.
+
+Le voyageur reste responsable de sa propre sécurité et est invité à souscrire une assurance voyage adaptée.
+
+## 7. Données personnelles
+
+Les données personnelles collectées sont traitées conformément à la [Politique de confidentialité](/politique-de-confidentialite).
+
+## 8. Droit applicable
+
+Les présentes conditions sont régies par le droit français. Tout litige relève de la compétence du tribunal de Cayenne (Guyane française).
+
+## 9. Contact
+
+[bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr)
+`,
+ },
+ {
+ slug: "mentions-legales",
+ title: "Mentions légales",
+ body: `*Dernière mise à jour : 2026-05-31*
+
+## Éditeur du site
+
+**Karbé** est édité par l'association porteuse du projet (en cours de constitution), basée à Cayenne (Guyane française).
+
+- Email : [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr)
+- Site : [karbe.cosmolan.fr](https://karbe.cosmolan.fr)
+
+## Hébergement
+
+Le site est hébergé sur des serveurs situés en France métropolitaine, opérés par l'infrastructure Cosmolan.
+
+## Crédits et licences
+
+- Images d'illustration : ressources libres ou produites pour le projet.
+- Code source du site : open source, disponible sur [git.cosmolan.fr/tarzzan/karbe](https://git.cosmolan.fr/tarzzan/karbe).
+
+## Propriété intellectuelle
+
+Le contenu rédactionnel des fiches de carbets appartient aux loueurs qui les ont publiées. La marque et les éléments visuels propres à Karbé restent la propriété de l'éditeur.
+
+## Signalement
+
+Pour signaler un contenu inapproprié ou exercer vos droits (RGPD), écrivez à [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr).
+`,
+ },
+ {
+ slug: "politique-de-confidentialite",
+ title: "Politique de confidentialité",
+ body: `*Dernière mise à jour : 2026-05-31*
+
+Karbé respecte votre vie privée. Cette politique décrit les données que nous collectons, leur usage et vos droits.
+
+## 1. Données collectées
+
+Nous collectons :
+
+- **Données d'identification** : email, nom, prénom, téléphone (renseignés lors de l'inscription).
+- **Données de réservation** : dates, carbet réservé, montant payé.
+- **Données techniques** : adresse IP, type de navigateur, pages consultées (uniquement pour la prévention de fraude et l'analyse d'usage agrégée).
+
+Nous **ne collectons pas** votre carte bancaire : le paiement est délégué à Stripe, qui dispose de sa propre certification PCI-DSS.
+
+## 2. Finalités
+
+- Permettre la mise en relation voyageur ↔ loueur.
+- Facturer et gérer les abonnements loueurs.
+- Prévenir la fraude.
+- Communiquer avec vous concernant votre compte ou vos réservations.
+
+## 3. Base légale
+
+Le traitement repose sur :
+
+- **Le contrat** vous liant à Karbé (compte utilisateur, réservation).
+- **L'obligation légale** pour les données comptables.
+- **L'intérêt légitime** pour la prévention de fraude.
+
+## 4. Durée de conservation
+
+- Compte actif : tant que vous l'utilisez.
+- Données comptables : 10 ans (obligation légale).
+- Journaux techniques : 12 mois maximum.
+
+## 5. Destinataires
+
+Vos données sont accessibles :
+
+- À l'équipe Karbé strictement dans le cadre de ses missions.
+- À Stripe pour le traitement des paiements.
+- Au loueur pour les besoins de votre séjour (nom, contact).
+
+Aucune donnée n'est revendue ni partagée avec des tiers à des fins publicitaires.
+
+## 6. Vos droits
+
+Conformément au RGPD, vous disposez d'un droit d'accès, de rectification, d'effacement, de portabilité et d'opposition. Pour les exercer, écrivez à [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr).
+
+Vous pouvez également déposer une réclamation auprès de la CNIL ([www.cnil.fr](https://www.cnil.fr)).
+
+## 7. Cookies
+
+Karbé utilise uniquement des cookies techniques (session, préférences de langue). Aucun cookie publicitaire ni cookie tiers de tracking n'est déposé.
+`,
+ },
+];
+
+export async function seedLegalPages(): Promise<{ created: number }> {
+ let created = 0;
+ for (const page of PAGES) {
+ await upsertContentPage({
+ slug: page.slug,
+ title: page.title,
+ body: page.body,
+ category: "legal",
+ published: true,
+ });
+ created += 1;
+ }
+ return { created };
+}
+
+export async function unpublishLegalPages(): Promise {
+ return await unpublishCategory("legal");
+}
+
+export async function republishLegalPages(): Promise {
+ let count = 0;
+ for (const page of PAGES) {
+ count += await setContentPagePublished(page.slug, "legal", true);
+ }
+ return count;
+}
diff --git a/src/lib/plugins/seeds/pirogue-providers-default.ts b/src/lib/plugins/seeds/pirogue-providers-default.ts
new file mode 100644
index 0000000..9360325
--- /dev/null
+++ b/src/lib/plugins/seeds/pirogue-providers-default.ts
@@ -0,0 +1,104 @@
+/**
+ * Seed du plugin `pirogue-providers` : 3 prestataires partenaires fictifs
+ * réalistes pour la démo, attachés à 4 fleuves majeurs de Guyane.
+ */
+
+import { prisma } from "@/lib/prisma";
+
+const PROVIDERS = [
+ {
+ id: "demo-provider-maroni",
+ name: "Pirogues du Maroni",
+ contactEmail: "contact@pirogues-maroni.demo",
+ contactPhone: "+594-694-100200",
+ rivers: ["Maroni", "Lawa"],
+ pricingNote: "≈ 150 € aller-retour depuis Apatou (selon distance carbet)",
+ description:
+ "Coopérative de piroguiers bushinengués. Aller-retour vers les carbets en aval d'Apatou, départs matin. Capacité 6-8 passagers, vestes de pluie fournies.",
+ },
+ {
+ id: "demo-provider-approuague",
+ name: "Approuague Aventures",
+ contactEmail: "info@approuague-aventures.demo",
+ contactPhone: "+594-694-300400",
+ rivers: ["Approuague"],
+ pricingNote: "≈ 250 € aller-retour Régina, ≈ 320 € jusqu'au saut Mapaou",
+ description:
+ "Prestataire historique de l'Approuague, basé à Régina. Pirogues 4 passagers + matériel. Possibilité de jour blanc (pêche, observation) en option.",
+ },
+ {
+ id: "demo-provider-oyapock",
+ name: "Oyapock Frontière",
+ contactEmail: "contact@oyapock-frontiere.demo",
+ contactPhone: "+594-694-500600",
+ rivers: ["Oyapock"],
+ pricingNote: "≈ 300 € aller-retour Saint-Georges (haute eau), tarif majoré en étiage",
+ description:
+ "Trajet vers les carbets côté Guyane et côté Brésilien (Vila Brasil). En étiage (oct-nov), prévoir une marge horaire — fond du fleuve parfois imprévisible.",
+ },
+] as const;
+
+const CARBET_PROVIDER_LINKS = [
+ { slug: "demo-karbe-awara-maroni", providerId: "demo-provider-maroni", mode: "PARTNER_PROVIDER" as const },
+ { slug: "demo-karbe-maripa-approuague", providerId: "demo-provider-approuague", mode: "PARTNER_PROVIDER" as const },
+ { slug: "demo-karbe-paripou-oyapock", providerId: "demo-provider-oyapock", mode: "PARTNER_PROVIDER" as const },
+ { slug: "demo-karbe-wapa-comte", providerId: null, mode: "OWNER_PROVIDES" as const },
+ { slug: "demo-karbe-mahury-ce-hopital", providerId: null, mode: "OWNER_PROVIDES" as const },
+ { slug: "demo-karbe-kourou-couleuvre", providerId: null, mode: "SELF_ARRANGE" as const },
+];
+
+export async function seedPirogueProviders(): Promise<{ providers: number; carbets: number }> {
+ let providers = 0;
+ for (const p of PROVIDERS) {
+ await prisma.pirogueProvider.upsert({
+ where: { id: p.id },
+ update: {
+ name: p.name,
+ contactEmail: p.contactEmail,
+ contactPhone: p.contactPhone,
+ rivers: [...p.rivers],
+ pricingNote: p.pricingNote,
+ description: p.description,
+ active: true,
+ },
+ create: {
+ id: p.id,
+ name: p.name,
+ contactEmail: p.contactEmail,
+ contactPhone: p.contactPhone,
+ rivers: [...p.rivers],
+ pricingNote: p.pricingNote,
+ description: p.description,
+ active: true,
+ },
+ });
+ providers += 1;
+ }
+
+ let carbets = 0;
+ for (const link of CARBET_PROVIDER_LINKS) {
+ const updated = await prisma.carbet.updateMany({
+ where: { slug: link.slug },
+ data: {
+ pirogueProviderId: link.providerId,
+ transportMode: link.mode,
+ },
+ });
+ carbets += updated.count;
+ }
+
+ return { providers, carbets };
+}
+
+export async function deactivatePirogueProviders(): Promise {
+ const result = await prisma.pirogueProvider.updateMany({
+ where: { id: { startsWith: "demo-provider-" } },
+ data: { active: false },
+ });
+ // Détache aussi les carbets démo
+ await prisma.carbet.updateMany({
+ where: { pirogueProviderId: { startsWith: "demo-provider-" } },
+ data: { pirogueProviderId: null, transportMode: null },
+ });
+ return result.count;
+}
diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts
new file mode 100644
index 0000000..41c27f1
--- /dev/null
+++ b/src/lib/rate-limit.ts
@@ -0,0 +1,86 @@
+/**
+ * Token-bucket en mémoire — best-effort par instance.
+ *
+ * Pour un déploiement multi-instance, swap pour un store partagé (Redis).
+ * Ici on tourne en mono-instance Next derrière nginx-proxy-manager, donc
+ * une Map locale suffit.
+ *
+ * Usage :
+ * const r = await rateLimit({ key: ip + ":signup", windowMs: 60_000, limit: 5 });
+ * if (!r.ok) return tooManyRequests(r.retryAfter);
+ */
+
+type Bucket = {
+ count: number;
+ resetAt: number;
+};
+
+const buckets = new Map();
+
+const SWEEP_INTERVAL_MS = 60_000;
+let lastSweep = 0;
+
+function sweep(now: number) {
+ if (now - lastSweep < SWEEP_INTERVAL_MS) return;
+ lastSweep = now;
+ for (const [k, b] of buckets) {
+ if (b.resetAt <= now) buckets.delete(k);
+ }
+}
+
+export type RateLimitOpts = {
+ key: string;
+ /** Fenêtre glissante en ms. */
+ windowMs: number;
+ /** Nombre max d'appels par fenêtre. */
+ limit: number;
+};
+
+export type RateLimitResult = {
+ ok: boolean;
+ remaining: number;
+ retryAfter: number; // secondes
+};
+
+export function rateLimit(opts: RateLimitOpts): RateLimitResult {
+ const now = Date.now();
+ sweep(now);
+
+ const b = buckets.get(opts.key);
+ if (!b || b.resetAt <= now) {
+ buckets.set(opts.key, { count: 1, resetAt: now + opts.windowMs });
+ return { ok: true, remaining: opts.limit - 1, retryAfter: 0 };
+ }
+
+ if (b.count >= opts.limit) {
+ return {
+ ok: false,
+ remaining: 0,
+ retryAfter: Math.max(1, Math.ceil((b.resetAt - now) / 1000)),
+ };
+ }
+
+ b.count++;
+ return { ok: true, remaining: opts.limit - b.count, retryAfter: 0 };
+}
+
+/** Extract a client IP from a request, fallback to a safe default. */
+export function getClientIp(req: Request): string {
+ // nginx-proxy-manager pose x-forwarded-for, x-real-ip
+ const xff = req.headers.get("x-forwarded-for");
+ if (xff) return xff.split(",")[0].trim();
+ const xri = req.headers.get("x-real-ip");
+ if (xri) return xri.trim();
+ return "unknown";
+}
+
+/** Helper pratique : extract IP + applique le bucket. */
+export function rateLimitRequest(
+ req: Request,
+ bucket: string,
+ windowMs: number,
+ limit: number,
+): RateLimitResult {
+ const ip = getClientIp(req);
+ return rateLimit({ key: `${ip}:${bucket}`, windowMs, limit });
+}
diff --git a/src/lib/reels.ts b/src/lib/reels.ts
new file mode 100644
index 0000000..6ac0033
--- /dev/null
+++ b/src/lib/reels.ts
@@ -0,0 +1,127 @@
+import "server-only";
+
+import { CarbetStatus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export type ReelMedia = {
+ id: string;
+ type: "PHOTO" | "VIDEO";
+ url: string;
+};
+
+export type ReelCarbet = {
+ id: string;
+ slug: string;
+ title: string;
+ river: string;
+ embarkPoint: string;
+ capacity: number;
+ nightlyPrice: string;
+ ownerFirstName: string;
+ averageRating: number | null;
+ reviewCount: number;
+ media: ReelMedia[];
+};
+
+export async function listReelCarbets(opts: { take?: number } = {}): Promise {
+ const take = opts.take ?? 30;
+ const rows = await prisma.carbet.findMany({
+ where: {
+ status: CarbetStatus.PUBLISHED,
+ media: { some: {} }, // au moins 1 média
+ },
+ orderBy: [{ lastBookedAt: { sort: "desc", nulls: "last" } }, { updatedAt: "desc" }],
+ take,
+ select: {
+ id: true,
+ slug: true,
+ title: true,
+ river: true,
+ embarkPoint: true,
+ capacity: true,
+ nightlyPrice: true,
+ owner: { select: { firstName: true } },
+ media: {
+ orderBy: { sortOrder: "asc" },
+ select: { id: true, type: true, s3Url: true },
+ },
+ reviews: { select: { rating: true } },
+ },
+ });
+
+ return rows.map((c) => {
+ const ratings = c.reviews.map((r) => r.rating);
+ const avg = ratings.length === 0 ? null : ratings.reduce((a, b) => a + b, 0) / ratings.length;
+ return {
+ id: c.id,
+ slug: c.slug,
+ title: c.title,
+ river: c.river,
+ embarkPoint: c.embarkPoint,
+ capacity: c.capacity,
+ nightlyPrice: c.nightlyPrice.toString(),
+ ownerFirstName: c.owner.firstName,
+ averageRating: avg,
+ reviewCount: ratings.length,
+ media: c.media.map((m) => ({
+ id: m.id,
+ type: m.type as "PHOTO" | "VIDEO",
+ url: m.s3Url,
+ })),
+ };
+ });
+}
+
+export async function listFavoriteCarbets(userId: string): Promise {
+ const favs = await prisma.favorite.findMany({
+ where: { userId },
+ select: { carbetId: true },
+ orderBy: { createdAt: "desc" },
+ });
+ if (favs.length === 0) return [];
+ const ids = favs.map((f) => f.carbetId);
+ const rows = await prisma.carbet.findMany({
+ where: { id: { in: ids }, status: CarbetStatus.PUBLISHED },
+ select: {
+ id: true,
+ slug: true,
+ title: true,
+ river: true,
+ embarkPoint: true,
+ capacity: true,
+ nightlyPrice: true,
+ owner: { select: { firstName: true } },
+ media: {
+ orderBy: { sortOrder: "asc" },
+ select: { id: true, type: true, s3Url: true },
+ },
+ reviews: { select: { rating: true } },
+ },
+ });
+ // Respecter l'ordre des favoris (le plus récent en premier)
+ const byId = new Map(rows.map((r) => [r.id, r]));
+ return ids
+ .map((id) => byId.get(id))
+ .filter((r): r is NonNullable => Boolean(r))
+ .map((c) => {
+ const ratings = c.reviews.map((r) => r.rating);
+ const avg = ratings.length === 0 ? null : ratings.reduce((a, b) => a + b, 0) / ratings.length;
+ return {
+ id: c.id,
+ slug: c.slug,
+ title: c.title,
+ river: c.river,
+ embarkPoint: c.embarkPoint,
+ capacity: c.capacity,
+ nightlyPrice: c.nightlyPrice.toString(),
+ ownerFirstName: c.owner.firstName,
+ averageRating: avg,
+ reviewCount: ratings.length,
+ media: c.media.map((m) => ({
+ id: m.id,
+ type: m.type as "PHOTO" | "VIDEO",
+ url: m.s3Url,
+ })),
+ };
+ });
+}
diff --git a/src/lib/rental-access.ts b/src/lib/rental-access.ts
new file mode 100644
index 0000000..8f5c9dc
--- /dev/null
+++ b/src/lib/rental-access.ts
@@ -0,0 +1,100 @@
+import "server-only";
+
+import { redirect } from "next/navigation";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+/**
+ * Garde-fou pour /espace-prestataire ET /espace-ce/materiel : accepte RENTAL_PROVIDER,
+ * CE_MANAGER, ADMIN. Chacun voit ensuite SON provider :
+ * - RENTAL_PROVIDER → via managedByUserId
+ * - CE_MANAGER → via organizationId
+ * - ADMIN → null si pas de providerId fourni (force le choix)
+ */
+export async function requireRentalProviderSession() {
+ const session = await auth();
+ if (!session?.user?.id) {
+ redirect("/connexion?next=/espace-prestataire");
+ }
+ const role = session.user.role;
+ if (
+ role !== UserRole.RENTAL_PROVIDER &&
+ role !== UserRole.CE_MANAGER &&
+ role !== UserRole.ADMIN
+ ) {
+ redirect("/");
+ }
+ return session;
+}
+
+/**
+ * Récupère le RentalProvider courant selon le rôle.
+ *
+ * - ADMIN avec `providerId` : retourne ce provider.
+ * - ADMIN sans `providerId` : retourne null (force le choix admin).
+ * - RENTAL_PROVIDER : retourne `findFirst({ managedByUserId })`.
+ * - CE_MANAGER : retourne `findFirst({ organizationId: session.user.organizationId })`.
+ */
+export async function getCurrentRentalProvider(opts: { providerId?: string } = {}) {
+ const session = await auth();
+ if (!session?.user?.id) return null;
+ const role = session.user.role;
+
+ if (role === UserRole.ADMIN && opts.providerId) {
+ return prisma.rentalProvider.findUnique({ where: { id: opts.providerId } });
+ }
+ if (role === UserRole.ADMIN && !opts.providerId) {
+ return null;
+ }
+ if (role === UserRole.CE_MANAGER && session.user.organizationId) {
+ return prisma.rentalProvider.findFirst({
+ where: { organizationId: session.user.organizationId },
+ });
+ }
+ // RENTAL_PROVIDER
+ return prisma.rentalProvider.findFirst({
+ where: { managedByUserId: session.user.id },
+ });
+}
+
+/**
+ * Variante explicite pour le contexte CE : retourne le provider lié à `organizationId`
+ * (ou null s'il n'a pas encore été activé). Utile dans /espace-ce/materiel pour
+ * détecter l'onboarding nécessaire.
+ */
+export async function getCurrentRentalProviderForCe(organizationId: string) {
+ return prisma.rentalProvider.findFirst({
+ where: { organizationId },
+ });
+}
+
+/**
+ * Vrai si :
+ * - ADMIN
+ * - le user est le manager nominal du provider (RENTAL_PROVIDER)
+ * - le user est CE_MANAGER ET son organizationId == provider.organizationId
+ */
+export async function canManageRentalProvider(
+ userId: string,
+ role: string | undefined,
+ providerId: string,
+ userOrgId?: string | null,
+): Promise {
+ if (role === UserRole.ADMIN) return true;
+ const provider = await prisma.rentalProvider.findUnique({
+ where: { id: providerId },
+ select: { managedByUserId: true, organizationId: true },
+ });
+ if (!provider) return false;
+ if (provider.managedByUserId === userId) return true;
+ if (
+ role === UserRole.CE_MANAGER &&
+ userOrgId &&
+ provider.organizationId === userOrgId
+ ) {
+ return true;
+ }
+ return false;
+}
diff --git a/src/lib/rental-cart-server.ts b/src/lib/rental-cart-server.ts
new file mode 100644
index 0000000..4d8989b
--- /dev/null
+++ b/src/lib/rental-cart-server.ts
@@ -0,0 +1,24 @@
+import "server-only";
+
+import { cookies } from "next/headers";
+
+import { CART_COOKIE, EMPTY_CART, parseCart, type Cart } from "./rental-cart";
+
+export async function readCartFromCookies(): Promise {
+ const c = await cookies();
+ return parseCart(c.get(CART_COOKIE)?.value);
+}
+
+export async function writeCartToCookies(cart: Cart): Promise {
+ const c = await cookies();
+ c.set(CART_COOKIE, JSON.stringify(cart), {
+ path: "/",
+ sameSite: "lax",
+ maxAge: 60 * 60 * 24 * 30, // 30 jours
+ });
+}
+
+export async function clearCartCookie(): Promise {
+ const c = await cookies();
+ c.set(CART_COOKIE, JSON.stringify(EMPTY_CART), { path: "/", maxAge: 0 });
+}
diff --git a/src/lib/rental-cart.ts b/src/lib/rental-cart.ts
new file mode 100644
index 0000000..c34b31e
--- /dev/null
+++ b/src/lib/rental-cart.ts
@@ -0,0 +1,50 @@
+/**
+ * Panier de location de matériel.
+ *
+ * Stockage : cookie HTTP `karbe-rental-cart` (JSON encoded).
+ * Manipulation : client React via context useCart() (composant ).
+ * Lecture serveur via `readCartFromCookies()`.
+ */
+
+import { z } from "zod";
+
+export const CART_COOKIE = "karbe-rental-cart";
+
+export const cartEntrySchema = z.object({
+ itemId: z.string().min(1).max(200),
+ qty: z.coerce.number().int().min(1).max(50),
+ startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+ endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+});
+
+export const cartSchema = z.object({
+ v: z.literal(1),
+ items: z.array(cartEntrySchema).max(50),
+ updatedAt: z.string().datetime().optional(),
+});
+
+export type CartEntry = z.infer;
+export type Cart = z.infer;
+
+export const EMPTY_CART: Cart = { v: 1, items: [] };
+
+export function parseCart(value: string | undefined | null): Cart {
+ if (!value) return EMPTY_CART;
+ try {
+ const json = JSON.parse(value);
+ const parsed = cartSchema.safeParse(json);
+ return parsed.success ? parsed.data : EMPTY_CART;
+ } catch {
+ return EMPTY_CART;
+ }
+}
+
+export function serializeCart(cart: Cart): string {
+ return JSON.stringify({ ...cart, updatedAt: new Date().toISOString() });
+}
+
+export function diffDays(start: string, end: string): number {
+ const s = new Date(start + "T00:00:00Z").getTime();
+ const e = new Date(end + "T00:00:00Z").getTime();
+ return Math.max(0, Math.round((e - s) / 86_400_000));
+}
diff --git a/src/lib/rental-category-labels.ts b/src/lib/rental-category-labels.ts
new file mode 100644
index 0000000..63f14a1
--- /dev/null
+++ b/src/lib/rental-category-labels.ts
@@ -0,0 +1,21 @@
+import { RentalCategory } from "@/generated/prisma/enums";
+
+export const RENTAL_CATEGORY_LABEL: Record = {
+ SLEEP: "💤 Couchage",
+ NAVIGATION: "🛶 Navigation",
+ FISHING: "🎣 Pêche",
+ COOKING: "🍳 Cuisine",
+ SAFETY: "🦺 Sécurité",
+};
+
+export const RENTAL_CATEGORIES: RentalCategory[] = [
+ RentalCategory.SLEEP,
+ RentalCategory.NAVIGATION,
+ RentalCategory.FISHING,
+ RentalCategory.COOKING,
+ RentalCategory.SAFETY,
+];
+
+export function isRentalCategory(v: string): v is RentalCategory {
+ return (RENTAL_CATEGORIES as string[]).includes(v);
+}
diff --git a/src/lib/rental-host.ts b/src/lib/rental-host.ts
new file mode 100644
index 0000000..8959b76
--- /dev/null
+++ b/src/lib/rental-host.ts
@@ -0,0 +1,124 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { RentalBookingStatus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export type HostRentalKpis = {
+ itemsTotal: number;
+ itemsActive: number;
+ bookingsPending: number;
+ bookingsConfirmed: number;
+ revenueTotal: string;
+ revenue30d: string;
+ nextHandover: {
+ id: string;
+ startDate: Date;
+ tenantName: string;
+ lineCount: number;
+ } | null;
+};
+
+export async function getHostRentalKpis(providerId: string): Promise {
+ const now = new Date();
+ const last30 = new Date(now.getTime() - 30 * 86_400_000);
+
+ const [itemsTotal, itemsActive, bookingsPending, bookingsConfirmed, revenueAll, revenue30, next] =
+ await Promise.all([
+ prisma.rentalItem.count({ where: { providerId } }),
+ prisma.rentalItem.count({ where: { providerId, active: true } }),
+ prisma.rentalBooking.count({
+ where: { providerId, status: RentalBookingStatus.PENDING },
+ }),
+ prisma.rentalBooking.count({
+ where: {
+ providerId,
+ status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER] },
+ startDate: { gte: now },
+ },
+ }),
+ prisma.rentalBooking.aggregate({
+ where: {
+ providerId,
+ status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER, RentalBookingStatus.RETURNED] },
+ },
+ _sum: { amount: true },
+ }),
+ prisma.rentalBooking.aggregate({
+ where: {
+ providerId,
+ status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER, RentalBookingStatus.RETURNED] },
+ createdAt: { gte: last30 },
+ },
+ _sum: { amount: true },
+ }),
+ prisma.rentalBooking.findFirst({
+ where: {
+ providerId,
+ status: RentalBookingStatus.CONFIRMED,
+ startDate: { gte: now },
+ },
+ orderBy: { startDate: "asc" },
+ select: {
+ id: true,
+ startDate: true,
+ tenant: { select: { firstName: true, lastName: true } },
+ _count: { select: { lines: true } },
+ },
+ }),
+ ]);
+
+ return {
+ itemsTotal,
+ itemsActive,
+ bookingsPending,
+ bookingsConfirmed,
+ revenueTotal: (revenueAll._sum.amount ?? 0).toString(),
+ revenue30d: (revenue30._sum.amount ?? 0).toString(),
+ nextHandover: next
+ ? {
+ id: next.id,
+ startDate: next.startDate,
+ tenantName: `${next.tenant.firstName} ${next.tenant.lastName}`.trim(),
+ lineCount: next._count.lines,
+ }
+ : null,
+ };
+}
+
+export async function listHostItems(providerId: string) {
+ return prisma.rentalItem.findMany({
+ where: { providerId },
+ orderBy: [{ category: "asc" }, { name: "asc" }],
+ include: { _count: { select: { lines: true } } },
+ });
+}
+
+export async function listHostBookings(providerId: string, filters: { status?: RentalBookingStatus } = {}) {
+ const where: Prisma.RentalBookingWhereInput = { providerId };
+ if (filters.status) where.status = filters.status;
+ return prisma.rentalBooking.findMany({
+ where,
+ orderBy: [{ status: "asc" }, { startDate: "asc" }],
+ take: 200,
+ include: {
+ tenant: { select: { id: true, firstName: true, lastName: true, email: true, phone: true } },
+ lines: { include: { item: { select: { id: true, name: true, category: true } } } },
+ booking: { select: { id: true, carbet: { select: { title: true, slug: true } } } },
+ },
+ });
+}
+
+export async function getHostItem(providerId: string, itemId: string) {
+ return prisma.rentalItem.findFirst({
+ where: { id: itemId, providerId },
+ include: {
+ availabilities: { orderBy: { startDate: "asc" } },
+ _count: { select: { lines: true } },
+ media: {
+ orderBy: { sortOrder: "asc" },
+ select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
+ },
+ },
+ });
+}
diff --git a/src/lib/rental-refund.ts b/src/lib/rental-refund.ts
new file mode 100644
index 0000000..4fd77d3
--- /dev/null
+++ b/src/lib/rental-refund.ts
@@ -0,0 +1,73 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+export type RefundPolicy = "FULL" | "PARTIAL_50" | "DEPOSIT_ONLY";
+
+export type RefundCalculation = {
+ /** Montant remboursé sur la location (hors caution). */
+ itemsRefund: Prisma.Decimal;
+ /** Montant remboursé sur la caution (rendue intégralement tant que pas HANDED_OVER). */
+ depositRefund: Prisma.Decimal;
+ /** Total remboursé (itemsRefund + depositRefund). */
+ totalRefund: Prisma.Decimal;
+ policy: RefundPolicy;
+ /** Description lisible de la politique appliquée, à inclure dans l'email. */
+ policyLabel: string;
+};
+
+/**
+ * Politique de remboursement v1 (simple, paramétrable plus tard) :
+ * - Annulation > 7 jours avant le début → remboursement intégral (FULL)
+ * - Annulation entre 1 et 7 jours avant le début → remboursement 50% items + caution intégrale (PARTIAL_50)
+ * - Annulation < 24h avant le début → seulement la caution est rendue (DEPOSIT_ONLY)
+ *
+ * La caution est TOUJOURS rendue tant que le matériel n'a pas été remis
+ * (`HANDED_OVER`), puisqu'elle ne couvre que les dégâts pendant l'usage.
+ */
+export function computeRentalRefund(opts: {
+ startDate: Date;
+ itemsTotal: string | number | Prisma.Decimal;
+ depositTotal: string | number | Prisma.Decimal;
+ now?: Date;
+}): RefundCalculation {
+ const now = opts.now ?? new Date();
+ const msUntilStart = opts.startDate.getTime() - now.getTime();
+ const daysUntilStart = msUntilStart / DAY_MS;
+
+ const itemsDecimal = new Prisma.Decimal(opts.itemsTotal.toString());
+ const depositDecimal = new Prisma.Decimal(opts.depositTotal.toString());
+
+ let policy: RefundPolicy;
+ let itemsRefund: Prisma.Decimal;
+ let policyLabel: string;
+
+ if (daysUntilStart >= 7) {
+ policy = "FULL";
+ itemsRefund = itemsDecimal;
+ policyLabel = "Annulation > 7 jours : remboursement intégral";
+ } else if (daysUntilStart >= 1) {
+ policy = "PARTIAL_50";
+ itemsRefund = itemsDecimal.mul("0.5").toDecimalPlaces(2);
+ policyLabel = "Annulation entre 1 et 7 jours : 50 % du montant location";
+ } else {
+ policy = "DEPOSIT_ONLY";
+ itemsRefund = new Prisma.Decimal(0);
+ policyLabel = "Annulation tardive : caution rendue, location non remboursée";
+ }
+
+ // La caution est toujours rendue tant que pas HANDED_OVER (vérifié côté action
+ // avant d'appeler ce helper).
+ const depositRefund = depositDecimal;
+ const totalRefund = itemsRefund.add(depositRefund).toDecimalPlaces(2);
+
+ return {
+ itemsRefund: itemsRefund.toDecimalPlaces(2),
+ depositRefund: depositRefund.toDecimalPlaces(2),
+ totalRefund,
+ policy,
+ policyLabel,
+ };
+}
diff --git a/src/lib/rentals-public.ts b/src/lib/rentals-public.ts
new file mode 100644
index 0000000..1ffea63
--- /dev/null
+++ b/src/lib/rentals-public.ts
@@ -0,0 +1,185 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { RentalCategory } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export type PublicRentalFilters = {
+ q?: string;
+ category?: RentalCategory;
+ providerId?: string;
+ river?: string;
+};
+
+export type PublicRentalItem = {
+ id: string;
+ name: string;
+ description: string | null;
+ category: RentalCategory;
+ imageUrl: string | null;
+ pricePerDay: string;
+ pricePerWeek: string | null;
+ deposit: string;
+ totalQty: number;
+ withMotor: boolean;
+ fuelIncluded: boolean;
+ requiresLicense: boolean;
+ provider: {
+ id: string;
+ name: string;
+ isSystemD: boolean;
+ rivers: string[];
+ };
+};
+
+export async function listPublicRentalItems(
+ filters: PublicRentalFilters = {},
+): Promise {
+ const where: Prisma.RentalItemWhereInput = {
+ active: true,
+ provider: { active: true, approved: true },
+ };
+ if (filters.q) {
+ where.OR = [
+ { name: { contains: filters.q, mode: "insensitive" } },
+ { description: { contains: filters.q, mode: "insensitive" } },
+ ];
+ }
+ if (filters.category) where.category = filters.category;
+ if (filters.providerId) where.providerId = filters.providerId;
+ if (filters.river) {
+ where.provider = { active: true, approved: true, rivers: { has: filters.river } };
+ }
+
+ const rows = await prisma.rentalItem.findMany({
+ where,
+ orderBy: [{ category: "asc" }, { name: "asc" }],
+ take: 200,
+ include: {
+ provider: { select: { id: true, name: true, isSystemD: true, rivers: true } },
+ },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ description: r.description,
+ category: r.category,
+ imageUrl: r.imageUrl,
+ pricePerDay: r.pricePerDay.toString(),
+ pricePerWeek: r.pricePerWeek?.toString() ?? null,
+ deposit: r.deposit.toString(),
+ totalQty: r.totalQty,
+ withMotor: r.withMotor,
+ fuelIncluded: r.fuelIncluded,
+ requiresLicense: r.requiresLicense,
+ provider: r.provider,
+ }));
+}
+
+export async function getPublicRentalItem(id: string) {
+ return prisma.rentalItem.findFirst({
+ where: { id, active: true, provider: { active: true, approved: true } },
+ include: {
+ provider: {
+ select: {
+ id: true,
+ name: true,
+ isSystemD: true,
+ rivers: true,
+ description: true,
+ contactEmail: true,
+ contactPhone: true,
+ },
+ },
+ media: {
+ orderBy: { sortOrder: "asc" },
+ select: { id: true, type: true, s3Url: true, sortOrder: true },
+ },
+ },
+ });
+}
+
+export type PublicProvider = {
+ id: string;
+ name: string;
+ isSystemD: boolean;
+ rivers: string[];
+ itemsCount: number;
+ description: string | null;
+};
+
+export async function listPublicProviders(): Promise {
+ const rows = await prisma.rentalProvider.findMany({
+ where: { active: true, approved: true },
+ orderBy: [{ isSystemD: "desc" }, { name: "asc" }],
+ select: {
+ id: true,
+ name: true,
+ isSystemD: true,
+ rivers: true,
+ description: true,
+ _count: { select: { items: { where: { active: true } } } },
+ },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ isSystemD: r.isSystemD,
+ rivers: r.rivers,
+ description: r.description,
+ itemsCount: r._count.items,
+ }));
+}
+
+export async function listPublicRivers(): Promise {
+ const rows = await prisma.rentalProvider.findMany({
+ where: { active: true, approved: true },
+ select: { rivers: true },
+ });
+ const set = new Set();
+ for (const r of rows) for (const x of r.rivers) set.add(x);
+ return Array.from(set).sort();
+}
+
+/**
+ * Calcule la disponibilité d'un item sur une plage : pour chaque jour, qty
+ * réservée (somme des RentalItemAvailability qui couvrent ce jour) vs
+ * totalQty. Renvoie la qty disponible jour par jour.
+ */
+export async function getItemAvailability(
+ itemId: string,
+ from: Date,
+ to: Date,
+): Promise<{ date: string; availableQty: number; bookedQty: number; totalQty: number }[]> {
+ const item = await prisma.rentalItem.findUnique({
+ where: { id: itemId },
+ select: { totalQty: true },
+ });
+ if (!item) return [];
+
+ const blocks = await prisma.rentalItemAvailability.findMany({
+ where: {
+ itemId,
+ startDate: { lt: to },
+ endDate: { gt: from },
+ },
+ select: { startDate: true, endDate: true, qty: true },
+ });
+
+ const days: { date: string; availableQty: number; bookedQty: number; totalQty: number }[] = [];
+ const DAY_MS = 86_400_000;
+ for (let t = from.getTime(); t < to.getTime(); t += DAY_MS) {
+ const dayStart = new Date(t);
+ const dayEnd = new Date(t + DAY_MS);
+ const booked = blocks
+ .filter((b) => b.startDate < dayEnd && b.endDate > dayStart)
+ .reduce((acc, b) => acc + b.qty, 0);
+ days.push({
+ date: dayStart.toISOString().slice(0, 10),
+ bookedQty: booked,
+ availableQty: Math.max(0, item.totalQty - booked),
+ totalQty: item.totalQty,
+ });
+ }
+ return days;
+}
diff --git a/src/lib/scheduled.ts b/src/lib/scheduled.ts
new file mode 100644
index 0000000..d3272f8
--- /dev/null
+++ b/src/lib/scheduled.ts
@@ -0,0 +1,111 @@
+/**
+ * Tâches planifiées exécutables via /api/cron/run/[task] avec le secret
+ * CRON_TOKEN. Idempotents, retournent un compteur d'actions.
+ */
+
+import "server-only";
+
+import { BookingStatus, MediaType } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { recordAudit } from "@/lib/admin/audit";
+import { purgeExpiredResetTokens } from "@/lib/password-reset";
+import { generateImageVariants } from "@/lib/variants-server";
+
+const PENDING_TTL_DAYS = 7;
+
+/** Annule les bookings PENDING créés il y a plus de N jours. */
+export async function autoCancelStalePending(): Promise<{ cancelled: number }> {
+ const cutoff = new Date(Date.now() - PENDING_TTL_DAYS * 86_400_000);
+ const stale = await prisma.booking.findMany({
+ where: { status: BookingStatus.PENDING, createdAt: { lt: cutoff } },
+ select: { id: true },
+ });
+ if (stale.length === 0) return { cancelled: 0 };
+ await prisma.booking.updateMany({
+ where: { id: { in: stale.map((s) => s.id) } },
+ data: { status: BookingStatus.CANCELLED },
+ });
+ await recordAudit({
+ scope: "cron",
+ event: "bookings.auto-cancel-stale",
+ actorEmail: null,
+ details: { count: stale.length, cutoff: cutoff.toISOString() },
+ });
+ return { cancelled: stale.length };
+}
+
+/** Purge les password reset tokens expirés. */
+export async function purgeResetTokens(): Promise<{ purged: number }> {
+ const count = await purgeExpiredResetTokens();
+ if (count > 0) {
+ await recordAudit({
+ scope: "cron",
+ event: "password.purge-expired-tokens",
+ actorEmail: null,
+ details: { count },
+ });
+ }
+ return { purged: count };
+}
+
+/** Logique simple : retourne juste la liste des bookings dont l'arrivée est dans 3 jours.
+ * L'envoi email réel est branché plus tard quand RESEND_API_KEY est posée. */
+export async function listUpcomingArrivalsInThreeDays() {
+ const now = new Date();
+ const in3 = new Date(now.getTime() + 3 * 86_400_000);
+ const in4 = new Date(now.getTime() + 4 * 86_400_000);
+ return prisma.booking.findMany({
+ where: {
+ status: BookingStatus.CONFIRMED,
+ startDate: { gte: in3, lt: in4 },
+ },
+ select: {
+ id: true,
+ startDate: true,
+ tenant: { select: { email: true, firstName: true } },
+ carbet: { select: { title: true, slug: true } },
+ },
+ });
+}
+
+/** Régénère les variantes responsives pour tous les Media PHOTO en base. */
+export async function regenerateAllVariants(): Promise<{ scanned: number; ok: number; skipped: number; failed: number }> {
+ const medias = await prisma.media.findMany({
+ where: { type: MediaType.PHOTO },
+ select: { id: true, s3Key: true },
+ });
+ let ok = 0;
+ let skipped = 0;
+ let failed = 0;
+ for (const m of medias) {
+ const ext = m.s3Key.split(".").pop()?.toLowerCase();
+ if (!ext || !["jpg", "jpeg", "png", "webp", "avif"].includes(ext)) {
+ skipped++;
+ continue;
+ }
+ const mime =
+ ext === "png" ? "image/png" :
+ ext === "webp" ? "image/webp" :
+ ext === "avif" ? "image/avif" :
+ "image/jpeg";
+ const result = await generateImageVariants({ originalS3Key: m.s3Key, mime });
+ if (result.skipped) skipped++;
+ else if (result.results.every((r) => r.ok)) ok++;
+ else failed++;
+ }
+ await recordAudit({
+ scope: "cron",
+ event: "variants.regenerate-all",
+ actorEmail: null,
+ details: { scanned: medias.length, ok, skipped, failed },
+ });
+ return { scanned: medias.length, ok, skipped, failed };
+}
+
+export const SCHEDULED_TASKS = {
+ "auto-cancel-stale-pending": autoCancelStalePending,
+ "purge-reset-tokens": purgeResetTokens,
+ "regenerate-variants": regenerateAllVariants,
+} as const;
+
+export type ScheduledTaskName = keyof typeof SCHEDULED_TASKS;
diff --git a/src/lib/search-profiles.ts b/src/lib/search-profiles.ts
new file mode 100644
index 0000000..cff37da
--- /dev/null
+++ b/src/lib/search-profiles.ts
@@ -0,0 +1,79 @@
+/**
+ * Profils de séjour prédéfinis — chips au-dessus des facettes.
+ * Chaque profil pose un set de query params qui pré-cochent les filtres.
+ */
+
+import { Electricity, RoadAccess } from "@/generated/prisma/enums";
+
+export type SearchProfile = {
+ id: string;
+ emoji: string;
+ label: string;
+ description: string;
+ params: Record;
+};
+
+export const SEARCH_PROFILES: SearchProfile[] = [
+ {
+ id: "deconnexion",
+ emoji: "🌿",
+ label: "Déconnexion totale",
+ description: "Zone blanche, pas d'électricité, accès pirogue, 2-4 personnes.",
+ params: {
+ roadAccess: RoadAccess.NONE,
+ electricity: `${Electricity.NONE},${Electricity.SOLAR}`,
+ capacityMax: "4",
+ },
+ },
+ {
+ id: "teletravail",
+ emoji: "💻",
+ label: "Télétravail nature",
+ description: "Route, EDF, 4G au carbet — bureau au bord du fleuve.",
+ params: {
+ roadAccess: RoadAccess.ALL_YEAR,
+ electricity: Electricity.EDF,
+ gsmMaxKm: "0",
+ },
+ },
+ {
+ id: "famille-weekend",
+ emoji: "🏝️",
+ label: "Famille week-end",
+ description: "Route toute saison, électricité, capacité 4-8.",
+ params: {
+ roadAccess: RoadAccess.ALL_YEAR,
+ electricity: `${Electricity.EDF},${Electricity.GENERATOR_READY}`,
+ capacity: "4",
+ capacityMax: "8",
+ },
+ },
+ {
+ id: "astreinte",
+ emoji: "📞",
+ label: "Astreinte sereine",
+ description: "Réseau accessible (au max 1 km), EDF, route saison sèche min.",
+ params: {
+ gsmMaxKm: "1",
+ electricity: `${Electricity.EDF},${Electricity.GENERATOR_READY}`,
+ roadAccess: `${RoadAccess.DRY_SEASON_ONLY},${RoadAccess.ALL_YEAR}`,
+ },
+ },
+ {
+ id: "aventure",
+ emoji: "🛶",
+ label: "Aventure expédition",
+ description: "Accès pirogue uniquement, petit groupe 2-4.",
+ params: {
+ roadAccess: RoadAccess.NONE,
+ capacityMax: "4",
+ },
+ },
+];
+
+export function buildProfileUrl(profileId: string): string {
+ const profile = SEARCH_PROFILES.find((p) => p.id === profileId);
+ if (!profile) return "/carbets";
+ const search = new URLSearchParams(profile.params);
+ return `/carbets?${search.toString()}`;
+}
diff --git a/src/lib/seasonality.ts b/src/lib/seasonality.ts
new file mode 100644
index 0000000..e63cdf4
--- /dev/null
+++ b/src/lib/seasonality.ts
@@ -0,0 +1,71 @@
+/**
+ * Saisons guyanaises — gated par le plugin `seasonality`.
+ *
+ * Guyane française :
+ * - DRY (juillet-septembre) : saison sèche, conditions idéales
+ * - LOW_WATER (octobre-mi-novembre) : étiage, fleuves bas, certains carbets
+ * fleuve-only peuvent ne pas être accessibles
+ * - WET (décembre-juin) : grande saison des pluies, pistes route
+ * parfois en mauvais état
+ *
+ * Volontairement simplifié — la vraie saisonnalité varie un peu selon le
+ * fleuve. Les contraintes fines vivent dans Carbet.seasonalConstraints.
+ */
+
+export type Season = "DRY" | "LOW_WATER" | "WET";
+
+export function currentSeason(date = new Date()): Season {
+ const month = date.getMonth() + 1; // 1..12
+ if (month >= 7 && month <= 9) return "DRY";
+ if (month === 10 || month === 11) return "LOW_WATER";
+ return "WET";
+}
+
+export type SeasonalConstraints = {
+ closedInLowWater?: boolean;
+ closedSeasons?: Season[];
+ note?: string;
+};
+
+export function parseSeasonalConstraints(value: unknown): SeasonalConstraints | null {
+ if (!value || typeof value !== "object") return null;
+ const v = value as Record;
+ const out: SeasonalConstraints = {};
+ if (typeof v.closedInLowWater === "boolean") out.closedInLowWater = v.closedInLowWater;
+ if (Array.isArray(v.closedSeasons)) {
+ out.closedSeasons = v.closedSeasons.filter(
+ (s): s is Season => s === "DRY" || s === "LOW_WATER" || s === "WET",
+ );
+ }
+ if (typeof v.note === "string") out.note = v.note;
+ return out;
+}
+
+export function isCurrentlyOpen(
+ constraints: SeasonalConstraints | null,
+ date = new Date(),
+): boolean {
+ if (!constraints) return true;
+ const s = currentSeason(date);
+ if (constraints.closedInLowWater && s === "LOW_WATER") return false;
+ if (constraints.closedSeasons?.includes(s)) return false;
+ return true;
+}
+
+export const SEASON_META: Record = {
+ DRY: {
+ label: "Saison sèche",
+ emoji: "☀️",
+ tone: "ok",
+ },
+ LOW_WATER: {
+ label: "Étiage",
+ emoji: "⚠️",
+ tone: "warn",
+ },
+ WET: {
+ label: "Saison des pluies",
+ emoji: "🌧",
+ tone: "info",
+ },
+};
diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts
index adda277..e0d1ca0 100644
--- a/src/lib/stripe.ts
+++ b/src/lib/stripe.ts
@@ -1,5 +1,13 @@
import Stripe from "stripe";
+/** Détecte si Stripe est utilisable (clé posée + pas un placeholder). */
+export function isStripeConfigured(): boolean {
+ const key = (process.env.STRIPE_SECRET_KEY ?? "").trim();
+ if (!key) return false;
+ if (key.includes("REPLACE_ME") || key.includes("PLACEHOLDER")) return false;
+ return key.startsWith("sk_test_") || key.startsWith("sk_live_") || key.startsWith("rk_");
+}
+
let stripeClient: Stripe | null = null;
export function getStripeClient(): Stripe {
diff --git a/src/lib/theme.ts b/src/lib/theme.ts
new file mode 100644
index 0000000..bf8c755
--- /dev/null
+++ b/src/lib/theme.ts
@@ -0,0 +1,28 @@
+/**
+ * Helpers theme — server-side.
+ *
+ * Centralise la résolution du theme actif (guyane | aquarelle | none) pour
+ * que chaque composant qui veut un rendu spécifique au theme appelle un seul
+ * helper plutôt que de checker `isPluginEnabled("theme-...")` individuellement.
+ */
+
+import "server-only";
+import { isPluginEnabled } from "@/lib/plugins/server";
+
+export type ActiveTheme = "guyane" | "aquarelle" | "none";
+
+export async function getActiveTheme(): Promise {
+ if (await isPluginEnabled("theme-aquarelle")) return "aquarelle";
+ if (await isPluginEnabled("theme-guyane")) return "guyane";
+ return "none";
+}
+
+/**
+ * URL publique d'une illustration aquarelle hébergée dans MinIO.
+ * Les fichiers sont uploadés dans karbe-medias/seed/aquarelle/ et servis via
+ * media.karbe.cosmolan.fr (bucket public-download).
+ */
+export function aquarelleUrl(filename: string): string {
+ const base = process.env.S3_PUBLIC_URL?.replace(/\/+$/, "") ?? "https://media.karbe.cosmolan.fr/karbe-medias";
+ return `${base}/seed/aquarelle/${filename}`;
+}
diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts
new file mode 100644
index 0000000..484775f
--- /dev/null
+++ b/src/lib/uploads.ts
@@ -0,0 +1,129 @@
+import "server-only";
+
+import crypto from "node:crypto";
+import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
+import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
+
+const ENDPOINT = process.env.S3_ENDPOINT ?? "";
+const PUBLIC_BASE = process.env.S3_PUBLIC_URL ?? "";
+const BUCKET = process.env.S3_BUCKET ?? "";
+const REGION = process.env.S3_REGION ?? "us-east-1";
+const ACCESS_KEY = process.env.MINIO_ROOT_USER ?? process.env.S3_ACCESS_KEY ?? "";
+const SECRET_KEY = process.env.MINIO_ROOT_PASSWORD ?? process.env.S3_SECRET_KEY ?? "";
+
+const PUBLIC_BASE_EXTERNAL =
+ process.env.S3_PUBLIC_URL_EXTERNAL ?? PUBLIC_BASE;
+const ENDPOINT_EXTERNAL = process.env.S3_ENDPOINT_EXTERNAL ?? ENDPOINT;
+
+const s3Internal = new S3Client({
+ endpoint: ENDPOINT,
+ region: REGION,
+ forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true",
+ credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
+});
+
+const s3Presign = new S3Client({
+ endpoint: ENDPOINT_EXTERNAL,
+ region: REGION,
+ forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true",
+ credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
+});
+
+export type PresignResult = {
+ s3Key: string;
+ uploadUrl: string;
+ publicUrl: string;
+ expiresIn: number;
+};
+
+const ALLOWED_PHOTO_MIMES = new Set(["image/jpeg", "image/png", "image/webp", "image/avif"]);
+const ALLOWED_VIDEO_MIMES = new Set(["video/mp4", "video/quicktime", "video/webm"]);
+
+export type UploadKind = "photo" | "video";
+
+export function classifyMime(mime: string): UploadKind | null {
+ if (ALLOWED_PHOTO_MIMES.has(mime)) return "photo";
+ if (ALLOWED_VIDEO_MIMES.has(mime)) return "video";
+ return null;
+}
+
+const MAX_PHOTO = 10 * 1024 * 1024;
+const MAX_VIDEO = 200 * 1024 * 1024;
+
+export function maxBytesFor(kind: UploadKind): number {
+ return kind === "photo" ? MAX_PHOTO : MAX_VIDEO;
+}
+
+export function extensionFor(mime: string): string {
+ switch (mime) {
+ case "image/jpeg":
+ return "jpg";
+ case "image/png":
+ return "png";
+ case "image/webp":
+ return "webp";
+ case "image/avif":
+ return "avif";
+ case "video/mp4":
+ return "mp4";
+ case "video/quicktime":
+ return "mov";
+ case "video/webm":
+ return "webm";
+ default:
+ return "bin";
+ }
+}
+
+export async function presignCarbetUpload(opts: {
+ carbetId: string;
+ mime: string;
+ sizeBytes: number;
+}): Promise {
+ const kind = classifyMime(opts.mime);
+ if (!kind) return { error: `Type non supporté : ${opts.mime}` };
+ const max = maxBytesFor(kind);
+ if (opts.sizeBytes > max) {
+ return { error: `Fichier trop volumineux (${Math.round(opts.sizeBytes / 1_000_000)} Mo, max ${Math.round(max / 1_000_000)} Mo).` };
+ }
+ const id = crypto.randomBytes(12).toString("hex");
+ const ext = extensionFor(opts.mime);
+ const s3Key = `carbets/${opts.carbetId}/${Date.now()}-${id}.${ext}`;
+
+ const cmd = new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: s3Key,
+ ContentType: opts.mime,
+ });
+ const uploadUrl = await getSignedUrl(s3Presign, cmd, { expiresIn: 600 });
+ const publicUrl = `${PUBLIC_BASE_EXTERNAL.replace(/\/$/, "")}/${s3Key}`;
+ return { s3Key, uploadUrl, publicUrl, expiresIn: 600 };
+}
+
+export async function presignRentalItemUpload(opts: {
+ itemId: string;
+ mime: string;
+ sizeBytes: number;
+}): Promise {
+ const kind = classifyMime(opts.mime);
+ if (!kind) return { error: `Type non supporté : ${opts.mime}` };
+ const max = maxBytesFor(kind);
+ if (opts.sizeBytes > max) {
+ return { error: `Fichier trop volumineux (${Math.round(opts.sizeBytes / 1_000_000)} Mo, max ${Math.round(max / 1_000_000)} Mo).` };
+ }
+ const id = crypto.randomBytes(12).toString("hex");
+ const ext = extensionFor(opts.mime);
+ const s3Key = `rental-items/${opts.itemId}/${Date.now()}-${id}.${ext}`;
+
+ const cmd = new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: s3Key,
+ ContentType: opts.mime,
+ });
+ const uploadUrl = await getSignedUrl(s3Presign, cmd, { expiresIn: 600 });
+ const publicUrl = `${PUBLIC_BASE_EXTERNAL.replace(/\/$/, "")}/${s3Key}`;
+ return { s3Key, uploadUrl, publicUrl, expiresIn: 600 };
+}
+
+export { s3Internal };
+export { BUCKET as UPLOAD_BUCKET };
diff --git a/src/lib/variants-server.ts b/src/lib/variants-server.ts
new file mode 100644
index 0000000..06f3177
--- /dev/null
+++ b/src/lib/variants-server.ts
@@ -0,0 +1,126 @@
+/**
+ * Génération de variantes responsive côté serveur (Node).
+ *
+ * - Télécharge l'original depuis MinIO via l'endpoint interne.
+ * - sharp → 3 variantes (320 / 800 / 1600 px de large max, JPEG quality 80).
+ * - Upload chaque variante avec naming convention -.jpg.
+ * - Skippe vidéos (sharp ne les traite pas).
+ *
+ * Best-effort : si une variante échoue, on log et on continue. L'original
+ * fonctionne toujours côté front grâce au srcset fallback.
+ */
+
+import "server-only";
+
+import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
+import type { Readable } from "node:stream";
+
+import { VARIANT_WIDTHS, variantS3Key, type VariantWidth } from "./image-variants";
+
+const ENDPOINT = process.env.S3_ENDPOINT ?? "";
+const BUCKET = process.env.S3_BUCKET ?? "";
+const REGION = process.env.S3_REGION ?? "us-east-1";
+const ACCESS_KEY = process.env.MINIO_ROOT_USER ?? process.env.S3_ACCESS_KEY ?? "";
+const SECRET_KEY = process.env.MINIO_ROOT_PASSWORD ?? process.env.S3_SECRET_KEY ?? "";
+
+const s3 = new S3Client({
+ endpoint: ENDPOINT,
+ region: REGION,
+ forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true",
+ credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
+});
+
+async function streamToBuffer(stream: Readable | ReadableStream): Promise {
+ if ("getReader" in stream) {
+ const reader = stream.getReader();
+ const chunks: Uint8Array[] = [];
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ if (value) chunks.push(value);
+ }
+ return Buffer.concat(chunks);
+ }
+ const chunks: Buffer[] = [];
+ for await (const c of stream as Readable) chunks.push(c as Buffer);
+ return Buffer.concat(chunks);
+}
+
+export type VariantResult = {
+ width: VariantWidth;
+ s3Key: string;
+ ok: boolean;
+ reason?: string;
+};
+
+/**
+ * Génère les 3 variantes responsives pour une image originale.
+ * Skip silencieusement si mime === video/*.
+ */
+export async function generateImageVariants(opts: {
+ originalS3Key: string;
+ mime: string;
+}): Promise<{ skipped: boolean; results: VariantResult[] }> {
+ if (opts.mime.startsWith("video/")) {
+ return { skipped: true, results: [] };
+ }
+
+ let sharp: (input: Buffer) => import("sharp").Sharp;
+ try {
+ const mod = await import("sharp");
+ sharp = (mod as unknown as { default: (input: Buffer) => import("sharp").Sharp }).default;
+ } catch {
+ return { skipped: true, results: [] };
+ }
+
+ // 1. Download original
+ let originalBuffer: Buffer;
+ try {
+ const get = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: opts.originalS3Key }));
+ if (!get.Body) throw new Error("Empty body");
+ originalBuffer = await streamToBuffer(get.Body as Readable);
+ } catch (e) {
+ return {
+ skipped: false,
+ results: VARIANT_WIDTHS.map((w) => ({
+ width: w,
+ s3Key: variantS3Key(opts.originalS3Key, w),
+ ok: false,
+ reason: e instanceof Error ? e.message : "download failed",
+ })),
+ };
+ }
+
+ // 2. Variantes en parallèle
+ const results = await Promise.all(
+ VARIANT_WIDTHS.map(async (w): Promise => {
+ const targetKey = variantS3Key(opts.originalS3Key, w);
+ try {
+ const buf = await sharp(originalBuffer)
+ .rotate() // respecte l'EXIF orientation
+ .resize({ width: w, withoutEnlargement: true })
+ .jpeg({ quality: 80, progressive: true, mozjpeg: true })
+ .toBuffer();
+ await s3.send(
+ new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: targetKey,
+ Body: buf,
+ ContentType: "image/jpeg",
+ CacheControl: "public, max-age=31536000, immutable",
+ }),
+ );
+ return { width: w, s3Key: targetKey, ok: true };
+ } catch (e) {
+ return {
+ width: w,
+ s3Key: targetKey,
+ ok: false,
+ reason: e instanceof Error ? e.message : "resize/upload failed",
+ };
+ }
+ }),
+ );
+
+ return { skipped: false, results };
+}
diff --git a/src/messages/en.json b/src/messages/en.json
new file mode 100644
index 0000000..4a32fe0
--- /dev/null
+++ b/src/messages/en.json
@@ -0,0 +1,71 @@
+{
+ "site.tagline": "Karbé — riverside carbets of French Guiana",
+ "site.description": "The not-for-profit marketplace to rent a carbet along the rivers of French Guiana.",
+
+ "hero.eyebrow": "Solidarity marketplace — zero commission on stays",
+ "hero.titleLine1": "The sleeping karbé",
+ "hero.titleAccent": "is waiting for you",
+ "hero.subtitle": "Rent a carbet along the Maroni, Approuague or Oyapock. The hammock is up, the pirogue glides, the silence is real. For a few nights, the river is yours.",
+ "hero.ctaDiscover": "Find a carbet",
+ "hero.ctaPropose": "List mine",
+
+ "experiences.eyebrow": "Two ways to live Karbé",
+ "experiences.title": "From riverbank to pirogue expedition.",
+ "experiences.subtitle": "Pick the carbet you reach by car for an easy weekend — or the one you can only reach by pirogue, hours from the last village.",
+ "experiences.roadFluve.tag": "🛣️ Road + river",
+ "experiences.roadFluve.title": "The weekend carbet",
+ "experiences.roadFluve.body": "Accessible by track from Kourou, Saint-Laurent or Régina. Park the car, grab your gear, you're there. For families, couples who want quiet without logistics, social committees booking short stays.",
+ "experiences.roadFluve.b1": "1 to 3 nights typical",
+ "experiences.roadFluve.b2": "Car or 4WD depending on the track",
+ "experiences.roadFluve.b3": "Equipped carbets, swimming possible",
+ "experiences.riverOnly.tag": "🛶 River expedition",
+ "experiences.riverOnly.title": "The carbet you earn",
+ "experiences.riverOnly.body": "No road. Board a pirogue at a 'dégrad', sometimes two or three hours upriver. For those who really want to sleep far — howler monkeys, sky without halo, the river five meters from your hammock.",
+ "experiences.riverOnly.b1": "2 nights minimum recommended",
+ "experiences.riverOnly.b2": "Pirogue with skipper (host or partner)",
+ "experiences.riverOnly.b3": "Dry season recommended (July-November)",
+
+ "howItWorks.eyebrow": "How it works",
+ "howItWorks.title": "Three steps to disappear.",
+ "howItWorks.step1.title": "Choose the river",
+ "howItWorks.step1.body": "Maroni, Approuague, Comté, Oyapock — each river has its mood, its embarkation point, its carbets. Filter by your level of adventure.",
+ "howItWorks.step2.title": "Book the carbet",
+ "howItWorks.step2.body": "Dates, party size, pirogue duration if needed. Secure Stripe payment, paid in full to the host — zero commission on the stay.",
+ "howItWorks.step3.title": "Sleep for real",
+ "howItWorks.step3.body": "The host (or their partner) picks you up at the dégrad if needed. You get the keys to the karbé, hang the hammock, listen. Nothing left to do.",
+
+ "ce.eyebrow": "For social committees",
+ "ce.title": "Carbets sleep when you're not there.",
+ "ce.titleAccent": "Let's share them.",
+ "ce.body": "Karbé is built so that social committees who already own a carbet can reserve it for their members on selected weekends, then open it to public travellers the rest of the year. Zero commission on stays: the payment goes straight back to the committee.",
+ "ce.kpi.commission": "commission on stays",
+ "ce.kpi.ceFirst": "your members book first",
+ "ce.kpi.publicOpen": "remaining dates feed the pot",
+ "ce.kpi.noPaperwork": "Stripe charges and remits directly",
+ "ce.cta": "Learn more for your committee",
+
+ "testimonials.eyebrow": "No marketing",
+ "testimonials.title": "They told us, just like that.",
+
+ "footer.tagline": "Marketplace for riverside carbets of French Guiana. Solidarity with local social committees. Zero commission on stays.",
+ "footer.col.discover": "Discover",
+ "footer.col.propose": "Host",
+ "footer.col.legal": "Legal",
+ "footer.copyright": "non-profit digital project in French Guiana.",
+
+ "season.dry": "Dry season",
+ "season.lowWater": "Low water",
+ "season.wet": "Rainy season",
+ "season.dry.message": "Optimal conditions: rivers navigable, roads in good shape, sunrise over burning water.",
+ "season.lowWater.message": "Low water period: river-only carbets may not be reachable. Filter available on the search page.",
+ "season.wet.message": "Frequent rain: the jungle is dense and alive, plan an adapted vehicle for road+river carbets.",
+
+ "access.roadAndRiver": "🛣️ Road + river",
+ "access.riverOnly": "🛶 River expedition",
+ "access.roadAndRiver.title": "Accessible by road and by river.",
+ "access.riverOnly.title": "Reachable only by pirogue from a 'dégrad'.",
+
+ "language.switch": "Language",
+ "language.fr": "Français",
+ "language.en": "English"
+}
diff --git a/src/messages/fr.json b/src/messages/fr.json
new file mode 100644
index 0000000..ad73350
--- /dev/null
+++ b/src/messages/fr.json
@@ -0,0 +1,71 @@
+{
+ "site.tagline": "Karbé — carbets fluviaux de Guyane",
+ "site.description": "La marketplace solidaire pour louer un carbet le long des fleuves de Guyane.",
+
+ "hero.eyebrow": "Marketplace solidaire — sans commission sur le séjour",
+ "hero.titleLine1": "Le karbé qui dort",
+ "hero.titleAccent": "vous attend",
+ "hero.subtitle": "Louez un carbet le long du Maroni, de l'Approuague ou de l'Oyapock. Le hamac est tendu, la pirogue glisse, le silence est vrai. Pour quelques nuits, le fleuve vous appartient.",
+ "hero.ctaDiscover": "Découvrir un carbet",
+ "hero.ctaPropose": "Proposer le mien",
+
+ "experiences.eyebrow": "Deux façons de vivre Karbé",
+ "experiences.title": "Du bord du fleuve à l'expédition pirogue.",
+ "experiences.subtitle": "Selon l'envie, on choisit le carbet qui se rejoint en voiture pour un week-end facile, ou celui qu'on n'atteint qu'en pirogue, à plusieurs heures du dernier village.",
+ "experiences.roadFluve.tag": "🛣️ Route + fleuve",
+ "experiences.roadFluve.title": "Le carbet du week-end",
+ "experiences.roadFluve.body": "Accessible par la piste depuis Kourou, Saint-Laurent ou Régina. Garez la voiture, prenez vos affaires et vous y êtes. Pour les familles, les couples qui veulent du calme sans logistique, les CE qui réservent des séjours courts.",
+ "experiences.roadFluve.b1": "1 à 3 nuits typiques",
+ "experiences.roadFluve.b2": "Voiture ou 4×4 selon la piste",
+ "experiences.roadFluve.b3": "Carbets équipés, baignade possible",
+ "experiences.riverOnly.tag": "🛶 Expédition fleuve",
+ "experiences.riverOnly.title": "Le carbet qu'on mérite",
+ "experiences.riverOnly.body": "Aucune route n'y mène. On embarque en pirogue depuis un dégrad, parfois deux ou trois heures de remontée. Pour ceux qui veulent vraiment dormir loin — singes hurleurs, ciel sans halo, l'eau du fleuve à 5 mètres du hamac.",
+ "experiences.riverOnly.b1": "2 nuits minimum recommandées",
+ "experiences.riverOnly.b2": "Pirogue avec passeur (loueur ou partenaire)",
+ "experiences.riverOnly.b3": "Saison sèche conseillée (juillet-novembre)",
+
+ "howItWorks.eyebrow": "Comment ça marche",
+ "howItWorks.title": "Trois étapes pour s'échapper.",
+ "howItWorks.step1.title": "Choisissez le fleuve",
+ "howItWorks.step1.body": "Maroni, Approuague, Comté, Oyapock — chaque fleuve a son ambiance, son embarquement, ses carbets. Filtrez selon votre niveau d'aventure.",
+ "howItWorks.step2.title": "Réservez le carbet",
+ "howItWorks.step2.body": "Dates, capacité, durée de pirogue le cas échéant. Paiement sécurisé Stripe, reversé au loueur sans commission sur le séjour.",
+ "howItWorks.step3.title": "Dormez vrai",
+ "howItWorks.step3.body": "Le loueur (ou son partenaire) vous récupère au dégrad si besoin. Vous récupérez les clés du karbé, tendez le hamac, écoutez. Plus rien à faire.",
+
+ "ce.eyebrow": "Pour comités d'entreprise",
+ "ce.title": "Les carbets dorment quand vous n'y êtes pas.",
+ "ce.titleAccent": "Partageons-les.",
+ "ce.body": "Karbé est conçu pour que les comités sociaux possédant déjà un carbet le réservent à leurs membres certains week-ends, et l'ouvrent au public touriste le reste de l'année. Sans commission sur le séjour : le paiement revient intégralement au CE.",
+ "ce.kpi.commission": "de commission sur le séjour",
+ "ce.kpi.ceFirst": "vos membres réservent en priorité",
+ "ce.kpi.publicOpen": "le reste des dates rentre dans le pot",
+ "ce.kpi.noPaperwork": "Stripe encaisse et reverse direct",
+ "ce.cta": "En savoir plus pour votre CE",
+
+ "testimonials.eyebrow": "Pas de marketing",
+ "testimonials.title": "Ils nous l'ont dit comme ça.",
+
+ "footer.tagline": "Marketplace des carbets fluviaux de Guyane. Solidaire avec les CE locaux. Sans commission sur le séjour.",
+ "footer.col.discover": "Découvrir",
+ "footer.col.propose": "Proposer",
+ "footer.col.legal": "Légal",
+ "footer.copyright": "projet associatif numérique en Guyane.",
+
+ "season.dry": "Saison sèche",
+ "season.lowWater": "Étiage",
+ "season.wet": "Saison des pluies",
+ "season.dry.message": "Conditions optimales : fleuves navigables, pistes route en bon état, lever de soleil sur l'eau brûlante.",
+ "season.lowWater.message": "Étiage en cours : les carbets fleuve uniquement peuvent ne pas être accessibles. Filtre dispo en page recherche.",
+ "season.wet.message": "Pluies fréquentes : la jungle est dense et vivante, prévoir un véhicule adapté pour les carbets route+fleuve.",
+
+ "access.roadAndRiver": "🛣️ Route + fleuve",
+ "access.riverOnly": "🛶 Expédition fleuve",
+ "access.roadAndRiver.title": "Accessible par la route et par le fleuve.",
+ "access.riverOnly.title": "Accessible uniquement par pirogue depuis un dégrad.",
+
+ "language.switch": "Langue",
+ "language.fr": "Français",
+ "language.en": "English"
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..db39867
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,23 @@
+/**
+ * Middleware Karbé.
+ *
+ * Pose `x-pathname` sur tous les requests pour que les server components puissent
+ * lire le path courant via `headers()` (utile pour SiteHeaderGuard qui décide
+ * de rendre ou non le header global selon /admin vs reste).
+ */
+
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+export function middleware(request: NextRequest) {
+ const response = NextResponse.next();
+ response.headers.set("x-pathname", request.nextUrl.pathname);
+ return response;
+}
+
+export const config = {
+ // Exclut les assets statiques + API auth (qu'on ne veut pas modifier).
+ matcher: [
+ "/((?!_next/static|_next/image|favicon.ico|api/auth|api/health|api/metrics).*)",
+ ],
+};
diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts
index d470fff..e9771ae 100644
--- a/src/types/next-auth.d.ts
+++ b/src/types/next-auth.d.ts
@@ -8,16 +8,19 @@ declare module "next-auth" {
user: {
id: string;
role?: UserRole;
+ organizationId?: string | null;
} & DefaultSession["user"];
}
interface User {
role?: UserRole;
+ organizationId?: string | null;
}
}
declare module "next-auth/jwt" {
interface JWT extends DefaultJWT {
role?: UserRole;
+ organizationId?: string | null;
}
}
diff --git a/tests/lib/booking.test.ts b/tests/lib/booking.test.ts
new file mode 100644
index 0000000..b25c14d
--- /dev/null
+++ b/tests/lib/booking.test.ts
@@ -0,0 +1,107 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ DAY_MS,
+ enumerateUtcDays,
+ hasOverlap,
+ isPublicAllowedByDefaultPolicy,
+ isWeekendUtcDay,
+ normalizeUtcDayStart,
+ parseIsoDate,
+} from "@/lib/booking";
+
+describe("parseIsoDate", () => {
+ it("accepts ISO YYYY-MM-DD", () => {
+ const d = parseIsoDate("2026-06-15");
+ expect(d).toBeInstanceOf(Date);
+ expect(d?.toISOString().startsWith("2026-06-15")).toBe(true);
+ });
+
+ it("returns null for garbage input", () => {
+ expect(parseIsoDate("not a date")).toBeNull();
+ expect(parseIsoDate(null)).toBeNull();
+ expect(parseIsoDate(undefined)).toBeNull();
+ expect(parseIsoDate(123)).toBeNull();
+ });
+});
+
+describe("normalizeUtcDayStart", () => {
+ it("zeroes out time components", () => {
+ const d = new Date("2026-06-15T17:30:45.123Z");
+ const n = normalizeUtcDayStart(d);
+ expect(n.getUTCHours()).toBe(0);
+ expect(n.getUTCMinutes()).toBe(0);
+ expect(n.getUTCSeconds()).toBe(0);
+ expect(n.getUTCMilliseconds()).toBe(0);
+ expect(n.toISOString().slice(0, 10)).toBe("2026-06-15");
+ });
+});
+
+describe("hasOverlap", () => {
+ const d = (iso: string) => new Date(iso);
+
+ it("detects partial overlap", () => {
+ expect(
+ hasOverlap(d("2026-06-10"), d("2026-06-15"), d("2026-06-13"), d("2026-06-20")),
+ ).toBe(true);
+ });
+
+ it("returns false for adjacent intervals (touching)", () => {
+ expect(
+ hasOverlap(d("2026-06-10"), d("2026-06-15"), d("2026-06-15"), d("2026-06-20")),
+ ).toBe(false);
+ });
+
+ it("returns false for fully separate", () => {
+ expect(
+ hasOverlap(d("2026-06-01"), d("2026-06-05"), d("2026-06-10"), d("2026-06-15")),
+ ).toBe(false);
+ });
+
+ it("returns true when one contains the other", () => {
+ expect(
+ hasOverlap(d("2026-06-01"), d("2026-06-30"), d("2026-06-10"), d("2026-06-15")),
+ ).toBe(true);
+ });
+});
+
+describe("enumerateUtcDays", () => {
+ it("enumerates each day between start and end (exclusive)", () => {
+ const days = enumerateUtcDays(new Date("2026-06-10"), new Date("2026-06-13"));
+ expect(days.length).toBe(3);
+ expect(days[0].toISOString().slice(0, 10)).toBe("2026-06-10");
+ expect(days[2].toISOString().slice(0, 10)).toBe("2026-06-12");
+ });
+
+ it("returns empty when start === end", () => {
+ const days = enumerateUtcDays(new Date("2026-06-10"), new Date("2026-06-10"));
+ expect(days).toEqual([]);
+ });
+});
+
+describe("isWeekendUtcDay", () => {
+ it("flags Saturday (2026-06-13)", () => {
+ expect(isWeekendUtcDay(new Date("2026-06-13"))).toBe(true);
+ });
+ it("flags Sunday (2026-06-14)", () => {
+ expect(isWeekendUtcDay(new Date("2026-06-14"))).toBe(true);
+ });
+ it("rejects Monday (2026-06-15)", () => {
+ expect(isWeekendUtcDay(new Date("2026-06-15"))).toBe(false);
+ });
+});
+
+describe("isPublicAllowedByDefaultPolicy", () => {
+ it("blocks weekends by default (CE-priority policy)", () => {
+ expect(isPublicAllowedByDefaultPolicy(new Date("2026-06-13"))).toBe(false);
+ });
+ it("allows weekdays", () => {
+ expect(isPublicAllowedByDefaultPolicy(new Date("2026-06-15"))).toBe(true);
+ });
+});
+
+describe("DAY_MS constant", () => {
+ it("equals 86_400_000", () => {
+ expect(DAY_MS).toBe(86_400_000);
+ });
+});
diff --git a/tests/lib/ce-access.test.ts b/tests/lib/ce-access.test.ts
new file mode 100644
index 0000000..ecb89ad
--- /dev/null
+++ b/tests/lib/ce-access.test.ts
@@ -0,0 +1,111 @@
+import { describe, it, expect, vi } from "vitest";
+
+// L'enum est aussi un type ; on l'importe de manière statique pour TS.
+import { UserRole } from "@/generated/prisma/enums";
+
+// next-auth tire next/server qui n'est pas résolu dans le tunnel vitest.
+// On stubbe les modules nécessaires avant d'importer carbet-access (qui
+// importe Session de next-auth uniquement en type-only, mais authorization.ts
+// dépend de auth() — d'où le mock).
+vi.mock("next-auth", () => ({ default: () => ({}) }));
+vi.mock("@/auth", () => ({ auth: () => Promise.resolve(null) }));
+vi.mock("@/lib/authorization", () => ({
+ requireRole: () => Promise.resolve({}),
+}));
+
+const { canManageCarbet } = await import("@/lib/carbet-access");
+
+// Pure-data shape qui satisfait la signature de canManageCarbet sans tirer
+// next-auth/server (incompatible vitest sans setup).
+type MinimalSession = {
+ user: {
+ id: string;
+ role: UserRole;
+ organizationId?: string | null;
+ email?: string | null;
+ };
+};
+
+function makeSession(opts: {
+ userId: string;
+ role: UserRole;
+ organizationId?: string | null;
+}): MinimalSession {
+ return {
+ user: {
+ id: opts.userId,
+ role: opts.role,
+ organizationId: opts.organizationId ?? null,
+ email: "test@example.com",
+ },
+ };
+}
+
+describe("canManageCarbet", () => {
+ it("admin can always manage", () => {
+ const session = makeSession({ userId: "u-admin", role: UserRole.ADMIN });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-other", [])).toBe(true);
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-other", ["org-x"])).toBe(true);
+ });
+
+ it("owner can manage their own carbet", () => {
+ const session = makeSession({ userId: "u1", role: UserRole.OWNER });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u1", [])).toBe(true);
+ });
+
+ it("owner cannot manage someone else's carbet", () => {
+ const session = makeSession({ userId: "u1", role: UserRole.OWNER });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u2", [])).toBe(false);
+ });
+
+ it("CE_MANAGER can manage carbet linked to their org via membership", () => {
+ const session = makeSession({
+ userId: "u-ce",
+ role: UserRole.CE_MANAGER,
+ organizationId: "org-1",
+ });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-creator", ["org-1"])).toBe(true);
+ });
+
+ it("CE_MANAGER cannot manage carbet of another org", () => {
+ const session = makeSession({
+ userId: "u-ce",
+ role: UserRole.CE_MANAGER,
+ organizationId: "org-1",
+ });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-creator", ["org-2"])).toBe(false);
+ });
+
+ it("CE_MANAGER cannot manage when carbet has no memberships", () => {
+ const session = makeSession({
+ userId: "u-ce",
+ role: UserRole.CE_MANAGER,
+ organizationId: "org-1",
+ });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-creator", [])).toBe(false);
+ });
+
+ it("CE_MANAGER without organizationId cannot manage anything via membership", () => {
+ const session = makeSession({
+ userId: "u-ce",
+ role: UserRole.CE_MANAGER,
+ organizationId: null,
+ });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-creator", ["org-1"])).toBe(false);
+ });
+
+ it("TOURIST cannot manage", () => {
+ const session = makeSession({ userId: "u1", role: UserRole.TOURIST });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-other", ["org-1"])).toBe(false);
+ expect(canManageCarbet(session as unknown as Parameters[0], "u1", ["org-1"])).toBe(true); // matches as owner
+ });
+
+ it("CE_MANAGER can also manage as direct owner (rare but possible)", () => {
+ const session = makeSession({
+ userId: "u-ce",
+ role: UserRole.CE_MANAGER,
+ organizationId: "org-1",
+ });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-ce", [])).toBe(true);
+ });
+});
diff --git a/tests/lib/ce-invites.test.ts b/tests/lib/ce-invites.test.ts
new file mode 100644
index 0000000..1d22d79
--- /dev/null
+++ b/tests/lib/ce-invites.test.ts
@@ -0,0 +1,52 @@
+import { describe, it, expect, vi } from "vitest";
+
+vi.mock("server-only", () => ({}));
+vi.mock("@/lib/prisma", () => ({ prisma: {} }));
+
+const { hashToken, isInviteValid } = await import("@/lib/ce-invites");
+
+describe("hashToken", () => {
+ it("est déterministe — même input → même hash", () => {
+ expect(hashToken("abc")).toBe(hashToken("abc"));
+ });
+
+ it("hash sha256 (64 hex chars)", () => {
+ expect(hashToken("token")).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ it("inputs différents → hashes différents", () => {
+ expect(hashToken("abc")).not.toBe(hashToken("abd"));
+ });
+
+ it("ne retourne pas le plain (jamais persisté)", () => {
+ expect(hashToken("secret-plain-text")).not.toContain("secret");
+ });
+});
+
+describe("isInviteValid", () => {
+ const future = new Date(Date.now() + 24 * 3600 * 1000);
+ const past = new Date(Date.now() - 24 * 3600 * 1000);
+
+ it("vrai si non consommé et non expiré", () => {
+ expect(isInviteValid({ expiresAt: future, usedAt: null })).toBe(true);
+ });
+
+ it("faux si déjà consommé", () => {
+ expect(isInviteValid({ expiresAt: future, usedAt: new Date() })).toBe(false);
+ });
+
+ it("faux si expiré", () => {
+ expect(isInviteValid({ expiresAt: past, usedAt: null })).toBe(false);
+ });
+
+ it("faux si consommé ET expiré (les 2 raisons)", () => {
+ expect(isInviteValid({ expiresAt: past, usedAt: new Date() })).toBe(false);
+ });
+
+ it("accepte un `now` injecté pour tests temporels", () => {
+ const ref = new Date("2026-06-01");
+ const justAfter = new Date("2026-06-02");
+ expect(isInviteValid({ expiresAt: justAfter, usedAt: null }, ref)).toBe(true);
+ expect(isInviteValid({ expiresAt: ref, usedAt: null }, justAfter)).toBe(false);
+ });
+});
diff --git a/tests/lib/cron-auth.test.ts b/tests/lib/cron-auth.test.ts
new file mode 100644
index 0000000..e31e2fa
--- /dev/null
+++ b/tests/lib/cron-auth.test.ts
@@ -0,0 +1,47 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+vi.mock("server-only", () => ({}));
+
+const { isAuthorizedCronRequest } = await import("@/lib/cron-auth");
+
+function mkReq(authHeader: string | null): Request {
+ return new Request("https://example.invalid/", {
+ headers: authHeader ? { authorization: authHeader } : {},
+ });
+}
+
+afterEach(() => {
+ delete process.env.CRON_TOKEN;
+});
+
+describe("isAuthorizedCronRequest", () => {
+ it("refuse si CRON_TOKEN absent côté serveur", () => {
+ expect(isAuthorizedCronRequest(mkReq("Bearer anything"))).toBe(false);
+ });
+
+ it("refuse si pas d'en-tête Authorization", () => {
+ process.env.CRON_TOKEN = "secret";
+ expect(isAuthorizedCronRequest(mkReq(null))).toBe(false);
+ });
+
+ it("refuse si format incorrect (pas Bearer)", () => {
+ process.env.CRON_TOKEN = "secret";
+ expect(isAuthorizedCronRequest(mkReq("Basic secret"))).toBe(false);
+ expect(isAuthorizedCronRequest(mkReq("Token secret"))).toBe(false);
+ });
+
+ it("refuse si token différent", () => {
+ process.env.CRON_TOKEN = "secret";
+ expect(isAuthorizedCronRequest(mkReq("Bearer wrong"))).toBe(false);
+ });
+
+ it("accepte si token exact", () => {
+ process.env.CRON_TOKEN = "secret";
+ expect(isAuthorizedCronRequest(mkReq("Bearer secret"))).toBe(true);
+ });
+
+ it("trim les espaces autour du token (defensive)", () => {
+ process.env.CRON_TOKEN = "secret";
+ expect(isAuthorizedCronRequest(mkReq("Bearer secret "))).toBe(true);
+ });
+});
diff --git a/tests/lib/email.test.ts b/tests/lib/email.test.ts
new file mode 100644
index 0000000..f8a965d
--- /dev/null
+++ b/tests/lib/email.test.ts
@@ -0,0 +1,30 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+// Mock server-only avant l'import du module sous test (sinon ça jette).
+vi.mock("server-only", () => ({}));
+
+describe("sendEmail (dry-run sans RESEND_API_KEY)", () => {
+ beforeEach(() => {
+ delete process.env.RESEND_API_KEY;
+ vi.resetModules();
+ });
+
+ it("renvoie ok=true + reason=dry-run quand pas de clé", async () => {
+ const { sendEmail } = await import("@/lib/email");
+ const res = await sendEmail({
+ to: "test@example.com",
+ subject: "hello",
+ html: "world
",
+ });
+ expect(res.ok).toBe(true);
+ expect(res.reason).toBe("dry-run");
+ });
+
+ it("n'écrit pas d'erreur quand pas de clé", async () => {
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const { sendEmail } = await import("@/lib/email");
+ await sendEmail({ to: "x@y.z", subject: "s", html: "h" });
+ expect(errSpy).not.toHaveBeenCalled();
+ errSpy.mockRestore();
+ });
+});
diff --git a/tests/lib/image-variants.test.ts b/tests/lib/image-variants.test.ts
new file mode 100644
index 0000000..18fec19
--- /dev/null
+++ b/tests/lib/image-variants.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from "vitest";
+
+import { VARIANT_WIDTHS, buildSrcSet, variantS3Key, variantUrl } from "@/lib/image-variants";
+
+describe("VARIANT_WIDTHS", () => {
+ it("contient 320, 800, 1600", () => {
+ expect(VARIANT_WIDTHS).toEqual([320, 800, 1600]);
+ });
+});
+
+describe("variantUrl", () => {
+ it("transforme .jpg en -320.jpg", () => {
+ expect(variantUrl("https://x/y/abc.jpg", 320)).toBe("https://x/y/abc-320.jpg");
+ });
+ it("force JPEG sortie même pour PNG/WebP en input", () => {
+ expect(variantUrl("https://x/y/abc.png", 800)).toBe("https://x/y/abc-800.jpg");
+ expect(variantUrl("https://x/y/abc.webp", 1600)).toBe("https://x/y/abc-1600.jpg");
+ });
+ it("renvoie l'original si pas d'extension", () => {
+ expect(variantUrl("https://x/y/abc", 320)).toBe("https://x/y/abc");
+ });
+});
+
+describe("variantS3Key", () => {
+ it("transforme correctement la s3Key", () => {
+ expect(variantS3Key("carbets/foo/123-abc.jpg", 800)).toBe("carbets/foo/123-abc-800.jpg");
+ });
+});
+
+describe("buildSrcSet", () => {
+ it("contient les 3 variantes + fallback original", () => {
+ const set = buildSrcSet("https://x/abc.jpg");
+ expect(set).toContain("abc-320.jpg 320w");
+ expect(set).toContain("abc-800.jpg 800w");
+ expect(set).toContain("abc-1600.jpg 1600w");
+ expect(set).toContain("abc.jpg 2000w");
+ });
+});
diff --git a/tests/lib/password.test.ts b/tests/lib/password.test.ts
new file mode 100644
index 0000000..3232701
--- /dev/null
+++ b/tests/lib/password.test.ts
@@ -0,0 +1,27 @@
+import { describe, it, expect } from "vitest";
+
+import { hashPassword, verifyPassword } from "@/lib/password";
+
+describe("password hashing", () => {
+ it("round-trips a correct password", async () => {
+ const plain = "correct horse battery staple";
+ const hash = await hashPassword(plain);
+ expect(hash).not.toEqual(plain);
+ expect(hash.startsWith("$2")).toBe(true);
+ expect(await verifyPassword(plain, hash)).toBe(true);
+ });
+
+ it("rejects incorrect password", async () => {
+ const hash = await hashPassword("rightpass123");
+ expect(await verifyPassword("wrongpass", hash)).toBe(false);
+ });
+
+ it("produces different hashes for the same plaintext (salted)", async () => {
+ const plain = "samepw";
+ const a = await hashPassword(plain);
+ const b = await hashPassword(plain);
+ expect(a).not.toEqual(b);
+ expect(await verifyPassword(plain, a)).toBe(true);
+ expect(await verifyPassword(plain, b)).toBe(true);
+ });
+});
diff --git a/tests/lib/payouts.test.ts b/tests/lib/payouts.test.ts
new file mode 100644
index 0000000..dfc0cb9
--- /dev/null
+++ b/tests/lib/payouts.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect, vi } from "vitest";
+
+vi.mock("server-only", () => ({}));
+// payouts.ts importe prisma qui jette si DATABASE_URL absent — mock le module entier.
+vi.mock("@/lib/prisma", () => ({ prisma: {} }));
+
+const { monthKey, formatMonth } = await import("@/lib/payouts");
+
+describe("monthKey", () => {
+ it("normalise à minuit UTC du 1er du mois", () => {
+ const k = monthKey(new Date("2026-03-15T14:30:00Z"));
+ expect(k.getUTCFullYear()).toBe(2026);
+ expect(k.getUTCMonth()).toBe(2); // mars = 2 (0-indexed)
+ expect(k.getUTCDate()).toBe(1);
+ expect(k.getUTCHours()).toBe(0);
+ expect(k.getUTCMinutes()).toBe(0);
+ });
+
+ it("idempotent (mêmes inputs → même sortie)", () => {
+ const a = monthKey(new Date("2026-06-30T23:59:59Z"));
+ const b = monthKey(new Date("2026-06-01T00:00:00Z"));
+ expect(a.getTime()).toBe(b.getTime());
+ });
+
+ it("traverse janvier sans bug", () => {
+ const k = monthKey(new Date("2026-01-15T10:00:00Z"));
+ expect(k.toISOString().slice(0, 10)).toBe("2026-01-01");
+ });
+});
+
+describe("formatMonth", () => {
+ it("rend un libellé fr-FR lisible", () => {
+ const label = formatMonth(new Date(Date.UTC(2026, 5, 1)));
+ expect(label).toContain("juin");
+ expect(label).toContain("2026");
+ });
+});
diff --git a/tests/lib/rate-limit.test.ts b/tests/lib/rate-limit.test.ts
new file mode 100644
index 0000000..4b97e3e
--- /dev/null
+++ b/tests/lib/rate-limit.test.ts
@@ -0,0 +1,44 @@
+import { describe, it, expect } from "vitest";
+
+import { rateLimit } from "@/lib/rate-limit";
+
+describe("rateLimit", () => {
+ it("allows up to limit calls in window", () => {
+ const key = "test:" + Math.random();
+ for (let i = 0; i < 5; i++) {
+ const r = rateLimit({ key, windowMs: 60_000, limit: 5 });
+ expect(r.ok).toBe(true);
+ }
+ });
+
+ it("blocks the (limit+1)th call with retryAfter > 0", () => {
+ const key = "test:" + Math.random();
+ for (let i = 0; i < 3; i++) {
+ rateLimit({ key, windowMs: 60_000, limit: 3 });
+ }
+ const r = rateLimit({ key, windowMs: 60_000, limit: 3 });
+ expect(r.ok).toBe(false);
+ expect(r.retryAfter).toBeGreaterThan(0);
+ expect(r.remaining).toBe(0);
+ });
+
+ it("isolates different keys", () => {
+ const k1 = "test:" + Math.random();
+ const k2 = "test:" + Math.random();
+ for (let i = 0; i < 5; i++) {
+ rateLimit({ key: k1, windowMs: 60_000, limit: 5 });
+ }
+ const r = rateLimit({ key: k2, windowMs: 60_000, limit: 5 });
+ expect(r.ok).toBe(true);
+ });
+
+ it("resets after window expires", async () => {
+ const key = "test:" + Math.random();
+ rateLimit({ key, windowMs: 10, limit: 1 });
+ const blocked = rateLimit({ key, windowMs: 10, limit: 1 });
+ expect(blocked.ok).toBe(false);
+ await new Promise((r) => setTimeout(r, 15));
+ const after = rateLimit({ key, windowMs: 10, limit: 1 });
+ expect(after.ok).toBe(true);
+ });
+});
diff --git a/tests/lib/rental-refund.test.ts b/tests/lib/rental-refund.test.ts
new file mode 100644
index 0000000..05260a6
--- /dev/null
+++ b/tests/lib/rental-refund.test.ts
@@ -0,0 +1,95 @@
+import { describe, it, expect, vi } from "vitest";
+
+// `server-only` n'est pas résolu sous vitest — stub minimal.
+vi.mock("server-only", () => ({}));
+
+const { computeRentalRefund } = await import("@/lib/rental-refund");
+
+function daysFromNow(d: number): Date {
+ return new Date(Date.now() + d * 24 * 60 * 60 * 1000);
+}
+
+describe("computeRentalRefund", () => {
+ it("FULL refund quand annulation à 10+ jours du début", () => {
+ const r = computeRentalRefund({
+ startDate: daysFromNow(10),
+ itemsTotal: 100,
+ depositTotal: 50,
+ });
+ expect(r.policy).toBe("FULL");
+ expect(r.itemsRefund.toNumber()).toBe(100);
+ expect(r.depositRefund.toNumber()).toBe(50);
+ expect(r.totalRefund.toNumber()).toBe(150);
+ });
+
+ it("FULL refund pile à 7 jours du début", () => {
+ const r = computeRentalRefund({
+ startDate: daysFromNow(7),
+ itemsTotal: 200,
+ depositTotal: 100,
+ });
+ expect(r.policy).toBe("FULL");
+ expect(r.totalRefund.toNumber()).toBe(300);
+ });
+
+ it("PARTIAL_50 quand annulation entre 1 et 7 jours", () => {
+ const r = computeRentalRefund({
+ startDate: daysFromNow(3),
+ itemsTotal: 200,
+ depositTotal: 100,
+ });
+ expect(r.policy).toBe("PARTIAL_50");
+ expect(r.itemsRefund.toNumber()).toBe(100);
+ expect(r.depositRefund.toNumber()).toBe(100);
+ expect(r.totalRefund.toNumber()).toBe(200);
+ });
+
+ it("DEPOSIT_ONLY quand annulation < 24h", () => {
+ const r = computeRentalRefund({
+ startDate: daysFromNow(0.5),
+ itemsTotal: 200,
+ depositTotal: 100,
+ });
+ expect(r.policy).toBe("DEPOSIT_ONLY");
+ expect(r.itemsRefund.toNumber()).toBe(0);
+ expect(r.depositRefund.toNumber()).toBe(100);
+ expect(r.totalRefund.toNumber()).toBe(100);
+ });
+
+ it("DEPOSIT_ONLY quand startDate déjà passée", () => {
+ const r = computeRentalRefund({
+ startDate: daysFromNow(-1),
+ itemsTotal: 200,
+ depositTotal: 100,
+ });
+ expect(r.policy).toBe("DEPOSIT_ONLY");
+ expect(r.itemsRefund.toNumber()).toBe(0);
+ expect(r.depositRefund.toNumber()).toBe(100);
+ });
+
+ it("arrondit au centime pour PARTIAL_50", () => {
+ const r = computeRentalRefund({
+ startDate: daysFromNow(3),
+ itemsTotal: 33.33,
+ depositTotal: 0,
+ });
+ expect(r.itemsRefund.toNumber()).toBe(16.67); // 33.33 / 2 = 16.665 → arrondi 16.67
+ expect(r.totalRefund.toNumber()).toBe(16.67);
+ });
+
+ it("Zéro caution → totalRefund = itemsRefund", () => {
+ const r = computeRentalRefund({
+ startDate: daysFromNow(10),
+ itemsTotal: 50,
+ depositTotal: 0,
+ });
+ expect(r.depositRefund.toNumber()).toBe(0);
+ expect(r.totalRefund.toNumber()).toBe(50);
+ });
+
+ it("policyLabel contient un texte lisible pour chaque branche", () => {
+ expect(computeRentalRefund({ startDate: daysFromNow(10), itemsTotal: 100, depositTotal: 0 }).policyLabel).toContain("intégral");
+ expect(computeRentalRefund({ startDate: daysFromNow(3), itemsTotal: 100, depositTotal: 0 }).policyLabel).toContain("50");
+ expect(computeRentalRefund({ startDate: daysFromNow(0), itemsTotal: 100, depositTotal: 0 }).policyLabel).toContain("tardive");
+ });
+});
diff --git a/tests/lib/rentals.test.ts b/tests/lib/rentals.test.ts
new file mode 100644
index 0000000..121ee06
--- /dev/null
+++ b/tests/lib/rentals.test.ts
@@ -0,0 +1,105 @@
+import { describe, it, expect } from "vitest";
+
+import { diffDays, parseCart, serializeCart, EMPTY_CART } from "@/lib/rental-cart";
+
+describe("diffDays", () => {
+ it("renvoie 0 pour mêmes dates", () => {
+ expect(diffDays("2026-06-01", "2026-06-01")).toBe(0);
+ });
+ it("compte 1 nuit entre J et J+1", () => {
+ expect(diffDays("2026-06-01", "2026-06-02")).toBe(1);
+ });
+ it("compte 7 jours sur une semaine", () => {
+ expect(diffDays("2026-06-01", "2026-06-08")).toBe(7);
+ });
+ it("ne renvoie pas de valeur négative si end < start", () => {
+ expect(diffDays("2026-06-08", "2026-06-01")).toBe(0);
+ });
+});
+
+describe("parseCart", () => {
+ it("retourne EMPTY_CART pour null/undefined/garbage", () => {
+ expect(parseCart(null)).toEqual(EMPTY_CART);
+ expect(parseCart(undefined)).toEqual(EMPTY_CART);
+ expect(parseCart("")).toEqual(EMPTY_CART);
+ expect(parseCart("not json")).toEqual(EMPTY_CART);
+ });
+ it("retourne EMPTY_CART quand le schéma est invalide", () => {
+ expect(parseCart(JSON.stringify({ v: 99, items: [] }))).toEqual(EMPTY_CART);
+ expect(parseCart(JSON.stringify({ v: 1, items: "nope" }))).toEqual(EMPTY_CART);
+ });
+ it("accepte un panier valide", () => {
+ const valid = {
+ v: 1,
+ items: [
+ { itemId: "abc", qty: 2, startDate: "2026-06-01", endDate: "2026-06-03" },
+ ],
+ };
+ const out = parseCart(JSON.stringify(valid));
+ expect(out.items).toHaveLength(1);
+ expect(out.items[0].qty).toBe(2);
+ });
+ it("rejette une date au mauvais format", () => {
+ const bad = {
+ v: 1,
+ items: [{ itemId: "abc", qty: 1, startDate: "1/6/2026", endDate: "2026-06-03" }],
+ };
+ expect(parseCart(JSON.stringify(bad))).toEqual(EMPTY_CART);
+ });
+});
+
+describe("serializeCart", () => {
+ it("ajoute un updatedAt ISO", () => {
+ const s = serializeCart({ v: 1, items: [] });
+ const parsed = JSON.parse(s);
+ expect(parsed.v).toBe(1);
+ expect(parsed.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+ });
+ it("roundtrip parse(serialize(x)) === x sur les items", () => {
+ const cart = {
+ v: 1 as const,
+ items: [
+ { itemId: "k1", qty: 3, startDate: "2026-07-01", endDate: "2026-07-08" },
+ ],
+ };
+ const round = parseCart(serializeCart(cart));
+ expect(round.items).toEqual(cart.items);
+ });
+});
+
+// Calcul de commission (snapshot de la logique métier dans l'API checkout).
+// Ce test sert de garde-fou : si la formule change, faire évoluer aussi
+// `/api/rentals/checkout` (cf. commissionAmount = itemsTotal * pct / 100).
+describe("rental commission formula", () => {
+ function commission(itemsTotal: number, pct: number): number {
+ return Math.round((itemsTotal * pct) / 100 * 100) / 100;
+ }
+
+ it("0% commission System D", () => {
+ expect(commission(120, 0)).toBe(0);
+ });
+ it("15% sur 200€ = 30€", () => {
+ expect(commission(200, 15)).toBe(30);
+ });
+ it("arrondit au centime", () => {
+ expect(commission(33.33, 15)).toBe(5);
+ });
+});
+
+// Disponibilité : la quantité libre = totalQty - somme des qty bloquées
+// chevauchant la fenêtre. Snapshot de la logique de `getItemAvailability`.
+describe("rental availability arithmetic", () => {
+ function availableQty(totalQty: number, blockedQtys: number[]): number {
+ const used = blockedQtys.reduce((a, b) => a + b, 0);
+ return Math.max(0, totalQty - used);
+ }
+ it("totalQty quand rien n'est bloqué", () => {
+ expect(availableQty(5, [])).toBe(5);
+ });
+ it("soustrait les blocages", () => {
+ expect(availableQty(5, [2, 1])).toBe(2);
+ });
+ it("ne renvoie jamais de valeur négative", () => {
+ expect(availableQty(3, [5])).toBe(0);
+ });
+});
diff --git a/tests/lib/reviews.test.ts b/tests/lib/reviews.test.ts
new file mode 100644
index 0000000..d136b8e
--- /dev/null
+++ b/tests/lib/reviews.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ REVIEW_COMMENT_MAX,
+ REVIEW_HOST_RESPONSE_MAX,
+ REVIEW_RATING_MAX,
+ REVIEW_RATING_MIN,
+ formatAverageRating,
+ isValidRating,
+} from "@/lib/reviews";
+
+describe("rating constants", () => {
+ it("min=1 max=5", () => {
+ expect(REVIEW_RATING_MIN).toBe(1);
+ expect(REVIEW_RATING_MAX).toBe(5);
+ });
+ it("comment + host response caps are sensible", () => {
+ expect(REVIEW_COMMENT_MAX).toBeGreaterThan(0);
+ expect(REVIEW_HOST_RESPONSE_MAX).toBeGreaterThan(0);
+ });
+});
+
+describe("isValidRating", () => {
+ it("accepts integers 1-5", () => {
+ for (let i = REVIEW_RATING_MIN; i <= REVIEW_RATING_MAX; i++) {
+ expect(isValidRating(i)).toBe(true);
+ }
+ });
+ it("rejects out-of-range", () => {
+ expect(isValidRating(0)).toBe(false);
+ expect(isValidRating(6)).toBe(false);
+ expect(isValidRating(-1)).toBe(false);
+ });
+ it("rejects non-integers and non-numbers", () => {
+ expect(isValidRating(3.5)).toBe(false);
+ expect(isValidRating("3")).toBe(false);
+ expect(isValidRating(null)).toBe(false);
+ expect(isValidRating(undefined)).toBe(false);
+ });
+});
+
+describe("formatAverageRating", () => {
+ it("returns dash for null", () => {
+ expect(formatAverageRating(null)).toMatch(/—|-|n\/a/i);
+ });
+ it("formats a number with one decimal", () => {
+ const s = formatAverageRating(4.567);
+ expect(s).toMatch(/4[.,]/);
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..997df99
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from "vitest/config";
+import path from "node:path";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["tests/**/*.test.ts"],
+ exclude: ["node_modules", ".next", "dist"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "json-summary"],
+ include: ["src/lib/**/*.ts"],
+ exclude: ["src/lib/**/*.d.ts", "src/lib/admin/**", "src/lib/plugins/**"],
+ },
+ },
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+});