Compare commits
No commits in common. "main" and "feat/admin-home-editor" have entirely different histories.
main
...
feat/admin
102 changed files with 200 additions and 9233 deletions
|
|
@ -1,59 +0,0 @@
|
|||
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 <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"
|
||||
2460
package-lock.json
generated
2460
package-lock.json
generated
File diff suppressed because it is too large
Load diff
18
package.json
18
package.json
|
|
@ -7,30 +7,18 @@
|
|||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"postinstall": "prisma generate",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"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": {
|
||||
|
|
@ -38,13 +26,11 @@
|
|||
"@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",
|
||||
"vitest": "^3.2.4"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
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");
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
ALTER TABLE "Carbet" ADD COLUMN "nightlyPrice" DECIMAL(10,2) NOT NULL DEFAULT 0;
|
||||
UPDATE "Carbet" SET "nightlyPrice" = 80 WHERE "nightlyPrice" = 0;
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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';
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
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");
|
||||
|
|
@ -124,13 +124,6 @@ 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?
|
||||
|
|
@ -366,36 +359,3 @@ model Translation {
|
|||
@@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
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 208 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 KiB |
|
|
@ -1,60 +0,0 @@
|
|||
{
|
||||
"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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
#!/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)"
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { IfPluginEnabled } from "@/components/IfPluginEnabled";
|
||||
import { HeroSection } from "@/components/landing/HeroSection";
|
||||
import { ExperiencesSection } from "@/components/landing/ExperiencesSection";
|
||||
import { HowItWorksSection } from "@/components/landing/HowItWorksSection";
|
||||
import { CESection } from "@/components/landing/CESection";
|
||||
import { TestimonialsSection } from "@/components/landing/TestimonialsSection";
|
||||
import { LandingFooter } from "@/components/landing/Footer";
|
||||
|
||||
export const metadata = { title: "Accueil — Karbé" };
|
||||
|
||||
/**
|
||||
* Landing « marketing » historique (hero + sections + footer riche). Conservée
|
||||
* à /accueil après la promotion de /decouvrir comme nouvelle page d'index.
|
||||
*/
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<>
|
||||
<IfPluginEnabled
|
||||
plugin="landing-hero"
|
||||
fallback={
|
||||
<div className="flex flex-1 items-center justify-center bg-zinc-50 px-6 dark:bg-black">
|
||||
<main className="flex w-full max-w-2xl flex-col items-center gap-6 text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-black sm:text-5xl dark:text-zinc-50">
|
||||
Karbé — carbets fluviaux de Guyane
|
||||
</h1>
|
||||
<p className="max-w-xl text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
La marketplace pour louer des carbets le long des fleuves de Guyane.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/decouvrir"
|
||||
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Au fil de l'eau
|
||||
</Link>
|
||||
<Link
|
||||
href="/carbets"
|
||||
className="rounded-md border border-zinc-300 px-5 py-2.5 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-900"
|
||||
>
|
||||
Catalogue
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HeroSection />
|
||||
</IfPluginEnabled>
|
||||
|
||||
<IfPluginEnabled plugin="landing-sections">
|
||||
<ExperiencesSection />
|
||||
<HowItWorksSection />
|
||||
<CESection />
|
||||
<TestimonialsSection />
|
||||
<LandingFooter />
|
||||
</IfPluginEnabled>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ 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<string, unknown>) {
|
||||
await recordAudit({ scope: "admin.bookings", event, target, actorEmail: actor, details });
|
||||
|
|
@ -32,32 +31,11 @@ export async function updateBookingStatusAction(id: string, status: string) {
|
|||
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({
|
||||
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 };
|
||||
|
|
@ -82,26 +60,14 @@ export async function updateBookingPaymentAction(id: string, paymentStatus: stri
|
|||
export async function refundBookingAction(id: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
const updated = await prisma.booking.update({
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import Image from "next/image";
|
||||
import { addMediaAction, removeMediaAction, reorderMediaAction } from "../../actions";
|
||||
import { FormField, inputCls, selectCls } from "@/components/admin/FormField";
|
||||
|
||||
|
|
@ -124,7 +125,7 @@ export function MediaManager({ carbetId, media: initial }: { carbetId: string; m
|
|||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
{/* Le serveur calcule un s3Key déterministe à partir de l'URL si vide. */}
|
||||
<input type="hidden" name="s3Key" value={`external/${Date.now()}`} />
|
||||
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from "@/lib/admin/carbets";
|
||||
import { CarbetForm } from "../_components/CarbetForm";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { MediaUploader } from "@/components/MediaUploader";
|
||||
import { MediaManager } from "./_components/MediaManager";
|
||||
import { StatusActions } from "./_components/StatusActions";
|
||||
import { updateCarbetAction } from "../actions";
|
||||
|
||||
|
|
@ -61,21 +61,16 @@ export default async function EditCarbetPage({ params }: PageProps) {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Médias
|
||||
</h2>
|
||||
<MediaUploader
|
||||
carbetId={carbet.id}
|
||||
initialMedia={carbet.media.map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
s3Key: m.s3Key,
|
||||
s3Url: m.s3Url,
|
||||
sortOrder: m.sortOrder,
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
<MediaManager
|
||||
carbetId={carbet.id}
|
||||
media={carbet.media.map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
s3Key: m.s3Key,
|
||||
s3Url: m.s3Url,
|
||||
sortOrder: m.sortOrder,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<CarbetForm
|
||||
owners={owners}
|
||||
|
|
@ -92,7 +87,6 @@ export default async function EditCarbetPage({ params }: PageProps) {
|
|||
latitude: carbet.latitude.toString(),
|
||||
longitude: carbet.longitude.toString(),
|
||||
capacity: carbet.capacity,
|
||||
nightlyPrice: carbet.nightlyPrice.toString(),
|
||||
accessType: carbet.accessType,
|
||||
roadAccessNote: carbet.roadAccessNote,
|
||||
pirogueDurationMin: carbet.pirogueDurationMin,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ export type CarbetFormInitial = {
|
|||
latitude?: number | string;
|
||||
longitude?: number | string;
|
||||
capacity?: number;
|
||||
nightlyPrice?: number | string;
|
||||
accessType?: string;
|
||||
roadAccessNote?: string | null;
|
||||
pirogueDurationMin?: number | null;
|
||||
|
|
@ -189,9 +188,9 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* Séjour & tarif */}
|
||||
{/* Séjour */}
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Séjour & tarif</h2>
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Séjour</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<FormField label="Capacité" required hint="Voyageurs max">
|
||||
<input
|
||||
|
|
@ -204,17 +203,6 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe
|
|||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Prix / nuit (€)" required hint="Pour le carbet entier.">
|
||||
<input
|
||||
name="nightlyPrice"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
defaultValue={initial.nightlyPrice?.toString() ?? ""}
|
||||
className={inputCls}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Capacité min recommandée" hint="Facultatif">
|
||||
<input
|
||||
name="minCapacity"
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ const baseCarbetSchema = z.object({
|
|||
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]),
|
||||
roadAccessNote: z.string().trim().max(1000).optional().nullable(),
|
||||
pirogueDurationMin: z.coerce.number().int().min(0).max(1440).optional().nullable(),
|
||||
|
|
|
|||
|
|
@ -99,7 +99,6 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) {
|
|||
<th className="px-4 py-2 text-left font-semibold">Fleuve</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Accès</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Cap.</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">€/nuit</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Médias</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Résas</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Propriétaire</th>
|
||||
|
|
@ -110,7 +109,7 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) {
|
|||
<tbody className="divide-y divide-zinc-100">
|
||||
{carbets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||
Aucun carbet ne correspond aux filtres.
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -130,7 +129,6 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) {
|
|||
{c.accessType === AccessType.RIVER_ONLY ? "🛶 Fleuve" : "🛣️ Route+fleuve"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.capacity}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(c.nightlyPrice).toFixed(0)}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.mediaCount}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.bookingsCount}</td>
|
||||
<td className="px-4 py-2 text-zinc-700">{c.ownerName}</td>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import Link from "next/link";
|
||||
import { formatEur, getAdminKpis } from "@/lib/admin/kpis";
|
||||
import { KPICard } from "@/components/admin/KPICard";
|
||||
|
||||
|
|
@ -67,34 +66,34 @@ export default async function AdminDashboard() {
|
|||
</h2>
|
||||
<ul className="mt-3 grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
<li>
|
||||
<Link href="/admin/carbets" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||
<a href="/admin/carbets" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||
Gérer les carbets
|
||||
</Link>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/admin/bookings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||
<a href="/admin/bookings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||
Voir les réservations
|
||||
</Link>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/admin/content-pages" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||
<a href="/admin/content-pages" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||
Éditer les pages
|
||||
</Link>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/admin/plugins" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||
<a href="/admin/plugins" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||
Activer / désactiver des plugins
|
||||
</Link>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/admin/users" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||
<a href="/admin/users" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||
Modérer les utilisateurs
|
||||
</Link>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/admin/settings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||
<a href="/admin/settings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
|
||||
Paramètres
|
||||
</Link>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ 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";
|
||||
|
||||
|
|
@ -29,14 +27,6 @@ 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 });
|
||||
|
|
@ -88,9 +78,6 @@ export async function POST(request: Request) {
|
|||
ownerId: true,
|
||||
capacity: true,
|
||||
status: true,
|
||||
nightlyPrice: true,
|
||||
title: true,
|
||||
owner: { select: { email: true, firstName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -196,12 +183,6 @@ 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,
|
||||
|
|
@ -210,7 +191,7 @@ export async function POST(request: Request) {
|
|||
endDate,
|
||||
guestCount,
|
||||
status: BookingStatus.PENDING,
|
||||
amount: computedAmount.toFixed(2),
|
||||
amount: 0,
|
||||
currency: "EUR",
|
||||
},
|
||||
select: {
|
||||
|
|
@ -226,34 +207,5 @@ 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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,101 +1,7 @@
|
|||
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<Probe> {
|
||||
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<Probe> {
|
||||
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() {
|
||||
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 },
|
||||
);
|
||||
return NextResponse.json({ status: "ok" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,103 +0,0 @@
|
|||
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}"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
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 },
|
||||
});
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { hashPassword } from "@/lib/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { sendSignupWelcome } from "@/lib/email";
|
||||
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||
|
||||
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]).default(UserRole.TOURIST),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// 5 inscriptions max par IP par heure.
|
||||
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;
|
||||
|
||||
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);
|
||||
const 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 },
|
||||
});
|
||||
|
||||
await recordAudit({
|
||||
scope: "public.signup",
|
||||
event: "user.create",
|
||||
target: user.id,
|
||||
actorEmail: user.email,
|
||||
details: { role: user.role },
|
||||
});
|
||||
|
||||
// Best-effort welcome email.
|
||||
sendSignupWelcome(user.email, data.firstName).catch(() => {});
|
||||
|
||||
return NextResponse.json({ ok: true, userId: user.id });
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -12,15 +12,10 @@ 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 { 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";
|
||||
|
||||
|
|
@ -132,20 +127,6 @@ export default async function PublicCarbetPage({ params }: PageProps) {
|
|||
<CarbetGallery title={carbet.title} media={carbet.media} />
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-3 text-base font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Critères opérationnels
|
||||
</h2>
|
||||
<OperationalBadges
|
||||
roadAccess={carbet.roadAccess}
|
||||
capacity={carbet.capacity}
|
||||
electricity={carbet.electricity}
|
||||
gsmAtCarbet={carbet.gsmAtCarbet}
|
||||
gsmExitDistanceKm={carbet.gsmExitDistanceKm}
|
||||
variant="full"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="mt-10 grid gap-10 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<section>
|
||||
|
|
@ -162,25 +143,6 @@ export default async function PublicCarbetPage({ params }: PageProps) {
|
|||
provider={carbet.pirogueProvider}
|
||||
/>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-xl font-semibold text-zinc-900">
|
||||
Où se trouve ce carbet
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
Fleuve <strong>{carbet.river}</strong> · embarquement à{" "}
|
||||
<strong>{carbet.embarkPoint}</strong>
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<CarbetMap
|
||||
latitude={Number(carbet.latitude)}
|
||||
longitude={Number(carbet.longitude)}
|
||||
title={carbet.title}
|
||||
river={carbet.river}
|
||||
embarkPoint={carbet.embarkPoint}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{carbet.amenities.length > 0 ? (
|
||||
<section className="mt-10">
|
||||
<h2 className="text-xl font-semibold text-zinc-900">
|
||||
|
|
@ -264,16 +226,10 @@ export default async function PublicCarbetPage({ params }: PageProps) {
|
|||
</dl>
|
||||
</div>
|
||||
|
||||
<BookingForm
|
||||
carbetId={carbet.id}
|
||||
slug={carbet.slug}
|
||||
nightlyPrice={Number(carbet.nightlyPrice)}
|
||||
capacity={carbet.capacity}
|
||||
minStayNights={carbet.minStayNights}
|
||||
maxStayNights={carbet.maxStayNights}
|
||||
isAuthenticated={Boolean(viewerId)}
|
||||
stripeEnabled={isStripeConfigured()}
|
||||
/>
|
||||
<p className="rounded-md bg-emerald-50 px-3 py-2 text-xs text-emerald-800">
|
||||
La réservation en ligne arrive bientôt. En attendant, contactez
|
||||
l'équipe Karbé pour organiser votre séjour.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,245 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { MiniCalendar } from "./mini-calendar";
|
||||
|
||||
type Props = {
|
||||
carbetId: string;
|
||||
slug: string;
|
||||
nightlyPrice: number;
|
||||
capacity: number;
|
||||
minStayNights: number | null;
|
||||
maxStayNights: number | null;
|
||||
isAuthenticated: boolean;
|
||||
stripeEnabled: boolean;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function diffDays(a: string, b: string): number {
|
||||
if (!a || !b) return 0;
|
||||
const da = new Date(a + "T00:00:00Z").getTime();
|
||||
const db = new Date(b + "T00:00:00Z").getTime();
|
||||
return Math.round((db - da) / 86400000);
|
||||
}
|
||||
|
||||
export function BookingForm({
|
||||
carbetId,
|
||||
slug,
|
||||
nightlyPrice,
|
||||
capacity,
|
||||
minStayNights,
|
||||
maxStayNights,
|
||||
isAuthenticated,
|
||||
stripeEnabled,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [startDate, setStartDate] = useState<string | null>(null);
|
||||
const [endDate, setEndDate] = useState<string | null>(null);
|
||||
const [guestCount, setGuestCount] = useState(Math.min(2, capacity));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [blockedDates, setBlockedDates] = useState<Set<string>>(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<string>();
|
||||
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 (
|
||||
<div className="space-y-3 rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<span className="text-2xl font-semibold text-zinc-900">{nightlyPrice.toFixed(0)} €</span>
|
||||
<span className="ml-1 text-sm text-zinc-500">/ nuit</span>
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500">jusqu'à {capacity} voyageurs</span>
|
||||
</div>
|
||||
|
||||
<MiniCalendar
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
blockedDates={blockedDates}
|
||||
onChange={(s, e) => {
|
||||
setStartDate(s);
|
||||
setEndDate(e);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{datesSelected ? (
|
||||
<div className="flex items-center justify-between rounded-md bg-zinc-50 px-3 py-1.5 text-xs text-zinc-700">
|
||||
<span>
|
||||
<strong>{startDate}</strong> → <strong>{endDate}</strong>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStartDate(null);
|
||||
setEndDate(null);
|
||||
}}
|
||||
className="text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-zinc-500">Voyageurs</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={capacity}
|
||||
value={guestCount}
|
||||
onChange={(e) => setGuestCount(Math.max(1, Math.min(capacity, Number(e.target.value) || 1)))}
|
||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{datesSelected ? (
|
||||
<div className="space-y-1 border-t border-zinc-100 pt-3 text-sm text-zinc-700">
|
||||
<div className="flex justify-between">
|
||||
<span>
|
||||
{nightlyPrice.toFixed(0)} € × {nights} nuit{nights > 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="font-mono">{(nightlyPrice * nights).toFixed(2)} €</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-semibold text-zinc-900">
|
||||
<span>Total</span>
|
||||
<span className="font-mono">{total.toFixed(2)} €</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{datesSelected && !nightsOk ? (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800">
|
||||
Séjour entre {minN} et {maxN} nuits requis.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">{error}</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!canSubmit}
|
||||
className="w-full rounded-md bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{busy
|
||||
? "Envoi…"
|
||||
: !isAuthenticated
|
||||
? "Se connecter pour réserver"
|
||||
: stripeEnabled
|
||||
? "Payer et réserver"
|
||||
: "Réserver"}
|
||||
</button>
|
||||
|
||||
{!isAuthenticated ? (
|
||||
<p className="text-center text-xs text-zinc-500">
|
||||
Pas encore de compte ?{" "}
|
||||
<Link href={`/inscription?next=${encodeURIComponent(`/carbets/${slug}`)}`} className="text-zinc-900 underline">
|
||||
Créer un compte
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p className="text-center text-[11px] text-zinc-500">
|
||||
{stripeEnabled
|
||||
? "Vous serez redirigé vers Stripe pour le paiement sécurisé."
|
||||
: "Le créneau est bloqué dès l'envoi. Statut « En attente » jusqu'à confirmation."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,9 +3,7 @@ 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";
|
||||
|
|
@ -16,14 +14,13 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
|
|||
<article className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm transition hover:border-emerald-300 hover:shadow-md">
|
||||
<Link href={href} className="relative block aspect-[4/3] bg-zinc-100">
|
||||
{carbet.coverUrl ? (
|
||||
// Use a plain <img> here — uploaded media URLs come from MinIO/S3 and
|
||||
// don't go through next/image's optimizer in this environment.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={carbet.coverUrl}
|
||||
srcSet={buildSrcSet(carbet.coverUrl)}
|
||||
sizes="(min-width: 1024px) 320px, (min-width: 640px) 50vw, 100vw"
|
||||
alt={`Photo de ${carbet.title}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover transition group-hover:scale-[1.02]"
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -42,18 +39,9 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
|
|||
<AccessTypeBadge accessType={carbet.accessType} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
Fleuve {carbet.river}
|
||||
Fleuve {carbet.river} · {carbet.capacity} voyageur
|
||||
{carbet.capacity > 1 ? "s" : ""}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<OperationalBadges
|
||||
roadAccess={carbet.roadAccess}
|
||||
capacity={carbet.capacity}
|
||||
electricity={carbet.electricity}
|
||||
gsmAtCarbet={carbet.gsmAtCarbet}
|
||||
gsmExitDistanceKm={carbet.gsmExitDistanceKm}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<StayConstraints
|
||||
minNights={carbet.minStayNights}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,14 @@
|
|||
"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[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Galerie publique : grille de vignettes ; clic = lightbox plein écran avec
|
||||
* navigation prev/next, fermeture par Esc ou clic backdrop. Pas de dep externe.
|
||||
*/
|
||||
// SSR-friendly gallery: shows a cover (photo or video) plus a strip of
|
||||
// secondary media. No client component — all native HTML controls.
|
||||
export function CarbetGallery({ title, media }: Props) {
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
|
||||
const close = useCallback(() => setActive(null), []);
|
||||
const next = useCallback(() => {
|
||||
setActive((i) => (i === null ? null : (i + 1) % media.length));
|
||||
}, [media.length]);
|
||||
const prev = useCallback(() => {
|
||||
setActive((i) => (i === null ? null : (i - 1 + media.length) % media.length));
|
||||
}, [media.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active === null) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") close();
|
||||
else if (e.key === "ArrowRight") next();
|
||||
else if (e.key === "ArrowLeft") prev();
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [active, close, next, prev]);
|
||||
|
||||
if (media.length === 0) {
|
||||
return (
|
||||
<div className="flex aspect-[16/9] w-full items-center justify-center rounded-lg bg-zinc-100 text-sm text-zinc-400">
|
||||
|
|
@ -49,159 +17,57 @@ export function CarbetGallery({ title, media }: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
const cover = media[0];
|
||||
const rest = media.slice(1);
|
||||
const current = active === null ? null : media[active];
|
||||
const [cover, ...rest] = media;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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 ? (
|
||||
<video
|
||||
src={cover.url}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="aspect-[16/9] w-full bg-black object-contain"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={cover.url}
|
||||
srcSet={buildSrcSet(cover.url)}
|
||||
sizes="(min-width: 768px) 800px, 100vw"
|
||||
alt={`Photo principale de ${title}`}
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
className="aspect-[16/9] w-full cursor-zoom-in object-cover"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<div className="space-y-3">
|
||||
<figure className="overflow-hidden rounded-lg bg-zinc-100">
|
||||
{cover.type === MediaType.VIDEO ? (
|
||||
<video
|
||||
src={cover.url}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="aspect-[16/9] w-full bg-black object-contain"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={cover.url}
|
||||
alt={`Photo principale de ${title}`}
|
||||
className="aspect-[16/9] w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</figure>
|
||||
|
||||
{rest.length > 0 ? (
|
||||
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{rest.map((item, idx) => (
|
||||
<li key={item.id} className="overflow-hidden rounded-md bg-zinc-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive(idx + 1)}
|
||||
className="block w-full"
|
||||
aria-label="Ouvrir en grand"
|
||||
>
|
||||
{item.type === MediaType.VIDEO ? (
|
||||
<video
|
||||
src={item.url}
|
||||
preload="metadata"
|
||||
controls
|
||||
playsInline
|
||||
className="aspect-square w-full bg-black object-contain"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={item.url}
|
||||
srcSet={buildSrcSet(item.url)}
|
||||
sizes="(min-width: 640px) 200px, 50vw"
|
||||
alt={`Média de ${title}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="aspect-square w-full cursor-zoom-in object-cover transition hover:scale-105"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{current ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm"
|
||||
onClick={close}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Galerie photo"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M6 6 L18 18 M6 18 L18 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{media.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
prev();
|
||||
}}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
|
||||
aria-label="Précédent"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M15 6 L9 12 L15 18" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
next();
|
||||
}}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
|
||||
aria-label="Suivant"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M9 6 L15 12 L9 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="max-h-[88vh] max-w-[92vw]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{current.type === MediaType.VIDEO ? (
|
||||
<video
|
||||
src={current.url}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="max-h-[88vh] max-w-[92vw] object-contain"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={current.url}
|
||||
srcSet={buildSrcSet(current.url)}
|
||||
sizes="(min-width: 1200px) 1600px, 92vw"
|
||||
alt={`Photo ${active! + 1} sur ${media.length} de ${title}`}
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
className="max-h-[88vh] max-w-[92vw] object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white">
|
||||
{active! + 1} / {media.length}
|
||||
</div>
|
||||
</div>
|
||||
{rest.length > 0 ? (
|
||||
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{rest.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="overflow-hidden rounded-md bg-zinc-100"
|
||||
>
|
||||
{item.type === MediaType.VIDEO ? (
|
||||
<video
|
||||
src={item.url}
|
||||
preload="metadata"
|
||||
controls
|
||||
playsInline
|
||||
className="aspect-square w-full bg-black object-contain"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={item.url}
|
||||
alt={`Média de ${title}`}
|
||||
loading="lazy"
|
||||
className="aspect-square w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Fix icône Leaflet (les paths par défaut pointent vers un CDN qui n'existe plus).
|
||||
// On utilise un SVG inline en data URL.
|
||||
const ICON = L.divIcon({
|
||||
className: "karbe-leaflet-marker",
|
||||
html: `
|
||||
<div style="
|
||||
width:32px;height:32px;
|
||||
transform:translate(-50%,-100%);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
">
|
||||
<svg viewBox="0 0 32 40" width="32" height="40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 0 C7 0 0 7 0 16 C0 26 16 40 16 40 C16 40 32 26 32 16 C32 7 25 0 16 0 Z"
|
||||
fill="#059669" stroke="#064e3b" stroke-width="1.5"/>
|
||||
<circle cx="16" cy="15" r="5" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [32, 40],
|
||||
iconAnchor: [16, 40],
|
||||
popupAnchor: [0, -36],
|
||||
});
|
||||
|
||||
type Props = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
title: string;
|
||||
river: string;
|
||||
embarkPoint: string;
|
||||
};
|
||||
|
||||
export function CarbetMapInner({ latitude, longitude, title, river, embarkPoint }: Props) {
|
||||
const position: [number, number] = [latitude, longitude];
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-200">
|
||||
<MapContainer
|
||||
center={position}
|
||||
zoom={11}
|
||||
scrollWheelZoom={false}
|
||||
style={{ height: 280, width: "100%" }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker position={position} icon={ICON}>
|
||||
<Popup>
|
||||
<strong>{title}</strong>
|
||||
<br />
|
||||
<span className="text-xs">Fleuve {river}</span>
|
||||
<br />
|
||||
<span className="text-xs text-zinc-600">Embarquement : {embarkPoint}</span>
|
||||
<br />
|
||||
<a
|
||||
href={`https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=14/${latitude}/${longitude}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-emerald-700 underline"
|
||||
>
|
||||
Ouvrir dans OpenStreetMap ↗
|
||||
</a>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Carte interactive sur la fiche carbet — Leaflet + OpenStreetMap.
|
||||
*
|
||||
* Chargée dynamiquement (ssr:false) car Leaflet manipule window.
|
||||
*/
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const CarbetMapInner = dynamic(
|
||||
() => import("./carbet-map-inner").then((m) => m.CarbetMapInner),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-[280px] w-full animate-pulse rounded-lg bg-zinc-100" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
type Props = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
title: string;
|
||||
river: string;
|
||||
embarkPoint: string;
|
||||
};
|
||||
|
||||
export function CarbetMap(props: Props) {
|
||||
return <CarbetMapInner {...props} />;
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
|
||||
import L, { LatLngBoundsExpression } from "leaflet";
|
||||
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
import type { CatalogMapPoint } from "./catalog-map";
|
||||
|
||||
const ICON = L.divIcon({
|
||||
className: "karbe-catalog-marker",
|
||||
html: `
|
||||
<div style="
|
||||
width:28px;height:36px;
|
||||
transform:translate(-50%,-100%);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
">
|
||||
<svg viewBox="0 0 32 40" width="28" height="36" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 0 C7 0 0 7 0 16 C0 26 16 40 16 40 C16 40 32 26 32 16 C32 7 25 0 16 0 Z"
|
||||
fill="#059669" stroke="#064e3b" stroke-width="1.5"/>
|
||||
<circle cx="16" cy="15" r="5" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [28, 36],
|
||||
iconAnchor: [14, 36],
|
||||
popupAnchor: [0, -32],
|
||||
});
|
||||
|
||||
export function CatalogMapInner({ points }: { points: CatalogMapPoint[] }) {
|
||||
const bounds = useMemo<LatLngBoundsExpression>(() => {
|
||||
if (points.length === 0) {
|
||||
// Centre par défaut sur la Guyane (Cayenne).
|
||||
return [
|
||||
[3.5, -54.5],
|
||||
[5.5, -52.0],
|
||||
];
|
||||
}
|
||||
const lats = points.map((p) => p.latitude);
|
||||
const lngs = points.map((p) => p.longitude);
|
||||
const minLat = Math.min(...lats);
|
||||
const maxLat = Math.max(...lats);
|
||||
const minLng = Math.min(...lngs);
|
||||
const maxLng = Math.max(...lngs);
|
||||
// Padding 0.1°
|
||||
return [
|
||||
[minLat - 0.1, minLng - 0.1],
|
||||
[maxLat + 0.1, maxLng + 0.1],
|
||||
];
|
||||
}, [points]);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-200">
|
||||
<MapContainer
|
||||
bounds={bounds}
|
||||
scrollWheelZoom={false}
|
||||
style={{ height: 360, width: "100%" }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{points.map((p) => (
|
||||
<Marker key={p.id} position={[p.latitude, p.longitude]} icon={ICON}>
|
||||
<Popup>
|
||||
<div style={{ minWidth: 180 }}>
|
||||
{p.coverUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={p.coverUrl}
|
||||
alt={p.title}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 110,
|
||||
objectFit: "cover",
|
||||
borderRadius: 4,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<strong>{p.title}</strong>
|
||||
<br />
|
||||
<span style={{ fontSize: 11, color: "#71717a" }}>
|
||||
Fleuve {p.river}
|
||||
</span>
|
||||
<br />
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
{Number(p.nightlyPrice).toFixed(0)} €
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "#71717a" }}> / nuit</span>
|
||||
<br />
|
||||
<Link
|
||||
href={`/carbets/${p.slug}`}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
marginTop: 6,
|
||||
color: "#059669",
|
||||
fontWeight: 600,
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
Voir la fiche →
|
||||
</Link>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const CatalogMapInner = dynamic(
|
||||
() => import("./catalog-map-inner").then((m) => m.CatalogMapInner),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-[360px] w-full animate-pulse rounded-lg bg-zinc-100" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export type CatalogMapPoint = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
river: string;
|
||||
nightlyPrice: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
coverUrl: string | null;
|
||||
};
|
||||
|
||||
export function CatalogMap({ points }: { points: CatalogMapPoint[] }) {
|
||||
if (points.length === 0) return null;
|
||||
return <CatalogMapInner points={points} />;
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
blockedDates: Set<string>;
|
||||
onChange: (start: string | null, end: string | null) => void;
|
||||
};
|
||||
|
||||
const MONTH_LABEL = [
|
||||
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
|
||||
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
|
||||
];
|
||||
const DOW_LABEL = ["L", "M", "M", "J", "V", "S", "D"];
|
||||
|
||||
function isoDay(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function startOfMonth(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
||||
}
|
||||
|
||||
function addMonths(d: Date, n: number): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
|
||||
}
|
||||
|
||||
/** Génère la grille du mois : 6 semaines × 7 jours, en commençant un lundi. */
|
||||
function monthGrid(monthStart: Date): (Date | null)[] {
|
||||
const year = monthStart.getUTCFullYear();
|
||||
const month = monthStart.getUTCMonth();
|
||||
// Premier jour du mois — décale pour que la semaine commence un lundi (0=L, 6=D)
|
||||
const firstDay = new Date(Date.UTC(year, month, 1));
|
||||
const firstDow = (firstDay.getUTCDay() + 6) % 7;
|
||||
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||||
const cells: (Date | null)[] = [];
|
||||
for (let i = 0; i < firstDow; i++) cells.push(null);
|
||||
for (let d = 1; d <= lastDay; d++) {
|
||||
cells.push(new Date(Date.UTC(year, month, d)));
|
||||
}
|
||||
while (cells.length % 7 !== 0) cells.push(null);
|
||||
// Toujours 6 lignes pour éviter le saut de hauteur
|
||||
while (cells.length < 42) cells.push(null);
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function MiniCalendar({ startDate, endDate, blockedDates, onChange }: Props) {
|
||||
const today = useMemo(() => {
|
||||
const d = new Date();
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
}, []);
|
||||
|
||||
const [viewMonth, setViewMonth] = useState<Date>(() => {
|
||||
const ref = startDate ? new Date(startDate + "T00:00:00Z") : today;
|
||||
return startOfMonth(ref);
|
||||
});
|
||||
|
||||
const cells = useMemo(() => monthGrid(viewMonth), [viewMonth]);
|
||||
|
||||
const startISO = startDate;
|
||||
const endISO = endDate;
|
||||
|
||||
function onClick(day: Date) {
|
||||
const iso = isoDay(day);
|
||||
if (day.getTime() < today.getTime()) return;
|
||||
if (blockedDates.has(iso)) return;
|
||||
|
||||
// Aucune sélection ou les deux déjà posées → reset + nouvelle start
|
||||
if (!startISO || (startISO && endISO)) {
|
||||
onChange(iso, null);
|
||||
return;
|
||||
}
|
||||
// Une seule (start) déjà sélectionnée
|
||||
if (iso === startISO) {
|
||||
onChange(null, null);
|
||||
return;
|
||||
}
|
||||
if (iso < startISO) {
|
||||
onChange(iso, null);
|
||||
return;
|
||||
}
|
||||
// Vérifie qu'aucun jour intermédiaire n'est bloqué
|
||||
const startMs = new Date(startISO + "T00:00:00Z").getTime();
|
||||
const endMs = day.getTime();
|
||||
for (let t = startMs; t < endMs; t += 86_400_000) {
|
||||
const d = new Date(t).toISOString().slice(0, 10);
|
||||
if (blockedDates.has(d)) {
|
||||
// Tombe sur un jour bloqué → on resélectionne start
|
||||
onChange(iso, null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
onChange(startISO, iso);
|
||||
}
|
||||
|
||||
const canGoBack = viewMonth > startOfMonth(today);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-200 bg-white p-2">
|
||||
<header className="mb-1 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGoBack}
|
||||
onClick={() => setViewMonth(addMonths(viewMonth, -1))}
|
||||
className="rounded p-1 text-zinc-600 hover:bg-zinc-100 disabled:opacity-30"
|
||||
aria-label="Mois précédent"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M15 6 L9 12 L15 18" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-zinc-900">
|
||||
{MONTH_LABEL[viewMonth.getUTCMonth()]} {viewMonth.getUTCFullYear()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMonth(addMonths(viewMonth, 1))}
|
||||
className="rounded p-1 text-zinc-600 hover:bg-zinc-100"
|
||||
aria-label="Mois suivant"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M9 6 L15 12 L9 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0.5 text-center text-[10px] uppercase tracking-wider text-zinc-400">
|
||||
{DOW_LABEL.map((d, i) => (
|
||||
<div key={i} className="py-0.5">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{cells.map((cell, i) => {
|
||||
if (!cell) return <div key={i} className="h-7" />;
|
||||
const iso = isoDay(cell);
|
||||
const isPast = cell.getTime() < today.getTime();
|
||||
const isBlocked = blockedDates.has(iso);
|
||||
const isStart = iso === startISO;
|
||||
const isEnd = iso === endISO;
|
||||
const inRange = startISO && endISO && iso > startISO && iso < endISO;
|
||||
const isToday = iso === isoDay(today);
|
||||
const disabled = isPast || isBlocked;
|
||||
|
||||
let cls =
|
||||
"relative h-7 rounded text-xs flex items-center justify-center transition";
|
||||
if (disabled) {
|
||||
cls += " text-zinc-300 cursor-not-allowed";
|
||||
if (isBlocked && !isPast) cls += " line-through";
|
||||
} else if (isStart || isEnd) {
|
||||
cls += " bg-emerald-600 text-white font-semibold cursor-pointer";
|
||||
} else if (inRange) {
|
||||
cls += " bg-emerald-100 text-emerald-900 cursor-pointer";
|
||||
} else {
|
||||
cls += " text-zinc-800 hover:bg-zinc-100 cursor-pointer";
|
||||
if (isToday) cls += " ring-1 ring-zinc-400";
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onClick(cell)}
|
||||
className={cls}
|
||||
>
|
||||
{cell.getUTCDate()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-[11px] text-zinc-500">
|
||||
{!startISO
|
||||
? "Choisissez votre date d'arrivée."
|
||||
: !endISO
|
||||
? "Choisissez votre date de départ."
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
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;
|
||||
|
|
@ -63,165 +61,18 @@ export function SearchFilters({ filters, rivers }: SearchFiltersProps) {
|
|||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-zinc-700">Voyageurs min</span>
|
||||
<span className="font-medium text-zinc-700">Voyageurs</span>
|
||||
<input
|
||||
type="number"
|
||||
name="capacity"
|
||||
min={1}
|
||||
max={100}
|
||||
defaultValue={filters.capacity ?? ""}
|
||||
placeholder="Au moins"
|
||||
placeholder="Nombre min."
|
||||
className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-900 focus:border-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-zinc-700">Voyageurs max</span>
|
||||
<input
|
||||
type="number"
|
||||
name="capacityMax"
|
||||
min={1}
|
||||
max={100}
|
||||
defaultValue={filters.capacityMax ?? ""}
|
||||
placeholder="Au plus"
|
||||
className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-900 focus:border-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-zinc-700">Budget max (€/nuit)</span>
|
||||
<input
|
||||
type="number"
|
||||
name="priceMax"
|
||||
min={1}
|
||||
step="10"
|
||||
defaultValue={filters.priceMax ?? ""}
|
||||
placeholder="ex. 100"
|
||||
className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-900 focus:border-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<fieldset className="flex flex-col gap-1 text-sm sm:col-span-2 lg:col-span-5">
|
||||
<legend className="font-medium text-zinc-700">Accès route</legend>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{[
|
||||
{ value: RoadAccess.ALL_YEAR, label: "🛣️ Route toute saison" },
|
||||
{ value: RoadAccess.DRY_SEASON_ONLY, label: "🟠 Route saison sèche" },
|
||||
{ value: RoadAccess.NONE, label: "🛶 Pirogue uniquement" },
|
||||
].map((opt) => {
|
||||
const checked = (filters.roadAccess ?? []).includes(opt.value);
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={
|
||||
"flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs " +
|
||||
(checked
|
||||
? "border-emerald-600 bg-emerald-50 text-emerald-900"
|
||||
: "border-zinc-300 bg-white text-zinc-700 hover:border-zinc-400")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="roadAccess"
|
||||
value={opt.value}
|
||||
defaultChecked={checked}
|
||||
className="sr-only"
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="flex flex-col gap-1 text-sm sm:col-span-2 lg:col-span-5">
|
||||
<legend className="font-medium text-zinc-700">Électricité</legend>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{[
|
||||
{ value: Electricity.EDF, label: "⚡ EDF / raccordé" },
|
||||
{ value: Electricity.GENERATOR_READY, label: "🔌 Préinstall groupe" },
|
||||
{ value: Electricity.SOLAR, label: "☀️ Solaire" },
|
||||
{ value: Electricity.NONE, label: "🕯️ Aucune" },
|
||||
].map((opt) => {
|
||||
const checked = (filters.electricity ?? []).includes(opt.value);
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={
|
||||
"flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs " +
|
||||
(checked
|
||||
? "border-emerald-600 bg-emerald-50 text-emerald-900"
|
||||
: "border-zinc-300 bg-white text-zinc-700 hover:border-zinc-400")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="electricity"
|
||||
value={opt.value}
|
||||
defaultChecked={checked}
|
||||
className="sr-only"
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm sm:col-span-2 lg:col-span-5">
|
||||
<span className="font-medium text-zinc-700">
|
||||
📶 Réseau GSM accessible — distance max{" "}
|
||||
<span className="font-mono text-emerald-700">
|
||||
{filters.gsmMaxKm === 0 ? "(au carbet)" : filters.gsmMaxKm ? `≤ ${filters.gsmMaxKm} km` : "(non filtré)"}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-zinc-500">Au carbet</span>
|
||||
<input
|
||||
type="range"
|
||||
name="gsmMaxKm"
|
||||
min={0}
|
||||
max={10}
|
||||
step={0.5}
|
||||
defaultValue={filters.gsmMaxKm ?? ""}
|
||||
className="flex-1 accent-emerald-600"
|
||||
/>
|
||||
<span className="text-xs text-zinc-500">10 km</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-500">
|
||||
0 km = exige le réseau directement au carbet · 10 km = peu importe.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<fieldset className="flex flex-col gap-1 text-sm sm:col-span-2 lg:col-span-5">
|
||||
<legend className="font-medium text-zinc-700">Équipements souhaités</legend>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{AMENITY_CATALOG.map((a) => {
|
||||
const checked = (filters.amenities ?? []).includes(a.key);
|
||||
return (
|
||||
<label
|
||||
key={a.key}
|
||||
className={
|
||||
"flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs " +
|
||||
(checked
|
||||
? "border-emerald-600 bg-emerald-50 text-emerald-900"
|
||||
: "border-zinc-300 bg-white text-zinc-700 hover:border-zinc-400")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="amenities"
|
||||
value={a.key}
|
||||
defaultChecked={checked}
|
||||
className="sr-only"
|
||||
/>
|
||||
{a.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5 lg:justify-end">
|
||||
<Link
|
||||
href="/carbets"
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { SEARCH_PROFILES, buildProfileUrl } from "@/lib/search-profiles";
|
||||
|
||||
export function SearchProfiles() {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 text-xs uppercase tracking-wider text-zinc-500">
|
||||
Profils de séjour
|
||||
</div>
|
||||
<ul className="-mx-1 flex flex-wrap gap-1.5 px-1">
|
||||
{SEARCH_PROFILES.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link
|
||||
href={buildProfileUrl(p.id)}
|
||||
title={p.description}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-zinc-200 bg-white px-3 py-1.5 text-sm text-zinc-800 transition hover:border-emerald-400 hover:bg-emerald-50 hover:text-emerald-900"
|
||||
>
|
||||
<span aria-hidden>{p.emoji}</span>
|
||||
<span className="font-medium">{p.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,9 +8,7 @@ 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",
|
||||
|
|
@ -58,7 +56,6 @@ export default async function CarbetsSearchPage({
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<SearchProfiles />
|
||||
<SearchFilters filters={filters} rivers={rivers} />
|
||||
|
||||
<section className="mt-8" aria-live="polite">
|
||||
|
|
@ -75,20 +72,6 @@ export default async function CarbetsSearchPage({
|
|||
{results.length} carbet{results.length > 1 ? "s" : ""} trouvé
|
||||
{results.length > 1 ? "s" : ""}.
|
||||
</p>
|
||||
<div className="mb-6">
|
||||
<CatalogMap
|
||||
points={results.map((c) => ({
|
||||
id: c.id,
|
||||
slug: c.slug,
|
||||
title: c.title,
|
||||
river: c.river,
|
||||
nightlyPrice: c.nightlyPrice,
|
||||
latitude: c.latitude,
|
||||
longitude: c.longitude,
|
||||
coverUrl: c.coverUrl,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{results.map((carbet) => (
|
||||
<li key={carbet.id}>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth, signIn } from "@/auth";
|
||||
|
||||
type Props = { searchParams: Promise<{ next?: string }> };
|
||||
|
||||
export default async function SignInPage({ searchParams }: Props) {
|
||||
export default async function SignInPage() {
|
||||
const session = await auth();
|
||||
const sp = await searchParams;
|
||||
const next = sp.next && sp.next.startsWith("/") ? sp.next : "/";
|
||||
if (session?.user?.id) {
|
||||
redirect(next);
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -53,20 +48,6 @@ export default async function SignInPage({ searchParams }: Props) {
|
|||
>
|
||||
Se connecter
|
||||
</button>
|
||||
<p className="text-center text-xs text-zinc-500">
|
||||
<Link href="/mot-de-passe-oublie" className="hover:text-zinc-900 underline">
|
||||
Mot de passe oublié ?
|
||||
</Link>
|
||||
</p>
|
||||
<p className="border-t border-zinc-100 pt-3 text-center text-sm text-zinc-500">
|
||||
Pas encore de compte ?{" "}
|
||||
<Link
|
||||
href={`/inscription${next !== "/" ? `?next=${encodeURIComponent(next)}` : ""}`}
|
||||
className="text-zinc-900 underline"
|
||||
>
|
||||
Créer un compte
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,403 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import type { ReelCarbet } from "@/lib/reels";
|
||||
import { buildSrcSet } from "@/lib/image-variants";
|
||||
|
||||
type Props = {
|
||||
carbet: ReelCarbet;
|
||||
isActive: boolean;
|
||||
shouldPreload: boolean;
|
||||
isFavorite: boolean;
|
||||
onToggleFavorite: () => void;
|
||||
};
|
||||
|
||||
const SWIPE_THRESHOLD_RATIO = 0.18; // % de la largeur pour valider le swipe
|
||||
const VELOCITY_THRESHOLD = 0.4; // px/ms — un flick rapide même court valide
|
||||
|
||||
export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggleFavorite }: Props) {
|
||||
const [mediaIndex, setMediaIndex] = useState(0);
|
||||
const [muted, setMuted] = useState(true);
|
||||
const [dragX, setDragX] = useState(0);
|
||||
const [transitioning, setTransitioning] = useState(false);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const videoRefs = useRef<Map<number, HTMLVideoElement>>(new Map());
|
||||
const drag = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
startTime: number;
|
||||
locked: "horizontal" | "vertical" | null;
|
||||
} | null>(null);
|
||||
|
||||
const total = carbet.media.length;
|
||||
const current = carbet.media[mediaIndex];
|
||||
|
||||
const goTo = useCallback(
|
||||
(next: number, animated = true) => {
|
||||
const clamped = ((next % total) + total) % total;
|
||||
setTransitioning(animated);
|
||||
setMediaIndex(clamped);
|
||||
setDragX(0);
|
||||
},
|
||||
[total],
|
||||
);
|
||||
|
||||
const nextMedia = useCallback(() => goTo(mediaIndex + 1), [goTo, mediaIndex]);
|
||||
const prevMedia = useCallback(() => goTo(mediaIndex - 1), [goTo, mediaIndex]);
|
||||
|
||||
// Suit la largeur du container pour les calculs de seuils / progress
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setContainerWidth(el.offsetWidth || window.innerWidth);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-play/pause vidéos selon média actif
|
||||
useEffect(() => {
|
||||
videoRefs.current.forEach((video, idx) => {
|
||||
if (idx === mediaIndex && isActive && carbet.media[idx]?.type === "VIDEO") {
|
||||
video.play().catch(() => {});
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
});
|
||||
}, [isActive, mediaIndex, carbet.media]);
|
||||
|
||||
// Reset au changement de slide carbet (différé pour éviter cascading renders)
|
||||
useEffect(() => {
|
||||
if (isActive) return;
|
||||
queueMicrotask(() => goTo(0, false));
|
||||
}, [isActive, goTo]);
|
||||
|
||||
// Navigation clavier ← →
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea") return;
|
||||
if (e.key === "ArrowRight" || e.key === "l") {
|
||||
e.preventDefault();
|
||||
nextMedia();
|
||||
} else if (e.key === "ArrowLeft" || e.key === "h") {
|
||||
e.preventDefault();
|
||||
prevMedia();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [isActive, nextMedia, prevMedia]);
|
||||
|
||||
function onTouchStart(e: React.TouchEvent) {
|
||||
const t = e.touches[0];
|
||||
drag.current = {
|
||||
startX: t.clientX,
|
||||
startY: t.clientY,
|
||||
startTime: Date.now(),
|
||||
locked: null,
|
||||
};
|
||||
setTransitioning(false);
|
||||
}
|
||||
|
||||
function onTouchMove(e: React.TouchEvent) {
|
||||
if (!drag.current) return;
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - drag.current.startX;
|
||||
const dy = t.clientY - drag.current.startY;
|
||||
|
||||
// Première détection : verrouille l'axe (horizontal ou vertical)
|
||||
if (drag.current.locked === null) {
|
||||
if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return; // trop petit, attend
|
||||
drag.current.locked = Math.abs(dx) > Math.abs(dy) ? "horizontal" : "vertical";
|
||||
}
|
||||
|
||||
if (drag.current.locked !== "horizontal") return;
|
||||
// Empêche le scroll vertical pendant un swipe horizontal
|
||||
e.stopPropagation();
|
||||
if (e.cancelable) e.preventDefault();
|
||||
|
||||
// Résistance aux bords : si on swipe gauche sur le 1er ou droite sur le dernier,
|
||||
// on glisse moins (effet rubber-band)
|
||||
let effective = dx;
|
||||
if (total <= 1) {
|
||||
effective = dx * 0.2;
|
||||
} else if (mediaIndex === 0 && dx > 0) {
|
||||
effective = dx * 0.35;
|
||||
} else if (mediaIndex === total - 1 && dx < 0) {
|
||||
effective = dx * 0.35;
|
||||
}
|
||||
setDragX(effective);
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (!drag.current) return;
|
||||
const wasHorizontal = drag.current.locked === "horizontal";
|
||||
const elapsed = Date.now() - drag.current.startTime;
|
||||
const width = containerWidth || window.innerWidth;
|
||||
const velocity = Math.abs(dragX) / Math.max(1, elapsed); // px/ms
|
||||
drag.current = null;
|
||||
|
||||
if (!wasHorizontal) {
|
||||
setDragX(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const distance = Math.abs(dragX);
|
||||
const isFlick = velocity > VELOCITY_THRESHOLD && distance > 20;
|
||||
const isSlow = distance > width * SWIPE_THRESHOLD_RATIO;
|
||||
const shouldChange = (isFlick || isSlow) && total > 1;
|
||||
|
||||
if (shouldChange) {
|
||||
if (dragX < 0 && mediaIndex < total - 1) {
|
||||
goTo(mediaIndex + 1);
|
||||
} else if (dragX > 0 && mediaIndex > 0) {
|
||||
goTo(mediaIndex - 1);
|
||||
} else {
|
||||
// Bord : retour à 0
|
||||
setTransitioning(true);
|
||||
setDragX(0);
|
||||
}
|
||||
} else {
|
||||
setTransitioning(true);
|
||||
setDragX(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Préchargement intelligent : current, current ± 1
|
||||
const preloadIndexes = useMemo(() => {
|
||||
const s = new Set<number>();
|
||||
s.add(mediaIndex);
|
||||
if (mediaIndex > 0) s.add(mediaIndex - 1);
|
||||
if (mediaIndex < total - 1) s.add(mediaIndex + 1);
|
||||
return s;
|
||||
}, [mediaIndex, total]);
|
||||
|
||||
const share = useCallback(async () => {
|
||||
const url = `${window.location.origin}/carbets/${carbet.slug}`;
|
||||
const title = carbet.title;
|
||||
if (navigator.share) {
|
||||
navigator.share({ title, url }).catch(() => {});
|
||||
} else {
|
||||
navigator.clipboard?.writeText(url).catch(() => {});
|
||||
}
|
||||
}, [carbet.slug, carbet.title]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const offsetPct = -mediaIndex * 100;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden bg-black">
|
||||
{/* Track : tous les médias en ligne, transformX selon index + drag */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 flex"
|
||||
style={{
|
||||
width: `${total * 100}%`,
|
||||
transform: `translateX(calc(${offsetPct / total}% + ${dragX}px))`,
|
||||
transition: transitioning ? "transform 320ms cubic-bezier(0.22, 0.61, 0.36, 1)" : "none",
|
||||
touchAction: "pan-y",
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTransitionEnd={() => setTransitioning(false)}
|
||||
>
|
||||
{carbet.media.map((m, idx) => {
|
||||
const visible = preloadIndexes.has(idx) || shouldPreload;
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="relative flex h-full shrink-0 items-center justify-center"
|
||||
style={{ width: `${100 / total}%` }}
|
||||
aria-hidden={idx !== mediaIndex}
|
||||
>
|
||||
{m.type === "VIDEO" ? (
|
||||
<video
|
||||
ref={(el) => {
|
||||
if (el) videoRefs.current.set(idx, el);
|
||||
else videoRefs.current.delete(idx);
|
||||
}}
|
||||
src={visible ? m.url : undefined}
|
||||
muted={muted}
|
||||
playsInline
|
||||
loop
|
||||
preload={visible ? "auto" : "none"}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={visible ? m.url : undefined}
|
||||
srcSet={visible ? buildSrcSet(m.url) : undefined}
|
||||
sizes="(min-width: 768px) 800px, 100vw"
|
||||
alt={`${carbet.title} — média ${idx + 1}`}
|
||||
loading={idx === mediaIndex ? "eager" : "lazy"}
|
||||
fetchPriority={idx === mediaIndex ? "high" : "auto"}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
className="h-full w-full select-none object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Voile dégradé en bas pour lisibilité */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
|
||||
|
||||
{/* Indicateurs progression médias (sticks en haut) */}
|
||||
{total > 1 ? (
|
||||
<div className="pointer-events-none absolute left-3 right-3 top-12 flex gap-1">
|
||||
{carbet.media.map((_, i) => {
|
||||
const isActiveStick = i === mediaIndex;
|
||||
const wasSeen = i < mediaIndex;
|
||||
// Progression visuelle pendant le drag (preview du swipe)
|
||||
const progress = isActiveStick && Math.abs(dragX) > 0 && containerWidth > 0
|
||||
? Math.min(1, Math.abs(dragX) / containerWidth)
|
||||
: 0;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={
|
||||
"relative h-0.5 flex-1 overflow-hidden rounded-full " +
|
||||
(isActiveStick ? "bg-white/30" : wasSeen ? "bg-white/60" : "bg-white/30")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"absolute inset-y-0 left-0 bg-white " +
|
||||
(isActiveStick ? "w-full" : wasSeen ? "w-full" : "w-0")
|
||||
}
|
||||
style={progress > 0 ? { width: `${progress * 100}%` } : undefined}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Zones tap horizontales (50/50) sur desktop */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevMedia}
|
||||
className="absolute inset-y-0 left-0 z-10 hidden w-1/3 cursor-default md:block"
|
||||
aria-label="Média précédent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextMedia}
|
||||
className="absolute inset-y-0 right-0 z-10 hidden w-1/3 cursor-default md:block"
|
||||
aria-label="Média suivant"
|
||||
/>
|
||||
|
||||
{/* Sidebar boutons droite (mobile) */}
|
||||
<div className="absolute bottom-32 right-3 z-20 flex flex-col items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFavorite}
|
||||
className="flex flex-col items-center text-white"
|
||||
aria-label={isFavorite ? "Retirer des favoris" : "Ajouter aux favoris"}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"flex h-12 w-12 items-center justify-center rounded-full backdrop-blur transition " +
|
||||
(isFavorite ? "bg-rose-500/90" : "bg-white/10 hover:bg-white/20")
|
||||
}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill={isFavorite ? "white" : "none"} stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="mt-0.5 text-[10px] font-semibold">Favori</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={share}
|
||||
className="flex flex-col items-center text-white"
|
||||
aria-label="Partager"
|
||||
>
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/10 backdrop-blur hover:bg-white/20">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<path d="M8.59 13.51 L15.42 17.49" />
|
||||
<path d="M15.41 6.51 L8.59 10.49" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="mt-0.5 text-[10px] font-semibold">Partager</span>
|
||||
</button>
|
||||
|
||||
{current.type === "VIDEO" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
className="flex flex-col items-center text-white"
|
||||
aria-label={muted ? "Activer le son" : "Couper le son"}
|
||||
>
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 backdrop-blur hover:bg-white/20">
|
||||
{muted ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
||||
<line x1="23" y1="9" x2="17" y2="15" />
|
||||
<line x1="17" y1="9" x2="23" y2="15" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Bloc info bas + CTAs */}
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 p-4 pb-6 text-white">
|
||||
<div className="mb-2 flex items-baseline gap-2">
|
||||
<h2 className="text-lg font-semibold">{carbet.title}</h2>
|
||||
{carbet.averageRating !== null ? (
|
||||
<span className="text-xs text-white/80">
|
||||
★ {carbet.averageRating.toFixed(1)} ({carbet.reviewCount})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/80">
|
||||
<span>📍 {carbet.river}</span>
|
||||
<span>·</span>
|
||||
<span>👥 jusqu'à {carbet.capacity}</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono font-semibold text-white">{Number(carbet.nightlyPrice).toFixed(0)} € / nuit</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/carbets/${carbet.slug}`}
|
||||
className="rounded-full bg-white/10 px-4 py-2 text-xs font-semibold backdrop-blur hover:bg-white/20"
|
||||
>
|
||||
Voir la fiche
|
||||
</Link>
|
||||
<Link
|
||||
href={`/carbets/${carbet.slug}#reserver`}
|
||||
className="rounded-full bg-emerald-500 px-4 py-2 text-xs font-semibold hover:bg-emerald-400"
|
||||
>
|
||||
Réserver
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { ReelCarbet } from "@/lib/reels";
|
||||
|
||||
import { ReelSlide } from "./ReelSlide";
|
||||
|
||||
type Props = {
|
||||
carbets: ReelCarbet[];
|
||||
initialFavoriteIds: string[];
|
||||
isAuthenticated: boolean;
|
||||
};
|
||||
|
||||
export function ReelsViewer({ carbets, initialFavoriteIds, isAuthenticated }: Props) {
|
||||
const router = useRouter();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const slideRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set(initialFavoriteIds));
|
||||
|
||||
// Détection du carbet actif via IntersectionObserver
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.filter((e) => e.isIntersecting);
|
||||
if (visible.length === 0) return;
|
||||
const best = visible.reduce((a, b) => (a.intersectionRatio > b.intersectionRatio ? a : b));
|
||||
const idx = slideRefs.current.findIndex((el) => el === best.target);
|
||||
if (idx !== -1) setActiveIndex(idx);
|
||||
},
|
||||
{ root: containerRef.current, threshold: [0.55, 0.85] },
|
||||
);
|
||||
slideRefs.current.forEach((el) => el && observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, [carbets.length]);
|
||||
|
||||
// Navigation clavier ↑↓
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea") return;
|
||||
if (e.key === "ArrowDown" || e.key === "j") {
|
||||
e.preventDefault();
|
||||
const next = Math.min(activeIndex + 1, carbets.length - 1);
|
||||
slideRefs.current[next]?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
} else if (e.key === "ArrowUp" || e.key === "k") {
|
||||
e.preventDefault();
|
||||
const prev = Math.max(activeIndex - 1, 0);
|
||||
slideRefs.current[prev]?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [activeIndex, carbets.length]);
|
||||
|
||||
const toggleFavorite = useCallback(
|
||||
async (carbetId: string) => {
|
||||
if (!isAuthenticated) {
|
||||
router.push(`/connexion?next=${encodeURIComponent("/decouvrir")}`);
|
||||
return;
|
||||
}
|
||||
const isFav = favorites.has(carbetId);
|
||||
// Optimistic update
|
||||
setFavorites((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isFav) next.delete(carbetId);
|
||||
else next.add(carbetId);
|
||||
return next;
|
||||
});
|
||||
const method = isFav ? "DELETE" : "POST";
|
||||
const res = await fetch("/api/favorites", {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ carbetId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Rollback
|
||||
setFavorites((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isFav) next.add(carbetId);
|
||||
else next.delete(carbetId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[favorites, isAuthenticated, router],
|
||||
);
|
||||
|
||||
// Préchargement N+1 et N-1 médias (un peu d'AGGRESSIVE prefetch)
|
||||
const preloadIndexes = useMemo(
|
||||
() => [activeIndex - 1, activeIndex, activeIndex + 1].filter((i) => i >= 0 && i < carbets.length),
|
||||
[activeIndex, carbets.length],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-10 bg-black"
|
||||
style={{
|
||||
// 100dvh sur navigateurs récents pour éviter le saut quand la barre d'URL mobile se masque
|
||||
height: "100dvh",
|
||||
}}
|
||||
>
|
||||
{/* Bouton retour catalogue */}
|
||||
<Link
|
||||
href="/carbets"
|
||||
className="absolute right-3 z-20 rounded-full bg-white/10 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur hover:bg-white/20"
|
||||
style={{ top: "max(0.75rem, env(safe-area-inset-top, 0px))" }}
|
||||
>
|
||||
← Catalogue
|
||||
</Link>
|
||||
|
||||
{/* Compteur */}
|
||||
<div
|
||||
className="absolute left-3 z-20 rounded-full bg-white/10 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur"
|
||||
style={{ top: "max(0.75rem, env(safe-area-inset-top, 0px))" }}
|
||||
>
|
||||
{activeIndex + 1} / {carbets.length}
|
||||
</div>
|
||||
|
||||
{/* Logo Karbé en surimpression haut centre */}
|
||||
<Link
|
||||
href="/accueil"
|
||||
className="absolute left-1/2 z-20 -translate-x-1/2 text-sm font-semibold text-white/90 hover:text-white"
|
||||
style={{ top: "max(1rem, env(safe-area-inset-top, 0px))" }}
|
||||
>
|
||||
Karbé
|
||||
</Link>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full snap-y snap-mandatory overflow-y-scroll overscroll-contain"
|
||||
style={{ scrollSnapType: "y mandatory" }}
|
||||
>
|
||||
{carbets.map((c, idx) => (
|
||||
<div
|
||||
key={c.id}
|
||||
ref={(el) => {
|
||||
slideRefs.current[idx] = el;
|
||||
}}
|
||||
className="h-full snap-start snap-always"
|
||||
style={{ scrollSnapAlign: "start" }}
|
||||
>
|
||||
<ReelSlide
|
||||
carbet={c}
|
||||
isActive={idx === activeIndex}
|
||||
shouldPreload={preloadIndexes.includes(idx)}
|
||||
isFavorite={favorites.has(c.id)}
|
||||
onToggleFavorite={() => toggleFavorite(c.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import Link from "next/link";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { listReelCarbets } from "@/lib/reels";
|
||||
|
||||
import { ReelsViewer } from "./_components/ReelsViewer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = {
|
||||
title: "Au fil de l'eau",
|
||||
description: "Découvrez les carbets de Guyane façon Reels — swipez pour explorer.",
|
||||
};
|
||||
|
||||
export default async function DecouvrirPage() {
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id ?? null;
|
||||
const [carbets, favoriteIds] = await Promise.all([
|
||||
listReelCarbets({ take: 30 }),
|
||||
userId
|
||||
? prisma.favorite.findMany({ where: { userId }, select: { carbetId: true } }).then((r) => r.map((x) => x.carbetId))
|
||||
: Promise.resolve([] as string[]),
|
||||
]);
|
||||
|
||||
if (carbets.length === 0) {
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-6 py-20 text-center">
|
||||
<h1 className="text-3xl font-semibold text-zinc-900">Au fil de l'eau</h1>
|
||||
<p className="mt-3 text-sm text-zinc-600">
|
||||
Pas encore assez de carbets avec des photos pour démarrer le mode immersif.
|
||||
</p>
|
||||
<Link
|
||||
href="/carbets"
|
||||
className="mt-6 inline-block rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Voir le catalogue
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReelsViewer
|
||||
carbets={carbets}
|
||||
initialFavoriteIds={favoriteIds}
|
||||
isAuthenticated={Boolean(userId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { confirmBookingAsHost, rejectBookingAsHost } from "../actions";
|
||||
|
||||
export function BookingDecision({ bookingId }: { bookingId: string }) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [confirmReject, setConfirmReject] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function accept() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await confirmBookingAsHost(bookingId);
|
||||
if (res && res.ok === false) setError(res.error);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
function reject() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await rejectBookingAsHost(bookingId);
|
||||
if (res && res.ok === false) setError(res.error);
|
||||
setConfirmReject(false);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{confirmReject ? (
|
||||
<div className="flex items-center gap-1.5 rounded border border-rose-300 bg-rose-50 px-2 py-1">
|
||||
<span className="text-xs text-rose-900">Refuser ?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reject}
|
||||
disabled={pending}
|
||||
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
|
||||
>
|
||||
Oui
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmReject(false)}
|
||||
disabled={pending}
|
||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={accept}
|
||||
disabled={pending}
|
||||
className="rounded bg-emerald-600 px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
Confirmer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmReject(true)}
|
||||
disabled={pending}
|
||||
className="rounded border border-rose-300 bg-white px-2.5 py-1 text-[11px] font-semibold text-rose-700 hover:bg-rose-50 disabled:opacity-50"
|
||||
>
|
||||
Refuser
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{error ? <span className="text-[11px] text-rose-700">{error}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { BookingStatus, UserRole } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { sendBookingConfirmed } from "@/lib/email";
|
||||
|
||||
async function requireBookingOwnership(bookingId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("Non authentifié");
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
carbet: { select: { ownerId: true, title: true } },
|
||||
tenant: { select: { email: true, firstName: true } },
|
||||
},
|
||||
});
|
||||
if (!booking) throw new Error("Réservation introuvable");
|
||||
const isAdmin = session.user.role === UserRole.ADMIN;
|
||||
if (!isAdmin && booking.carbet.ownerId !== session.user.id) {
|
||||
throw new Error("Accès refusé");
|
||||
}
|
||||
return { session, booking };
|
||||
}
|
||||
|
||||
export async function confirmBookingAsHost(bookingId: string) {
|
||||
const { session, booking } = await requireBookingOwnership(bookingId);
|
||||
if (booking.status !== BookingStatus.PENDING) {
|
||||
return { ok: false as const, error: "Cette réservation ne peut plus être confirmée." };
|
||||
}
|
||||
const updated = await prisma.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: { status: BookingStatus.CONFIRMED },
|
||||
});
|
||||
await recordAudit({
|
||||
scope: "host.bookings",
|
||||
event: "confirm",
|
||||
target: bookingId,
|
||||
actorEmail: session.user.email ?? null,
|
||||
details: {},
|
||||
});
|
||||
sendBookingConfirmed(
|
||||
booking.tenant.email,
|
||||
booking.tenant.firstName,
|
||||
bookingId,
|
||||
booking.carbet.title,
|
||||
updated.startDate,
|
||||
updated.endDate,
|
||||
).catch(() => {});
|
||||
revalidatePath("/espace-hote");
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function rejectBookingAsHost(bookingId: string) {
|
||||
const { session, booking } = await requireBookingOwnership(bookingId);
|
||||
if (booking.status !== BookingStatus.PENDING) {
|
||||
return { ok: false as const, error: "Cette réservation ne peut plus être refusée." };
|
||||
}
|
||||
await prisma.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: { status: BookingStatus.CANCELLED },
|
||||
});
|
||||
await recordAudit({
|
||||
scope: "host.bookings",
|
||||
event: "reject",
|
||||
target: bookingId,
|
||||
actorEmail: session.user.email ?? null,
|
||||
details: {},
|
||||
});
|
||||
revalidatePath("/espace-hote");
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
|
@ -3,10 +3,11 @@ import { notFound } from "next/navigation";
|
|||
|
||||
import { canManageCarbet, requireOwnerSession } from "@/lib/carbet-access";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { MediaUploader } from "@/components/MediaUploader";
|
||||
import { isStorageConfigured } from "@/lib/storage";
|
||||
|
||||
import { updateCarbet } from "../actions";
|
||||
import { CarbetForm } from "../_components/carbet-form";
|
||||
import { MediaManager } from "../_components/media-manager";
|
||||
|
||||
export default async function EditCarbetPage({
|
||||
params,
|
||||
|
|
@ -35,7 +36,7 @@ export default async function EditCarbetPage({
|
|||
status: true,
|
||||
media: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
|
||||
select: { id: true, type: true, s3Url: true, sortOrder: true },
|
||||
},
|
||||
amenities: { select: { amenity: { select: { key: true } } } },
|
||||
},
|
||||
|
|
@ -79,10 +80,14 @@ export default async function EditCarbetPage({
|
|||
<section className="mt-8">
|
||||
<h2 className="text-lg font-semibold text-zinc-900">Médias</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-zinc-600">
|
||||
Déposez photos et vidéos courtes, réorganisez par glisser-déposer.
|
||||
Le premier média sert de cover sur le catalogue et la home.
|
||||
Le premier média sert de photo de couverture. Réordonnez avec les
|
||||
flèches.
|
||||
</p>
|
||||
<MediaUploader carbetId={carbet.id} initialMedia={carbet.media} />
|
||||
<MediaManager
|
||||
carbetId={carbet.id}
|
||||
media={carbet.media}
|
||||
storageConfigured={isStorageConfigured()}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mt-10 border-t border-zinc-200 pt-8">
|
||||
|
|
|
|||
|
|
@ -1,287 +1,25 @@
|
|||
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";
|
||||
|
||||
import { BookingDecision } from "./_components/BookingDecision";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STATUS_TONES: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ring-1 ring-inset ${tone}`}>
|
||||
{STATUS_LABEL[value] ?? value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
export default async function HostPage() {
|
||||
const session = await requireRole(["OWNER", "ADMIN"]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-10">
|
||||
<header className="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-zinc-900">Espace hôte</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
Bienvenue {session?.user?.name || session?.user?.email}.{" "}
|
||||
{isAdmin ? "Vue globale (admin)." : "Vue limitée à vos carbets."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href="/espace-hote/carbets/nouveau"
|
||||
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
+ Nouveau carbet
|
||||
</Link>
|
||||
<Link
|
||||
href="/espace-hote/carbets"
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
|
||||
>
|
||||
Tous mes carbets
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-4xl px-6 py-12">
|
||||
<h1 className="text-3xl font-semibold">Espace hôte</h1>
|
||||
<p className="mt-4 text-zinc-700">
|
||||
Accès autorisé pour {session.user.email} ({session.user.role}).
|
||||
</p>
|
||||
|
||||
<section className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<Kpi label="CA total" value={fmtEur(kpis.revenueTotal, "EUR")} />
|
||||
<Kpi label="CA 30 j" value={fmtEur(kpis.revenue30d, "EUR")} />
|
||||
<Kpi label="CA 12 mois" value={fmtEur(kpis.revenue365d, "EUR")} />
|
||||
<Kpi
|
||||
label="À confirmer"
|
||||
value={String(kpis.bookingsPending)}
|
||||
tone={kpis.bookingsPending > 0 ? "warn" : "neutral"}
|
||||
/>
|
||||
<Kpi label="Confirmées à venir" value={String(kpis.bookingsConfirmedUpcoming)} />
|
||||
<Kpi label="Occupation 30 j" value={`${Math.round(kpis.occupancyRate30d * 100)} %`} />
|
||||
</section>
|
||||
|
||||
{kpis.nextArrival ? (
|
||||
<section className="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 p-4">
|
||||
<div className="text-xs uppercase tracking-wider text-emerald-700">Prochaine arrivée</div>
|
||||
<div className="mt-1 text-base font-semibold text-emerald-900">
|
||||
{kpis.nextArrival.tenantName} · {kpis.nextArrival.carbetTitle}
|
||||
</div>
|
||||
<div className="text-sm text-emerald-800">
|
||||
{dateFmt.format(kpis.nextArrival.startDate)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{pendingBookings.length > 0 ? (
|
||||
<section className="mb-6 rounded-lg border border-amber-300 bg-amber-50 p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-amber-900">
|
||||
Demandes en attente ({pendingBookings.length})
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{pendingBookings.map((b) => (
|
||||
<li key={b.id} className="flex flex-wrap items-center justify-between gap-3 rounded border border-amber-200 bg-white px-3 py-2 text-sm">
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-900">
|
||||
{b.tenantName} — {b.carbetTitle}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-600">
|
||||
{dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)} ·{" "}
|
||||
{b.guestCount} pers · {fmtEur(b.amount, b.currency)}
|
||||
</div>
|
||||
</div>
|
||||
<BookingDecision bookingId={b.id} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Mes carbets ({carbets.length})
|
||||
</h2>
|
||||
{carbets.length === 0 ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500">
|
||||
Aucun carbet pour l'instant.{" "}
|
||||
<Link href="/espace-hote/carbets/nouveau" className="text-emerald-700 underline">
|
||||
Créer mon premier carbet
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-semibold">Titre</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Fleuve</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">€/nuit</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Cap.</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Médias</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Résas</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Avis</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{carbets.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-zinc-50">
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
href={`/espace-hote/carbets/${c.id}`}
|
||||
className="font-medium text-zinc-900 hover:underline"
|
||||
>
|
||||
{c.title}
|
||||
</Link>
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
<code>/{c.slug}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-700">{c.river}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">
|
||||
{Number(c.nightlyPrice).toFixed(0)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.capacity}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">
|
||||
{c._count.media}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">
|
||||
{c._count.bookings}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">
|
||||
{c._count.reviews}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge value={c.status} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{recent.length > 0 ? (
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Activité récente
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-semibold">Carbet</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Locataire</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Séjour</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Montant</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Résa</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Paiement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{recent.map((b) => (
|
||||
<tr key={b.id} className="hover:bg-zinc-50">
|
||||
<td className="px-4 py-2 text-zinc-900">{b.carbetTitle}</td>
|
||||
<td className="px-4 py-2 text-zinc-700">{b.tenantName}</td>
|
||||
<td className="px-4 py-2 text-zinc-700">
|
||||
{dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">
|
||||
{fmtEur(b.amount, b.currency)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge value={b.status} />
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge value={b.paymentStatus} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
href="/espace-hote/carbets"
|
||||
className="inline-block rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Gérer mes carbets
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({
|
||||
label,
|
||||
value,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "neutral" | "warn";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"rounded-lg border bg-white p-3 shadow-sm " +
|
||||
(tone === "warn" ? "border-amber-300" : "border-zinc-200")
|
||||
}
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-wider text-zinc-500">{label}</div>
|
||||
<div className={"mt-1 text-xl font-semibold " + (tone === "warn" ? "text-amber-700" : "text-zinc-900")}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
type Props = { next: string };
|
||||
|
||||
export function SignupForm({ next }: Props) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [role, setRole] = useState<"TOURIST" | "OWNER">("TOURIST");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, firstName, lastName, phone: phone || null, role }),
|
||||
});
|
||||
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 (
|
||||
<form action={onSubmit} className="space-y-3">
|
||||
<fieldset disabled={pending} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Prénom</span>
|
||||
<input name="firstName" type="text" required maxLength={100} className={inputCls + " mt-0.5"} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Nom</span>
|
||||
<input name="lastName" type="text" required maxLength={100} className={inputCls + " mt-0.5"} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Email</span>
|
||||
<input name="email" type="email" required maxLength={200} className={inputCls + " mt-0.5"} />
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Mot de passe (8 caractères min.)</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
maxLength={200}
|
||||
className={inputCls + " mt-0.5"}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Téléphone (optionnel)</span>
|
||||
<input name="phone" type="tel" maxLength={40} className={inputCls + " mt-0.5"} />
|
||||
</label>
|
||||
|
||||
<fieldset className="space-y-1">
|
||||
<legend className="text-xs text-zinc-600">Type de compte</legend>
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
<label
|
||||
className={
|
||||
"flex cursor-pointer flex-col items-start rounded-md border px-3 py-2 text-sm " +
|
||||
(role === "TOURIST"
|
||||
? "border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900"
|
||||
: "border-zinc-300 hover:bg-zinc-50")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="role"
|
||||
value="TOURIST"
|
||||
checked={role === "TOURIST"}
|
||||
onChange={() => setRole("TOURIST")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="font-semibold text-zinc-900">Voyageur</span>
|
||||
<span className="text-[11px] text-zinc-500">Réserver un séjour.</span>
|
||||
</label>
|
||||
<label
|
||||
className={
|
||||
"flex cursor-pointer flex-col items-start rounded-md border px-3 py-2 text-sm " +
|
||||
(role === "OWNER"
|
||||
? "border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900"
|
||||
: "border-zinc-300 hover:bg-zinc-50")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="role"
|
||||
value="OWNER"
|
||||
checked={role === "OWNER"}
|
||||
onChange={() => setRole("OWNER")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="font-semibold text-zinc-900">Hôte</span>
|
||||
<span className="text-[11px] text-zinc-500">Publier un carbet.</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Création…" : "Créer mon compte"}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { SignupForm } from "./_components/SignupForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ next?: 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);
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-[70vh] max-w-md items-center px-6 py-12">
|
||||
<div className="w-full space-y-4 rounded-xl border border-zinc-200 bg-white p-6 shadow-sm">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Créer un compte</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Un compte vous permet de réserver un séjour ou, en tant qu'hôte, de publier votre carbet.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SignupForm next={next} />
|
||||
|
||||
<p className="border-t border-zinc-100 pt-3 text-center text-sm text-zinc-500">
|
||||
Déjà un compte ?{" "}
|
||||
<Link href={`/connexion${next !== "/" ? `?next=${encodeURIComponent(next)}` : ""}`} className="text-zinc-900 underline">
|
||||
Se connecter
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ 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 { LocaleProvider } from "@/lib/i18n/client";
|
||||
import { dict, getLocale } from "@/lib/i18n/server";
|
||||
|
||||
|
|
@ -52,21 +51,6 @@ 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é",
|
||||
|
|
@ -77,13 +61,6 @@ 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<{
|
||||
|
|
@ -125,7 +102,6 @@ export default async function RootLayout({
|
|||
<PluginProvider enabledKeys={enabledKeys}>
|
||||
<LocaleProvider locale={locale} messages={messages}>
|
||||
<SeasonBanner />
|
||||
<SiteHeaderGuard />
|
||||
{children}
|
||||
</LocaleProvider>
|
||||
</PluginProvider>
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
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 (
|
||||
<main className="mx-auto max-w-5xl px-6 py-10">
|
||||
<h1 className="text-3xl font-semibold text-zinc-900">Mes favoris</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
{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" : ""}.`}
|
||||
</p>
|
||||
|
||||
{carbets.length === 0 ? (
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
href="/decouvrir"
|
||||
className="inline-block rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Découvrir des carbets
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{carbets.map((c) => (
|
||||
<li key={c.id} className="overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm">
|
||||
<Link href={`/carbets/${c.slug}`} className="block">
|
||||
{c.media[0] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={c.media[0].url}
|
||||
srcSet={buildSrcSet(c.media[0].url)}
|
||||
sizes="(min-width: 1024px) 320px, (min-width: 640px) 50vw, 100vw"
|
||||
alt={c.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="aspect-video w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-video w-full bg-zinc-100" />
|
||||
)}
|
||||
<div className="p-3">
|
||||
<h2 className="font-semibold text-zinc-900">{c.title}</h2>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
{c.river} · {Number(c.nightlyPrice).toFixed(0)} € / nuit
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
"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 (
|
||||
<div className="space-y-2 text-sm text-zinc-700">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
{step === "idle" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2 rounded border border-rose-300 bg-rose-50 p-3 text-sm">
|
||||
<p className="text-rose-900">
|
||||
Pour confirmer, saisissez <code className="rounded bg-white px-1">SUPPRIMER</code> ci-dessous.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={typed}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep("idle")}
|
||||
disabled={pending}
|
||||
className="text-xs text-zinc-600 hover:text-zinc-900"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={typed !== "SUPPRIMER" || pending}
|
||||
onClick={deleteAccount}
|
||||
className="rounded-md bg-rose-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Suppression…" : "Confirmer la suppression"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
|
||||
import { changePasswordAction } from "../actions";
|
||||
|
||||
export function PasswordForm() {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
function onSubmit(fd: FormData) {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
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 (
|
||||
<form ref={formRef} action={onSubmit} className="space-y-3">
|
||||
<fieldset disabled={pending} className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Mot de passe actuel</span>
|
||||
<input name="current" type="password" required className={inputCls} />
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Nouveau mot de passe</span>
|
||||
<input name="next" type="password" required minLength={8} className={inputCls} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Confirmer</span>
|
||||
<input name="confirm" type="password" required minLength={8} className={inputCls} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
{success ? (
|
||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Mise à jour…" : "Changer le mot de passe"}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
"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<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(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 (
|
||||
<form action={onSubmit} className="space-y-3">
|
||||
<fieldset disabled={pending} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Prénom</span>
|
||||
<input
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
maxLength={100}
|
||||
defaultValue={initial.firstName}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Nom</span>
|
||||
<input
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
maxLength={100}
|
||||
defaultValue={initial.lastName}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Téléphone (optionnel)</span>
|
||||
<input
|
||||
name="phone"
|
||||
type="tel"
|
||||
maxLength={40}
|
||||
defaultValue={initial.phone ?? ""}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
{success ? (
|
||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Enregistrement…" : "Enregistrer"}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
"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");
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
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 (
|
||||
<main className="mx-auto max-w-3xl px-6 py-10">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-3xl font-semibold text-zinc-900">Mon compte</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
Connecté avec <strong>{user.email}</strong> · inscrit le {dateFmt.format(user.createdAt)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Identité</h2>
|
||||
<ProfileForm
|
||||
initial={{ firstName: user.firstName, lastName: user.lastName, phone: user.phone }}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Sécurité
|
||||
</h2>
|
||||
<PasswordForm />
|
||||
</section>
|
||||
|
||||
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Mes données (RGPD)
|
||||
</h2>
|
||||
<p className="mb-3 text-sm text-zinc-600">
|
||||
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é).
|
||||
</p>
|
||||
<a
|
||||
href="/api/me/export"
|
||||
className="inline-flex items-center gap-2 rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm font-semibold text-zinc-900 hover:bg-zinc-50"
|
||||
>
|
||||
Télécharger mes données
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-rose-200 bg-rose-50/50 p-5">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-rose-700">
|
||||
Zone dangereuse
|
||||
</h2>
|
||||
<DangerZone />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
"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<string | null>(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 (
|
||||
<form action={onSubmit} className="space-y-3">
|
||||
<fieldset disabled={pending} className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Nouveau mot de passe</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Confirmer le mot de passe</span>
|
||||
<input
|
||||
name="confirm"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Enregistrement…" : "Définir le nouveau mot de passe"}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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 (
|
||||
<main className="mx-auto flex min-h-[70vh] max-w-md items-center px-6 py-12">
|
||||
<div className="w-full space-y-4 rounded-xl border border-zinc-200 bg-white p-6 shadow-sm">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Nouveau mot de passe</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Choisissez un mot de passe d'au moins 8 caractères. Vous serez redirigé vers la
|
||||
connexion une fois enregistré.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ResetForm token={token} />
|
||||
|
||||
<p className="border-t border-zinc-100 pt-3 text-center text-sm text-zinc-500">
|
||||
<Link href="/connexion" className="text-zinc-900 underline">
|
||||
Retour à la connexion
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
"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 (
|
||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
|
||||
Si un compte existe pour cet email, vous recevrez un lien dans quelques instants. Pensez à
|
||||
vérifier vos spams.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={onSubmit} className="space-y-3">
|
||||
<fieldset disabled={pending} className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Email</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Envoi…" : "Envoyer le lien"}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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 (
|
||||
<main className="mx-auto flex min-h-[70vh] max-w-md items-center px-6 py-12">
|
||||
<div className="w-full space-y-4 rounded-xl border border-zinc-200 bg-white p-6 shadow-sm">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Mot de passe oublié</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Saisissez votre email. Si un compte existe, vous recevrez un lien valable 1 heure pour
|
||||
choisir un nouveau mot de passe.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ResetRequestForm />
|
||||
|
||||
<p className="border-t border-zinc-100 pt-3 text-center text-sm text-zinc-500">
|
||||
<Link href="/connexion" className="text-zinc-900 underline">
|
||||
Retour à la connexion
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,63 @@
|
|||
import { redirect } from "next/navigation";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Home redirige vers le mode immersif « Au fil de l'eau » par défaut.
|
||||
* L'ancien hero/landing reste accessible via /accueil.
|
||||
* 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.
|
||||
*/
|
||||
export default function Home() {
|
||||
redirect("/decouvrir");
|
||||
return (
|
||||
<>
|
||||
<IfPluginEnabled
|
||||
plugin="landing-hero"
|
||||
fallback={
|
||||
// Fallback héro minimaliste — historique
|
||||
<div className="flex flex-1 items-center justify-center bg-zinc-50 px-6 dark:bg-black">
|
||||
<main className="flex w-full max-w-2xl flex-col items-center gap-6 text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-black sm:text-5xl dark:text-zinc-50">
|
||||
Karbé — carbets fluviaux de Guyane
|
||||
</h1>
|
||||
<p className="max-w-xl text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
La marketplace pour louer des carbets le long des fleuves de Guyane.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/carbets"
|
||||
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Découvrir les carbets
|
||||
</Link>
|
||||
<Link
|
||||
href="/espace-hote"
|
||||
className="rounded-md border border-zinc-300 px-5 py-2.5 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-900"
|
||||
>
|
||||
Espace hôte
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HeroSection />
|
||||
</IfPluginEnabled>
|
||||
|
||||
<IfPluginEnabled plugin="landing-sections">
|
||||
<ExperiencesSection />
|
||||
<HowItWorksSection />
|
||||
<CESection />
|
||||
<TestimonialsSection />
|
||||
<LandingFooter />
|
||||
</IfPluginEnabled>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
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<string, string> = {
|
||||
PENDING: "En attente de confirmation",
|
||||
CONFIRMED: "Confirmée",
|
||||
CANCELLED: "Annulée",
|
||||
COMPLETED: "Terminée",
|
||||
};
|
||||
|
||||
const PAYMENT_LABEL: Record<string, string> = {
|
||||
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 } },
|
||||
},
|
||||
});
|
||||
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 (
|
||||
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||
<h1 className="text-3xl font-semibold text-zinc-900">Demande de réservation envoyée</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600">
|
||||
Votre demande pour <strong>{booking.carbet.title}</strong> a bien été enregistrée. Vous recevrez
|
||||
un email dès que l'hôte ou l'équipe Karbé l'aura confirmée.
|
||||
</p>
|
||||
|
||||
<section className="mt-6 space-y-3 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span className="text-xs uppercase tracking-wider text-zinc-500">Référence</span>
|
||||
<code className="font-mono text-sm text-zinc-900">{booking.id}</code>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 border-t border-zinc-100 pt-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">Carbet</div>
|
||||
<Link href={`/carbets/${booking.carbet.slug}`} className="font-semibold text-zinc-900 hover:underline">
|
||||
{booking.carbet.title}
|
||||
</Link>
|
||||
<div className="text-xs text-zinc-500">{booking.carbet.river}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">Voyageurs</div>
|
||||
<div className="font-semibold text-zinc-900">
|
||||
{booking.guestCount} personne{booking.guestCount > 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">Arrivée</div>
|
||||
<div className="font-semibold text-zinc-900">{dateFmt.format(booking.startDate)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">Départ</div>
|
||||
<div className="font-semibold text-zinc-900">{dateFmt.format(booking.endDate)}</div>
|
||||
</div>
|
||||
<div className="col-span-2 border-t border-zinc-100 pt-3">
|
||||
<div className="text-xs text-zinc-500">Total ({nights} nuit{nights > 1 ? "s" : ""})</div>
|
||||
<div className="text-2xl font-semibold text-zinc-900 font-mono">
|
||||
{Number(booking.amount).toFixed(2)} {booking.currency}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-zinc-100 pt-3 text-xs">
|
||||
<span className="rounded-full bg-sky-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-sky-800 ring-1 ring-inset ring-sky-300">
|
||||
{STATUS_LABEL[booking.status] ?? booking.status}
|
||||
</span>
|
||||
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-zinc-700 ring-1 ring-inset ring-zinc-300">
|
||||
{PAYMENT_LABEL[booking.paymentStatus] ?? booking.paymentStatus}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between text-sm">
|
||||
<Link href={`/carbets/${booking.carbet.slug}`} className="text-zinc-700 hover:text-zinc-900 hover:underline">
|
||||
← Retour au carbet
|
||||
</Link>
|
||||
<Link href="/" className="text-zinc-700 hover:text-zinc-900 hover:underline">
|
||||
Accueil
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,380 +0,0 @@
|
|||
"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;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
carbetId: string;
|
||||
initialMedia: MediaItem[];
|
||||
};
|
||||
|
||||
type UploadEntry = {
|
||||
tempId: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
mime: string;
|
||||
progress: number;
|
||||
error?: string;
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
const MAX_PARALLEL = 3;
|
||||
|
||||
export function MediaUploader({ carbetId, initialMedia }: Props) {
|
||||
const [items, setItems] = useState<MediaItem[]>(
|
||||
[...initialMedia].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
);
|
||||
const [uploads, setUploads] = useState<UploadEntry[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const inputId = useId();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const queueRef = useRef<File[]>([]);
|
||||
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("/api/media/reorder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ carbetId, orderedIds }),
|
||||
}).catch(() => {});
|
||||
},
|
||||
[carbetId],
|
||||
);
|
||||
|
||||
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(`/api/media/${id}`, { method: "DELETE" });
|
||||
if (res.ok) setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
}, []);
|
||||
|
||||
const processFile = useCallback(async function processFile(file: File): Promise<void> {
|
||||
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("/api/uploads/presign", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ carbetId, mime: file.type, sizeBytes: file.size }),
|
||||
});
|
||||
const presign = await presignRes.json();
|
||||
if (!presignRes.ok) throw new Error(presign?.error || "presign refusé");
|
||||
|
||||
await new Promise<void>((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("/api/uploads/finalize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
carbetId,
|
||||
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)));
|
||||
}
|
||||
}, [carbetId]);
|
||||
|
||||
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<HTMLInputElement>) {
|
||||
if (e.target.files) addFiles(e.target.files);
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
}
|
||||
|
||||
function onDrop(e: React.DragEvent<HTMLDivElement>) {
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
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")
|
||||
}
|
||||
>
|
||||
<label htmlFor={inputId} className="block cursor-pointer">
|
||||
<div className="text-sm text-zinc-700">
|
||||
<strong>Déposez vos photos ou vidéos</strong> ici, ou cliquez pour parcourir
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
JPG / PNG / WebP / AVIF (max 10 Mo) · MP4 / MOV / WebM (max 200 Mo) · plusieurs fichiers OK
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
id={inputId}
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/avif,video/mp4,video/quicktime,video/webm"
|
||||
multiple
|
||||
capture="environment"
|
||||
onChange={onChange}
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploads.length > 0 ? (
|
||||
<ul className="space-y-1.5">
|
||||
{uploads.map((u) => (
|
||||
<li key={u.tempId} className="rounded border border-zinc-200 bg-white px-3 py-2 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="truncate font-medium text-zinc-700">{u.name}</span>
|
||||
<span className="ml-2 text-zinc-500">
|
||||
{u.error
|
||||
? "❌"
|
||||
: u.done
|
||||
? "✓"
|
||||
: `${Math.round(u.sizeBytes / 1000)} ko · ${u.progress}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1 overflow-hidden rounded-full bg-zinc-200">
|
||||
<div
|
||||
className={
|
||||
"h-full transition-all " +
|
||||
(u.error ? "bg-rose-500" : u.done ? "bg-emerald-500" : "bg-emerald-600")
|
||||
}
|
||||
style={{ width: `${u.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{u.error ? <div className="mt-1 text-rose-700">{u.error}</div> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={allIds} strategy={rectSortingStrategy}>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
|
||||
{items.map((item, idx) => (
|
||||
<SortableTile
|
||||
key={item.id}
|
||||
item={item}
|
||||
isCover={idx === 0}
|
||||
onSetCover={() => setCover(item.id)}
|
||||
onDelete={() => removeItem(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<p className="rounded border border-dashed border-zinc-200 px-3 py-6 text-center text-xs text-zinc-500">
|
||||
Pas encore de média. Ajoutez votre premier ci-dessus.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-zinc-500">
|
||||
Glissez-déposez pour réordonner · Étoile = cover (image principale sur le catalogue)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={
|
||||
"group relative overflow-hidden rounded-md border bg-zinc-100 " +
|
||||
(isCover ? "border-emerald-500 ring-2 ring-emerald-300" : "border-zinc-200")
|
||||
}
|
||||
>
|
||||
<div {...attributes} {...listeners} className="aspect-square w-full cursor-grab touch-none active:cursor-grabbing">
|
||||
{item.type === "VIDEO" ? (
|
||||
<video
|
||||
src={item.s3Url}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
className="h-full w-full bg-black object-cover"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={item.s3Url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isCover ? (
|
||||
<span className="absolute left-1 top-1 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
|
||||
Cover
|
||||
</span>
|
||||
) : null}
|
||||
<span className="pointer-events-none absolute right-1 top-1 rounded bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-white">
|
||||
{item.type}
|
||||
</span>
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-end gap-1 bg-gradient-to-t from-black/70 to-transparent p-1.5 opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{!isCover ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSetCover}
|
||||
className="rounded bg-white/90 px-2 py-0.5 text-[10px] font-semibold text-zinc-900 hover:bg-white"
|
||||
title="Définir comme cover"
|
||||
>
|
||||
★ Cover
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="rounded bg-rose-600 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-rose-700"
|
||||
title="Supprimer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
/**
|
||||
* 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<Badge["tone"], string> = {
|
||||
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<Badge["tone"], string> = {
|
||||
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 (
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{badges.map((b, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ring-inset " +
|
||||
TONE_CLASSES_COMPACT[b.tone]
|
||||
}
|
||||
>
|
||||
<span aria-hidden>{b.emoji}</span>
|
||||
<span>{b.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// full : grille 2×2 pour la fiche
|
||||
return (
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{badges.map((b, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={
|
||||
"flex items-center gap-3 rounded-lg border px-3 py-2 ring-1 ring-inset " +
|
||||
TONE_CLASSES_FULL[b.tone]
|
||||
}
|
||||
>
|
||||
<span aria-hidden className="text-xl">{b.emoji}</span>
|
||||
<span className="text-sm font-medium">{b.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
/**
|
||||
* <img> avec srcset/sizes pré-rempli sur les variantes Karbé.
|
||||
* Drop-in remplacement pour les balises `<img src=… />` 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
|
||||
<img
|
||||
src={src}
|
||||
srcSet={buildSrcSet(src)}
|
||||
sizes={sizes}
|
||||
alt={alt}
|
||||
loading={loading}
|
||||
fetchPriority={fetchPriority}
|
||||
decoding={decoding}
|
||||
width={width}
|
||||
height={height}
|
||||
draggable={draggable}
|
||||
style={style}
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { signOut } from "@/auth";
|
||||
|
||||
export function SignOutButton() {
|
||||
return (
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signOut({ redirectTo: "/" });
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="text-xs text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
Se déconnecter
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
/**
|
||||
* 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 { 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;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-zinc-200 bg-white/85 backdrop-blur supports-[backdrop-filter]:bg-white/70">
|
||||
<div className="mx-auto flex h-12 max-w-7xl items-center justify-between gap-4 px-4">
|
||||
<Link href="/" className="flex items-center gap-2 text-base font-semibold text-zinc-900">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-emerald-600 text-xs font-bold text-white">
|
||||
K
|
||||
</span>
|
||||
Karbé
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-5 text-sm text-zinc-700 sm:flex">
|
||||
<Link href="/decouvrir" className="hover:text-zinc-900">
|
||||
Au fil de l'eau
|
||||
</Link>
|
||||
<Link href="/carbets" className="hover:text-zinc-900">
|
||||
Catalogue
|
||||
</Link>
|
||||
<Link href="/comment-ca-marche" className="hover:text-zinc-900">
|
||||
Comment ça marche
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
{u ? (
|
||||
<>
|
||||
<Link href="/mes-favoris" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
|
||||
Favoris
|
||||
</Link>
|
||||
<Link href="/mes-reservations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
|
||||
Mes réservations
|
||||
</Link>
|
||||
<Link href="/mon-compte" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
|
||||
Mon compte
|
||||
</Link>
|
||||
{isOwner ? (
|
||||
<Link href="/espace-hote" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
|
||||
Espace hôte
|
||||
</Link>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Link href="/admin" className="hidden rounded-md bg-zinc-900 px-2.5 py-1 text-xs font-semibold text-white hover:bg-zinc-800 sm:inline-block">
|
||||
Admin
|
||||
</Link>
|
||||
) : null}
|
||||
<span className="hidden max-w-[14ch] truncate text-xs text-zinc-500 md:inline" title={u.email ?? ""}>
|
||||
{u.name || u.email}
|
||||
</span>
|
||||
<SignOutButton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/connexion" className="text-zinc-700 hover:text-zinc-900">
|
||||
Connexion
|
||||
</Link>
|
||||
<Link
|
||||
href="/inscription"
|
||||
className="rounded-md bg-zinc-900 px-3 py-1 text-xs font-semibold text-white hover:bg-zinc-800"
|
||||
>
|
||||
Créer un compte
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
/**
|
||||
* 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 <SiteHeader />;
|
||||
}
|
||||
|
|
@ -50,15 +50,12 @@ export function CommandPalette() {
|
|||
}, []);
|
||||
|
||||
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(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setHits([]);
|
||||
setSelected(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const runSearch = useCallback(async (q: string) => {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,12 @@
|
|||
"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;
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function TopBar({ userEmail }: { userEmail: string }) {
|
||||
const isMac = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
useEffect(() => {
|
||||
setIsMac(navigator.userAgent.includes("Mac"));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-zinc-200 bg-white px-4">
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export type AdminCarbetListItem = {
|
|||
title: string;
|
||||
river: string;
|
||||
capacity: number;
|
||||
nightlyPrice: string;
|
||||
status: CarbetStatus;
|
||||
accessType: AccessType;
|
||||
ownerName: string;
|
||||
|
|
@ -53,7 +52,6 @@ export async function listCarbetsAdmin(filters: AdminCarbetFilters = {}): Promis
|
|||
title: true,
|
||||
river: true,
|
||||
capacity: true,
|
||||
nightlyPrice: true,
|
||||
status: true,
|
||||
accessType: true,
|
||||
updatedAt: true,
|
||||
|
|
@ -68,7 +66,6 @@ export async function listCarbetsAdmin(filters: AdminCarbetFilters = {}): Promis
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -27,11 +27,6 @@ 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;
|
||||
|
|
@ -65,11 +60,6 @@ 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,
|
||||
|
|
@ -120,11 +110,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import {
|
|||
AvailabilityBlockReason,
|
||||
AvailabilityScope,
|
||||
CarbetStatus,
|
||||
Electricity,
|
||||
RoadAccess,
|
||||
} from "@/generated/prisma/enums";
|
||||
import { getCarbetReviewStatsMany } from "@/lib/reviews-server";
|
||||
|
||||
|
|
@ -15,16 +13,9 @@ export type CarbetSearchFilters = {
|
|||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
capacity?: number;
|
||||
capacityMax?: number;
|
||||
// Filtre plugin access-type : si "river-only" exclu, on garde uniquement
|
||||
// ROAD_AND_RIVER. Si "all" ou non spécifié, tout passe.
|
||||
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 = {
|
||||
|
|
@ -78,63 +69,6 @@ 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;
|
||||
}
|
||||
|
||||
|
|
@ -156,13 +90,6 @@ export type CarbetSearchResult = {
|
|||
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
|
||||
|
|
@ -177,46 +104,14 @@ function buildWhere(filters: CarbetSearchFilters): Prisma.CarbetWhereInput {
|
|||
where.river = { contains: filters.river, mode: "insensitive" };
|
||||
}
|
||||
|
||||
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.capacity) {
|
||||
where.capacity = { gte: filters.capacity };
|
||||
}
|
||||
|
||||
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: {
|
||||
|
|
@ -254,13 +149,6 @@ export async function searchCarbets(
|
|||
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,
|
||||
|
|
@ -295,13 +183,6 @@ export async function searchCarbets(
|
|||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
224
src/lib/email.ts
224
src/lib/email.ts
|
|
@ -1,224 +0,0 @@
|
|||
/**
|
||||
* 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<import("resend").Resend | null> {
|
||||
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é <no-reply@karbe.cosmolan.fr>";
|
||||
|
||||
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 `<!doctype html><html><body style="background:#fafafa;margin:0;padding:24px 12px;">
|
||||
<div style="${baseStyle}background:white;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,0.05);">
|
||||
<h1 style="margin:0 0 16px;font-size:22px;font-weight:600;color:#18181b;">${title}</h1>
|
||||
${content}
|
||||
<hr style="margin:24px 0;border:0;border-top:1px solid #e4e4e7;" />
|
||||
<p style="font-size:11px;color:#71717a;margin:0;">
|
||||
Karbé · <a href="${SITE_URL}" style="color:#71717a;">${SITE_URL}</a><br/>
|
||||
Cet email a été envoyé suite à une action sur votre compte. Si ce n'est pas vous, ignorez-le.
|
||||
</p>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
export async function sendSignupWelcome(to: string, firstName: string): Promise<void> {
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: "Bienvenue sur Karbé",
|
||||
html: wrap(
|
||||
`Bienvenue ${firstName} !`,
|
||||
`<p>Votre compte Karbé est créé. Vous pouvez désormais réserver un séjour ou, si vous êtes hôte, publier votre carbet.</p>
|
||||
<p><a href="${SITE_URL}/carbets" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Découvrir les carbets</a></p>`,
|
||||
),
|
||||
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<void> {
|
||||
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",
|
||||
`<p>Bonjour ${firstName},</p>
|
||||
<p>Votre demande de réservation pour <strong>${carbetTitle}</strong> a bien été enregistrée :</p>
|
||||
<ul>
|
||||
<li>Arrivée : ${fmt(startDate)}</li>
|
||||
<li>Départ : ${fmt(endDate)}</li>
|
||||
<li>Montant : ${Number(amount).toFixed(2)} ${currency}</li>
|
||||
</ul>
|
||||
<p>Vous recevrez un nouvel email dès que l'hôte ou l'équipe Karbé confirmera votre séjour.</p>
|
||||
<p><a href="${SITE_URL}/reservations/${bookingId}" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma réservation</a></p>`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendBookingRequestToOwner(
|
||||
to: string,
|
||||
ownerFirstName: string,
|
||||
bookingId: string,
|
||||
carbetTitle: string,
|
||||
tenantName: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<void> {
|
||||
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",
|
||||
`<p>Bonjour ${ownerFirstName},</p>
|
||||
<p><strong>${tenantName}</strong> souhaite réserver <strong>${carbetTitle}</strong> :</p>
|
||||
<ul>
|
||||
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
|
||||
</ul>
|
||||
<p>Connectez-vous à votre espace hôte pour confirmer ou refuser.</p>
|
||||
<p><a href="${SITE_URL}/espace-hote" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mon espace hôte</a></p>`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendBookingConfirmed(
|
||||
to: string,
|
||||
firstName: string,
|
||||
bookingId: string,
|
||||
carbetTitle: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<void> {
|
||||
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é",
|
||||
`<p>Bonjour ${firstName},</p>
|
||||
<p>Votre réservation pour <strong>${carbetTitle}</strong> du ${fmt(startDate)} au ${fmt(endDate)} est confirmée.</p>
|
||||
<p><a href="${SITE_URL}/reservations/${bookingId}" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma réservation</a></p>`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendPasswordReset(
|
||||
to: string,
|
||||
resetUrl: string,
|
||||
): Promise<void> {
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: "Réinitialisation de votre mot de passe Karbé",
|
||||
html: wrap(
|
||||
"Réinitialiser votre mot de passe",
|
||||
`<p>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) :</p>
|
||||
<p><a href="${resetUrl}" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Réinitialiser mon mot de passe</a></p>
|
||||
<p style="font-size:12px;color:#71717a;">Si vous n'avez pas fait cette demande, ignorez simplement cet email — votre mot de passe ne change pas.</p>`,
|
||||
),
|
||||
text: `Réinitialiser votre mot de passe Karbé : ${resetUrl} (valable 1h).`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendBookingRefunded(
|
||||
to: string,
|
||||
firstName: string,
|
||||
bookingId: string,
|
||||
carbetTitle: string,
|
||||
amount: string,
|
||||
currency: string,
|
||||
): Promise<void> {
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: `Remboursement traité — ${carbetTitle}`,
|
||||
html: wrap(
|
||||
"Remboursement en cours",
|
||||
`<p>Bonjour ${firstName},</p>
|
||||
<p>Votre réservation pour <strong>${carbetTitle}</strong> a été annulée et le remboursement de <strong>${Number(amount).toFixed(2)} ${currency}</strong> est en cours de traitement par Stripe (3 à 5 jours ouvrés).</p>
|
||||
<p><a href="${SITE_URL}/reservations/${bookingId}" style="color:#18181b;">Détails de la réservation</a></p>`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
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<HostKpis> {
|
||||
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<HostRecentBooking[]> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
/**
|
||||
* Variantes responsive : génération + URL helpers.
|
||||
*
|
||||
* Convention de nommage : <s3Key>.jpg -> <s3Key>-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 `<img>`. 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(", ");
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
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<string> {
|
||||
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<number> {
|
||||
const result = await prisma.passwordResetToken.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
/**
|
||||
* 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<string, Bucket>();
|
||||
|
||||
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 });
|
||||
}
|
||||
127
src/lib/reels.ts
127
src/lib/reels.ts
|
|
@ -1,127 +0,0 @@
|
|||
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<ReelCarbet[]> {
|
||||
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<ReelCarbet[]> {
|
||||
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<typeof r> => 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,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
/**
|
||||
* 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<string, string>;
|
||||
};
|
||||
|
||||
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()}`;
|
||||
}
|
||||
|
|
@ -1,13 +1,5 @@
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
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<PresignResult | { error: string }> {
|
||||
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 { s3Internal };
|
||||
export { BUCKET as UPLOAD_BUCKET };
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
/**
|
||||
* 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 <base>-<w>.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<Uint8Array>): Promise<Buffer> {
|
||||
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<VariantResult> => {
|
||||
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 };
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
/**
|
||||
* 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).*)",
|
||||
],
|
||||
};
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
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: "<p>world</p>",
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue