diff --git a/prisma/migrations/20260531000000_add_access_type/migration.sql b/prisma/migrations/20260531000000_add_access_type/migration.sql new file mode 100644 index 0000000..e15c7db --- /dev/null +++ b/prisma/migrations/20260531000000_add_access_type/migration.sql @@ -0,0 +1,13 @@ +-- Plugin access-type : distinction route+fleuve / fleuve only + +CREATE TYPE "AccessType" AS ENUM ('ROAD_AND_RIVER', 'RIVER_ONLY'); + +ALTER TABLE "Carbet" + ADD COLUMN "accessType" "AccessType" NOT NULL DEFAULT 'ROAD_AND_RIVER', + ADD COLUMN "roadAccessNote" TEXT; + +-- La pirogue n'est obligatoire qu'en RIVER_ONLY. Pour ROAD_AND_RIVER, la valeur +-- est optionnelle (estimation pour ceux qui veulent quand même venir en pirogue). +ALTER TABLE "Carbet" ALTER COLUMN "pirogueDurationMin" DROP NOT NULL; + +CREATE INDEX "Carbet_accessType_idx" ON "Carbet" ("accessType"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c908b52..b8c9cd6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -106,7 +106,12 @@ model Carbet { latitude Decimal @db.Decimal(9, 6) longitude Decimal @db.Decimal(9, 6) embarkPoint String - pirogueDurationMin Int + // Pirogue : obligatoire pour RIVER_ONLY, optionnelle pour ROAD_AND_RIVER + // (estimation pour ceux qui veulent quand même venir en pirogue). + pirogueDurationMin Int? + accessType AccessType @default(ROAD_AND_RIVER) + // Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste). + roadAccessNote String? capacity Int status CarbetStatus @default(DRAFT) lastBookedAt DateTime? @@ -124,6 +129,7 @@ model Carbet { @@index([ownerId]) @@index([status]) @@index([river]) + @@index([accessType]) } model Amenity { diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index ae88cad..6acb95b 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -15,6 +15,7 @@ import { formatAverageRating } from "@/lib/reviews"; import { CarbetGallery } from "../_components/carbet-gallery"; import { ReviewsSection } from "../_components/reviews-section"; import { StarRating } from "../_components/star-rating"; +import { AccessTypeBadge } from "@/components/AccessTypeBadge"; type PageProps = { params: Promise<{ slug: string }>; @@ -88,17 +89,23 @@ export default async function PublicCarbetPage({ params }: PageProps) {
-

- Fleuve {carbet.river} -

+
+

+ Fleuve {carbet.river} +

+ +

{carbet.title}

Accueil par {carbet.ownerFirstName} · {carbet.capacity} voyageur - {carbet.capacity > 1 ? "s" : ""} · Pirogue{" "} - {formatPirogueDuration(carbet.pirogueDurationMin)} depuis{" "} - {carbet.embarkPoint} + {carbet.capacity > 1 ? "s" : ""} + {carbet.accessType === "RIVER_ONLY" + ? ` · Pirogue ${formatPirogueDuration(carbet.pirogueDurationMin)} depuis ${carbet.embarkPoint}` + : carbet.pirogueDurationMin + ? ` · Route + ${formatPirogueDuration(carbet.pirogueDurationMin)} pirogue depuis ${carbet.embarkPoint}` + : ` · Route directe (embarquement ${carbet.embarkPoint})`}

{carbet.reviewStats.count > 0 && carbet.reviewStats.averageRating !== null ? ( @@ -157,16 +164,32 @@ export default async function PublicCarbetPage({ params }: PageProps) { Accès au carbet
+
+
Type d'accès
+
+ {carbet.accessType === "RIVER_ONLY" + ? "Expédition fleuve uniquement" + : "Route + fleuve"} +
+
+ {carbet.roadAccessNote ? ( +
+
Accès route
+
{carbet.roadAccessNote}
+
+ ) : null}
Point d'embarquement
{carbet.embarkPoint}
-
-
Trajet pirogue
-
{formatPirogueDuration(carbet.pirogueDurationMin)}
-
+ {carbet.pirogueDurationMin !== null ? ( +
+
Trajet pirogue
+
{formatPirogueDuration(carbet.pirogueDurationMin)}
+
+ ) : null}
Coordonnées GPS
diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx index feecf82..a757b1a 100644 --- a/src/app/carbets/_components/carbet-card.tsx +++ b/src/app/carbets/_components/carbet-card.tsx @@ -3,6 +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 { AccessTypeBadge } from "@/components/AccessTypeBadge"; import { StarRating } from "./star-rating"; @@ -28,11 +29,14 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) { )}
-

- - {carbet.title} - -

+
+

+ + {carbet.title} + +

+ +

Fleuve {carbet.river} · {carbet.capacity} voyageur {carbet.capacity > 1 ? "s" : ""} @@ -50,8 +54,11 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) { {truncate(carbet.description, 180)}

- Pirogue {formatPirogueDuration(carbet.pirogueDurationMin)} depuis{" "} - {carbet.embarkPoint} + {carbet.accessType === "RIVER_ONLY" + ? `Pirogue ${formatPirogueDuration(carbet.pirogueDurationMin)} depuis ${carbet.embarkPoint}` + : carbet.pirogueDurationMin + ? `Route + ${formatPirogueDuration(carbet.pirogueDurationMin)} pirogue depuis ${carbet.embarkPoint}` + : `Accessible par la route — embarquement possible à ${carbet.embarkPoint}`}

diff --git a/src/components/AccessTypeBadge.tsx b/src/components/AccessTypeBadge.tsx new file mode 100644 index 0000000..27b8d84 --- /dev/null +++ b/src/components/AccessTypeBadge.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useIsPluginEnabled } from "@/lib/plugins/client"; +import type { AccessType } from "@/generated/prisma/enums"; + +/** + * Badge route+fleuve vs fleuve only. Gated par le plugin `access-type`. + * Si le plugin est désactivé, rien n'est rendu — la fiche tombe sur le + * comportement legacy (pirogue toujours mentionnée). + */ +export function AccessTypeBadge({ + accessType, + size = "sm", +}: { + accessType: AccessType; + size?: "sm" | "md"; +}) { + const enabled = useIsPluginEnabled("access-type"); + if (!enabled) return null; + + const isExpedition = accessType === "RIVER_ONLY"; + const label = isExpedition ? "🛶 Expédition fleuve" : "🛣️ Route + fleuve"; + const styles = isExpedition + ? "bg-[var(--color-karbe-laterite-300)]/25 text-[var(--color-karbe-laterite-700)] ring-[var(--color-karbe-laterite-500)]/30" + : "bg-[var(--color-karbe-canopy-50)] text-[var(--color-karbe-canopy-700)] ring-[var(--color-karbe-canopy-500)]/30"; + const sizing = + size === "md" + ? "px-3 py-1.5 text-xs" + : "px-2 py-0.5 text-[11px]"; + + return ( + + {label} + + ); +} diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts index afc59e0..233898c 100644 --- a/src/lib/carbet-public.ts +++ b/src/lib/carbet-public.ts @@ -2,7 +2,7 @@ import { cache } from "react"; import { prisma } from "@/lib/prisma"; import { amenityLabel } from "@/lib/amenities"; -import { CarbetStatus, MediaType } from "@/generated/prisma/enums"; +import { AccessType, CarbetStatus, MediaType } from "@/generated/prisma/enums"; import type { CarbetReview, CarbetReviewStats } from "@/lib/reviews"; import { getCarbetReviewStats, @@ -22,7 +22,9 @@ export type PublicCarbetDetail = { description: string; river: string; embarkPoint: string; - pirogueDurationMin: number; + pirogueDurationMin: number | null; + accessType: AccessType; + roadAccessNote: string | null; capacity: number; latitude: string; longitude: string; @@ -48,6 +50,8 @@ export const getPublicCarbet = cache( river: true, embarkPoint: true, pirogueDurationMin: true, + accessType: true, + roadAccessNote: true, capacity: true, latitude: true, longitude: true, @@ -78,6 +82,8 @@ export const getPublicCarbet = cache( river: carbet.river, embarkPoint: carbet.embarkPoint, pirogueDurationMin: carbet.pirogueDurationMin, + accessType: carbet.accessType, + roadAccessNote: carbet.roadAccessNote, capacity: carbet.capacity, latitude: carbet.latitude.toString(), longitude: carbet.longitude.toString(), diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts index b463430..aa8b4be 100644 --- a/src/lib/carbet-search.ts +++ b/src/lib/carbet-search.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/prisma"; import { Prisma } from "@/generated/prisma/client"; import { + AccessType, AvailabilityBlockReason, AvailabilityScope, CarbetStatus, @@ -12,6 +13,9 @@ 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. + accessibility?: "road-only" | "all"; }; export type RawSearchParams = { @@ -60,6 +64,11 @@ export function parseSearchFilters( } } + const accessibility = pickString(searchParams.accessibility); + if (accessibility === "road-only" || accessibility === "all") { + filters.accessibility = accessibility; + } + return filters; } @@ -69,7 +78,9 @@ export type CarbetSearchResult = { title: string; river: string; embarkPoint: string; - pirogueDurationMin: number; + pirogueDurationMin: number | null; + accessType: AccessType; + roadAccessNote: string | null; capacity: number; description: string; coverUrl: string | null; @@ -94,6 +105,10 @@ function buildWhere(filters: CarbetSearchFilters): Prisma.CarbetWhereInput { where.capacity = { gte: filters.capacity }; } + if (filters.accessibility === "road-only") { + where.accessType = AccessType.ROAD_AND_RIVER; + } + if (filters.startDate && filters.endDate) { where.availabilities = { some: { @@ -124,6 +139,8 @@ export async function searchCarbets( river: true, embarkPoint: true, pirogueDurationMin: true, + accessType: true, + roadAccessNote: true, capacity: true, description: true, media: { @@ -149,6 +166,8 @@ export async function searchCarbets( river: carbet.river, embarkPoint: carbet.embarkPoint, pirogueDurationMin: carbet.pirogueDurationMin, + accessType: carbet.accessType, + roadAccessNote: carbet.roadAccessNote, capacity: carbet.capacity, description: carbet.description, coverUrl: carbet.media[0]?.s3Url ?? null, diff --git a/src/lib/format.ts b/src/lib/format.ts index 5770802..facf408 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,6 +1,7 @@ // Format a pirogue trip duration (minutes) into a human readable French label -// such as "45 min" or "1 h 20". -export function formatPirogueDuration(minutes: number): string { +// such as "45 min" or "1 h 20". Null = pas de pirogue requise (carbet routier). +export function formatPirogueDuration(minutes: number | null | undefined): string { + if (minutes === null || minutes === undefined) return "—"; if (minutes < 60) return `${minutes} min`; const hours = Math.floor(minutes / 60); const rest = minutes % 60; diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts index ad20403..452c5c8 100644 --- a/src/lib/plugins/hooks.ts +++ b/src/lib/plugins/hooks.ts @@ -14,6 +14,21 @@ export interface PluginHookSet { onDisable?: PluginHook; } -// Pour l'instant, vide : les plugins métier ajouteront leurs hooks ici -// au fur et à mesure (sans toucher au runtime du système Plugin). -export const pluginHooks: Record = {}; +import { archiveDemoCarbets, seedDemoCarbets } from "./seeds/demo-carbets"; + +export const pluginHooks: Record = { + "demo-carbets-seed": { + onEnable: async () => { + const { created, existing } = await seedDemoCarbets(); + console.log( + `[plugin demo-carbets-seed] seed terminé : ${created} créés, ${existing} déjà présents`, + ); + }, + onDisable: async () => { + const archived = await archiveDemoCarbets(); + console.log( + `[plugin demo-carbets-seed] disable : ${archived} carbets démo archivés`, + ); + }, + }, +}; diff --git a/src/lib/plugins/seeds/demo-carbets.ts b/src/lib/plugins/seeds/demo-carbets.ts new file mode 100644 index 0000000..eafdbc7 --- /dev/null +++ b/src/lib/plugins/seeds/demo-carbets.ts @@ -0,0 +1,221 @@ +/** + * Seed du plugin `demo-carbets-seed`. + * + * Crée 3 propriétaires fictifs et 6 carbets démo répartis sur 4 fleuves + * (Maroni, Approuague, Comté, Oyapock) et 2 types d'accès. Les carbets démo + * sont tagués par leur slug préfixé `demo-` pour pouvoir être soft-deleted + * (`status=ARCHIVED`) au disable du plugin sans toucher aux carbets utilisateurs. + */ + +import { prisma } from "@/lib/prisma"; +import { hashPassword } from "@/lib/password"; +import { + AccessType, + CarbetStatus, + UserRole, +} from "@/generated/prisma/enums"; + +const DEMO_OWNERS = [ + { + email: "demo-yann@karbe.demo", + firstName: "Yann", + lastName: "Cabassou", + phone: "+594-694-001122", + }, + { + email: "demo-emilie@karbe.demo", + firstName: "Émilie", + lastName: "Sénégal", + phone: "+594-694-003344", + }, + { + email: "demo-ce-hopital@karbe.demo", + firstName: "CE", + lastName: "Hôpital Cayenne", + phone: "+594-594-005566", + }, +] as const; + +type DemoCarbet = { + slug: string; + title: string; + ownerIdx: 0 | 1 | 2; + river: string; + embarkPoint: string; + accessType: AccessType; + pirogueDurationMin: number | null; + roadAccessNote: string | null; + latitude: number; + longitude: number; + capacity: number; + description: string; +}; + +const DEMO_CARBETS: DemoCarbet[] = [ + { + slug: "demo-karbe-awara-maroni", + title: "Karbé Awara", + ownerIdx: 0, + river: "Maroni", + embarkPoint: "Dégrad Apatou", + accessType: AccessType.RIVER_ONLY, + pirogueDurationMin: 90, + roadAccessNote: null, + latitude: 5.2008, + longitude: -53.9519, + capacity: 6, + description: + "Au cœur du Maroni, à 1h30 de pirogue d'Apatou. Trois hamacs, une terrasse qui domine le fleuve, le silence total. Bois local, panneau solaire, eau pluviale. Le passeur fait l'aller-retour à votre demande.", + }, + { + slug: "demo-karbe-wapa-comte", + title: "Karbé Wapa", + ownerIdx: 1, + river: "Comté", + embarkPoint: "Roura, ponton municipal", + accessType: AccessType.ROAD_AND_RIVER, + pirogueDurationMin: 20, + roadAccessNote: + "30 km de piste depuis Roura, dernier kilomètre en 4×4 conseillé en saison des pluies. Parking sécurisé au ponton.", + latitude: 4.7281, + longitude: -52.3261, + capacity: 4, + description: + "Carbet familial accessible en voiture, à 30 min du centre de Roura. Idéal week-end : on arrive vendredi soir, on dort au bord du Comté, on baigne dimanche matin. Pirogue dispo pour explorer en amont.", + }, + { + slug: "demo-karbe-maripa-approuague", + title: "Karbé Maripa", + ownerIdx: 0, + river: "Approuague", + embarkPoint: "Saint-Georges, dégrad principal", + accessType: AccessType.RIVER_ONLY, + pirogueDurationMin: 180, + roadAccessNote: null, + latitude: 3.9001, + longitude: -51.8101, + capacity: 8, + description: + "Trois heures de remontée de l'Approuague, plus rien ne vient brouiller la nuit. Carbet ancien rénové, deux pièces séparées, cuisine au feu de bois. Singes hurleurs garantis au lever du soleil.", + }, + { + slug: "demo-karbe-paripou-oyapock", + title: "Karbé Paripou", + ownerIdx: 1, + river: "Oyapock", + embarkPoint: "Saint-Georges, embarcadère mairie", + accessType: AccessType.RIVER_ONLY, + pirogueDurationMin: 240, + roadAccessNote: null, + latitude: 3.7501, + longitude: -51.5801, + capacity: 4, + description: + "Côté Oyapock, vis-à-vis du Brésil, quatre heures de pirogue depuis Saint-Georges. Pour ceux qui veulent vraiment dormir loin. Saison sèche uniquement : étiage rend l'Oyapock difficile en mai-juin.", + }, + { + slug: "demo-karbe-mahury-ce-hopital", + title: "Karbé du CE — bord du Mahury", + ownerIdx: 2, + river: "Mahury", + embarkPoint: "Rémire-Montjoly, base nautique", + accessType: AccessType.ROAD_AND_RIVER, + pirogueDurationMin: 15, + roadAccessNote: + "Accès depuis Rémire-Montjoly, route asphaltée jusqu'à la base nautique. Parking signalé.", + latitude: 4.8801, + longitude: -52.2691, + capacity: 12, + description: + "Le carbet du Comité Social de l'Hôpital de Cayenne, réservé aux agents en semaine et ouvert au public le week-end (sauf jours fériés). Spacieux, équipé pour familles : groupe électrogène, frigo, plancha.", + }, + { + slug: "demo-karbe-kourou-couleuvre", + title: "Karbé Couleuvre", + ownerIdx: 1, + river: "Kourou", + embarkPoint: "Pont du Kourou", + accessType: AccessType.ROAD_AND_RIVER, + pirogueDurationMin: null, + roadAccessNote: + "100 % accessible par la voiture. Garez-vous au pont du Kourou, comptez 10 min à pied par la berge.", + latitude: 5.1568, + longitude: -52.6504, + capacity: 3, + description: + "Petit carbet pour couple, sur la berge du Kourou. Accès route uniquement, idéal nuit improvisée après le boulot. Pas de pirogue ici, juste un hamac, un livre, le clapotis.", + }, +]; + +const DEMO_PASSWORD = "demo-karbe-2026"; + +async function ensureOwner(idx: 0 | 1 | 2): Promise { + const owner = DEMO_OWNERS[idx]; + const existing = await prisma.user.findUnique({ where: { email: owner.email } }); + if (existing) return existing.id; + + const passwordHash = await hashPassword(DEMO_PASSWORD); + const created = await prisma.user.create({ + data: { + email: owner.email, + passwordHash, + firstName: owner.firstName, + lastName: owner.lastName, + phone: owner.phone, + role: UserRole.OWNER, + isActive: true, + }, + }); + return created.id; +} + +export async function seedDemoCarbets(): Promise<{ created: number; existing: number }> { + const ownerIds: string[] = []; + for (const idx of [0, 1, 2] as const) { + ownerIds.push(await ensureOwner(idx)); + } + + let created = 0; + let existing = 0; + for (const carbet of DEMO_CARBETS) { + const found = await prisma.carbet.findUnique({ where: { slug: carbet.slug } }); + if (found) { + // Si désactivé/archivé puis re-activé, on remet en PUBLISHED. + if (found.status !== CarbetStatus.PUBLISHED) { + await prisma.carbet.update({ + where: { id: found.id }, + data: { status: CarbetStatus.PUBLISHED }, + }); + } + existing += 1; + continue; + } + await prisma.carbet.create({ + data: { + slug: carbet.slug, + title: carbet.title, + description: carbet.description, + river: carbet.river, + embarkPoint: carbet.embarkPoint, + accessType: carbet.accessType, + pirogueDurationMin: carbet.pirogueDurationMin, + roadAccessNote: carbet.roadAccessNote, + latitude: carbet.latitude, + longitude: carbet.longitude, + capacity: carbet.capacity, + status: CarbetStatus.PUBLISHED, + ownerId: ownerIds[carbet.ownerIdx], + }, + }); + created += 1; + } + return { created, existing }; +} + +export async function archiveDemoCarbets(): Promise { + const result = await prisma.carbet.updateMany({ + where: { slug: { startsWith: "demo-karbe-" } }, + data: { status: CarbetStatus.ARCHIVED }, + }); + return result.count; +}