Compare commits

..

No commits in common. "main" and "feat/admin-home-editor" have entirely different histories.

102 changed files with 200 additions and 9233 deletions

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -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");

View file

@ -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;

View file

@ -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';

View file

@ -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");

View file

@ -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

View file

@ -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" }]
}
]
}

View file

@ -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)"

View file

@ -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&apos;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>
</>
);
}

View file

@ -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 };

View file

@ -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

View file

@ -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,

View file

@ -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 &amp; 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"

View file

@ -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(),

View file

@ -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>

View file

@ -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>

View file

@ -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 });
}

View file

@ -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 },
);
}
}

View file

@ -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 });
}
}

View file

@ -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" });
}

View file

@ -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}"`,
},
},
);
}

View file

@ -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 });
}
}

View file

@ -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 });
}

View file

@ -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 },
});
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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);
}

View file

@ -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">
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&apos;équipe Karbé pour organiser votre séjour.
</p>
</aside>
</div>

View file

@ -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&apos;à {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>
);
}

View file

@ -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}

View file

@ -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>
);
}

View file

@ -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='&copy; <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>
);
}

View file

@ -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} />;
}

View file

@ -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='&copy; <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>
);
}

View file

@ -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} />;
}

View file

@ -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>
);
}

View file

@ -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"

View file

@ -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>
);
}

View file

@ -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}>

View file

@ -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>
);

View file

@ -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&apos;à {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>
);
}

View file

@ -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>
);
}

View file

@ -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&apos;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)}
/>
);
}

View file

@ -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>
);
}

View file

@ -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 };
}

View file

@ -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">

View file

@ -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&apos;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>
);
}

View file

@ -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>
);
}

View file

@ -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&apos;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>
);
}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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");
}

View file

@ -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&apos;intégralité des données associées à votre compte au format JSON,
conformément à l&apos;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>
);
}

View file

@ -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>
);
}

View file

@ -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&apos;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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View file

@ -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 é enregistrée. Vous recevrez
un email dès que l&apos;hôte ou l&apos;équipe Karbé l&apos;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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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}
/>
);
}

View file

@ -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>
);
}

View file

@ -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&apos;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>
);
}

View file

@ -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 />;
}

View file

@ -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) => {

View file

@ -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">

View file

@ -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,

View file

@ -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,

View file

@ -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,
};
});
}

View file

@ -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 é 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 é 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 é 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>`,
),
});
}

View file

@ -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;
}

View file

@ -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(", ");
}

View file

@ -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;
}

View file

@ -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 });
}

View file

@ -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,
})),
};
});
}

View file

@ -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;

View file

@ -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()}`;
}

View file

@ -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 {

View file

@ -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 };

View file

@ -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 };
}

View file

@ -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).*)",
],
};

View file

@ -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);
});
});

View file

@ -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();
});
});

View file

@ -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");
});
});

View file

@ -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);
});
});

View file

@ -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