From dc2b07507fe23290815c6c6f1ad61f567ad8cbb8 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 02:26:02 +0000 Subject: [PATCH] =?UTF-8?q?feat:=204=20crit=C3=A8res=20op=C3=A9rationnels?= =?UTF-8?q?=20(route/capacit=C3=A9/=C3=A9lectricit=C3=A9/GSM)=20+=20preset?= =?UTF-8?q?s=20profils=20+=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 15 +++ prisma/schema.prisma | 18 +++ src/app/carbets/[slug]/page.tsx | 15 +++ src/app/carbets/_components/carbet-card.tsx | 14 +- .../carbets/_components/search-filters.tsx | 110 +++++++++++++++- .../carbets/_components/search-profiles.tsx | 29 +++++ src/app/carbets/page.tsx | 2 + src/components/OperationalBadges.tsx | 120 ++++++++++++++++++ src/lib/carbet-public.ts | 12 ++ src/lib/carbet-search.ts | 88 ++++++++++++- src/lib/search-profiles.ts | 79 ++++++++++++ 11 files changed, 494 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260602030000_operational_criteria/migration.sql create mode 100644 src/app/carbets/_components/search-profiles.tsx create mode 100644 src/components/OperationalBadges.tsx create mode 100644 src/lib/search-profiles.ts diff --git a/prisma/migrations/20260602030000_operational_criteria/migration.sql b/prisma/migrations/20260602030000_operational_criteria/migration.sql new file mode 100644 index 0000000..5bdca5f --- /dev/null +++ b/prisma/migrations/20260602030000_operational_criteria/migration.sql @@ -0,0 +1,15 @@ +CREATE TYPE "RoadAccess" AS ENUM ('NONE', 'DRY_SEASON_ONLY', 'ALL_YEAR'); +CREATE TYPE "Electricity" AS ENUM ('NONE', 'SOLAR', 'GENERATOR_READY', 'EDF'); + +ALTER TABLE "Carbet" ADD COLUMN "roadAccess" "RoadAccess"; +ALTER TABLE "Carbet" ADD COLUMN "electricity" "Electricity"; +ALTER TABLE "Carbet" ADD COLUMN "gsmAtCarbet" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Carbet" ADD COLUMN "gsmExitDistanceKm" DECIMAL(4,2); + +-- Seed des 6 carbets démo avec valeurs réalistes +UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 1.5 WHERE id = 'demo-carbet-awara'; +UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-kourou'; +UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-mahury'; +UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'GENERATOR_READY', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 4.0 WHERE id = 'demo-carbet-maripa'; +UPDATE "Carbet" SET "roadAccess" = 'DRY_SEASON_ONLY', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 0.5 WHERE id = 'demo-carbet-paripou'; +UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-wapa'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 83d75c2..0636340 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -124,6 +124,11 @@ 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. @@ -381,3 +386,16 @@ model Favorite { @@index([userId]) @@index([carbetId]) } + +enum RoadAccess { + NONE + DRY_SEASON_ONLY + ALL_YEAR +} + +enum Electricity { + NONE + SOLAR + GENERATOR_READY + EDF +} diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index f37adae..ae53374 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -20,6 +20,7 @@ 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"; @@ -131,6 +132,20 @@ export default async function PublicCarbetPage({ params }: PageProps) { +
+

+ Critères opérationnels +

+ +
+
diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx index c11003a..f32cc8e 100644 --- a/src/app/carbets/_components/carbet-card.tsx +++ b/src/app/carbets/_components/carbet-card.tsx @@ -5,6 +5,7 @@ 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"; @@ -41,9 +42,18 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {

- Fleuve {carbet.river} · {carbet.capacity} voyageur - {carbet.capacity > 1 ? "s" : ""} + Fleuve {carbet.river}

+
+ +
+ + @@ -87,6 +101,98 @@ export function SearchFilters({ filters, rivers }: SearchFiltersProps) { /> +
+ Accès route +
+ {[ + { value: RoadAccess.ALL_YEAR, label: "🛣️ Route toute saison" }, + { value: RoadAccess.DRY_SEASON_ONLY, label: "🟠 Route saison sèche" }, + { value: RoadAccess.NONE, label: "🛶 Pirogue uniquement" }, + ].map((opt) => { + const checked = (filters.roadAccess ?? []).includes(opt.value); + return ( + + ); + })} +
+
+ +
+ Électricité +
+ {[ + { value: Electricity.EDF, label: "⚡ EDF / raccordé" }, + { value: Electricity.GENERATOR_READY, label: "🔌 Préinstall groupe" }, + { value: Electricity.SOLAR, label: "☀️ Solaire" }, + { value: Electricity.NONE, label: "🕯️ Aucune" }, + ].map((opt) => { + const checked = (filters.electricity ?? []).includes(opt.value); + return ( + + ); + })} +
+
+ + +
Équipements souhaités
diff --git a/src/app/carbets/_components/search-profiles.tsx b/src/app/carbets/_components/search-profiles.tsx new file mode 100644 index 0000000..cd11732 --- /dev/null +++ b/src/app/carbets/_components/search-profiles.tsx @@ -0,0 +1,29 @@ +"use client"; + +import Link from "next/link"; + +import { SEARCH_PROFILES, buildProfileUrl } from "@/lib/search-profiles"; + +export function SearchProfiles() { + return ( +
+
+ Profils de séjour +
+
    + {SEARCH_PROFILES.map((p) => ( +
  • + + {p.emoji} + {p.label} + +
  • + ))} +
+
+ ); +} diff --git a/src/app/carbets/page.tsx b/src/app/carbets/page.tsx index b700fed..512c79f 100644 --- a/src/app/carbets/page.tsx +++ b/src/app/carbets/page.tsx @@ -10,6 +10,7 @@ import { 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", @@ -57,6 +58,7 @@ export default async function CarbetsSearchPage({

+
diff --git a/src/components/OperationalBadges.tsx b/src/components/OperationalBadges.tsx new file mode 100644 index 0000000..e2f3ea0 --- /dev/null +++ b/src/components/OperationalBadges.tsx @@ -0,0 +1,120 @@ +/** + * Badges opérationnels Karbé : 4 critères dealbreakers affichés en compact + * sur les cards catalog + en gros sur la fiche carbet. + * + * - Route (NONE / DRY_SEASON_ONLY / ALL_YEAR) + * - Capacité (X voyageurs max) + * - Électricité (NONE / SOLAR / GENERATOR_READY / EDF) + * - GSM (au carbet OUI / à X km / zone blanche) + */ + +import { Electricity, RoadAccess } from "@/generated/prisma/enums"; + +type Props = { + roadAccess: RoadAccess | null; + capacity: number; + electricity: Electricity | null; + gsmAtCarbet: boolean; + gsmExitDistanceKm: number | null; + /** "compact" pour les cards, "full" pour la fiche détail. */ + variant?: "compact" | "full"; +}; + +type Badge = { + emoji: string; + label: string; + tone: "good" | "neutral" | "warn"; +}; + +function roadBadge(r: RoadAccess | null): Badge { + if (r === RoadAccess.ALL_YEAR) return { emoji: "🛣️", label: "Route toute saison", tone: "good" }; + if (r === RoadAccess.DRY_SEASON_ONLY) return { emoji: "🛣️", label: "Route saison sèche", tone: "warn" }; + if (r === RoadAccess.NONE) return { emoji: "🛶", label: "Pirogue uniquement", tone: "neutral" }; + return { emoji: "🛣️", label: "Accès non précisé", tone: "neutral" }; +} + +function capacityBadge(c: number): Badge { + return { emoji: "👥", label: `${c} voyageur${c > 1 ? "s" : ""}`, tone: "neutral" }; +} + +function electricityBadge(e: Electricity | null): Badge { + if (e === Electricity.EDF) return { emoji: "⚡", label: "EDF / raccordé", tone: "good" }; + if (e === Electricity.GENERATOR_READY) return { emoji: "🔌", label: "Préinstall groupe", tone: "good" }; + if (e === Electricity.SOLAR) return { emoji: "☀️", label: "Solaire", tone: "neutral" }; + if (e === Electricity.NONE) return { emoji: "🕯️", label: "Aucune électricité", tone: "warn" }; + return { emoji: "⚡", label: "Électricité non précisée", tone: "neutral" }; +} + +function gsmBadge(atCarbet: boolean, exitKm: number | null): Badge { + if (atCarbet) return { emoji: "📶", label: "Réseau au carbet", tone: "good" }; + if (exitKm !== null) { + const tone: Badge["tone"] = exitKm <= 1 ? "neutral" : "warn"; + return { emoji: "📵", label: `Réseau à ${exitKm.toFixed(exitKm < 1 ? 1 : 0)} km`, tone }; + } + return { emoji: "📵", label: "Zone blanche", tone: "warn" }; +} + +const TONE_CLASSES_COMPACT: Record = { + good: "bg-emerald-50 text-emerald-800 ring-emerald-200", + neutral: "bg-zinc-100 text-zinc-700 ring-zinc-200", + warn: "bg-amber-50 text-amber-800 ring-amber-200", +}; + +const TONE_CLASSES_FULL: Record = { + good: "bg-emerald-50 text-emerald-900 ring-emerald-300 border-emerald-200", + neutral: "bg-white text-zinc-900 ring-zinc-300 border-zinc-200", + warn: "bg-amber-50 text-amber-900 ring-amber-300 border-amber-200", +}; + +export function OperationalBadges({ + roadAccess, + capacity, + electricity, + gsmAtCarbet, + gsmExitDistanceKm, + variant = "compact", +}: Props) { + const badges: Badge[] = [ + roadBadge(roadAccess), + capacityBadge(capacity), + electricityBadge(electricity), + gsmBadge(gsmAtCarbet, gsmExitDistanceKm), + ]; + + if (variant === "compact") { + return ( +
    + {badges.map((b, i) => ( +
  • + {b.emoji} + {b.label} +
  • + ))} +
+ ); + } + + // full : grille 2×2 pour la fiche + return ( +
    + {badges.map((b, i) => ( +
  • + {b.emoji} + {b.label} +
  • + ))} +
+ ); +} diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts index 61af5c4..c09b2fb 100644 --- a/src/lib/carbet-public.ts +++ b/src/lib/carbet-public.ts @@ -28,6 +28,10 @@ export type PublicCarbetDetail = { roadAccessNote: string | null; capacity: number; nightlyPrice: string; + roadAccess: import("@/generated/prisma/enums").RoadAccess | null; + electricity: import("@/generated/prisma/enums").Electricity | null; + gsmAtCarbet: boolean; + gsmExitDistanceKm: number | null; minStayNights: number | null; maxStayNights: number | null; minCapacity: number | null; @@ -62,6 +66,10 @@ export const getPublicCarbet = cache( roadAccessNote: true, capacity: true, nightlyPrice: true, + roadAccess: true, + electricity: true, + gsmAtCarbet: true, + gsmExitDistanceKm: true, minStayNights: true, maxStayNights: true, minCapacity: true, @@ -113,6 +121,10 @@ export const getPublicCarbet = cache( roadAccessNote: carbet.roadAccessNote, capacity: carbet.capacity, nightlyPrice: carbet.nightlyPrice.toString(), + roadAccess: carbet.roadAccess, + electricity: carbet.electricity, + gsmAtCarbet: carbet.gsmAtCarbet, + gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? Number(carbet.gsmExitDistanceKm) : null, minStayNights: carbet.minStayNights, maxStayNights: carbet.maxStayNights, minCapacity: carbet.minCapacity, diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts index b2cb041..cd53126 100644 --- a/src/lib/carbet-search.ts +++ b/src/lib/carbet-search.ts @@ -5,6 +5,8 @@ import { AvailabilityBlockReason, AvailabilityScope, CarbetStatus, + Electricity, + RoadAccess, } from "@/generated/prisma/enums"; import { getCarbetReviewStatsMany } from "@/lib/reviews-server"; @@ -13,11 +15,16 @@ export type CarbetSearchFilters = { startDate?: Date; endDate?: Date; capacity?: number; - // Filtre plugin access-type : si "river-only" exclu, on garde uniquement - // ROAD_AND_RIVER. Si "all" ou non spécifié, tout passe. + capacityMax?: number; accessibility?: "road-only" | "all"; priceMax?: number; amenities?: string[]; + /** Niveaux d'accès route acceptés (multi). */ + roadAccess?: RoadAccess[]; + /** Niveaux d'électricité acceptés (multi). */ + electricity?: Electricity[]; + /** Distance max en km pour atteindre le réseau GSM. 0 = exige le réseau au carbet. */ + gsmMaxKm?: number; }; export type RawSearchParams = { @@ -71,6 +78,45 @@ export function parseSearchFilters( filters.accessibility = accessibility; } + const capacityMaxRaw = pickString(searchParams.capacityMax); + if (capacityMaxRaw) { + const cmax = Number(capacityMaxRaw); + if (Number.isInteger(cmax) && cmax > 0 && cmax <= 100) filters.capacityMax = cmax; + } + + const roadRaw = searchParams.roadAccess; + if (roadRaw) { + const arr = Array.isArray(roadRaw) ? roadRaw : [roadRaw]; + const keys = arr + .flatMap((s) => s.split(",")) + .map((s) => s.trim()) + .filter((s): s is RoadAccess => + s === RoadAccess.NONE || s === RoadAccess.DRY_SEASON_ONLY || s === RoadAccess.ALL_YEAR, + ); + if (keys.length > 0) filters.roadAccess = Array.from(new Set(keys)); + } + + const elecRaw = searchParams.electricity; + if (elecRaw) { + const arr = Array.isArray(elecRaw) ? elecRaw : [elecRaw]; + const keys = arr + .flatMap((s) => s.split(",")) + .map((s) => s.trim()) + .filter((s): s is Electricity => + s === Electricity.NONE || + s === Electricity.SOLAR || + s === Electricity.GENERATOR_READY || + s === Electricity.EDF, + ); + if (keys.length > 0) filters.electricity = Array.from(new Set(keys)); + } + + const gsmMaxRaw = pickString(searchParams.gsmMaxKm); + if (gsmMaxRaw) { + const km = Number(gsmMaxRaw); + if (Number.isFinite(km) && km >= 0 && km <= 50) filters.gsmMaxKm = km; + } + const priceMaxRaw = pickString(searchParams.priceMax); if (priceMaxRaw) { const priceMax = Number(priceMaxRaw); @@ -113,6 +159,10 @@ export type CarbetSearchResult = { nightlyPrice: string; latitude: number; longitude: number; + roadAccess: RoadAccess | null; + electricity: Electricity | null; + gsmAtCarbet: boolean; + gsmExitDistanceKm: number | null; }; // Build the Prisma where-clause for a public carbet search. A carbet is only @@ -127,8 +177,30 @@ function buildWhere(filters: CarbetSearchFilters): Prisma.CarbetWhereInput { where.river = { contains: filters.river, mode: "insensitive" }; } - if (filters.capacity) { - where.capacity = { gte: filters.capacity }; + if (filters.capacity || filters.capacityMax) { + where.capacity = {}; + if (filters.capacity) where.capacity.gte = filters.capacity; + if (filters.capacityMax) where.capacity.lte = filters.capacityMax; + } + + if (filters.roadAccess && filters.roadAccess.length > 0) { + where.roadAccess = { in: filters.roadAccess }; + } + + if (filters.electricity && filters.electricity.length > 0) { + where.electricity = { in: filters.electricity }; + } + + if (filters.gsmMaxKm !== undefined) { + if (filters.gsmMaxKm === 0) { + where.gsmAtCarbet = true; + } else { + where.OR = [ + ...(where.OR ?? []), + { gsmAtCarbet: true }, + { gsmExitDistanceKm: { lte: filters.gsmMaxKm } }, + ]; + } } if (filters.accessibility === "road-only") { @@ -182,6 +254,10 @@ export async function searchCarbets( maxStayNights: true, minCapacity: true, description: true, + roadAccess: true, + electricity: true, + gsmAtCarbet: true, + gsmExitDistanceKm: true, nightlyPrice: true, latitude: true, longitude: true, @@ -222,6 +298,10 @@ export async function searchCarbets( nightlyPrice: carbet.nightlyPrice.toString(), latitude: Number(carbet.latitude), longitude: Number(carbet.longitude), + roadAccess: carbet.roadAccess, + electricity: carbet.electricity, + gsmAtCarbet: carbet.gsmAtCarbet, + gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? Number(carbet.gsmExitDistanceKm) : null, }; }); } diff --git a/src/lib/search-profiles.ts b/src/lib/search-profiles.ts new file mode 100644 index 0000000..cff37da --- /dev/null +++ b/src/lib/search-profiles.ts @@ -0,0 +1,79 @@ +/** + * Profils de séjour prédéfinis — chips au-dessus des facettes. + * Chaque profil pose un set de query params qui pré-cochent les filtres. + */ + +import { Electricity, RoadAccess } from "@/generated/prisma/enums"; + +export type SearchProfile = { + id: string; + emoji: string; + label: string; + description: string; + params: Record; +}; + +export const SEARCH_PROFILES: SearchProfile[] = [ + { + id: "deconnexion", + emoji: "🌿", + label: "Déconnexion totale", + description: "Zone blanche, pas d'électricité, accès pirogue, 2-4 personnes.", + params: { + roadAccess: RoadAccess.NONE, + electricity: `${Electricity.NONE},${Electricity.SOLAR}`, + capacityMax: "4", + }, + }, + { + id: "teletravail", + emoji: "💻", + label: "Télétravail nature", + description: "Route, EDF, 4G au carbet — bureau au bord du fleuve.", + params: { + roadAccess: RoadAccess.ALL_YEAR, + electricity: Electricity.EDF, + gsmMaxKm: "0", + }, + }, + { + id: "famille-weekend", + emoji: "🏝️", + label: "Famille week-end", + description: "Route toute saison, électricité, capacité 4-8.", + params: { + roadAccess: RoadAccess.ALL_YEAR, + electricity: `${Electricity.EDF},${Electricity.GENERATOR_READY}`, + capacity: "4", + capacityMax: "8", + }, + }, + { + id: "astreinte", + emoji: "📞", + label: "Astreinte sereine", + description: "Réseau accessible (au max 1 km), EDF, route saison sèche min.", + params: { + gsmMaxKm: "1", + electricity: `${Electricity.EDF},${Electricity.GENERATOR_READY}`, + roadAccess: `${RoadAccess.DRY_SEASON_ONLY},${RoadAccess.ALL_YEAR}`, + }, + }, + { + id: "aventure", + emoji: "🛶", + label: "Aventure expédition", + description: "Accès pirogue uniquement, petit groupe 2-4.", + params: { + roadAccess: RoadAccess.NONE, + capacityMax: "4", + }, + }, +]; + +export function buildProfileUrl(profileId: string): string { + const profile = SEARCH_PROFILES.find((p) => p.id === profileId); + if (!profile) return "/carbets"; + const search = new URLSearchParams(profile.params); + return `/carbets?${search.toString()}`; +}