Compare commits

..

No commits in common. "main" and "feat/site-header" have entirely different histories.

134 changed files with 270 additions and 11158 deletions

128
package-lock.json generated
View file

@ -10,23 +10,15 @@
"hasInstallScript": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.1056.0",
"@aws-sdk/s3-request-presigner": "^3.1058.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@types/leaflet": "^1.9.21",
"bcryptjs": "^3.0.3",
"leaflet": "^1.9.4",
"next": "16.2.6",
"next-auth": "^5.0.0-beta.31",
"pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-leaflet": "^5.0.0",
"resend": "^4.8.0",
"sharp": "^0.34.5",
"stripe": "^18.3.0"
},
"devDependencies": {
@ -514,23 +506,6 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/s3-request-presigner": {
"version": "3.1058.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1058.0.tgz",
"integrity": "sha512-IRgNfn8U3zfsZ0JkpmwjS59R/XyHMHxpuwW6HVuJhik+FsbClhNkujEO0w1WqJvXrF4FX+7qIAwUrvlwNvaZ7Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.15",
"@aws-sdk/signature-v4-multi-region": "^3.996.30",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.5",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.30",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz",
@ -861,59 +836,6 @@
"node": ">=18"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.1.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@electric-sql/pglite": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz",
@ -1647,6 +1569,7 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@ -2834,17 +2757,6 @@
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
}
},
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
@ -3697,12 +3609,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -3717,15 +3623,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
@ -5398,6 +5295,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@ -7699,12 +7597,6 @@
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -9162,20 +9054,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^3.0.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/react-promise-suspense": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
@ -9573,6 +9451,7 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@ -9616,6 +9495,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},

View file

@ -14,23 +14,15 @@
},
"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": {
@ -47,4 +39,4 @@
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}
}

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

@ -1,112 +0,0 @@
-- UserRole : ajouter RENTAL_PROVIDER
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'RENTAL_PROVIDER';
-- Enums dédiés
CREATE TYPE "RentalCategory" AS ENUM ('SLEEP', 'NAVIGATION', 'FISHING', 'COOKING', 'SAFETY');
CREATE TYPE "RentalBookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'HANDED_OVER', 'RETURNED', 'CANCELLED');
-- RentalProvider
CREATE TABLE "RentalProvider" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isSystemD" BOOLEAN NOT NULL DEFAULT false,
"managedByUserId" TEXT,
"contactEmail" TEXT,
"contactPhone" TEXT,
"rivers" TEXT[] DEFAULT ARRAY[]::TEXT[],
"description" TEXT,
"commissionPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"approved" BOOLEAN NOT NULL DEFAULT false,
"approvedAt" TIMESTAMP(3),
"approvedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RentalProvider_pkey" PRIMARY KEY ("id"),
CONSTRAINT "RentalProvider_managedByUserId_fkey" FOREIGN KEY ("managedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE INDEX "RentalProvider_active_approved_idx" ON "RentalProvider"("active", "approved");
CREATE INDEX "RentalProvider_managedByUserId_idx" ON "RentalProvider"("managedByUserId");
-- RentalItem
CREATE TABLE "RentalItem" (
"id" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"category" "RentalCategory" NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"imageUrl" TEXT,
"pricePerDay" DECIMAL(8,2) NOT NULL,
"pricePerWeek" DECIMAL(8,2),
"deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
"totalQty" INTEGER NOT NULL DEFAULT 1,
"withMotor" BOOLEAN NOT NULL DEFAULT false,
"fuelIncluded" BOOLEAN NOT NULL DEFAULT false,
"requiresLicense" BOOLEAN NOT NULL DEFAULT false,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RentalItem_pkey" PRIMARY KEY ("id"),
CONSTRAINT "RentalItem_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "RentalItem_providerId_idx" ON "RentalItem"("providerId");
CREATE INDEX "RentalItem_category_active_idx" ON "RentalItem"("category", "active");
-- RentalItemAvailability
CREATE TABLE "RentalItemAvailability" (
"id" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"qty" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"rentalBookingId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RentalItemAvailability_pkey" PRIMARY KEY ("id"),
CONSTRAINT "RentalItemAvailability_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "RentalItemAvailability_itemId_startDate_endDate_idx" ON "RentalItemAvailability"("itemId", "startDate", "endDate");
CREATE INDEX "RentalItemAvailability_rentalBookingId_idx" ON "RentalItemAvailability"("rentalBookingId");
-- RentalBooking
CREATE TABLE "RentalBooking" (
"id" TEXT NOT NULL,
"bookingId" TEXT,
"tenantId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"status" "RentalBookingStatus" NOT NULL DEFAULT 'PENDING',
"paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"itemsTotal" DECIMAL(10,2) NOT NULL,
"depositTotal" DECIMAL(10,2) NOT NULL,
"commissionAmount" DECIMAL(10,2) NOT NULL DEFAULT 0,
"amount" DECIMAL(10,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'EUR',
"stripeSessionId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RentalBooking_pkey" PRIMARY KEY ("id"),
CONSTRAINT "RentalBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "RentalBooking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "RentalBooking_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE INDEX "RentalBooking_tenantId_status_idx" ON "RentalBooking"("tenantId", "status");
CREATE INDEX "RentalBooking_providerId_status_idx" ON "RentalBooking"("providerId", "status");
CREATE INDEX "RentalBooking_bookingId_idx" ON "RentalBooking"("bookingId");
CREATE INDEX "RentalBooking_startDate_endDate_idx" ON "RentalBooking"("startDate", "endDate");
-- RentalLine
CREATE TABLE "RentalLine" (
"id" TEXT NOT NULL,
"rentalBookingId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"qty" INTEGER NOT NULL,
"pricePerDay" DECIMAL(8,2) NOT NULL,
"deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
"lineTotal" DECIMAL(10,2) NOT NULL,
CONSTRAINT "RentalLine_pkey" PRIMARY KEY ("id"),
CONSTRAINT "RentalLine_rentalBookingId_fkey" FOREIGN KEY ("rentalBookingId") REFERENCES "RentalBooking"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "RentalLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE INDEX "RentalLine_rentalBookingId_idx" ON "RentalLine"("rentalBookingId");

View file

@ -13,7 +13,6 @@ enum UserRole {
CE_MEMBER
TOURIST
ADMIN
RENTAL_PROVIDER
}
enum CarbetStatus {
@ -98,13 +97,11 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
carbets Carbet[] @relation("CarbetOwner")
bookings Booking[] @relation("BookingTenant")
reviews Review[] @relation("ReviewAuthor")
subscriptions Subscription[]
rentalProviders RentalProvider[]
rentalBookings RentalBooking[] @relation("RentalBookingTenant")
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
carbets Carbet[] @relation("CarbetOwner")
bookings Booking[] @relation("BookingTenant")
reviews Review[] @relation("ReviewAuthor")
subscriptions Subscription[]
@@index([organizationId])
@@index([role])
@ -127,11 +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.
@ -252,8 +244,7 @@ model Booking {
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
review Review?
rentalBookings RentalBooking[]
review Review?
@@index([carbetId])
@@index([tenantId])
@ -370,163 +361,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
}
enum RentalCategory {
SLEEP
NAVIGATION
FISHING
COOKING
SAFETY
}
enum RentalBookingStatus {
PENDING
CONFIRMED
HANDED_OVER
RETURNED
CANCELLED
}
model RentalProvider {
id String @id @default(cuid())
name String
isSystemD Boolean @default(false)
managedByUserId String?
contactEmail String?
contactPhone String?
rivers String[] @default([])
description String?
commissionPct Decimal @db.Decimal(5, 2) @default(0)
active Boolean @default(true)
approved Boolean @default(false)
approvedAt DateTime?
approvedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
manager User? @relation(fields: [managedByUserId], references: [id], onDelete: SetNull)
items RentalItem[]
rentalBookings RentalBooking[]
@@index([active, approved])
@@index([managedByUserId])
}
model RentalItem {
id String @id @default(cuid())
providerId String
category RentalCategory
name String
description String?
imageUrl String?
pricePerDay Decimal @db.Decimal(8, 2)
pricePerWeek Decimal? @db.Decimal(8, 2)
deposit Decimal @db.Decimal(8, 2) @default(0)
totalQty Int @default(1)
withMotor Boolean @default(false)
fuelIncluded Boolean @default(false)
requiresLicense Boolean @default(false)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
availabilities RentalItemAvailability[]
lines RentalLine[]
@@index([providerId])
@@index([category, active])
}
model RentalItemAvailability {
id String @id @default(cuid())
itemId String
startDate DateTime
endDate DateTime
qty Int
reason String
rentalBookingId String?
createdAt DateTime @default(now())
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@index([itemId, startDate, endDate])
@@index([rentalBookingId])
}
model RentalBooking {
id String @id @default(cuid())
bookingId String?
tenantId String
providerId String
startDate DateTime
endDate DateTime
status RentalBookingStatus @default(PENDING)
paymentStatus PaymentStatus @default(PENDING)
itemsTotal Decimal @db.Decimal(10, 2)
depositTotal Decimal @db.Decimal(10, 2)
commissionAmount Decimal @db.Decimal(10, 2) @default(0)
amount Decimal @db.Decimal(10, 2)
currency String @default("EUR")
stripeSessionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
tenant User @relation("RentalBookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Restrict)
lines RentalLine[]
@@index([tenantId, status])
@@index([providerId, status])
@@index([bookingId])
@@index([startDate, endDate])
}
model RentalLine {
id String @id @default(cuid())
rentalBookingId String
itemId String
qty Int
pricePerDay Decimal @db.Decimal(8, 2)
deposit Decimal @db.Decimal(8, 2) @default(0)
lineTotal Decimal @db.Decimal(10, 2)
rentalBooking RentalBooking @relation(fields: [rentalBookingId], references: [id], onDelete: Cascade)
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
@@index([rentalBookingId])
}

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

@ -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}
@ -94,10 +89,6 @@ export default async function EditCarbetPage({ params }: PageProps) {
capacity: carbet.capacity,
nightlyPrice: carbet.nightlyPrice.toString(),
accessType: carbet.accessType,
roadAccess: carbet.roadAccess,
electricity: carbet.electricity,
gsmAtCarbet: carbet.gsmAtCarbet,
gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : null,
roadAccessNote: carbet.roadAccessNote,
pirogueDurationMin: carbet.pirogueDurationMin,
minStayNights: carbet.minStayNights,

View file

@ -20,10 +20,6 @@ export type CarbetFormInitial = {
capacity?: number;
nightlyPrice?: number | string;
accessType?: string;
roadAccess?: string | null;
electricity?: string | null;
gsmAtCarbet?: boolean;
gsmExitDistanceKm?: number | string | null;
roadAccessNote?: string | null;
pirogueDurationMin?: number | null;
minStayNights?: number | null;
@ -193,63 +189,6 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe
</div>
</section>
{/* Critères opérationnels */}
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-1 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Critères opérationnels
</h2>
<p className="mb-4 text-xs text-zinc-500">
Les 4 dealbreakers d&apos;un séjour en carbet guyanais. Indispensable pour les filtres recherche.
</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="🛣️ Accès route" hint="Praticabilité de l'accès depuis la route">
<select name="roadAccess" defaultValue={initial.roadAccess ?? ""} className={selectCls}>
<option value=""> non précisé </option>
<option value="ALL_YEAR">🛣 Toute saison</option>
<option value="DRY_SEASON_ONLY">🟠 Saison sèche uniquement</option>
<option value="NONE">🛶 Pirogue uniquement</option>
</select>
</FormField>
<FormField label="⚡ Électricité" hint="Comment est alimenté le carbet ?">
<select name="electricity" defaultValue={initial.electricity ?? ""} className={selectCls}>
<option value=""> non précisé </option>
<option value="EDF"> EDF / raccordé réseau</option>
<option value="GENERATOR_READY">🔌 Préinstallation groupe électrogène</option>
<option value="SOLAR"> Solaire</option>
<option value="NONE">🕯 Aucune électricité</option>
</select>
</FormField>
<FormField label="📶 Réseau GSM au carbet" hint="Téléphone capte directement sur place ?">
<select
name="gsmAtCarbet"
defaultValue={initial.gsmAtCarbet ? "yes" : "no"}
className={selectCls}
>
<option value="yes"> Oui, signal au carbet</option>
<option value="no"> Non, zone sans réseau</option>
</select>
</FormField>
<FormField
label="📵 Distance pour atteindre le réseau (km)"
hint="Si pas de réseau au carbet — sinon laisser vide"
>
<input
name="gsmExitDistanceKm"
type="number"
min={0}
max={50}
step="0.1"
defaultValue={initial.gsmExitDistanceKm?.toString() ?? ""}
placeholder="ex. 1.5"
className={inputCls}
/>
</FormField>
</div>
</section>
{/* Séjour & tarif */}
<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>

View file

@ -10,9 +10,7 @@ import { prisma } from "@/lib/prisma";
import {
AccessType,
CarbetStatus,
Electricity,
MediaType,
RoadAccess,
TransportMode,
UserRole,
} from "@/generated/prisma/enums";
@ -31,16 +29,6 @@ const baseCarbetSchema = z.object({
capacity: z.coerce.number().int().min(1).max(100),
nightlyPrice: z.coerce.number().min(0).max(100000),
accessType: z.enum([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]),
roadAccess: z
.enum([RoadAccess.NONE, RoadAccess.DRY_SEASON_ONLY, RoadAccess.ALL_YEAR])
.optional()
.nullable(),
electricity: z
.enum([Electricity.NONE, Electricity.SOLAR, Electricity.GENERATOR_READY, Electricity.EDF])
.optional()
.nullable(),
gsmAtCarbet: z.preprocess((v) => v === "yes" || v === true, z.boolean()),
gsmExitDistanceKm: z.coerce.number().min(0).max(50).optional().nullable(),
roadAccessNote: z.string().trim().max(1000).optional().nullable(),
pirogueDurationMin: z.coerce.number().int().min(0).max(1440).optional().nullable(),
minStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(),
@ -65,11 +53,9 @@ function parseFromFormData(fd: FormData) {
if (typeof v === "string") obj[k] = v;
}
// Normalise les champs optionnels nullables
["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId", "roadAccess", "electricity", "gsmExitDistanceKm"].forEach(
["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId"].forEach(
(k) => (obj[k] = normalizeNullable(obj[k] as string | null | undefined)),
);
// gsmAtCarbet : si pas posté, on garde la valeur (sera traité par preprocess Zod)
if (!("gsmAtCarbet" in obj)) obj.gsmAtCarbet = "no";
return obj;
}

View file

@ -1,86 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
type Props = {
active: boolean;
toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
};
export function ItemInlineActions({ active, toggleActiveAction, deleteAction }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState<string | null>(null);
function toggle() {
setError(null);
startTransition(async () => {
const res = await toggleActiveAction(!active);
if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
router.refresh();
});
}
function del() {
setError(null);
startTransition(async () => {
const res = await deleteAction();
if (res && (res as { ok?: boolean }).ok === false) {
setError((res as { error: string }).error);
setConfirmDelete(false);
}
});
}
return (
<div className="flex flex-col items-end gap-2">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={toggle}
disabled={pending}
className={
active
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
}
>
{active ? "Désactiver" : "Réactiver"}
</button>
{confirmDelete ? (
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
<span className="text-xs text-rose-900">Supprimer ?</span>
<button
type="button"
onClick={del}
disabled={pending}
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
>
Oui
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
disabled={pending}
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
Supprimer
</button>
)}
</div>
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
</div>
);
}

View file

@ -1,83 +0,0 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { getRentalItemForAdmin, listProvidersForSelect, RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
import { ItemForm } from "../_components/ItemForm";
import { ItemInlineActions } from "./_components/ItemInlineActions";
import { deleteRentalItemAction, toggleRentalItemActiveAction, updateRentalItemAction } from "../actions";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ id: string }> };
export default async function EditRentalItemPage({ params }: PageProps) {
const { id } = await params;
const [item, providers] = await Promise.all([getRentalItemForAdmin(id), listProvidersForSelect()]);
if (!item) notFound();
const updateThis = async (fd: FormData) => {
"use server";
return await updateRentalItemAction(id, fd);
};
const toggleActiveThis = async (active: boolean) => {
"use server";
return await toggleRentalItemActiveAction(id, active);
};
const deleteThis = async () => {
"use server";
return await deleteRentalItemAction(id);
};
return (
<div className="mx-auto max-w-4xl space-y-6">
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/admin/rental-items" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les items
</Link>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
{item.name}
<StatusBadge status={item.active ? "ACTIVE" : "INACTIVE"} />
</h1>
<p className="mt-1 text-sm text-zinc-500">
{RENTAL_CATEGORY_LABEL[item.category]} ·{" "}
<Link href={`/admin/rental-providers/${item.provider.id}`} className="text-zinc-900 hover:underline">
{item.provider.name}
</Link>
{item.provider.isSystemD ? " (System D)" : ""}
</p>
</div>
<ItemInlineActions
active={item.active}
toggleActiveAction={toggleActiveThis}
deleteAction={deleteThis}
/>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<ItemForm
providers={providers}
action={updateThis}
submitLabel="Enregistrer les modifications"
initial={{
providerId: item.providerId,
category: item.category,
name: item.name,
description: item.description,
imageUrl: item.imageUrl,
pricePerDay: item.pricePerDay.toString(),
pricePerWeek: item.pricePerWeek?.toString() ?? null,
deposit: item.deposit.toString(),
totalQty: item.totalQty,
withMotor: item.withMotor,
fuelIncluded: item.fuelIncluded,
requiresLicense: item.requiresLicense,
active: item.active,
}}
/>
</section>
</div>
);
}

View file

@ -1,132 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField";
import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels";
type Props = {
providers: { id: string; name: string; isSystemD: boolean }[];
initial?: {
providerId?: string;
category?: string;
name?: string;
description?: string | null;
imageUrl?: string | null;
pricePerDay?: string | number;
pricePerWeek?: string | number | null;
deposit?: string | number;
totalQty?: number;
withMotor?: boolean;
fuelIncluded?: boolean;
requiresLicense?: boolean;
active?: boolean;
};
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
submitLabel?: string;
};
export function ItemForm({ providers, initial = {}, action, submitLabel = "Enregistrer" }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
function onSubmit(fd: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await action(fd);
if (res && res.ok === false) setError(res.error);
else if (res && res.ok === true) setSuccess("Enregistré.");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Prestataire" required>
<select name="providerId" defaultValue={initial.providerId ?? ""} required className={selectCls}>
<option value="" disabled> sélectionner </option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.isSystemD ? " (System D)" : ""}
</option>
))}
</select>
</FormField>
<FormField label="Catégorie" required>
<select name="category" defaultValue={initial.category ?? ""} required className={selectCls}>
<option value="" disabled> sélectionner </option>
{RENTAL_CATEGORIES.map((c) => (
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
))}
</select>
</FormField>
<FormField label="Nom de l'item" required className="sm:col-span-2">
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} placeholder="ex. Hamac coton large, Pirogue 5m avec moteur 15CV" />
</FormField>
<FormField label="Description" className="sm:col-span-2">
<textarea name="description" rows={3} defaultValue={initial.description ?? ""} maxLength={5000} className={textareaCls} />
</FormField>
<FormField label="URL image" hint="Optionnel, URL publique vers photo MinIO.">
<input name="imageUrl" type="url" defaultValue={initial.imageUrl ?? ""} maxLength={500} className={inputCls} />
</FormField>
<FormField label="Stock total (qté)" required>
<input name="totalQty" type="number" min={1} max={1000} defaultValue={initial.totalQty?.toString() ?? "1"} required className={inputCls} />
</FormField>
<FormField label="Prix / jour (€)" required>
<input name="pricePerDay" type="number" min={0} step="0.5" defaultValue={initial.pricePerDay?.toString() ?? ""} required className={inputCls} />
</FormField>
<FormField label="Prix / semaine (€)" hint="Optionnel — tarif dégressif sur 7+ jours.">
<input name="pricePerWeek" type="number" min={0} step="0.5" defaultValue={initial.pricePerWeek?.toString() ?? ""} className={inputCls} />
</FormField>
<FormField label="Caution (€)" hint="Dépôt de garantie (bloqué pendant la location).">
<input name="deposit" type="number" min={0} step="1" defaultValue={initial.deposit?.toString() ?? "0"} className={inputCls} />
</FormField>
<FormField label="Statut">
<label className="flex items-center gap-2 px-1 py-2 text-sm">
<input type="checkbox" name="active" defaultChecked={initial.active ?? true} className="h-4 w-4 rounded border-zinc-300" />
Actif (visible au catalogue)
</label>
</FormField>
</div>
<fieldset className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wider text-zinc-500">
Spécifications navigation
</legend>
<div className="flex flex-wrap gap-4 pt-1 text-sm">
<label className="flex items-center gap-2">
<input type="checkbox" name="withMotor" defaultChecked={initial.withMotor ?? false} className="h-4 w-4 rounded border-zinc-300" />
Avec moteur
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="fuelIncluded" defaultChecked={initial.fuelIncluded ?? false} className="h-4 w-4 rounded border-zinc-300" />
Essence incluse
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="requiresLicense" defaultChecked={initial.requiresLicense ?? false} className="h-4 w-4 rounded border-zinc-300" />
Permis bateau requis
</label>
</div>
</fieldset>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-end">
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -1,129 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth";
import { RentalCategory, UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
const itemSchema = z.object({
providerId: z.string().min(1),
category: z.enum([
RentalCategory.SLEEP,
RentalCategory.NAVIGATION,
RentalCategory.FISHING,
RentalCategory.COOKING,
RentalCategory.SAFETY,
]),
name: z.string().trim().min(2).max(200),
description: z.string().trim().max(5000).nullable().optional(),
imageUrl: z.string().trim().url().max(500).nullable().optional(),
pricePerDay: z.coerce.number().min(0).max(10000),
pricePerWeek: z.coerce.number().min(0).max(50000).nullable().optional(),
deposit: z.coerce.number().min(0).max(10000),
totalQty: z.coerce.number().int().min(1).max(1000),
withMotor: z.boolean(),
fuelIncluded: z.boolean(),
requiresLicense: z.boolean(),
active: z.boolean(),
});
function parseFD(fd: FormData) {
const get = (k: string) => {
const v = (fd.get(k) as string | null) ?? "";
return v.trim() === "" ? null : v.trim();
};
return {
providerId: ((fd.get("providerId") as string | null) ?? "").trim(),
category: ((fd.get("category") as string | null) ?? "").trim(),
name: ((fd.get("name") as string | null) ?? "").trim(),
description: get("description"),
imageUrl: get("imageUrl"),
pricePerDay: fd.get("pricePerDay"),
pricePerWeek: get("pricePerWeek"),
deposit: fd.get("deposit") ?? "0",
totalQty: fd.get("totalQty") ?? "1",
withMotor: fd.get("withMotor") === "on",
fuelIncluded: fd.get("fuelIncluded") === "on",
requiresLicense: fd.get("requiresLicense") === "on",
active: fd.get("active") === "on",
};
}
export async function createRentalItemAction(fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = itemSchema.safeParse(parseFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
const created = await prisma.rentalItem.create({ data: parsed.data });
await recordAudit({
scope: "admin.rental-items",
event: "create",
target: created.id,
actorEmail: session?.user?.email ?? null,
details: { name: created.name, providerId: created.providerId },
});
revalidatePath("/admin/rental-items");
redirect(`/admin/rental-items/${created.id}`);
}
export async function updateRentalItemAction(id: string, fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = itemSchema.safeParse(parseFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
await prisma.rentalItem.update({ where: { id }, data: parsed.data });
await recordAudit({
scope: "admin.rental-items",
event: "update",
target: id,
actorEmail: session?.user?.email ?? null,
details: { name: parsed.data.name },
});
revalidatePath("/admin/rental-items");
revalidatePath(`/admin/rental-items/${id}`);
return { ok: true as const };
}
export async function toggleRentalItemActiveAction(id: string, active: boolean) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.rentalItem.update({ where: { id }, data: { active } });
await recordAudit({
scope: "admin.rental-items",
event: "active.update",
target: id,
actorEmail: session?.user?.email ?? null,
details: { active },
});
revalidatePath("/admin/rental-items");
revalidatePath(`/admin/rental-items/${id}`);
return { ok: true as const };
}
export async function deleteRentalItemAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const linesCount = await prisma.rentalLine.count({ where: { itemId: id } });
if (linesCount > 0) {
return { ok: false as const, error: `Impossible : ${linesCount} ligne(s) de réservation pointe(nt) sur cet item.` };
}
await prisma.rentalItem.delete({ where: { id } });
await recordAudit({
scope: "admin.rental-items",
event: "delete",
target: id,
actorEmail: session?.user?.email ?? null,
details: {},
});
revalidatePath("/admin/rental-items");
redirect("/admin/rental-items");
}

View file

@ -1,31 +0,0 @@
import Link from "next/link";
import { ItemForm } from "../_components/ItemForm";
import { createRentalItemAction } from "../actions";
import { listProvidersForSelect } from "@/lib/admin/rental-items";
export const dynamic = "force-dynamic";
type PageProps = { searchParams: Promise<{ providerId?: string }> };
export default async function NewRentalItemPage({ searchParams }: PageProps) {
const sp = await searchParams;
const providers = await listProvidersForSelect();
return (
<div className="mx-auto max-w-3xl space-y-6">
<header className="mt-2">
<Link href="/admin/rental-items" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les items
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvel item locable</h1>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<ItemForm
providers={providers}
action={createRentalItemAction}
submitLabel="Créer l'item"
initial={{ providerId: sp.providerId, active: true, totalQty: 1 }}
/>
</section>
</div>
);
}

View file

@ -1,152 +0,0 @@
import Link from "next/link";
import { RentalCategory } from "@/generated/prisma/enums";
import {
RENTAL_CATEGORY_LABEL,
isRentalCategory,
listProvidersForSelect,
listRentalItemsAdmin,
} from "@/lib/admin/rental-items";
import { StatusBadge } from "@/components/admin/StatusBadge";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
category?: string;
providerId?: string;
active?: string;
}>;
};
export default async function RentalItemsAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
category: sp.category && isRentalCategory(sp.category) ? sp.category : undefined,
providerId: sp.providerId || undefined,
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
};
const [rows, providers] = await Promise.all([listRentalItemsAdmin(filters), listProvidersForSelect()]);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Catalogue d&apos;items locables</h1>
<p className="mt-1 text-sm text-zinc-500">
{rows.length} item{rows.length > 1 ? "s" : ""}
</p>
</div>
<Link
href="/admin/rental-items/new"
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
>
+ Nouvel item
</Link>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche nom, description…"
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="category"
defaultValue={filters.category ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Toutes catégories</option>
{Object.values(RentalCategory).map((c) => (
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
))}
</select>
<select
name="providerId"
defaultValue={filters.providerId ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous prestataires</option>
{providers.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<select
name="active"
defaultValue={filters.active ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Actifs + inactifs</option>
<option value="yes">Actifs</option>
<option value="no">Inactifs</option>
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.category || filters.providerId || filters.active) ? (
<Link href="/admin/rental-items" className="text-sm text-zinc-500 hover:text-zinc-900">Réinit.</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Nom</th>
<th className="px-4 py-2 text-left font-semibold">Catégorie</th>
<th className="px-4 py-2 text-left font-semibold">Prestataire</th>
<th className="px-4 py-2 text-right font-semibold"> / jour</th>
<th className="px-4 py-2 text-right font-semibold">Stock</th>
<th className="px-4 py-2 text-right font-semibold">Caution</th>
<th className="px-4 py-2 text-left font-semibold">État</th>
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucun item.
</td>
</tr>
) : null}
{rows.map((i) => (
<tr key={i.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/rental-items/${i.id}`} className="font-medium text-zinc-900 hover:underline">
{i.name}
</Link>
<div className="text-[11px] text-zinc-500">
{i.withMotor ? "⚙️ moteur · " : ""}
{i.requiresLicense ? "🪪 permis · " : ""}
{i.fuelIncluded ? "⛽ essence · " : ""}
</div>
</td>
<td className="px-4 py-2 text-zinc-700">{RENTAL_CATEGORY_LABEL[i.category]}</td>
<td className="px-4 py-2">
<Link href={`/admin/rental-providers/${i.providerId}`} className="text-zinc-900 hover:underline">
{i.providerName}
</Link>
{i.providerIsSystemD ? (
<span className="ml-1 rounded-full bg-emerald-100 px-1 py-0 text-[9px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
SD
</span>
) : null}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.pricePerDay).toFixed(0)}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i.totalQty}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.deposit).toFixed(0)}</td>
<td className="px-4 py-2"><StatusBadge status={i.active ? "ACTIVE" : "INACTIVE"} /></td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(i.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,120 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
type Props = {
approved: boolean;
active: boolean;
itemsCount: number;
approveAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
};
export function ProviderInlineActions({
approved,
active,
itemsCount,
approveAction,
toggleActiveAction,
deleteAction,
}: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState<string | null>(null);
function approve() {
setError(null);
startTransition(async () => {
const res = await approveAction();
if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
router.refresh();
});
}
function toggle() {
setError(null);
startTransition(async () => {
const res = await toggleActiveAction(!active);
if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
router.refresh();
});
}
function del() {
setError(null);
startTransition(async () => {
const res = await deleteAction();
if (res && (res as { ok?: boolean }).ok === false) {
setError((res as { error: string }).error);
setConfirmDelete(false);
}
});
}
return (
<div className="flex flex-col items-end gap-2">
<div className="flex flex-wrap items-center gap-2">
{!approved ? (
<button
type="button"
onClick={approve}
disabled={pending}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
Approuver
</button>
) : null}
<button
type="button"
onClick={toggle}
disabled={pending}
className={
active
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
}
>
{active ? "Désactiver" : "Réactiver"}
</button>
{itemsCount === 0 ? (
confirmDelete ? (
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
<span className="text-xs text-rose-900">Supprimer ?</span>
<button
type="button"
onClick={del}
disabled={pending}
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
>
Oui
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
disabled={pending}
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
Supprimer
</button>
)
) : (
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] text-zinc-500">
{itemsCount} item(s) supprimez-les d&apos;abord
</span>
)}
</div>
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
</div>
);
}

View file

@ -1,136 +0,0 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { getRentalProviderForAdmin } from "@/lib/admin/rental-providers";
import { RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
import { ProviderForm } from "../_components/ProviderForm";
import { ProviderInlineActions } from "./_components/ProviderInlineActions";
import {
approveRentalProviderAction,
deleteRentalProviderAction,
toggleRentalProviderActiveAction,
updateRentalProviderAction,
} from "../actions";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ id: string }> };
export default async function EditRentalProviderPage({ params }: PageProps) {
const { id } = await params;
const p = await getRentalProviderForAdmin(id);
if (!p) notFound();
const updateThis = async (fd: FormData) => {
"use server";
return await updateRentalProviderAction(id, fd);
};
const approveThis = async () => {
"use server";
return await approveRentalProviderAction(id);
};
const toggleActiveThis = async (active: boolean) => {
"use server";
return await toggleRentalProviderActiveAction(id, active);
};
const deleteThis = async () => {
"use server";
return await deleteRentalProviderAction(id);
};
return (
<div className="mx-auto max-w-5xl space-y-6">
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/admin/rental-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les prestataires
</Link>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
{p.name}
{p.isSystemD ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
System D
</span>
) : null}
<StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} />
{p.approved ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
Approuvé
</span>
) : (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
En attente
</span>
)}
</h1>
<p className="mt-1 text-sm text-zinc-500">
Fleuves : {p.rivers.join(", ") || "—"} · {p._count.items} item(s) · {p._count.rentalBookings} réservation(s) · Commission {Number(p.commissionPct).toFixed(1)}%
</p>
</div>
<ProviderInlineActions
approved={p.approved}
active={p.active}
itemsCount={p._count.items}
approveAction={approveThis}
toggleActiveAction={toggleActiveThis}
deleteAction={deleteThis}
/>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Informations</h2>
<ProviderForm
action={updateThis}
submitLabel="Enregistrer"
initial={{
name: p.name,
isSystemD: p.isSystemD,
contactEmail: p.contactEmail,
contactPhone: p.contactPhone,
rivers: p.rivers,
description: p.description,
commissionPct: p.commissionPct.toString(),
active: p.active,
}}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 flex items-center justify-between text-sm font-semibold uppercase tracking-wider text-zinc-500">
<span>Items ({p.items.length})</span>
<Link href={`/admin/rental-items?providerId=${p.id}`} className="text-xs normal-case tracking-normal text-zinc-700 underline hover:text-zinc-900">
Voir tous les items
</Link>
</h2>
{p.items.length === 0 ? (
<p className="text-sm text-zinc-500">
Pas encore d&apos;item.{" "}
<Link href={`/admin/rental-items/new?providerId=${p.id}`} className="text-zinc-900 underline">
Créer un premier item
</Link>
</p>
) : (
<ul className="divide-y divide-zinc-100">
{p.items.map((i) => (
<li key={i.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<Link href={`/admin/rental-items/${i.id}`} className="text-zinc-900 hover:underline">
{i.name}
<span className="ml-2 text-[11px] text-zinc-500">
{RENTAL_CATEGORY_LABEL[i.category]}
</span>
</Link>
<span className="flex items-center gap-3">
<span className="font-mono text-xs text-zinc-700">{Number(i.pricePerDay).toFixed(0)} /j</span>
<span className="text-[11px] text-zinc-500">qty {i.totalQty}</span>
<StatusBadge status={i.active ? "ACTIVE" : "INACTIVE"} />
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}

View file

@ -1,132 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
type Props = {
initial?: {
name?: string;
isSystemD?: boolean;
contactEmail?: string | null;
contactPhone?: string | null;
rivers?: string[];
description?: string | null;
commissionPct?: number | string;
active?: boolean;
};
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
submitLabel?: string;
};
export function ProviderForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
function onSubmit(fd: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await action(fd);
if (res && res.ok === false) setError(res.error);
else if (res && res.ok === true) setSuccess("Enregistré.");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Nom du prestataire" required>
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} />
</FormField>
<FormField label="Type">
<label className="flex items-center gap-2 px-1 py-2 text-sm">
<input
type="checkbox"
name="isSystemD"
defaultChecked={initial.isSystemD ?? false}
className="h-4 w-4 rounded border-zinc-300"
/>
Fournisseur officiel System D (0 % commission)
</label>
</FormField>
<FormField label="Email contact">
<input
name="contactEmail"
type="email"
defaultValue={initial.contactEmail ?? ""}
maxLength={200}
className={inputCls}
/>
</FormField>
<FormField label="Téléphone contact">
<input
name="contactPhone"
defaultValue={initial.contactPhone ?? ""}
maxLength={50}
className={inputCls}
/>
</FormField>
<FormField label="Commission (%)" hint="0 pour System D, 5-15 % pour les prestataires externes.">
<input
name="commissionPct"
type="number"
min={0}
max={50}
step="0.5"
defaultValue={initial.commissionPct?.toString() ?? "10"}
className={inputCls}
/>
</FormField>
<FormField label="Statut">
<label className="flex items-center gap-2 px-1 py-2 text-sm">
<input
type="checkbox"
name="active"
defaultChecked={initial.active ?? true}
className="h-4 w-4 rounded border-zinc-300"
/>
Actif
</label>
</FormField>
</div>
<FormField label="Fleuves desservis" required hint="Séparer par virgule, point-virgule ou retour à la ligne.">
<input
name="rivers"
defaultValue={(initial.rivers ?? []).join(", ")}
placeholder="Maroni, Approuague, Oyapock"
className={inputCls}
/>
</FormField>
<FormField label="Description" hint="Présentation, points forts, conditions particulières.">
<textarea
name="description"
rows={4}
defaultValue={initial.description ?? ""}
maxLength={5000}
className={textareaCls}
/>
</FormField>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-end gap-2">
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -1,150 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
const providerSchema = z.object({
name: z.string().trim().min(2).max(200),
isSystemD: z.boolean(),
managedByUserId: z.string().nullable().optional(),
contactEmail: z.string().trim().email().max(200).nullable().optional(),
contactPhone: z.string().trim().max(50).nullable().optional(),
rivers: z.array(z.string().trim().min(1).max(80)).max(20),
description: z.string().trim().max(5000).nullable().optional(),
commissionPct: z.coerce.number().min(0).max(50),
active: z.boolean(),
});
function parseFD(fd: FormData) {
const riversRaw = (fd.get("rivers") as string | null) ?? "";
const rivers = riversRaw
.split(/[,;\n]/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
const get = (k: string) => {
const v = (fd.get(k) as string | null) ?? "";
return v.trim() === "" ? null : v.trim();
};
return {
name: ((fd.get("name") as string | null) ?? "").trim(),
isSystemD: fd.get("isSystemD") === "on",
managedByUserId: get("managedByUserId"),
contactEmail: get("contactEmail"),
contactPhone: get("contactPhone"),
rivers,
description: get("description"),
commissionPct: fd.get("commissionPct"),
active: fd.get("active") === "on",
};
}
export async function createRentalProviderAction(fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = providerSchema.safeParse(parseFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
const created = await prisma.rentalProvider.create({
data: {
...parsed.data,
approved: true, // créé par admin → approuvé d'office
approvedAt: new Date(),
approvedBy: session?.user?.email ?? null,
},
});
await recordAudit({
scope: "admin.rental-providers",
event: "create",
target: created.id,
actorEmail: session?.user?.email ?? null,
details: { name: created.name, isSystemD: created.isSystemD },
});
revalidatePath("/admin/rental-providers");
redirect(`/admin/rental-providers/${created.id}`);
}
export async function updateRentalProviderAction(id: string, fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = providerSchema.safeParse(parseFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
await prisma.rentalProvider.update({ where: { id }, data: parsed.data });
await recordAudit({
scope: "admin.rental-providers",
event: "update",
target: id,
actorEmail: session?.user?.email ?? null,
details: { name: parsed.data.name },
});
revalidatePath("/admin/rental-providers");
revalidatePath(`/admin/rental-providers/${id}`);
return { ok: true as const };
}
export async function approveRentalProviderAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.rentalProvider.update({
where: { id },
data: {
approved: true,
approvedAt: new Date(),
approvedBy: session?.user?.email ?? null,
},
});
await recordAudit({
scope: "admin.rental-providers",
event: "approve",
target: id,
actorEmail: session?.user?.email ?? null,
details: {},
});
revalidatePath("/admin/rental-providers");
revalidatePath(`/admin/rental-providers/${id}`);
return { ok: true as const };
}
export async function toggleRentalProviderActiveAction(id: string, active: boolean) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.rentalProvider.update({ where: { id }, data: { active } });
await recordAudit({
scope: "admin.rental-providers",
event: "active.update",
target: id,
actorEmail: session?.user?.email ?? null,
details: { active },
});
revalidatePath("/admin/rental-providers");
revalidatePath(`/admin/rental-providers/${id}`);
return { ok: true as const };
}
export async function deleteRentalProviderAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const itemsCount = await prisma.rentalItem.count({ where: { providerId: id } });
if (itemsCount > 0) {
return { ok: false as const, error: `Impossible : ${itemsCount} item(s) attaché(s).` };
}
await prisma.rentalProvider.delete({ where: { id } });
await recordAudit({
scope: "admin.rental-providers",
event: "delete",
target: id,
actorEmail: session?.user?.email ?? null,
details: {},
});
revalidatePath("/admin/rental-providers");
redirect("/admin/rental-providers");
}

View file

@ -1,21 +0,0 @@
import Link from "next/link";
import { ProviderForm } from "../_components/ProviderForm";
import { createRentalProviderAction } from "../actions";
export const dynamic = "force-dynamic";
export default function NewRentalProviderPage() {
return (
<div className="mx-auto max-w-3xl space-y-6">
<header className="mt-2">
<Link href="/admin/rental-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les prestataires
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouveau prestataire location</h1>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<ProviderForm action={createRentalProviderAction} submitLabel="Créer le prestataire" />
</section>
</div>
);
}

View file

@ -1,149 +0,0 @@
import Link from "next/link";
import { listRentalProvidersAdmin, listRentalProviderRivers } from "@/lib/admin/rental-providers";
import { StatusBadge } from "@/components/admin/StatusBadge";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
approved?: string;
active?: string;
river?: string;
}>;
};
export default async function RentalProvidersAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
approved: sp.approved === "yes" || sp.approved === "no" ? (sp.approved as "yes" | "no") : undefined,
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
river: sp.river || undefined,
};
const [rows, rivers] = await Promise.all([listRentalProvidersAdmin(filters), listRentalProviderRivers()]);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Prestataires location matériel</h1>
<p className="mt-1 text-sm text-zinc-500">
{rows.length} résultat{rows.length > 1 ? "s" : ""}
</p>
</div>
<Link
href="/admin/rental-providers/new"
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
>
+ Nouveau prestataire
</Link>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche nom, email, description…"
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="approved"
defaultValue={filters.approved ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous statuts approbation</option>
<option value="yes">Approuvés</option>
<option value="no">En attente</option>
</select>
<select
name="active"
defaultValue={filters.active ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Actifs + inactifs</option>
<option value="yes">Actifs</option>
<option value="no">Inactifs</option>
</select>
<select
name="river"
defaultValue={filters.river ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous fleuves</option>
{rivers.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.approved || filters.active || filters.river) ? (
<Link href="/admin/rental-providers" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Nom</th>
<th className="px-4 py-2 text-left font-semibold">Fleuves</th>
<th className="px-4 py-2 text-right font-semibold">Items</th>
<th className="px-4 py-2 text-right font-semibold">Comm.</th>
<th className="px-4 py-2 text-left font-semibold">Approbation</th>
<th className="px-4 py-2 text-left font-semibold">État</th>
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucun prestataire ne correspond aux filtres.
</td>
</tr>
) : null}
{rows.map((p) => (
<tr key={p.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/rental-providers/${p.id}`} className="font-medium text-zinc-900 hover:underline">
{p.name}
</Link>
{p.isSystemD ? (
<span className="ml-2 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
System D
</span>
) : null}
<div className="text-[11px] text-zinc-500">{p.contactEmail ?? "—"}</div>
</td>
<td className="px-4 py-2 text-zinc-700">
{p.rivers.length === 0 ? <span className="text-zinc-400"></span> : p.rivers.join(", ")}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{p.itemsCount}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(p.commissionPct).toFixed(1)}%</td>
<td className="px-4 py-2">
{p.approved ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
Approuvé
</span>
) : (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
En attente
</span>
)}
</td>
<td className="px-4 py-2"><StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} /></td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(p.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,141 +0,0 @@
import Link from "next/link";
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
import { listRentalBookingsAdmin, RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
import { StatusBadge } from "@/components/admin/StatusBadge";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
status?: string;
paymentStatus?: string;
providerId?: string;
}>;
};
const RENTAL_STATUS_VALUES = new Set<string>([
RentalBookingStatus.PENDING,
RentalBookingStatus.CONFIRMED,
RentalBookingStatus.HANDED_OVER,
RentalBookingStatus.RETURNED,
RentalBookingStatus.CANCELLED,
]);
const PAYMENT_VALUES = new Set<string>([
PaymentStatus.PENDING,
PaymentStatus.AUTHORIZED,
PaymentStatus.SUCCEEDED,
PaymentStatus.FAILED,
PaymentStatus.REFUNDED,
]);
export default async function RentalsAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
status: RENTAL_STATUS_VALUES.has(sp.status ?? "") ? (sp.status as RentalBookingStatus) : undefined,
paymentStatus: PAYMENT_VALUES.has(sp.paymentStatus ?? "") ? (sp.paymentStatus as PaymentStatus) : undefined,
providerId: sp.providerId || undefined,
};
const rows = await listRentalBookingsAdmin(filters);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Réservations matériel</h1>
<p className="mt-1 text-sm text-zinc-500">
{rows.length} résultat{rows.length > 1 ? "s" : ""}
</p>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche ID, email locataire, prestataire…"
className="flex-1 min-w-[200px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select name="status" defaultValue={filters.status ?? ""} className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none">
<option value="">Tous statuts</option>
{Object.values(RentalBookingStatus).map((s) => (
<option key={s} value={s}>{RENTAL_STATUS_LABEL[s]}</option>
))}
</select>
<select name="paymentStatus" defaultValue={filters.paymentStatus ?? ""} className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none">
<option value="">Tous paiements</option>
{Object.values(PaymentStatus).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.status || filters.paymentStatus) ? (
<Link href="/admin/rentals" className="text-sm text-zinc-500 hover:text-zinc-900">Réinit.</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">ID</th>
<th className="px-4 py-2 text-left font-semibold">Locataire</th>
<th className="px-4 py-2 text-left font-semibold">Prestataire</th>
<th className="px-4 py-2 text-left font-semibold">Items</th>
<th className="px-4 py-2 text-left font-semibold">Période</th>
<th className="px-4 py-2 text-right font-semibold">Montant</th>
<th className="px-4 py-2 text-left font-semibold">Statut</th>
<th className="px-4 py-2 text-left font-semibold">Paiement</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucune réservation matériel.
</td>
</tr>
) : null}
{rows.map((r) => (
<tr key={r.id} className="hover:bg-zinc-50">
<td className="px-4 py-2 font-mono text-[11px] text-zinc-700">{r.id.slice(0, 10)}</td>
<td className="px-4 py-2 text-zinc-700">
{r.tenant.firstName} {r.tenant.lastName}
<div className="text-[11px] text-zinc-500">{r.tenant.email}</div>
</td>
<td className="px-4 py-2">
<Link href={`/admin/rental-providers/${r.provider.id}`} className="text-zinc-900 hover:underline">
{r.provider.name}
</Link>
{r.provider.isSystemD ? <span className="ml-1 text-[9px] font-semibold text-emerald-700">SD</span> : null}
</td>
<td className="px-4 py-2 text-zinc-700">
{r.lines.length} ligne{r.lines.length > 1 ? "s" : ""}
<div className="text-[11px] text-zinc-500 truncate max-w-[200px]">
{r.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ")}
</div>
</td>
<td className="px-4 py-2 text-zinc-700">
{dateFmt.format(r.startDate)} {dateFmt.format(r.endDate)}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-900">
{Number(r.amount).toFixed(2)} {r.currency}
</td>
<td className="px-4 py-2">
<StatusBadge status={r.status} />
</td>
<td className="px-4 py-2">
<StatusBadge status={r.paymentStatus} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -17,7 +17,6 @@ import {
} 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 +28,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 });

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,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,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,313 +0,0 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
import { Prisma } from "@/generated/prisma/client";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
import { CART_COOKIE, EMPTY_CART, diffDays, parseCart } from "@/lib/rental-cart";
import {
getStripeClient,
isStripeConfigured,
toStripeAmountCents,
} from "@/lib/stripe";
export const runtime = "nodejs";
type LineInput = {
itemId: string;
qty: number;
startDate: Date;
endDate: Date;
nights: number;
};
function parseDateOnly(s: string): Date {
return new Date(s + "T00:00:00Z");
}
export async function POST() {
const session = await auth();
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: "Connectez-vous pour finaliser." }, { status: 401 });
}
const jar = await cookies();
const cart = parseCart(jar.get(CART_COOKIE)?.value);
if (cart.items.length === 0) {
return NextResponse.json({ error: "Panier vide." }, { status: 400 });
}
// Charge tous les items du panier
const itemIds = Array.from(new Set(cart.items.map((e) => e.itemId)));
const items = await prisma.rentalItem.findMany({
where: { id: { in: itemIds }, active: true },
include: {
provider: {
select: {
id: true,
name: true,
active: true,
approved: true,
commissionPct: true,
isSystemD: true,
},
},
},
});
const itemById = new Map(items.map((i) => [i.id, i]));
// Validations préliminaires : items valides + provider actif/approved
for (const entry of cart.items) {
const it = itemById.get(entry.itemId);
if (!it) {
return NextResponse.json(
{ error: `Item ${entry.itemId} introuvable ou désactivé.` },
{ status: 409 },
);
}
if (!it.provider.active || !it.provider.approved) {
return NextResponse.json(
{ error: `Prestataire ${it.provider.name} indisponible.` },
{ status: 409 },
);
}
if (entry.qty < 1 || entry.qty > it.totalQty) {
return NextResponse.json(
{ error: `Quantité invalide pour « ${it.name} ».` },
{ status: 400 },
);
}
const start = parseDateOnly(entry.startDate);
const end = parseDateOnly(entry.endDate);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) {
return NextResponse.json(
{ error: `Dates invalides pour « ${it.name} ».` },
{ status: 400 },
);
}
}
// Groupe par provider
type Group = {
providerId: string;
providerName: string;
commissionPct: number;
lines: LineInput[];
itemsTotal: Prisma.Decimal;
depositTotal: Prisma.Decimal;
startDate: Date;
endDate: Date;
};
const groups = new Map<string, Group>();
for (const entry of cart.items) {
const it = itemById.get(entry.itemId)!;
const start = parseDateOnly(entry.startDate);
const end = parseDateOnly(entry.endDate);
const nights = Math.max(1, diffDays(entry.startDate, entry.endDate));
const lineSub = new Prisma.Decimal(it.pricePerDay).mul(entry.qty).mul(nights);
const lineDeposit = new Prisma.Decimal(it.deposit).mul(entry.qty);
let g = groups.get(it.provider.id);
if (!g) {
g = {
providerId: it.provider.id,
providerName: it.provider.name,
commissionPct: Number(it.provider.commissionPct),
lines: [],
itemsTotal: new Prisma.Decimal(0),
depositTotal: new Prisma.Decimal(0),
startDate: start,
endDate: end,
};
groups.set(it.provider.id, g);
}
g.lines.push({ itemId: it.id, qty: entry.qty, startDate: start, endDate: end, nights });
g.itemsTotal = g.itemsTotal.add(lineSub);
g.depositTotal = g.depositTotal.add(lineDeposit);
if (start < g.startDate) g.startDate = start;
if (end > g.endDate) g.endDate = end;
}
// Transaction : recheck stock + crée RentalBookings + Lines + Availabilities
let grandTotal = new Prisma.Decimal(0);
let grandDeposit = new Prisma.Decimal(0);
let rentalBookingIds: string[] = [];
try {
rentalBookingIds = await prisma.$transaction(async (tx) => {
const created: string[] = [];
for (const g of groups.values()) {
// Recheck stock disponible pour chaque ligne
for (const line of g.lines) {
const blocked = await tx.rentalItemAvailability.aggregate({
where: {
itemId: line.itemId,
startDate: { lt: line.endDate },
endDate: { gt: line.startDate },
},
_sum: { qty: true },
});
const item = itemById.get(line.itemId)!;
const used = Number(blocked._sum.qty ?? 0);
const free = item.totalQty - used;
if (line.qty > free) {
throw new Error(`Stock insuffisant pour « ${item.name} » sur les dates demandées (libre: ${free}).`);
}
}
const commissionAmount = g.itemsTotal
.mul(g.commissionPct)
.div(100)
.toDecimalPlaces(2);
const amount = g.itemsTotal.add(g.depositTotal).toDecimalPlaces(2);
const rb = await tx.rentalBooking.create({
data: {
tenantId: session.user!.id!,
providerId: g.providerId,
startDate: g.startDate,
endDate: g.endDate,
status: RentalBookingStatus.PENDING,
paymentStatus: PaymentStatus.PENDING,
itemsTotal: g.itemsTotal.toDecimalPlaces(2),
depositTotal: g.depositTotal.toDecimalPlaces(2),
commissionAmount,
amount,
currency: "EUR",
lines: {
create: g.lines.map((line) => {
const item = itemById.get(line.itemId)!;
const lineTotal = new Prisma.Decimal(item.pricePerDay)
.mul(line.qty)
.mul(line.nights)
.toDecimalPlaces(2);
return {
itemId: line.itemId,
qty: line.qty,
pricePerDay: new Prisma.Decimal(item.pricePerDay),
deposit: new Prisma.Decimal(item.deposit),
lineTotal,
};
}),
},
},
select: { id: true },
});
// Bloque les dispos
for (const line of g.lines) {
await tx.rentalItemAvailability.create({
data: {
itemId: line.itemId,
startDate: line.startDate,
endDate: line.endDate,
qty: line.qty,
reason: "RENTAL_BOOKING",
rentalBookingId: rb.id,
},
});
}
created.push(rb.id);
grandTotal = grandTotal.add(g.itemsTotal);
grandDeposit = grandDeposit.add(g.depositTotal);
}
return created;
});
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Erreur lors de la création." },
{ status: 409 },
);
}
const totalAmount = grandTotal.add(grandDeposit).toDecimalPlaces(2);
await recordAudit({
scope: "rental",
event: "rental.checkout.created",
target: rentalBookingIds.join(","),
actorEmail: session.user.email,
details: {
rentalBookingIds,
amount: totalAmount.toNumber(),
depositTotal: grandDeposit.toNumber(),
providers: Array.from(groups.keys()),
},
});
// Vide le panier
jar.set(CART_COOKIE, JSON.stringify(EMPTY_CART), {
httpOnly: false,
sameSite: "lax",
path: "/",
maxAge: 0,
});
// Stripe ou paiement différé
if (!isStripeConfigured()) {
return NextResponse.json(
{ rentalBookingIds, totalAmount: totalAmount.toNumber() },
{ status: 201 },
);
}
const appUrl = process.env.APP_URL;
if (!appUrl) {
return NextResponse.json({ error: "APP_URL manquante." }, { status: 500 });
}
// Une session Stripe avec une ligne par RentalBooking (agrégée)
const stripe = getStripeClient();
const bookingDetails = await prisma.rentalBooking.findMany({
where: { id: { in: rentalBookingIds } },
include: {
provider: { select: { name: true } },
lines: { select: { qty: true, item: { select: { name: true } } } },
},
});
const line_items = bookingDetails.map((rb) => ({
quantity: 1,
price_data: {
currency: "eur",
unit_amount: toStripeAmountCents(Number(rb.amount)),
product_data: {
name: `Matériel — ${rb.provider.name}`,
description: rb.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ").slice(0, 500),
},
},
}));
const checkoutSession = await stripe.checkout.sessions.create({
mode: "payment",
success_url: `${appUrl}/mes-locations?payment=success&ids=${rentalBookingIds.join(",")}`,
cancel_url: `${appUrl}/panier?payment=cancel`,
customer_email: session.user.email,
line_items,
metadata: {
type: "rental-bundle",
rentalBookingIds: rentalBookingIds.join(","),
},
});
await prisma.rentalBooking.updateMany({
where: { id: { in: rentalBookingIds } },
data: { stripeSessionId: checkoutSession.id },
});
return NextResponse.json(
{
rentalBookingIds,
totalAmount: totalAmount.toNumber(),
checkoutSessionId: checkoutSession.id,
checkoutUrl: checkoutSession.url,
},
{ status: 201 },
);
}

View file

@ -1,31 +0,0 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getItemAvailability } from "@/lib/rentals-public";
import { parseIsoDate, normalizeUtcDayStart } from "@/lib/booking";
export const runtime = "nodejs";
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
const from = parseIsoDate(req.nextUrl.searchParams.get("from"));
const to = parseIsoDate(req.nextUrl.searchParams.get("to"));
if (!from || !to) {
return NextResponse.json(
{ error: "Paramètres from et to (YYYY-MM-DD) requis." },
{ status: 400 },
);
}
const start = normalizeUtcDayStart(from);
const end = normalizeUtcDayStart(to);
if (end <= start) {
return NextResponse.json({ error: "to doit être > from." }, { status: 400 });
}
const calendar = await getItemAvailability(id, start, end);
return NextResponse.json({
itemId: id,
from: start.toISOString(),
to: end.toISOString(),
calendar,
});
}

View file

@ -5,8 +5,7 @@ import { UserRole } from "@/generated/prisma/enums";
import { hashPassword } from "@/lib/password";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
import { sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email";
import { rateLimitRequest } from "@/lib/rate-limit";
import { sendSignupWelcome } from "@/lib/email";
export const runtime = "nodejs";
@ -16,21 +15,10 @@ const schema = 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(),
role: z
.enum([UserRole.TOURIST, UserRole.OWNER, UserRole.RENTAL_PROVIDER])
.default(UserRole.TOURIST),
providerName: z.string().trim().min(2).max(200).optional(),
providerRivers: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
role: z.enum([UserRole.TOURIST, UserRole.OWNER]).default(UserRole.TOURIST),
});
export async function POST(req: Request) {
const rl = rateLimitRequest(req, "signup", 60 * 60 * 1000, 5);
if (!rl.ok) {
return NextResponse.json(
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
);
}
let body: unknown;
try {
body = await req.json();
@ -46,10 +34,6 @@ export async function POST(req: Request) {
}
const data = parsed.data;
if (data.role === UserRole.RENTAL_PROVIDER && (!data.providerName || data.providerName.trim().length < 2)) {
return NextResponse.json({ error: "Nom de votre activité requis." }, { status: 400 });
}
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 });
@ -69,36 +53,16 @@ export async function POST(req: Request) {
select: { id: true, email: true, role: true },
});
// Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation.
let createdProviderId: string | null = null;
if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) {
const provider = await prisma.rentalProvider.create({
data: {
name: data.providerName,
isSystemD: false,
managedByUserId: user.id,
contactEmail: user.email,
contactPhone: data.phone?.trim() || null,
rivers: data.providerRivers ?? [],
commissionPct: 10, // valeur par défaut, ajustable par admin
active: true,
approved: false,
},
select: { id: true, name: true },
});
createdProviderId = provider.id;
sendNewRentalProviderRequest(provider.name, user.email).catch(() => {});
}
await recordAudit({
scope: "public.signup",
event: "user.create",
target: user.id,
actorEmail: user.email,
details: { role: user.role, rentalProviderId: createdProviderId },
details: { role: user.role },
});
// Best-effort welcome email.
sendSignupWelcome(user.email, data.firstName).catch(() => {});
return NextResponse.json({ ok: true, userId: user.id, providerId: createdProviderId });
return NextResponse.json({ ok: true, userId: user.id });
}

View file

@ -4,7 +4,6 @@ import Stripe from "stripe";
import {
BookingStatus,
PaymentStatus,
RentalBookingStatus,
SubscriptionStatus,
} from "@/generated/prisma/enums";
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
@ -52,21 +51,6 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
return;
}
if (type === "rental-bundle") {
const idsRaw = session.metadata?.rentalBookingIds;
if (!idsRaw) return;
const ids = idsRaw.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length === 0) return;
await prisma.rentalBooking.updateMany({
where: { id: { in: ids } },
data: {
paymentStatus: PaymentStatus.SUCCEEDED,
status: RentalBookingStatus.CONFIRMED,
},
});
return;
}
if (type === "owner_subscription") {
const ownerId = session.metadata?.ownerId;
const carbetId = session.metadata?.carbetId;
@ -95,27 +79,6 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) {
const bookingId = paymentIntent.metadata?.bookingId;
const rentalIdsRaw = paymentIntent.metadata?.rentalBookingIds;
if (rentalIdsRaw) {
const ids = rentalIdsRaw.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) {
// Marque les paiements échoués + libère les blocages de dispo
await prisma.$transaction([
prisma.rentalBooking.updateMany({
where: { id: { in: ids } },
data: {
paymentStatus: PaymentStatus.FAILED,
status: RentalBookingStatus.CANCELLED,
},
}),
prisma.rentalItemAvailability.deleteMany({
where: { rentalBookingId: { in: ids } },
}),
]);
}
}
if (!bookingId) {
return;
}

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

@ -1,103 +0,0 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
type Props = {
river: string;
capacity: number;
};
const EMOJI: Record<string, string> = {
SLEEP: "💤",
NAVIGATION: "🛶",
FISHING: "🎣",
COOKING: "🍳",
SAFETY: "🦺",
};
export async function CompleteYourStay({ river, capacity }: Props) {
const providers = await prisma.rentalProvider.findMany({
where: {
active: true,
approved: true,
OR: [
{ isSystemD: true },
{ rivers: { has: river } },
],
},
select: {
id: true,
items: {
where: { active: true },
orderBy: [{ category: "asc" }, { pricePerDay: "asc" }],
take: 24,
select: {
id: true,
name: true,
category: true,
imageUrl: true,
pricePerDay: true,
provider: { select: { name: true, isSystemD: true } },
},
},
},
});
const items = providers.flatMap((p) => p.items).slice(0, 9);
if (items.length === 0) return null;
return (
<section className="my-8 rounded-lg border border-emerald-200 bg-emerald-50/40 p-5">
<header className="flex items-baseline justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-emerald-900">
Compléter votre séjour
</h2>
<p className="text-xs text-emerald-800">
Pour {capacity} voyageur{capacity > 1 ? "s" : ""} sur le {river},
pensez à louer hamacs, moustiquaires, pirogue ou kayak auprès des
prestataires locaux.
</p>
</div>
<Link href="/materiel" className="text-xs font-semibold text-emerald-800 hover:underline">
Voir tout
</Link>
</header>
<ul className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
{items.map((it) => (
<li
key={it.id}
className="overflow-hidden rounded-md border border-emerald-100 bg-white shadow-sm"
>
<Link href={`/materiel/${it.id}`} className="block">
<div className="flex aspect-video items-center justify-center bg-emerald-50 text-3xl">
{it.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={it.imageUrl} alt={it.name} className="h-full w-full object-cover" />
) : (
<span>{EMOJI[it.category] ?? "🎒"}</span>
)}
</div>
<div className="px-2.5 py-1.5">
<p className="truncate text-xs font-semibold text-zinc-900">{it.name}</p>
<div className="flex items-center justify-between text-[10px] text-zinc-500">
<span>{RENTAL_CATEGORY_LABEL[it.category]}</span>
<span className="font-mono font-semibold text-emerald-700">
{Number(it.pricePerDay).toFixed(0)} /j
</span>
</div>
{it.provider.isSystemD ? (
<span className="mt-1 inline-block rounded-full bg-emerald-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-white">
Karbé
</span>
) : null}
</div>
</Link>
</li>
))}
</ul>
</section>
);
}

View file

@ -12,16 +12,11 @@ import {
import { MediaType, UserRole } from "@/generated/prisma/enums";
import { formatAverageRating } from "@/lib/reviews";
import { isStripeConfigured } from "@/lib/stripe";
import { BookingForm } from "../_components/booking-form";
import { CompleteYourStay } from "./_components/CompleteYourStay";
import { CarbetGallery } from "../_components/carbet-gallery";
import { CarbetMap } from "../_components/carbet-map";
import { ReviewsSection } from "../_components/reviews-section";
import { StarRating } from "../_components/star-rating";
import { AccessTypeBadge } from "@/components/AccessTypeBadge";
import { OperationalBadges } from "@/components/OperationalBadges";
import { StayConstraints } from "@/components/StayConstraints";
import { PirogueTransportBlock } from "@/components/PirogueTransportBlock";
@ -133,20 +128,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>
@ -163,25 +144,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">
@ -273,13 +235,10 @@ export default async function PublicCarbetPage({ params }: PageProps) {
minStayNights={carbet.minStayNights}
maxStayNights={carbet.maxStayNights}
isAuthenticated={Boolean(viewerId)}
stripeEnabled={isStripeConfigured()}
/>
</aside>
</div>
<CompleteYourStay river={carbet.river} capacity={carbet.capacity} />
<ReviewsSection
stats={carbet.reviewStats}
reviews={carbet.reviews}

View file

@ -4,8 +4,6 @@ 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;
@ -14,7 +12,6 @@ type Props = {
minStayNights: number | null;
maxStayNights: number | null;
isAuthenticated: boolean;
stripeEnabled: boolean;
};
function todayPlus(n: number): string {
@ -39,11 +36,10 @@ export function BookingForm({
minStayNights,
maxStayNights,
isAuthenticated,
stripeEnabled,
}: Props) {
const router = useRouter();
const [startDate, setStartDate] = useState<string | null>(null);
const [endDate, setEndDate] = useState<string | null>(null);
const [startDate, setStartDate] = useState(todayPlus(7));
const [endDate, setEndDate] = useState(todayPlus(7 + (minStayNights ?? 2)));
const [guestCount, setGuestCount] = useState(Math.min(2, capacity));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -68,18 +64,34 @@ export function BookingForm({
return () => ctrl.abort();
}, [carbetId]);
const nights = useMemo(
() => (startDate && endDate ? Math.max(0, diffDays(startDate, endDate)) : 0),
[startDate, endDate],
);
const nights = useMemo(() => Math.max(0, diffDays(startDate, endDate)), [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 nightsOk = nights >= minN && nights <= maxN;
const guestOk = guestCount >= 1 && guestCount <= capacity;
const canSubmit = nightsOk && guestOk && !busy;
// Vérifie qu'aucun jour de la plage sélectionnée n'est bloqué.
const conflictDates = useMemo(() => {
if (blockedDates.size === 0 || nights === 0) return [];
const out: string[] = [];
const startMs = new Date(startDate + "T00:00:00Z").getTime();
for (let i = 0; i < nights; i++) {
const d = new Date(startMs + i * 86400000).toISOString().slice(0, 10);
if (blockedDates.has(d)) out.push(d);
}
return out;
}, [blockedDates, startDate, nights]);
const hasConflict = conflictDates.length > 0;
const canSubmit = nightsOk && guestOk && !busy && !hasConflict;
// Prochaines dates bloquées (max 6) pour affichage informatif.
const upcomingBlocked = useMemo(() => {
return Array.from(blockedDates)
.sort()
.slice(0, 6);
}, [blockedDates]);
async function submit() {
if (!isAuthenticated) {
@ -90,34 +102,6 @@ export function BookingForm({
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" },
@ -145,34 +129,28 @@ export function BookingForm({
<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}
<div className="grid grid-cols-2 gap-2 text-sm">
<label className="block">
<span className="text-xs text-zinc-500">Arrivée</span>
<input
type="date"
value={startDate}
min={todayPlus(0)}
onChange={(e) => setStartDate(e.target.value)}
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
/>
</label>
<label className="block">
<span className="text-xs text-zinc-500">Départ</span>
<input
type="date"
value={endDate}
min={startDate || todayPlus(1)}
onChange={(e) => setEndDate(e.target.value)}
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
/>
</label>
</div>
<label className="block text-sm">
<span className="text-xs text-zinc-500">Voyageurs</span>
@ -186,27 +164,50 @@ export function BookingForm({
/>
</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 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>
) : null}
<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>
{datesSelected && !nightsOk ? (
{!nightsOk && nights > 0 ? (
<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}
{hasConflict ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">
Cette plage chevauche {conflictDates.length} jour{conflictDates.length > 1 ? "s" : ""} déjà
pris ou bloqué{conflictDates.length > 1 ? "s" : ""} (
{conflictDates.slice(0, 3).join(", ")}
{conflictDates.length > 3 ? "…" : ""}). Changez les dates.
</div>
) : null}
{upcomingBlocked.length > 0 && !hasConflict ? (
<details className="rounded border border-zinc-100 bg-zinc-50 px-3 py-1.5 text-xs text-zinc-600">
<summary className="cursor-pointer">Voir les prochaines dates indisponibles</summary>
<div className="mt-1.5 flex flex-wrap gap-1">
{upcomingBlocked.map((d) => (
<code key={d} className="rounded bg-white px-1.5 py-0.5 text-[10px] text-zinc-700">
{d}
</code>
))}
{blockedDates.size > upcomingBlocked.length ? (
<span className="text-[10px] text-zinc-500">+ {blockedDates.size - upcomingBlocked.length} autres</span>
) : null}
</div>
</details>
) : null}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">{error}</div>
) : null}
@ -217,13 +218,7 @@ export function BookingForm({
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"}
{busy ? "Envoi…" : isAuthenticated ? "Réserver" : "Se connecter pour réserver"}
</button>
{!isAuthenticated ? (
@ -236,9 +231,7 @@ export function BookingForm({
) : 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."}
Le créneau est bloqué dès l&apos;envoi. Statut « En attente » jusqu&apos;à confirmation du paiement.
</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

@ -53,11 +53,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

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,
@ -32,14 +33,10 @@ export default async function EditCarbetPage({
embarkPoint: true,
pirogueDurationMin: true,
capacity: true,
roadAccess: true,
electricity: true,
gsmAtCarbet: true,
gsmExitDistanceKm: true,
status: true,
media: {
orderBy: { sortOrder: "asc" },
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
select: { id: true, type: true, s3Url: true, sortOrder: true },
},
amenities: { select: { amenity: { select: { key: true } } } },
},
@ -58,10 +55,6 @@ export default async function EditCarbetPage({
embarkPoint: carbet.embarkPoint,
pirogueDurationMin: String(carbet.pirogueDurationMin),
capacity: String(carbet.capacity),
roadAccess: carbet.roadAccess ?? "",
electricity: carbet.electricity ?? "",
gsmAtCarbet: carbet.gsmAtCarbet,
gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : "",
status: carbet.status,
amenityKeys: carbet.amenities.map((entry) => entry.amenity.key),
};
@ -87,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

@ -17,10 +17,6 @@ export type CarbetFormDefaults = {
embarkPoint: string;
pirogueDurationMin: string;
capacity: string;
roadAccess: string;
electricity: string;
gsmAtCarbet: boolean;
gsmExitDistanceKm: string;
status: CarbetStatus;
amenityKeys: string[];
};
@ -220,90 +216,6 @@ export function CarbetForm({
</div>
</section>
<section className="space-y-4 rounded-md border border-emerald-200 bg-emerald-50/30 p-4">
<div>
<h2 className="text-lg font-semibold text-zinc-900">
Critères opérationnels
</h2>
<p className="text-xs text-zinc-600">
Les 4 dealbreakers d&apos;un séjour en carbet. Ces critères apparaissent
en grand sur votre fiche et alimentent les filtres recherche.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass} htmlFor="roadAccess">
🛣 Accès route
</label>
<select
id="roadAccess"
name="roadAccess"
defaultValue={defaults.roadAccess ?? ""}
className={inputClass}
>
<option value=""> non précisé </option>
<option value="ALL_YEAR">🛣 Toute saison</option>
<option value="DRY_SEASON_ONLY">🟠 Saison sèche uniquement</option>
<option value="NONE">🛶 Pirogue uniquement</option>
</select>
<FieldError message={state.errors.roadAccess} />
</div>
<div>
<label className={labelClass} htmlFor="electricity">
Électricité
</label>
<select
id="electricity"
name="electricity"
defaultValue={defaults.electricity ?? ""}
className={inputClass}
>
<option value=""> non précisé </option>
<option value="EDF"> EDF / raccordé réseau</option>
<option value="GENERATOR_READY">🔌 Préinstall groupe électrogène</option>
<option value="SOLAR"> Solaire</option>
<option value="NONE">🕯 Aucune électricité</option>
</select>
<FieldError message={state.errors.electricity} />
</div>
<div>
<label className={labelClass} htmlFor="gsmAtCarbet">
📶 Réseau GSM au carbet
</label>
<select
id="gsmAtCarbet"
name="gsmAtCarbet"
defaultValue={defaults.gsmAtCarbet ? "yes" : "no"}
className={inputClass}
>
<option value="yes"> Oui, signal au carbet</option>
<option value="no"> Non, zone sans réseau</option>
</select>
<FieldError message={state.errors.gsmAtCarbet} />
</div>
<div>
<label className={labelClass} htmlFor="gsmExitDistanceKm">
📵 Distance pour atteindre le réseau (km)
</label>
<input
id="gsmExitDistanceKm"
name="gsmExitDistanceKm"
type="number"
min={0}
max={50}
step="0.1"
defaultValue={defaults.gsmExitDistanceKm ?? ""}
placeholder="ex. 1.5"
className={inputClass}
/>
<p className="mt-1 text-xs text-zinc-500">
Laissez vide si réseau au carbet
</p>
<FieldError message={state.errors.gsmExitDistanceKm} />
</div>
</div>
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold text-zinc-900">Commodités</h2>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">

View file

@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma";
import { ensureUniqueCarbetSlug } from "@/lib/slug";
import { deleteObject } from "@/lib/storage";
import { Prisma } from "@/generated/prisma/client";
import { CarbetStatus, Electricity, RoadAccess } from "@/generated/prisma/enums";
import { CarbetStatus } from "@/generated/prisma/enums";
import type { CarbetFormState } from "./form-types";
@ -22,26 +22,10 @@ type ParsedCarbet = {
embarkPoint: string;
pirogueDurationMin: number;
capacity: number;
roadAccess: RoadAccess | null;
electricity: Electricity | null;
gsmAtCarbet: boolean;
gsmExitDistanceKm: number | null;
status: CarbetStatus;
amenities: string[];
};
function isRoadAccess(v: string): v is RoadAccess {
return v === RoadAccess.NONE || v === RoadAccess.DRY_SEASON_ONLY || v === RoadAccess.ALL_YEAR;
}
function isElectricity(v: string): v is Electricity {
return (
v === Electricity.NONE ||
v === Electricity.SOLAR ||
v === Electricity.GENERATOR_READY ||
v === Electricity.EDF
);
}
function isCarbetStatus(value: string): value is CarbetStatus {
return (Object.values(CarbetStatus) as string[]).includes(value);
}
@ -123,29 +107,6 @@ function parseCarbetForm(formData: FormData): {
const status = isCarbetStatus(statusRaw) ? statusRaw : CarbetStatus.DRAFT;
// Critères opérationnels
const roadAccessRaw = String(formData.get("roadAccess") ?? "").trim();
const roadAccess = isRoadAccess(roadAccessRaw) ? roadAccessRaw : null;
const electricityRaw = String(formData.get("electricity") ?? "").trim();
const electricity = isElectricity(electricityRaw) ? electricityRaw : null;
const gsmAtCarbet = String(formData.get("gsmAtCarbet") ?? "no") === "yes";
const gsmExitRaw = String(formData.get("gsmExitDistanceKm") ?? "").trim();
let gsmExitDistanceKm: number | null = null;
if (gsmExitRaw) {
const n = Number(gsmExitRaw);
if (Number.isFinite(n) && n >= 0 && n <= 50) {
gsmExitDistanceKm = n;
} else {
errors.gsmExitDistanceKm = "Distance invalide (0 à 50 km).";
}
}
// Cohérence : si GSM au carbet, on ignore la distance
const finalGsmExitDistanceKm = gsmAtCarbet ? null : gsmExitDistanceKm;
return {
data: {
title,
@ -156,10 +117,6 @@ function parseCarbetForm(formData: FormData): {
embarkPoint,
pirogueDurationMin,
capacity,
roadAccess,
electricity,
gsmAtCarbet,
gsmExitDistanceKm: finalGsmExitDistanceKm,
status,
amenities,
},
@ -226,10 +183,6 @@ export async function createCarbet(
embarkPoint: data.embarkPoint,
pirogueDurationMin: data.pirogueDurationMin,
capacity: data.capacity,
roadAccess: data.roadAccess,
electricity: data.electricity,
gsmAtCarbet: data.gsmAtCarbet,
gsmExitDistanceKm: data.gsmExitDistanceKm,
status: CarbetStatus.DRAFT,
},
select: { id: true },
@ -286,10 +239,6 @@ export async function updateCarbet(
embarkPoint: data.embarkPoint,
pirogueDurationMin: data.pirogueDurationMin,
capacity: data.capacity,
roadAccess: data.roadAccess,
electricity: data.electricity,
gsmAtCarbet: data.gsmAtCarbet,
gsmExitDistanceKm: data.gsmExitDistanceKm,
status: data.status,
},
});

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,237 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth";
import { RentalBookingStatus, RentalCategory, UserRole } from "@/generated/prisma/enums";
import { canManageRentalProvider, getCurrentRentalProvider } from "@/lib/rental-access";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
const itemSchema = z.object({
category: z.enum([
RentalCategory.SLEEP,
RentalCategory.NAVIGATION,
RentalCategory.FISHING,
RentalCategory.COOKING,
RentalCategory.SAFETY,
]),
name: z.string().trim().min(2).max(200),
description: z.string().trim().max(5000).nullable().optional(),
imageUrl: z.string().trim().url().max(500).nullable().optional(),
pricePerDay: z.coerce.number().min(0).max(10000),
pricePerWeek: z.coerce.number().min(0).max(50000).nullable().optional(),
deposit: z.coerce.number().min(0).max(10000),
totalQty: z.coerce.number().int().min(1).max(1000),
withMotor: z.boolean(),
fuelIncluded: z.boolean(),
requiresLicense: z.boolean(),
active: z.boolean(),
});
async function requireOwnedProvider(): Promise<{ providerId: string; actorEmail: string | null }> {
const session = await auth();
if (!session?.user?.id) throw new Error("Non authentifié");
const provider = await getCurrentRentalProvider();
if (!provider) throw new Error("Aucun provider associé");
return { providerId: provider.id, actorEmail: session.user.email ?? null };
}
function parseItemFD(fd: FormData) {
const get = (k: string) => {
const v = (fd.get(k) as string | null) ?? "";
return v.trim() === "" ? null : v.trim();
};
return {
category: ((fd.get("category") as string | null) ?? "").trim(),
name: ((fd.get("name") as string | null) ?? "").trim(),
description: get("description"),
imageUrl: get("imageUrl"),
pricePerDay: fd.get("pricePerDay"),
pricePerWeek: get("pricePerWeek"),
deposit: fd.get("deposit") ?? "0",
totalQty: fd.get("totalQty") ?? "1",
withMotor: fd.get("withMotor") === "on",
fuelIncluded: fd.get("fuelIncluded") === "on",
requiresLicense: fd.get("requiresLicense") === "on",
active: fd.get("active") === "on",
};
}
export async function createHostItemAction(fd: FormData) {
const { providerId, actorEmail } = await requireOwnedProvider();
const parsed = itemSchema.safeParse(parseItemFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const created = await prisma.rentalItem.create({ data: { ...parsed.data, providerId } });
await recordAudit({
scope: "host.rental-items",
event: "create",
target: created.id,
actorEmail,
details: { name: created.name, providerId },
});
revalidatePath("/espace-prestataire/items");
redirect(`/espace-prestataire/items/${created.id}`);
}
export async function updateHostItemAction(itemId: string, fd: FormData) {
const { providerId, actorEmail } = await requireOwnedProvider();
const session = await auth();
if (!(await canManageRentalProvider(session!.user.id, session?.user?.role, providerId))) {
return { ok: false as const, error: "Accès refusé" };
}
const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } });
if (!existing || existing.providerId !== providerId) {
return { ok: false as const, error: "Item introuvable." };
}
const parsed = itemSchema.safeParse(parseItemFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
await prisma.rentalItem.update({ where: { id: itemId }, data: parsed.data });
await recordAudit({
scope: "host.rental-items",
event: "update",
target: itemId,
actorEmail,
details: { name: parsed.data.name },
});
revalidatePath("/espace-prestataire/items");
revalidatePath(`/espace-prestataire/items/${itemId}`);
return { ok: true as const };
}
export async function deleteHostItemAction(itemId: string) {
const { providerId, actorEmail } = await requireOwnedProvider();
const existing = await prisma.rentalItem.findUnique({
where: { id: itemId },
select: { providerId: true, _count: { select: { lines: true } } },
});
if (!existing || existing.providerId !== providerId) {
return { ok: false as const, error: "Item introuvable." };
}
if (existing._count.lines > 0) {
return { ok: false as const, error: "Impossible : item référencé par des locations." };
}
await prisma.rentalItem.delete({ where: { id: itemId } });
await recordAudit({
scope: "host.rental-items",
event: "delete",
target: itemId,
actorEmail,
details: {},
});
revalidatePath("/espace-prestataire/items");
redirect("/espace-prestataire/items");
}
const blockSchema = z.object({
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
qty: z.coerce.number().int().min(1).max(1000),
reason: z.enum(["MAINTENANCE", "MANUAL_BLOCK"]),
});
export async function addItemBlockAction(itemId: string, fd: FormData) {
const { providerId, actorEmail } = await requireOwnedProvider();
const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } });
if (!existing || existing.providerId !== providerId) {
return { ok: false as const, error: "Item introuvable." };
}
const parsed = blockSchema.safeParse({
startDate: fd.get("startDate"),
endDate: fd.get("endDate"),
qty: fd.get("qty"),
reason: fd.get("reason"),
});
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const start = new Date(`${parsed.data.startDate}T00:00:00.000Z`);
const end = new Date(`${parsed.data.endDate}T00:00:00.000Z`);
if (end <= start) return { ok: false as const, error: "Date de fin doit être après début." };
await prisma.rentalItemAvailability.create({
data: {
itemId,
startDate: start,
endDate: end,
qty: parsed.data.qty,
reason: parsed.data.reason,
},
});
await recordAudit({
scope: "host.rental-items",
event: "block.add",
target: itemId,
actorEmail,
details: { ...parsed.data },
});
revalidatePath(`/espace-prestataire/items/${itemId}`);
return { ok: true as const };
}
export async function removeItemBlockAction(blockId: string) {
const { providerId, actorEmail } = await requireOwnedProvider();
const block = await prisma.rentalItemAvailability.findUnique({
where: { id: blockId },
select: { itemId: true, rentalBookingId: true, item: { select: { providerId: true } } },
});
if (!block || block.item.providerId !== providerId) {
return { ok: false as const, error: "Blocage introuvable." };
}
if (block.rentalBookingId) {
return { ok: false as const, error: "Blocage lié à une réservation : annulez la réservation à la place." };
}
await prisma.rentalItemAvailability.delete({ where: { id: blockId } });
await recordAudit({
scope: "host.rental-items",
event: "block.remove",
target: blockId,
actorEmail,
details: { itemId: block.itemId },
});
revalidatePath(`/espace-prestataire/items/${block.itemId}`);
return { ok: true as const };
}
const statusSchema = z.enum([
RentalBookingStatus.PENDING,
RentalBookingStatus.CONFIRMED,
RentalBookingStatus.HANDED_OVER,
RentalBookingStatus.RETURNED,
RentalBookingStatus.CANCELLED,
]);
export async function updateBookingStatusAction(bookingId: string, status: string) {
const { providerId, actorEmail } = await requireOwnedProvider();
const session = await auth();
const role = session?.user?.role;
const parsed = statusSchema.safeParse(status);
if (!parsed.success) return { ok: false as const, error: "Statut invalide." };
const existing = await prisma.rentalBooking.findUnique({
where: { id: bookingId },
select: { providerId: true },
});
if (!existing || (existing.providerId !== providerId && role !== UserRole.ADMIN)) {
return { ok: false as const, error: "Réservation introuvable." };
}
await prisma.rentalBooking.update({
where: { id: bookingId },
data: { status: parsed.data },
});
await recordAudit({
scope: "host.rental-bookings",
event: "status.update",
target: bookingId,
actorEmail,
details: { status: parsed.data },
});
revalidatePath("/espace-prestataire/reservations");
return { ok: true as const };
}

View file

@ -1,151 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
type Block = {
id: string;
startDate: string;
endDate: string;
qty: number;
reason: string;
isBooking: boolean;
};
type Props = {
blocks: Block[];
totalQty: number;
addAction: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
removeAction: (blockId: string) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
};
const REASON_LABEL: Record<string, string> = {
MAINTENANCE: "🔧 Maintenance",
MANUAL_BLOCK: "⛔ Blocage personnel",
RENTAL_BOOKING: "🛒 Réservation",
};
export function ItemBlocksManager({ blocks, totalQty, addAction, removeAction }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function onAdd(fd: FormData) {
setError(null);
startTransition(async () => {
const res = await addAction(fd);
if (res && res.ok === false) setError(res.error);
router.refresh();
});
}
function onRemove(blockId: string) {
setError(null);
startTransition(async () => {
const res = await removeAction(blockId);
if (res && res.ok === false) setError(res.error);
router.refresh();
});
}
return (
<div className="space-y-4">
<form action={onAdd} className="grid grid-cols-1 gap-2 rounded-md border border-zinc-200 bg-zinc-50 p-3 sm:grid-cols-5">
<fieldset disabled={pending} className="contents">
<label className="block text-xs">
<span className="text-zinc-600">Du</span>
<input
name="startDate"
type="date"
required
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="block text-xs">
<span className="text-zinc-600">Au</span>
<input
name="endDate"
type="date"
required
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="block text-xs">
<span className="text-zinc-600">Quantité</span>
<input
name="qty"
type="number"
min={1}
max={totalQty}
defaultValue={totalQty}
required
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="block text-xs">
<span className="text-zinc-600">Raison</span>
<select
name="reason"
defaultValue="MAINTENANCE"
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
>
<option value="MAINTENANCE">Maintenance</option>
<option value="MANUAL_BLOCK">Blocage perso</option>
</select>
</label>
<div className="flex items-end">
<button
type="submit"
className="w-full rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{pending ? "…" : "Ajouter blocage"}
</button>
</div>
</fieldset>
</form>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{blocks.length === 0 ? (
<p className="rounded border border-dashed border-zinc-200 px-3 py-6 text-center text-xs text-zinc-500">
Aucun blocage manuel. Toutes les dates sont disponibles.
</p>
) : (
<ul className="space-y-1.5">
{blocks.map((b) => (
<li
key={b.id}
className={
"flex items-center justify-between gap-3 rounded-md border px-3 py-1.5 text-sm " +
(b.isBooking ? "border-sky-200 bg-sky-50" : "border-amber-200 bg-amber-50")
}
>
<div>
<span className="font-medium text-zinc-900">
{b.startDate} {b.endDate}
</span>
<span className="ml-2 text-xs text-zinc-600">
{b.qty} unité{b.qty > 1 ? "s" : ""} · {REASON_LABEL[b.reason] ?? b.reason}
</span>
</div>
{!b.isBooking ? (
<button
type="button"
onClick={() => onRemove(b.id)}
disabled={pending}
className="text-xs font-semibold text-rose-700 hover:text-rose-900 disabled:opacity-50"
>
Supprimer
</button>
) : (
<span className="text-[10px] uppercase tracking-wider text-sky-700">Auto</span>
)}
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -1,71 +0,0 @@
"use client";
import { useState, useTransition } from "react";
type Props = {
canDelete: boolean;
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
};
export function ItemInlineDelete({ canDelete, deleteAction }: Props) {
const [pending, startTransition] = useTransition();
const [confirm, setConfirm] = useState(false);
const [error, setError] = useState<string | null>(null);
function run() {
setError(null);
startTransition(async () => {
const res = await deleteAction();
if (res && (res as { ok?: boolean }).ok === false) {
setError((res as { error: string }).error);
setConfirm(false);
}
});
}
if (!canDelete) {
return (
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] text-zinc-500">
Suppression impossible item référencé par des locations
</span>
);
}
return (
<div className="flex flex-col items-end gap-1">
{confirm ? (
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
<span className="text-xs text-rose-900">Supprimer ?</span>
<button
type="button"
onClick={run}
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={() => setConfirm(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirm(true)}
disabled={pending}
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
Supprimer l&apos;item
</button>
)}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div>
) : null}
</div>
);
}

View file

@ -1,107 +0,0 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
import { getHostItem } from "@/lib/rental-host";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
import { HostItemForm } from "../_components/ItemForm";
import { ItemBlocksManager } from "./_components/ItemBlocksManager";
import { ItemInlineDelete } from "./_components/ItemInlineDelete";
import {
addItemBlockAction,
deleteHostItemAction,
removeItemBlockAction,
updateHostItemAction,
} from "../../actions";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ itemId: string }> };
export default async function EditHostItemPage({ params }: PageProps) {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
if (!provider) redirect("/admin/rental-providers");
const { itemId } = await params;
const item = await getHostItem(provider.id, itemId);
if (!item) notFound();
const updateThis = async (fd: FormData) => {
"use server";
return await updateHostItemAction(itemId, fd);
};
const deleteThis = async () => {
"use server";
return await deleteHostItemAction(itemId);
};
const addBlockThis = async (fd: FormData) => {
"use server";
return await addItemBlockAction(itemId, fd);
};
const removeBlockThis = async (blockId: string) => {
"use server";
return await removeItemBlockAction(blockId);
};
return (
<main className="mx-auto max-w-4xl px-6 py-10 space-y-6">
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/espace-prestataire/items" className="text-xs text-zinc-500 hover:text-zinc-900">
Mes items
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">{item.name}</h1>
<p className="mt-1 text-sm text-zinc-500">
{RENTAL_CATEGORY_LABEL[item.category]} · Stock : {item.totalQty} · {item._count.lines} location(s) historique
</p>
</div>
<ItemInlineDelete deleteAction={deleteThis} canDelete={item._count.lines === 0} />
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<HostItemForm
action={updateThis}
submitLabel="Enregistrer les modifications"
initial={{
category: item.category,
name: item.name,
description: item.description,
imageUrl: item.imageUrl,
pricePerDay: item.pricePerDay.toString(),
pricePerWeek: item.pricePerWeek?.toString() ?? null,
deposit: item.deposit.toString(),
totalQty: item.totalQty,
withMotor: item.withMotor,
fuelIncluded: item.fuelIncluded,
requiresLicense: item.requiresLicense,
active: item.active,
}}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Calendrier de disponibilité
</h2>
<p className="mb-3 text-xs text-zinc-600">
Bloquez ici des dates pour maintenance, indisponibilité personnelle, etc. Les réservations
confirmées sont gérées automatiquement.
</p>
<ItemBlocksManager
blocks={item.availabilities.map((a) => ({
id: a.id,
startDate: a.startDate.toISOString().slice(0, 10),
endDate: a.endDate.toISOString().slice(0, 10),
qty: a.qty,
reason: a.reason,
isBooking: Boolean(a.rentalBookingId),
}))}
addAction={addBlockThis}
removeAction={removeBlockThis}
totalQty={item.totalQty}
/>
</section>
</main>
);
}

View file

@ -1,133 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels";
const inputCls =
"mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none";
const labelCls = "block text-sm font-medium text-zinc-800";
type Props = {
initial?: {
category?: string;
name?: string;
description?: string | null;
imageUrl?: string | null;
pricePerDay?: string | number;
pricePerWeek?: string | number | null;
deposit?: string | number;
totalQty?: number;
withMotor?: boolean;
fuelIncluded?: boolean;
requiresLicense?: boolean;
active?: boolean;
};
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
submitLabel?: string;
};
export function HostItemForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
function onSubmit(fd: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await action(fd);
if (res && res.ok === false) setError(res.error);
else if (res && res.ok === true) setSuccess("Enregistré.");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block">
<span className={labelCls}>Catégorie</span>
<select name="category" defaultValue={initial.category ?? ""} required className={inputCls}>
<option value="" disabled> sélectionner </option>
{RENTAL_CATEGORIES.map((c) => (
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
))}
</select>
</label>
<label className="block">
<span className={labelCls}>Statut</span>
<label className="flex items-center gap-2 px-1 py-2 text-sm">
<input type="checkbox" name="active" defaultChecked={initial.active ?? true} className="h-4 w-4 rounded border-zinc-300" />
Actif (visible au catalogue)
</label>
</label>
<label className="block sm:col-span-2">
<span className={labelCls}>Nom de l&apos;item</span>
<input name="name" required maxLength={200} defaultValue={initial.name ?? ""} className={inputCls} placeholder="ex. Hamac coton large" />
</label>
<label className="block sm:col-span-2">
<span className={labelCls}>Description</span>
<textarea name="description" rows={3} maxLength={5000} defaultValue={initial.description ?? ""} className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>URL image</span>
<input name="imageUrl" type="url" maxLength={500} defaultValue={initial.imageUrl ?? ""} className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>Stock total (qté)</span>
<input name="totalQty" type="number" min={1} max={1000} defaultValue={initial.totalQty?.toString() ?? "1"} required className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>Prix / jour ()</span>
<input name="pricePerDay" type="number" min={0} step="0.5" defaultValue={initial.pricePerDay?.toString() ?? ""} required className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>Prix / semaine ()</span>
<input name="pricePerWeek" type="number" min={0} step="0.5" defaultValue={initial.pricePerWeek?.toString() ?? ""} className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>Caution ()</span>
<input name="deposit" type="number" min={0} step="1" defaultValue={initial.deposit?.toString() ?? "0"} className={inputCls} />
</label>
</div>
<fieldset className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wider text-zinc-500">
Spécifications
</legend>
<div className="flex flex-wrap gap-4 pt-1 text-sm">
<label className="flex items-center gap-2">
<input type="checkbox" name="withMotor" defaultChecked={initial.withMotor ?? false} className="h-4 w-4 rounded border-zinc-300" />
Avec moteur
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="fuelIncluded" defaultChecked={initial.fuelIncluded ?? false} className="h-4 w-4 rounded border-zinc-300" />
Essence incluse
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="requiresLicense" defaultChecked={initial.requiresLicense ?? false} className="h-4 w-4 rounded border-zinc-300" />
Permis bateau requis
</label>
</div>
</fieldset>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-end">
<button
type="submit"
className="rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -1,23 +0,0 @@
import Link from "next/link";
import { requireRentalProviderSession } from "@/lib/rental-access";
import { HostItemForm } from "../_components/ItemForm";
import { createHostItemAction } from "../../actions";
export const dynamic = "force-dynamic";
export default async function NewHostItemPage() {
await requireRentalProviderSession();
return (
<main className="mx-auto max-w-3xl px-6 py-10">
<Link href="/espace-prestataire/items" className="text-xs text-zinc-500 hover:text-zinc-900">
Mes items
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvel item</h1>
<section className="mt-5 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<HostItemForm action={createHostItemAction} submitLabel="Créer l'item" initial={{ active: true, totalQty: 1 }} />
</section>
</main>
);
}

View file

@ -1,93 +0,0 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
import { listHostItems } from "@/lib/rental-host";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
export const dynamic = "force-dynamic";
export default async function HostItemsPage() {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
if (!provider) redirect("/admin/rental-providers");
const items = await listHostItems(provider.id);
return (
<main className="mx-auto max-w-6xl px-6 py-10">
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/espace-prestataire" className="text-xs text-zinc-500 hover:text-zinc-900">
Dashboard
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Mes items locables</h1>
<p className="mt-1 text-sm text-zinc-500">{items.length} item{items.length > 1 ? "s" : ""}</p>
</div>
<Link
href="/espace-prestataire/items/new"
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700"
>
+ Nouvel item
</Link>
</header>
{items.length === 0 ? (
<div className="rounded-lg border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
Pas encore d&apos;item.{" "}
<Link href="/espace-prestataire/items/new" className="text-emerald-700 underline">
Créer mon premier item
</Link>
</div>
) : (
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Nom</th>
<th className="px-4 py-2 text-left font-semibold">Catégorie</th>
<th className="px-4 py-2 text-right font-semibold">/j</th>
<th className="px-4 py-2 text-right font-semibold">Stock</th>
<th className="px-4 py-2 text-right font-semibold">Caution</th>
<th className="px-4 py-2 text-right font-semibold">Résa</th>
<th className="px-4 py-2 text-left font-semibold">État</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{items.map((i) => (
<tr key={i.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/espace-prestataire/items/${i.id}`} className="font-medium text-zinc-900 hover:underline">
{i.name}
</Link>
<div className="text-[11px] text-zinc-500">
{i.withMotor ? "⚙️ moteur · " : ""}
{i.requiresLicense ? "🪪 permis · " : ""}
{i.fuelIncluded ? "⛽ essence " : ""}
</div>
</td>
<td className="px-4 py-2 text-zinc-700">{RENTAL_CATEGORY_LABEL[i.category]}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.pricePerDay).toFixed(0)}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i.totalQty}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.deposit).toFixed(0)}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i._count.lines}</td>
<td className="px-4 py-2">
{i.active ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
Actif
</span>
) : (
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-500 ring-1 ring-inset ring-zinc-300">
Inactif
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
);
}

View file

@ -1,153 +0,0 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
import { getHostRentalKpis } from "@/lib/rental-host";
export const dynamic = "force-dynamic";
function fmtEur(amount: string | number): string {
const n = Number(amount);
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
}
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "long",
year: "numeric",
});
export default async function ProviderDashboardPage() {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
if (!provider) {
// Admin sans providerId ciblé : redirect vers liste admin
redirect("/admin/rental-providers");
}
const kpis = await getHostRentalKpis(provider.id);
return (
<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 prestataire</h1>
<p className="mt-1 text-sm text-zinc-600">
{provider.name}
{provider.isSystemD ? " · Fournisseur officiel Karbé" : ""}
</p>
</div>
<div className="flex gap-2">
<Link
href="/espace-prestataire/items/new"
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700"
>
+ Nouvel item
</Link>
<Link
href="/espace-prestataire/items"
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
Mes items
</Link>
<Link
href="/espace-prestataire/reservations"
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
Réservations
</Link>
</div>
</header>
{!provider.approved ? (
<div className="mb-6 rounded-lg border border-amber-300 bg-amber-50 p-4">
<div className="text-xs uppercase tracking-wider text-amber-900">Compte en attente de validation</div>
<p className="mt-1 text-sm text-amber-900">
Vos items ne sont <strong>pas encore visibles</strong> sur le catalogue public.
L&apos;équipe Karbé contactera bientôt {provider.contactEmail ?? "votre email"} pour finaliser
votre adhésion. Vous pouvez toutefois préparer vos items dès maintenant.
</p>
</div>
) : null}
<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)} />
<Kpi label="CA 30 j" value={fmtEur(kpis.revenue30d)} />
<Kpi
label="À confirmer"
value={String(kpis.bookingsPending)}
tone={kpis.bookingsPending > 0 ? "warn" : "neutral"}
/>
<Kpi label="Confirmées à venir" value={String(kpis.bookingsConfirmed)} />
<Kpi label="Items au catalogue" value={String(kpis.itemsActive)} />
<Kpi label="Items total" value={String(kpis.itemsTotal)} />
</section>
{kpis.nextHandover ? (
<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 remise</div>
<div className="mt-1 text-base font-semibold text-emerald-900">
{kpis.nextHandover.tenantName} · {kpis.nextHandover.lineCount} ligne(s)
</div>
<div className="text-sm text-emerald-800">
{dateFmt.format(kpis.nextHandover.startDate)}
</div>
<Link
href={`/espace-prestataire/reservations`}
className="mt-2 inline-block text-xs font-semibold text-emerald-900 underline"
>
Voir le détail
</Link>
</section>
) : null}
<section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Mon activité</h2>
<ul className="space-y-2 text-sm">
<li className="rounded border border-zinc-200 bg-white px-3 py-2">
<span className="text-zinc-500">Fleuves desservis :</span>{" "}
<strong className="text-zinc-900">{provider.rivers.join(", ") || "—"}</strong>
</li>
<li className="rounded border border-zinc-200 bg-white px-3 py-2">
<span className="text-zinc-500">Commission Karbé :</span>{" "}
<strong className="text-zinc-900">{Number(provider.commissionPct).toFixed(1)}%</strong>
</li>
<li className="rounded border border-zinc-200 bg-white px-3 py-2">
<span className="text-zinc-500">Statut :</span>{" "}
<strong className="text-zinc-900">{provider.active ? "Actif" : "Inactif"}</strong>
{" · "}
<strong className="text-zinc-900">{provider.approved ? "Approuvé" : "En attente"}</strong>
</li>
</ul>
</section>
</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,96 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { RentalBookingStatus } from "@/generated/prisma/enums";
import { updateBookingStatusAction } from "../../actions";
const btnBase =
"rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:opacity-50";
export function BookingDecision({ bookingId, status }: { bookingId: string; status: string }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [confirmCancel, setConfirmCancel] = useState(false);
function set(next: string) {
setError(null);
startTransition(async () => {
const res = await updateBookingStatusAction(bookingId, next);
if (res && res.ok === false) setError(res.error);
setConfirmCancel(false);
router.refresh();
});
}
return (
<div className="flex flex-wrap items-center gap-2">
{status === RentalBookingStatus.PENDING ? (
<button
type="button"
onClick={() => set(RentalBookingStatus.CONFIRMED)}
disabled={pending}
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Confirmer
</button>
) : null}
{status === RentalBookingStatus.CONFIRMED ? (
<button
type="button"
onClick={() => set(RentalBookingStatus.HANDED_OVER)}
disabled={pending}
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Marquer remis client
</button>
) : null}
{status === RentalBookingStatus.HANDED_OVER ? (
<button
type="button"
onClick={() => set(RentalBookingStatus.RETURNED)}
disabled={pending}
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Marquer retourné
</button>
) : null}
{status !== RentalBookingStatus.CANCELLED && status !== RentalBookingStatus.RETURNED ? (
confirmCancel ? (
<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">Annuler ?</span>
<button
type="button"
onClick={() => set(RentalBookingStatus.CANCELLED)}
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={() => setConfirmCancel(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Non
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmCancel(true)}
disabled={pending}
className={`${btnBase} border border-rose-300 bg-white text-rose-700 hover:bg-rose-50`}
>
Annuler
</button>
)
) : null}
{error ? <span className="text-xs text-rose-700">{error}</span> : null}
</div>
);
}

View file

@ -1,137 +0,0 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { RentalBookingStatus } from "@/generated/prisma/enums";
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
import { listHostBookings } from "@/lib/rental-host";
import { RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
import { BookingDecision } from "./_components/BookingDecision";
export const dynamic = "force-dynamic";
const STATUS_VALUES = new Set<string>([
RentalBookingStatus.PENDING,
RentalBookingStatus.CONFIRMED,
RentalBookingStatus.HANDED_OVER,
RentalBookingStatus.RETURNED,
RentalBookingStatus.CANCELLED,
]);
type PageProps = {
searchParams: Promise<{ status?: string }>;
};
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
year: "2-digit",
});
export default async function HostReservationsPage({ searchParams }: PageProps) {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
if (!provider) redirect("/admin/rental-providers");
const sp = await searchParams;
const status = STATUS_VALUES.has(sp.status ?? "")
? (sp.status as RentalBookingStatus)
: undefined;
const bookings = await listHostBookings(provider.id, { status });
return (
<main className="mx-auto max-w-6xl px-6 py-10">
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/espace-prestataire" className="text-xs text-zinc-500 hover:text-zinc-900">
Dashboard
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Réservations</h1>
<p className="mt-1 text-sm text-zinc-500">{bookings.length} résultat{bookings.length > 1 ? "s" : ""}</p>
</div>
<form method="get" className="flex items-center gap-2 text-sm">
<select
name="status"
defaultValue={status ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-emerald-500 focus:outline-none"
>
<option value="">Tous statuts</option>
{Object.values(RentalBookingStatus).map((s) => (
<option key={s} value={s}>{RENTAL_STATUS_LABEL[s]}</option>
))}
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
</form>
</header>
{bookings.length === 0 ? (
<div className="rounded-lg border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
Aucune réservation matériel.
</div>
) : (
<ul className="space-y-3">
{bookings.map((b) => (
<li key={b.id} className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h2 className="text-base font-semibold text-zinc-900">
{b.tenant.firstName} {b.tenant.lastName}
</h2>
<p className="text-xs text-zinc-500">
{b.tenant.email}
{b.tenant.phone ? ` · ${b.tenant.phone}` : ""}
</p>
{b.booking ? (
<p className="mt-0.5 text-xs text-emerald-700">
🏠 Lié à la résa carbet :{" "}
<Link href={`/reservations/${b.booking.id}`} className="underline">
{b.booking.carbet.title}
</Link>
</p>
) : (
<p className="mt-0.5 text-xs text-zinc-500">Location standalone (sans carbet)</p>
)}
</div>
<div className="text-right">
<div className="text-xs text-zinc-500">
{dateFmt.format(b.startDate)} {dateFmt.format(b.endDate)}
</div>
<div className="font-mono text-base font-semibold text-zinc-900">
{Number(b.amount).toFixed(2)} {b.currency}
</div>
</div>
</div>
<ul className="mt-2 space-y-1 border-t border-zinc-100 pt-2 text-sm text-zinc-700">
{b.lines.map((l) => (
<li key={l.id} className="flex items-center justify-between">
<span>
{l.qty}× <strong>{l.item.name}</strong>
</span>
<span className="font-mono text-xs text-zinc-600">
{Number(l.lineTotal).toFixed(2)}
</span>
</li>
))}
</ul>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-zinc-100 pt-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-zinc-700 ring-1 ring-inset ring-zinc-300">
{RENTAL_STATUS_LABEL[b.status]}
</span>
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-zinc-700 ring-1 ring-inset ring-zinc-300">
{b.paymentStatus}
</span>
</div>
<BookingDecision bookingId={b.id} status={b.status} />
</div>
</li>
))}
</ul>
)}
</main>
);
}

View file

@ -10,9 +10,7 @@ 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" | "RENTAL_PROVIDER">("TOURIST");
const [providerName, setProviderName] = useState("");
const [providerRivers, setProviderRivers] = useState("");
const [role, setRole] = useState<"TOURIST" | "OWNER">("TOURIST");
function onSubmit(formData: FormData) {
setError(null);
@ -26,31 +24,12 @@ export function SignupForm({ next }: Props) {
setError("Le mot de passe doit faire au moins 8 caractères.");
return;
}
if (role === "RENTAL_PROVIDER" && providerName.trim().length < 2) {
setError("Le nom de votre activité de loueur est requis.");
return;
}
startTransition(async () => {
const body: Record<string, unknown> = {
email,
password,
firstName,
lastName,
phone: phone || null,
role,
};
if (role === "RENTAL_PROVIDER") {
body.providerName = providerName.trim();
body.providerRivers = providerRivers
.split(/[,;\n]/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
const res = await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
body: JSON.stringify({ email, password, firstName, lastName, phone: phone || null, role }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
@ -112,7 +91,7 @@ export function SignupForm({ next }: Props) {
<fieldset className="space-y-1">
<legend className="text-xs text-zinc-600">Type de compte</legend>
<div className="grid grid-cols-1 gap-2 pt-1 sm:grid-cols-3">
<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 " +
@ -151,59 +130,9 @@ export function SignupForm({ next }: Props) {
<span className="font-semibold text-zinc-900">Hôte</span>
<span className="text-[11px] text-zinc-500">Publier un carbet.</span>
</label>
<label
className={
"flex cursor-pointer flex-col items-start rounded-md border px-3 py-2 text-sm " +
(role === "RENTAL_PROVIDER"
? "border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900"
: "border-zinc-300 hover:bg-zinc-50")
}
>
<input
type="radio"
name="role"
value="RENTAL_PROVIDER"
checked={role === "RENTAL_PROVIDER"}
onChange={() => setRole("RENTAL_PROVIDER")}
className="sr-only"
/>
<span className="font-semibold text-zinc-900">Loueur matériel</span>
<span className="text-[11px] text-zinc-500">Hamac, pirogue, kayak</span>
</label>
</div>
</fieldset>
{role === "RENTAL_PROVIDER" ? (
<div className="space-y-2 rounded-md border border-emerald-200 bg-emerald-50/30 p-3">
<p className="text-[11px] text-emerald-900">
Votre compte sera créé en <strong>attente de validation</strong>. Un admin Karbé
vous contactera pour confirmer votre activité avant publication de vos items.
</p>
<label className="block">
<span className="text-xs text-zinc-600">Nom de votre activité</span>
<input
type="text"
value={providerName}
onChange={(e) => setProviderName(e.target.value)}
placeholder="ex. Pirogues du Bas-Oyapock"
maxLength={200}
className={inputCls + " mt-0.5"}
/>
</label>
<label className="block">
<span className="text-xs text-zinc-600">Fleuves desservis (séparés par virgule)</span>
<input
type="text"
value={providerRivers}
onChange={(e) => setProviderRivers(e.target.value)}
placeholder="Maroni, Oyapock"
maxLength={300}
className={inputCls + " mt-0.5"}
/>
</label>
</div>
) : null}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}

View file

@ -5,8 +5,6 @@ import { PluginProvider } from "@/lib/plugins/client";
import { getEnabledPluginKeys, syncPluginsFromRegistry } from "@/lib/plugins/server";
import { SeasonBanner } from "@/components/SeasonBanner";
import { SiteHeaderGuard } from "@/components/SiteHeaderGuard";
import { RentalCartProvider } from "@/components/RentalCartProvider";
import { readCartFromCookies } from "@/lib/rental-cart-server";
import { LocaleProvider } from "@/lib/i18n/client";
import { dict, getLocale } from "@/lib/i18n/server";
@ -54,21 +52,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é",
@ -79,13 +62,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<{
@ -114,7 +90,6 @@ export default async function RootLayout({
const locale = await getLocale();
const messages = await dict(locale);
const initialCart = await readCartFromCookies();
return (
<html
@ -127,11 +102,9 @@ export default async function RootLayout({
>
<PluginProvider enabledKeys={enabledKeys}>
<LocaleProvider locale={locale} messages={messages}>
<RentalCartProvider initial={initialCart}>
<SeasonBanner />
<SiteHeaderGuard />
{children}
</RentalCartProvider>
<SeasonBanner />
<SiteHeaderGuard />
{children}
</LocaleProvider>
</PluginProvider>
</body>

View file

@ -1,119 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useCart } from "@/components/RentalCartProvider";
import { diffDays } from "@/lib/rental-cart";
type Props = {
itemId: string;
pricePerDay: number;
deposit: number;
maxQty: number;
};
function todayPlus(n: number): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + n);
return d.toISOString().slice(0, 10);
}
export function AddToCart({ itemId, pricePerDay, deposit, maxQty }: Props) {
const { addEntry, cart } = useCart();
const [start, setStart] = useState(todayPlus(7));
const [end, setEnd] = useState(todayPlus(9));
const [qty, setQty] = useState(1);
const [added, setAdded] = useState(false);
const nights = Math.max(1, diffDays(start, end));
const subtotal = nights * qty * pricePerDay;
const depositTotal = qty * deposit;
const alreadyInCart = cart.items.some(
(e) => e.itemId === itemId && e.startDate === start && e.endDate === end,
);
function onAdd() {
addEntry({ itemId, qty, startDate: start, endDate: end });
setAdded(true);
}
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 text-sm">
<label className="block">
<span className="text-xs text-zinc-500">Du</span>
<input
type="date"
value={start}
min={todayPlus(0)}
onChange={(e) => setStart(e.target.value)}
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
/>
</label>
<label className="block">
<span className="text-xs text-zinc-500">Au</span>
<input
type="date"
value={end}
min={start || todayPlus(1)}
onChange={(e) => setEnd(e.target.value)}
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
/>
</label>
</div>
<label className="block text-sm">
<span className="text-xs text-zinc-500">Quantité</span>
<input
type="number"
value={qty}
min={1}
max={maxQty}
onChange={(e) => setQty(Math.max(1, Math.min(maxQty, Number(e.target.value) || 1)))}
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
/>
</label>
<div className="border-t border-zinc-100 pt-2 text-sm text-zinc-700">
<div className="flex justify-between">
<span>
{pricePerDay.toFixed(0)} × {nights} jour{nights > 1 ? "s" : ""} × {qty}
</span>
<span className="font-mono">{subtotal.toFixed(2)} </span>
</div>
{depositTotal > 0 ? (
<div className="flex justify-between text-xs text-zinc-500">
<span>+ Caution (récupérable)</span>
<span className="font-mono">{depositTotal.toFixed(2)} </span>
</div>
) : null}
</div>
{!added ? (
<button
type="button"
onClick={onAdd}
disabled={alreadyInCart || nights === 0}
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"
>
{alreadyInCart ? "Déjà dans le panier" : "Ajouter au panier"}
</button>
) : (
<div className="space-y-2">
<div className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
Ajouté au panier
</div>
<Link
href="/panier"
className="block w-full rounded-md bg-emerald-600 px-4 py-2.5 text-center text-sm font-semibold text-white hover:bg-emerald-700"
>
Voir mon panier
</Link>
</div>
)}
</div>
);
}

View file

@ -1,56 +0,0 @@
"use client";
import { useEffect, useState } from "react";
type Day = {
date: string;
availableQty: number;
bookedQty: number;
totalQty: number;
};
export function AvailabilityPreview({ itemId }: { itemId: string }) {
const [calendar, setCalendar] = useState<Day[] | null>(null);
useEffect(() => {
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const to = new Date(today.getTime() + 30 * 86_400_000);
const fromStr = today.toISOString().slice(0, 10);
const toStr = to.toISOString().slice(0, 10);
fetch(`/api/rentals/items/${itemId}/availability?from=${fromStr}&to=${toStr}`)
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
if (j?.calendar) setCalendar(j.calendar);
})
.catch(() => {});
}, [itemId]);
if (!calendar) {
return <div className="h-16 w-full animate-pulse rounded-md bg-zinc-100" />;
}
return (
<div>
<p className="text-xs text-zinc-500 mb-2">
Disponibilité sur les 30 prochains jours (vert = stock dispo, gris = épuisé) :
</p>
<div className="grid grid-cols-15 gap-0.5 sm:grid-cols-30" style={{ gridTemplateColumns: `repeat(${calendar.length}, minmax(0, 1fr))` }}>
{calendar.map((d) => {
const ratio = d.availableQty / Math.max(1, d.totalQty);
const tone =
d.availableQty === 0 ? "bg-zinc-300" :
ratio < 0.3 ? "bg-amber-300" :
"bg-emerald-400";
return (
<div
key={d.date}
className={`h-4 rounded-sm ${tone}`}
title={`${d.date} : ${d.availableQty}/${d.totalQty} dispo`}
/>
);
})}
</div>
</div>
);
}

View file

@ -1,162 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPublicRentalItem } from "@/lib/rentals-public";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
import { AddToCart } from "./_components/AddToCart";
import { AvailabilityPreview } from "./_components/AvailabilityPreview";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ itemId: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { itemId } = await params;
const item = await getPublicRentalItem(itemId);
if (!item) return { title: "Item introuvable", robots: { index: false } };
return {
title: `${item.name} — Location matériel`,
description: item.description ?? `Location de ${item.name} via ${item.provider.name}.`,
};
}
export default async function RentalItemDetailPage({ params }: PageProps) {
const { itemId } = await params;
const item = await getPublicRentalItem(itemId);
if (!item) notFound();
const categoryEmoji =
item.category === "SLEEP" ? "💤" :
item.category === "NAVIGATION" ? "🛶" :
item.category === "FISHING" ? "🎣" :
item.category === "COOKING" ? "🍳" : "🦺";
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<Link href="/materiel" className="text-sm text-zinc-600 hover:text-zinc-900">
Tout le matériel
</Link>
<div className="mt-3 grid gap-8 lg:grid-cols-3">
<div className="lg:col-span-2">
<header>
<p className="text-xs uppercase tracking-wider text-zinc-500">
{RENTAL_CATEGORY_LABEL[item.category]}
</p>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">{item.name}</h1>
<p className="mt-1 text-sm text-zinc-600">
Loué par <strong>{item.provider.name}</strong>
{item.provider.isSystemD ? (
<span className="ml-2 rounded-full bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
Fournisseur Karbé
</span>
) : null}
</p>
</header>
<div className="mt-5 overflow-hidden rounded-lg bg-zinc-100">
{item.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.imageUrl}
alt={item.name}
className="aspect-[4/3] w-full object-cover"
/>
) : (
<div className="flex aspect-[4/3] w-full items-center justify-center text-7xl text-zinc-300">
{categoryEmoji}
</div>
)}
</div>
{item.description ? (
<section className="mt-6">
<h2 className="text-xl font-semibold text-zinc-900">Description</h2>
<p className="mt-2 whitespace-pre-line text-sm text-zinc-700">{item.description}</p>
</section>
) : null}
<section className="mt-6">
<h2 className="text-xl font-semibold text-zinc-900">Caractéristiques</h2>
<ul className="mt-3 grid grid-cols-2 gap-2 text-sm sm:grid-cols-3">
<li className="rounded-md border border-zinc-200 bg-white px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">Stock disponible</div>
<div className="font-mono font-semibold text-zinc-900">{item.totalQty} unités</div>
</li>
{Number(item.deposit) > 0 ? (
<li className="rounded-md border border-zinc-200 bg-white px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">Caution</div>
<div className="font-mono font-semibold text-zinc-900">{Number(item.deposit).toFixed(0)} </div>
</li>
) : null}
{item.withMotor ? (
<li className="rounded-md border border-zinc-200 bg-white px-3 py-2"> Avec moteur</li>
) : null}
{item.fuelIncluded ? (
<li className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-emerald-900"> Essence incluse</li>
) : null}
{item.requiresLicense ? (
<li className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-amber-900">🪪 Permis bateau requis</li>
) : null}
</ul>
</section>
<section className="mt-6">
<h2 className="text-xl font-semibold text-zinc-900">Disponibilité</h2>
<div className="mt-3 rounded-lg border border-zinc-200 bg-white p-4">
<AvailabilityPreview itemId={item.id} />
</div>
</section>
</div>
<aside className="space-y-4 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<div>
<div className="flex items-baseline justify-between">
<span>
<span className="text-3xl font-semibold text-zinc-900">
{Number(item.pricePerDay).toFixed(0)}
</span>
<span className="ml-1 text-sm text-zinc-500">/ jour</span>
</span>
</div>
{item.pricePerWeek ? (
<p className="mt-1 text-xs text-zinc-500">
Forfait semaine : {Number(item.pricePerWeek).toFixed(0)} ( 7 jours)
</p>
) : null}
</div>
<AddToCart
itemId={item.id}
pricePerDay={Number(item.pricePerDay)}
deposit={Number(item.deposit)}
maxQty={item.totalQty}
/>
<div className="border-t border-zinc-100 pt-3">
<h3 className="text-sm font-semibold text-zinc-900">{item.provider.name}</h3>
{item.provider.isSystemD ? (
<p className="mt-1 text-xs text-emerald-700">Fournisseur officiel Karbé (0% commission).</p>
) : null}
{item.provider.description ? (
<p className="mt-2 text-xs text-zinc-600">{item.provider.description}</p>
) : null}
<div className="mt-2 space-y-1 text-xs text-zinc-700">
{item.provider.contactEmail ? (
<p>📧 <a href={`mailto:${item.provider.contactEmail}`} className="underline">{item.provider.contactEmail}</a></p>
) : null}
{item.provider.contactPhone ? (
<p>📞 {item.provider.contactPhone}</p>
) : null}
<p className="text-zinc-500">
Fleuves desservis : {item.provider.rivers.join(", ") || "—"}
</p>
</div>
</div>
</aside>
</div>
</main>
);
}

View file

@ -1,100 +0,0 @@
import Link from "next/link";
import { RentalCategory } from "@/generated/prisma/enums";
import { RENTAL_CATEGORIES, RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
type Props = {
filters: {
q?: string;
category?: RentalCategory;
providerId?: string;
river?: string;
};
rivers: string[];
providers: { id: string; name: string; isSystemD: boolean }[];
};
export function RentalFilters({ filters, rivers, providers }: Props) {
return (
<form method="get" action="/materiel" className="space-y-3 rounded-lg border border-zinc-200 bg-white p-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Recherche</span>
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="nom, description…"
className="rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Fleuve</span>
<select
name="river"
defaultValue={filters.river ?? ""}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
>
<option value="">Tous fleuves</option>
{rivers.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Prestataire</span>
<select
name="providerId"
defaultValue={filters.providerId ?? ""}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
>
<option value="">Tous prestataires</option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.isSystemD ? " (Karbé)" : ""}
</option>
))}
</select>
</label>
</div>
<fieldset>
<legend className="text-xs uppercase tracking-wider text-zinc-500">Catégorie</legend>
<div className="mt-1 flex flex-wrap gap-1.5">
{RENTAL_CATEGORIES.map((c) => {
const checked = filters.category === c;
return (
<label
key={c}
className={
"flex cursor-pointer items-center gap-1 rounded-full border px-3 py-1 text-sm transition " +
(checked
? "border-emerald-600 bg-emerald-50 text-emerald-900"
: "border-zinc-300 bg-white text-zinc-700 hover:border-zinc-400")
}
>
<input
type="radio"
name="category"
value={c}
defaultChecked={checked}
className="sr-only"
/>
{RENTAL_CATEGORY_LABEL[c]}
</label>
);
})}
</div>
</fieldset>
<div className="flex items-center gap-2">
<button type="submit" className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">
Filtrer
</button>
<Link href="/materiel" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
</div>
</form>
);
}

View file

@ -1,76 +0,0 @@
import Link from "next/link";
import type { PublicRentalItem } from "@/lib/rentals-public";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
export function RentalItemCard({ item }: { item: PublicRentalItem }) {
return (
<Link
href={`/materiel/${item.id}`}
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"
>
<div className="relative aspect-[4/3] bg-zinc-100">
{item.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.imageUrl}
alt={item.name}
loading="lazy"
className="h-full w-full object-cover transition group-hover:scale-[1.02]"
/>
) : (
<div className="flex h-full items-center justify-center text-4xl text-zinc-300">
{item.category === "SLEEP" ? "💤" :
item.category === "NAVIGATION" ? "🛶" :
item.category === "FISHING" ? "🎣" :
item.category === "COOKING" ? "🍳" : "🦺"}
</div>
)}
<span className="absolute left-2 top-2 rounded-full bg-white/90 px-2 py-0.5 text-[10px] font-semibold text-zinc-800 ring-1 ring-zinc-200">
{RENTAL_CATEGORY_LABEL[item.category]}
</span>
{item.provider.isSystemD ? (
<span className="absolute right-2 top-2 rounded-full bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
Karbé
</span>
) : null}
</div>
<div className="flex flex-1 flex-col p-3">
<h3 className="text-base font-semibold text-zinc-900 group-hover:text-emerald-700">
{item.name}
</h3>
<p className="mt-0.5 text-xs text-zinc-500">{item.provider.name}</p>
<p className="mt-1 line-clamp-2 text-xs text-zinc-600">{item.description ?? ""}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[10px]">
{item.withMotor ? (
<span className="rounded-full bg-zinc-100 px-1.5 py-0.5 text-zinc-700"> moteur</span>
) : null}
{item.requiresLicense ? (
<span className="rounded-full bg-amber-50 px-1.5 py-0.5 text-amber-800">🪪 permis</span>
) : null}
{item.fuelIncluded ? (
<span className="rounded-full bg-emerald-50 px-1.5 py-0.5 text-emerald-800"> essence</span>
) : null}
{Number(item.deposit) > 0 ? (
<span className="rounded-full bg-zinc-100 px-1.5 py-0.5 text-zinc-700">
Caution {Number(item.deposit).toFixed(0)}
</span>
) : null}
</div>
<div className="mt-3 flex items-baseline justify-between border-t border-zinc-100 pt-2">
<span>
<span className="text-lg font-semibold text-zinc-900">
{Number(item.pricePerDay).toFixed(0)}
</span>
<span className="ml-1 text-xs text-zinc-500">/ jour</span>
</span>
{item.pricePerWeek ? (
<span className="text-[10px] text-zinc-500">
{Number(item.pricePerWeek).toFixed(0)} / semaine
</span>
) : null}
</div>
</div>
</Link>
);
}

View file

@ -1,121 +0,0 @@
import type { Metadata } from "next";
import { RentalCategory } from "@/generated/prisma/enums";
import { isRentalCategory } from "@/lib/rental-category-labels";
import {
listPublicProviders,
listPublicRentalItems,
listPublicRivers,
} from "@/lib/rentals-public";
import { RentalFilters } from "./_components/rental-filters";
import { RentalItemCard } from "./_components/rental-item-card";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Louer du matériel",
description:
"Hamac, moustiquaire, pirogue, kayak, barque, gilet, réchaud… Toutes les locations de matériel pour réussir votre séjour en carbet guyanais, fournies par l'association System D et des prestataires locaux validés.",
};
type PageProps = {
searchParams: Promise<{
q?: string;
category?: string;
providerId?: string;
river?: string;
}>;
};
export default async function MaterialPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
category: sp.category && isRentalCategory(sp.category) ? (sp.category as RentalCategory) : undefined,
providerId: sp.providerId || undefined,
river: sp.river || undefined,
};
const [items, providers, rivers] = await Promise.all([
listPublicRentalItems(filters),
listPublicProviders(),
listPublicRivers(),
]);
return (
<main className="mx-auto max-w-7xl px-6 py-10">
<header className="mb-6">
<h1 className="text-3xl font-semibold text-zinc-900 sm:text-4xl">
Matériel à louer
</h1>
<p className="mt-2 max-w-2xl text-sm text-zinc-600">
Hamac, moustiquaire, pirogue, kayak, barque, réchaud, gilet de sauvetage
Tout le matériel pour réussir votre séjour, mis à disposition par
l&apos;<strong>association System D</strong> ou par des prestataires
locaux validés.
</p>
</header>
<RentalFilters filters={filters} rivers={rivers} providers={providers} />
<section className="mt-6" aria-live="polite">
<p className="mb-3 text-sm text-zinc-600">
{items.length} item{items.length > 1 ? "s" : ""} disponible
{items.length > 1 ? "s" : ""}
</p>
{items.length === 0 ? (
<div className="rounded-md border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
Aucun item ne correspond à votre recherche. Essayez d&apos;élargir
les filtres.
</div>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{items.map((item) => (
<li key={item.id}>
<RentalItemCard item={item} />
</li>
))}
</ul>
)}
</section>
{providers.length > 0 ? (
<section className="mt-12 border-t border-zinc-200 pt-8">
<h2 className="text-xl font-semibold text-zinc-900">
Nos prestataires partenaires
</h2>
<p className="mt-1 text-sm text-zinc-600">
{providers.length} prestataire{providers.length > 1 ? "s" : ""} valid
{providers.length > 1 ? "és" : "é"} sur Karbé.
</p>
<ul className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{providers.map((p) => (
<li
key={p.id}
className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm"
>
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-base font-semibold text-zinc-900">{p.name}</h3>
{p.isSystemD ? (
<span className="rounded-full bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
Karbé
</span>
) : null}
</div>
<p className="mt-1 text-xs text-zinc-500">
Fleuves : {p.rivers.join(", ") || "—"} · {p.itemsCount} item
{p.itemsCount > 1 ? "s" : ""}
</p>
{p.description ? (
<p className="mt-2 line-clamp-3 text-xs text-zinc-600">
{p.description}
</p>
) : null}
</li>
))}
</ul>
</section>
) : null}
</main>
);
}

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,141 +0,0 @@
import Link from "next/link";
import { requireAuth } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
export const dynamic = "force-dynamic";
export const metadata = { title: "Mes locations matériel" };
const STATUS_LABEL: Record<string, string> = {
PENDING: "En attente",
CONFIRMED: "Confirmée",
HANDED_OVER: "Remis",
RETURNED: "Retourné",
CANCELLED: "Annulée",
};
const PAYMENT_LABEL: Record<string, string> = {
PENDING: "Paiement en attente",
AUTHORIZED: "Paiement autorisé",
SUCCEEDED: "Paiement reçu",
FAILED: "Paiement échoué",
REFUNDED: "Remboursé",
};
type SearchParams = Promise<{ payment?: string; ids?: string; ok?: string }>;
export default async function MyRentalsPage({ searchParams }: { searchParams: SearchParams }) {
const session = await requireAuth();
const sp = await searchParams;
const rentals = await prisma.rentalBooking.findMany({
where: { tenantId: session.user.id },
orderBy: [{ startDate: "desc" }],
include: {
provider: { select: { id: true, name: true, isSystemD: true, contactPhone: true, contactEmail: true } },
lines: { include: { item: { select: { id: true, name: true, category: true, imageUrl: true } } } },
booking: { select: { id: true, carbet: { select: { slug: true, title: true } } } },
},
});
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
const showSuccess = sp.payment === "success" || sp.ok;
return (
<main className="mx-auto w-full max-w-3xl flex-1 px-6 py-10">
<header>
<h1 className="text-3xl font-semibold text-zinc-900">Mes locations matériel</h1>
<p className="mt-2 text-sm text-zinc-600">
Récap des hamacs, kayaks, pirogues et autres équipements loués pour vos séjours.
</p>
</header>
{showSuccess ? (
<div className="mt-4 rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
Votre commande de matériel a bien é enregistrée. Vous recevrez un email de confirmation.
</div>
) : null}
{rentals.length === 0 ? (
<p className="mt-10 rounded-md border border-dashed border-zinc-300 bg-zinc-50 p-6 text-center text-sm text-zinc-600">
Vous n&apos;avez pas encore loué de matériel.{" "}
<Link href="/materiel" className="text-emerald-700 hover:underline">
Découvrir le matériel disponible
</Link>
.
</p>
) : (
<ul className="mt-8 space-y-5">
{rentals.map((rb) => (
<li key={rb.id} className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h2 className="text-lg font-semibold text-zinc-900">{rb.provider.name}</h2>
{rb.booking?.carbet ? (
<p className="text-xs text-zinc-500">
Pour le séjour{" "}
<Link href={`/carbets/${rb.booking.carbet.slug}`} className="hover:underline">
{rb.booking.carbet.title}
</Link>
</p>
) : (
<p className="text-xs text-zinc-500">Location indépendante</p>
)}
</div>
<div className="flex flex-col items-end gap-1 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[rb.status] ?? rb.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[rb.paymentStatus] ?? rb.paymentStatus}
</span>
</div>
</div>
<p className="mt-2 text-sm text-zinc-700">
Du {dateFmt.format(rb.startDate)} au {dateFmt.format(rb.endDate)}
</p>
<ul className="mt-3 divide-y divide-zinc-100 text-sm">
{rb.lines.map((line) => (
<li key={line.id} className="flex items-center justify-between py-2">
<span>
<span className="font-medium text-zinc-900">{line.qty}×</span>{" "}
<Link href={`/materiel/${line.item.id}`} className="hover:underline">
{line.item.name}
</Link>
<span className="ml-2 text-xs text-zinc-500">
{RENTAL_CATEGORY_LABEL[line.item.category]}
</span>
</span>
<span className="font-mono text-xs text-zinc-700">
{Number(line.lineTotal).toFixed(2)}
</span>
</li>
))}
</ul>
<div className="mt-3 flex items-baseline justify-between border-t border-zinc-100 pt-2 text-sm">
<span className="text-zinc-600">Total</span>
<span className="font-mono font-semibold text-zinc-900">
{Number(rb.amount).toFixed(2)} {rb.currency}
</span>
</div>
{(rb.provider.contactPhone || rb.provider.contactEmail) && rb.status !== "CANCELLED" ? (
<p className="mt-2 text-xs text-zinc-500">
Contact prestataire :{" "}
{rb.provider.contactPhone ? <span>📞 {rb.provider.contactPhone} </span> : null}
{rb.provider.contactEmail ? <span> {rb.provider.contactEmail}</span> : null}
</p>
) : null}
</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,249 +0,0 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCart } from "@/components/RentalCartProvider";
import { diffDays } from "@/lib/rental-cart";
type ItemSnapshot = {
id: string;
name: string;
category: string;
imageUrl: string | null;
pricePerDay: string;
deposit: string;
totalQty: number;
provider: { id: string; name: string; isSystemD: boolean };
};
type Line = {
idx: number;
entry: { itemId: string; qty: number; startDate: string; endDate: string };
item: ItemSnapshot;
};
export function CartReview({ lines }: { lines: Line[] }) {
const router = useRouter();
const { removeEntry, updateEntry, clear } = useCart();
const [busy, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
// Groupe par prestataire
const groups = useMemo(() => {
const map = new Map<string, { providerName: string; isSystemD: boolean; lines: Line[]; subtotal: number; deposit: number }>();
for (const l of lines) {
const nights = Math.max(1, diffDays(l.entry.startDate, l.entry.endDate));
const lineSub = nights * l.entry.qty * Number(l.item.pricePerDay);
const lineDeposit = l.entry.qty * Number(l.item.deposit);
const existing = map.get(l.item.provider.id);
if (existing) {
existing.lines.push(l);
existing.subtotal += lineSub;
existing.deposit += lineDeposit;
} else {
map.set(l.item.provider.id, {
providerName: l.item.provider.name,
isSystemD: l.item.provider.isSystemD,
lines: [l],
subtotal: lineSub,
deposit: lineDeposit,
});
}
}
return Array.from(map.values());
}, [lines]);
const grandTotal = groups.reduce((acc, g) => acc + g.subtotal, 0);
const grandDeposit = groups.reduce((acc, g) => acc + g.deposit, 0);
function checkout() {
setError(null);
startTransition(async () => {
const res = await fetch("/api/rentals/checkout", { method: "POST" });
const json = await res.json().catch(() => ({}));
if (!res.ok) {
setError(json?.error || `Erreur ${res.status}`);
return;
}
if (json.checkoutUrl) {
window.location.assign(json.checkoutUrl);
return;
}
if (json.rentalBookingIds?.length) {
clear();
router.push(`/mes-locations?ok=${json.rentalBookingIds[0]}`);
return;
}
router.push("/mes-locations");
});
}
return (
<div className="space-y-6">
{groups.map((g) => (
<section key={g.providerName} className="rounded-lg border border-zinc-200 bg-white shadow-sm">
<header className="border-b border-zinc-100 px-4 py-3">
<h2 className="text-base font-semibold text-zinc-900">
{g.providerName}
{g.isSystemD ? (
<span className="ml-2 rounded-full bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
Karbé
</span>
) : null}
</h2>
</header>
<ul className="divide-y divide-zinc-100">
{g.lines.map((l) => (
<CartLineItem
key={l.idx}
line={l}
onRemove={() => removeEntry(l.idx)}
onChangeQty={(qty) => updateEntry(l.idx, { qty })}
onChangeDates={(startDate, endDate) => updateEntry(l.idx, { startDate, endDate })}
disabled={busy}
/>
))}
</ul>
<footer className="flex items-center justify-between border-t border-zinc-100 bg-zinc-50 px-4 py-2 text-sm">
<span className="text-zinc-600">Sous-total prestataire</span>
<span className="font-mono font-semibold text-zinc-900">{g.subtotal.toFixed(2)} </span>
</footer>
</section>
))}
<aside className="sticky bottom-3 z-10 rounded-lg border border-zinc-200 bg-white p-4 shadow-md">
<dl className="space-y-1 text-sm">
<div className="flex justify-between">
<dt>Total location</dt>
<dd className="font-mono font-semibold">{grandTotal.toFixed(2)} </dd>
</div>
{grandDeposit > 0 ? (
<div className="flex justify-between text-xs text-zinc-500">
<dt>+ Caution récupérable</dt>
<dd className="font-mono">{grandDeposit.toFixed(2)} </dd>
</div>
) : null}
<div className="flex justify-between border-t border-zinc-100 pt-2 text-base font-semibold text-zinc-900">
<dt>À régler</dt>
<dd className="font-mono">{(grandTotal + grandDeposit).toFixed(2)} </dd>
</div>
</dl>
{error ? (
<div className="mt-2 rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">
{error}
</div>
) : null}
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<button
type="button"
onClick={clear}
disabled={busy}
className="text-xs text-zinc-500 hover:text-zinc-900"
>
Vider le panier
</button>
<button
type="button"
onClick={checkout}
disabled={busy || lines.length === 0}
className="rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{busy ? "Envoi…" : "Valider et payer"}
</button>
</div>
<p className="mt-2 text-center text-[11px] text-zinc-500">
Vous devez être <Link href="/connexion?next=/panier" className="underline">connecté</Link> pour finaliser.
</p>
</aside>
</div>
);
}
function CartLineItem({
line,
onRemove,
onChangeQty,
onChangeDates,
disabled,
}: {
line: Line;
onRemove: () => void;
onChangeQty: (qty: number) => void;
onChangeDates: (start: string, end: string) => void;
disabled?: boolean;
}) {
const nights = Math.max(1, diffDays(line.entry.startDate, line.entry.endDate));
const lineTotal = nights * line.entry.qty * Number(line.item.pricePerDay);
return (
<li className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="hidden h-14 w-20 shrink-0 overflow-hidden rounded bg-zinc-100 sm:block">
{line.item.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={line.item.imageUrl} alt={line.item.name} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-2xl text-zinc-300">
{line.item.category === "SLEEP" ? "💤" :
line.item.category === "NAVIGATION" ? "🛶" :
line.item.category === "FISHING" ? "🎣" :
line.item.category === "COOKING" ? "🍳" : "🦺"}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<Link href={`/materiel/${line.item.id}`} className="font-medium text-zinc-900 hover:underline">
{line.item.name}
</Link>
<div className="mt-1 grid grid-cols-2 gap-1 text-xs sm:grid-cols-4">
<label className="block">
<span className="text-zinc-500">Du</span>
<input
type="date"
value={line.entry.startDate}
onChange={(e) => onChangeDates(e.target.value, line.entry.endDate)}
disabled={disabled}
className="block w-full rounded border border-zinc-200 px-1.5 py-0.5"
/>
</label>
<label className="block">
<span className="text-zinc-500">Au</span>
<input
type="date"
value={line.entry.endDate}
onChange={(e) => onChangeDates(line.entry.startDate, e.target.value)}
disabled={disabled}
className="block w-full rounded border border-zinc-200 px-1.5 py-0.5"
/>
</label>
<label className="block">
<span className="text-zinc-500">Qté</span>
<input
type="number"
min={1}
max={line.item.totalQty}
value={line.entry.qty}
onChange={(e) => onChangeQty(Math.max(1, Math.min(line.item.totalQty, Number(e.target.value) || 1)))}
disabled={disabled}
className="block w-full rounded border border-zinc-200 px-1.5 py-0.5"
/>
</label>
<div className="flex flex-col text-right">
<span className="text-zinc-500">{nights} j × {Number(line.item.pricePerDay).toFixed(0)} </span>
<span className="font-mono font-semibold text-zinc-900">{lineTotal.toFixed(2)} </span>
</div>
</div>
</div>
<button
type="button"
onClick={onRemove}
disabled={disabled}
className="text-xs text-rose-700 hover:text-rose-900 disabled:opacity-50"
>
Retirer
</button>
</li>
);
}

Some files were not shown because too many files have changed in this diff Show more