Merge pull request 'feat: critères opérationnels Guyane' (#71) from feat/operational-criteria into main
All checks were successful
CI / test (push) Successful in 2m5s
All checks were successful
CI / test (push) Successful in 2m5s
This commit is contained in:
commit
1f8250ad7e
11 changed files with 494 additions and 8 deletions
|
|
@ -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';
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<AccessTypeBadge accessType={carbet.accessType} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
Fleuve {carbet.river} · {carbet.capacity} voyageur
|
||||
{carbet.capacity > 1 ? "s" : ""}
|
||||
Fleuve {carbet.river}
|
||||
</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}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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;
|
||||
|
|
@ -62,14 +63,27 @@ export function SearchFilters({ filters, rivers }: SearchFiltersProps) {
|
|||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-zinc-700">Voyageurs</span>
|
||||
<span className="font-medium text-zinc-700">Voyageurs min</span>
|
||||
<input
|
||||
type="number"
|
||||
name="capacity"
|
||||
min={1}
|
||||
max={100}
|
||||
defaultValue={filters.capacity ?? ""}
|
||||
placeholder="Nombre min."
|
||||
placeholder="Au moins"
|
||||
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>
|
||||
|
|
@ -87,6 +101,98 @@ export function SearchFilters({ filters, rivers }: SearchFiltersProps) {
|
|||
/>
|
||||
</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">
|
||||
|
|
|
|||
29
src/app/carbets/_components/search-profiles.tsx
Normal file
29
src/app/carbets/_components/search-profiles.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<SearchProfiles />
|
||||
<SearchFilters filters={filters} rivers={rivers} />
|
||||
|
||||
<section className="mt-8" aria-live="polite">
|
||||
|
|
|
|||
120
src/components/OperationalBadges.tsx
Normal file
120
src/components/OperationalBadges.tsx
Normal file
|
|
@ -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<Badge["tone"], string> = {
|
||||
good: "bg-emerald-50 text-emerald-800 ring-emerald-200",
|
||||
neutral: "bg-zinc-100 text-zinc-700 ring-zinc-200",
|
||||
warn: "bg-amber-50 text-amber-800 ring-amber-200",
|
||||
};
|
||||
|
||||
const TONE_CLASSES_FULL: Record<Badge["tone"], string> = {
|
||||
good: "bg-emerald-50 text-emerald-900 ring-emerald-300 border-emerald-200",
|
||||
neutral: "bg-white text-zinc-900 ring-zinc-300 border-zinc-200",
|
||||
warn: "bg-amber-50 text-amber-900 ring-amber-300 border-amber-200",
|
||||
};
|
||||
|
||||
export function OperationalBadges({
|
||||
roadAccess,
|
||||
capacity,
|
||||
electricity,
|
||||
gsmAtCarbet,
|
||||
gsmExitDistanceKm,
|
||||
variant = "compact",
|
||||
}: Props) {
|
||||
const badges: Badge[] = [
|
||||
roadBadge(roadAccess),
|
||||
capacityBadge(capacity),
|
||||
electricityBadge(electricity),
|
||||
gsmBadge(gsmAtCarbet, gsmExitDistanceKm),
|
||||
];
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{badges.map((b, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ring-inset " +
|
||||
TONE_CLASSES_COMPACT[b.tone]
|
||||
}
|
||||
>
|
||||
<span aria-hidden>{b.emoji}</span>
|
||||
<span>{b.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// full : grille 2×2 pour la fiche
|
||||
return (
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{badges.map((b, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={
|
||||
"flex items-center gap-3 rounded-lg border px-3 py-2 ring-1 ring-inset " +
|
||||
TONE_CLASSES_FULL[b.tone]
|
||||
}
|
||||
>
|
||||
<span aria-hidden className="text-xl">{b.emoji}</span>
|
||||
<span className="text-sm font-medium">{b.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
79
src/lib/search-profiles.ts
Normal file
79
src/lib/search-profiles.ts
Normal file
|
|
@ -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<string, string>;
|
||||
};
|
||||
|
||||
export const SEARCH_PROFILES: SearchProfile[] = [
|
||||
{
|
||||
id: "deconnexion",
|
||||
emoji: "🌿",
|
||||
label: "Déconnexion totale",
|
||||
description: "Zone blanche, pas d'électricité, accès pirogue, 2-4 personnes.",
|
||||
params: {
|
||||
roadAccess: RoadAccess.NONE,
|
||||
electricity: `${Electricity.NONE},${Electricity.SOLAR}`,
|
||||
capacityMax: "4",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "teletravail",
|
||||
emoji: "💻",
|
||||
label: "Télétravail nature",
|
||||
description: "Route, EDF, 4G au carbet — bureau au bord du fleuve.",
|
||||
params: {
|
||||
roadAccess: RoadAccess.ALL_YEAR,
|
||||
electricity: Electricity.EDF,
|
||||
gsmMaxKm: "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "famille-weekend",
|
||||
emoji: "🏝️",
|
||||
label: "Famille week-end",
|
||||
description: "Route toute saison, électricité, capacité 4-8.",
|
||||
params: {
|
||||
roadAccess: RoadAccess.ALL_YEAR,
|
||||
electricity: `${Electricity.EDF},${Electricity.GENERATOR_READY}`,
|
||||
capacity: "4",
|
||||
capacityMax: "8",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "astreinte",
|
||||
emoji: "📞",
|
||||
label: "Astreinte sereine",
|
||||
description: "Réseau accessible (au max 1 km), EDF, route saison sèche min.",
|
||||
params: {
|
||||
gsmMaxKm: "1",
|
||||
electricity: `${Electricity.EDF},${Electricity.GENERATOR_READY}`,
|
||||
roadAccess: `${RoadAccess.DRY_SEASON_ONLY},${RoadAccess.ALL_YEAR}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "aventure",
|
||||
emoji: "🛶",
|
||||
label: "Aventure expédition",
|
||||
description: "Accès pirogue uniquement, petit groupe 2-4.",
|
||||
params: {
|
||||
roadAccess: RoadAccess.NONE,
|
||||
capacityMax: "4",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function buildProfileUrl(profileId: string): string {
|
||||
const profile = SEARCH_PROFILES.find((p) => p.id === profileId);
|
||||
if (!profile) return "/carbets";
|
||||
const search = new URLSearchParams(profile.params);
|
||||
return `/carbets?${search.toString()}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue