From a174f99ebae3c39fcbf1d1b38842d442f27f8279 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 11:29:29 +0000 Subject: [PATCH] feat(plugin): pirogue-providers (Phase 3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modèle PirogueProvider (id, name, contacts, fleuves, tarif, description) + enum TransportMode (OWNER_PROVIDES, SELF_ARRANGE, PARTNER_PROVIDER) sur Carbet + relation Carbet → PirogueProvider (nullable, ondelete:SetNull) Composants : - PirogueTransportBlock (server, gated par plugin) sur fiche carbet : affiche le mode + provider partenaire avec contacts/tarif/description - Page publique /partenaires-pirogue : liste des partenaires actifs Seed onEnable : - 3 partenaires démo (Pirogues du Maroni, Approuague Aventures, Oyapock Frontière) avec tarifs estimatifs et fleuves desservis réels - Attribution aux 6 carbets démo : · Awara (Maroni), Maripa (Approuague), Paripou (Oyapock) → PARTNER_PROVIDER · Wapa (Comté), Mahury CE → OWNER_PROVIDES · Kourou Couleuvre → SELF_ARRANGE onDisable désactive les partenaires démo et détache les carbets démo. --- .../migration.sql | 29 +++++ prisma/schema.prisma | 40 ++++++- src/app/carbets/[slug]/page.tsx | 6 + src/app/partenaires-pirogue/page.tsx | 88 +++++++++++++++ src/components/PirogueTransportBlock.tsx | 80 ++++++++++++++ src/lib/carbet-public.ts | 32 +++++- src/lib/pirogue-providers.ts | 50 +++++++++ src/lib/plugins/hooks.ts | 16 +++ .../seeds/pirogue-providers-default.ts | 104 ++++++++++++++++++ 9 files changed, 438 insertions(+), 7 deletions(-) create mode 100644 prisma/migrations/20260531200000_add_pirogue_providers/migration.sql create mode 100644 src/app/partenaires-pirogue/page.tsx create mode 100644 src/components/PirogueTransportBlock.tsx create mode 100644 src/lib/pirogue-providers.ts create mode 100644 src/lib/plugins/seeds/pirogue-providers-default.ts diff --git a/prisma/migrations/20260531200000_add_pirogue_providers/migration.sql b/prisma/migrations/20260531200000_add_pirogue_providers/migration.sql new file mode 100644 index 0000000..3f25550 --- /dev/null +++ b/prisma/migrations/20260531200000_add_pirogue_providers/migration.sql @@ -0,0 +1,29 @@ +-- Plugin pirogue-providers : modèle PirogueProvider + transportMode sur Carbet + +CREATE TYPE "TransportMode" AS ENUM ('OWNER_PROVIDES', 'SELF_ARRANGE', 'PARTNER_PROVIDER'); + +CREATE TABLE "PirogueProvider" ( + "id" TEXT PRIMARY KEY, + "name" TEXT NOT NULL, + "contactEmail" TEXT, + "contactPhone" TEXT, + "rivers" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "pricingNote" TEXT, + "description" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL +); + +CREATE INDEX "PirogueProvider_active_idx" ON "PirogueProvider" ("active"); + +ALTER TABLE "Carbet" + ADD COLUMN "transportMode" "TransportMode", + ADD COLUMN "pirogueProviderId" TEXT; + +ALTER TABLE "Carbet" + ADD CONSTRAINT "Carbet_pirogueProviderId_fkey" + FOREIGN KEY ("pirogueProviderId") REFERENCES "PirogueProvider"("id") + ON DELETE SET NULL; + +CREATE INDEX "Carbet_pirogueProviderId_idx" ON "Carbet" ("pirogueProviderId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4fd694e..5a05f4f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -64,6 +64,12 @@ enum AccessType { RIVER_ONLY } +enum TransportMode { + OWNER_PROVIDES + SELF_ARRANGE + PARTNER_PROVIDER +} + model Organization { id String @id @default(cuid()) name String @@ -125,23 +131,45 @@ model Carbet { // Contraintes saisonnières (plugin seasonality). JSON libre, schéma type : // { closedInLowWater: bool, closedSeasons: ["WET"|"DRY"|"LOW_WATER"][], note: string } seasonalConstraints Json? + // Plugin pirogue-providers : qui organise le transport ? + transportMode TransportMode? + pirogueProviderId String? status CarbetStatus @default(DRAFT) lastBookedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict) - amenities CarbetAmenity[] - media Media[] - availabilities Availability[] - bookings Booking[] - reviews Review[] + owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict) + pirogueProvider PirogueProvider? @relation(fields: [pirogueProviderId], references: [id], onDelete: SetNull) + amenities CarbetAmenity[] + media Media[] + availabilities Availability[] + bookings Booking[] + reviews Review[] subscriptions Subscription[] @@index([ownerId]) @@index([status]) @@index([river]) @@index([accessType]) + @@index([pirogueProviderId]) +} + +model PirogueProvider { + id String @id @default(cuid()) + name String + contactEmail String? + contactPhone String? + rivers String[] @default([]) + pricingNote String? + description String? + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + carbets Carbet[] + + @@index([active]) } model Amenity { diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index 227b305..9a3e51c 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -17,6 +17,7 @@ import { ReviewsSection } from "../_components/reviews-section"; import { StarRating } from "../_components/star-rating"; import { AccessTypeBadge } from "@/components/AccessTypeBadge"; import { StayConstraints } from "@/components/StayConstraints"; +import { PirogueTransportBlock } from "@/components/PirogueTransportBlock"; type PageProps = { params: Promise<{ slug: string }>; @@ -137,6 +138,11 @@ export default async function PublicCarbetPage({ params }: PageProps) {

+ + {carbet.amenities.length > 0 ? (

diff --git a/src/app/partenaires-pirogue/page.tsx b/src/app/partenaires-pirogue/page.tsx new file mode 100644 index 0000000..812be29 --- /dev/null +++ b/src/app/partenaires-pirogue/page.tsx @@ -0,0 +1,88 @@ +import { notFound } from "next/navigation"; +import { isPluginEnabled } from "@/lib/plugins/server"; +import { listActiveProviders } from "@/lib/pirogue-providers"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata() { + return { title: "Partenaires pirogue" }; +} + +export default async function ProvidersPage() { + if (!(await isPluginEnabled("pirogue-providers"))) notFound(); + const providers = await listActiveProviders(); + + return ( +
+
+ + Transport + +

+ Nos partenaires pirogue +

+

+ Pour les carbets accessibles uniquement par le fleuve, on travaille avec des piroguiers + locaux référencés. Tarifs estimatifs ci-dessous ; le détail de votre trajet est calé + directement avec le partenaire après réservation. +

+
+ + {providers.length === 0 ? ( +

+ Aucun partenaire référencé pour le moment. +

+ ) : ( +
    + {providers.map((p) => ( +
  • +
    +

    {p.name}

    +
    + {p.rivers.map((r) => ( + + {r} + + ))} +
    +
    + {p.description ? ( +

    {p.description}

    + ) : null} + {p.pricingNote ? ( +

    {p.pricingNote}

    + ) : null} +
    + {p.contactEmail ? ( +
    +
    Email ·
    +
    + + {p.contactEmail} + +
    +
    + ) : null} + {p.contactPhone ? ( +
    +
    Tél. ·
    +
    {p.contactPhone}
    +
    + ) : null} +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/PirogueTransportBlock.tsx b/src/components/PirogueTransportBlock.tsx new file mode 100644 index 0000000..149d75e --- /dev/null +++ b/src/components/PirogueTransportBlock.tsx @@ -0,0 +1,80 @@ +import { isPluginEnabled } from "@/lib/plugins/server"; +import { + TRANSPORT_MODE_EMOJI, + TRANSPORT_MODE_LABEL, + type PirogueProvider, +} from "@/lib/pirogue-providers"; + +/** + * Bloc transport pirogue sur la fiche carbet (server component). + * Gated par le plugin `pirogue-providers`. Sans le plugin, retourne null. + */ +export async function PirogueTransportBlock({ + transportMode, + provider, +}: { + transportMode: string | null; + provider: PirogueProvider | null; +}) { + if (!(await isPluginEnabled("pirogue-providers"))) return null; + if (!transportMode) return null; + + const emoji = TRANSPORT_MODE_EMOJI[transportMode] ?? "🛶"; + const label = TRANSPORT_MODE_LABEL[transportMode] ?? "Transport pirogue"; + + return ( +
+
+ {emoji} +

+ Transport pirogue — {label} +

+
+ + {transportMode === "PARTNER_PROVIDER" && provider ? ( +
+

+ Ce carbet travaille avec un partenaire référencé :{" "} + {provider.name} +

+ {provider.description ?

{provider.description}

: null} + {provider.pricingNote ? ( +

{provider.pricingNote}

+ ) : null} +
+ {provider.contactEmail ? ( + + ) : null} + {provider.contactPhone ? ( +
+
Tél. ·
+
{provider.contactPhone}
+
+ ) : null} +
+
+ ) : transportMode === "OWNER_PROVIDES" ? ( +

+ Le loueur s'occupe du transport : il vous récupère au point d'embarquement et + vous ramène en fin de séjour. Détails de l'heure et du point de rendez-vous transmis + par e-mail après réservation. +

+ ) : ( +

+ Le transport est à votre charge. Renseignez-vous auprès des piroguiers locaux du dégrad + d'embarquement, ou prévenez-nous : on peut vous orienter vers un partenaire. +

+ )} +
+ ); +} diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts index dc32e1f..82ed693 100644 --- a/src/lib/carbet-public.ts +++ b/src/lib/carbet-public.ts @@ -2,12 +2,13 @@ import { cache } from "react"; import { prisma } from "@/lib/prisma"; import { amenityLabel } from "@/lib/amenities"; -import { AccessType, CarbetStatus, MediaType } from "@/generated/prisma/enums"; +import { AccessType, CarbetStatus, MediaType, TransportMode } from "@/generated/prisma/enums"; import type { CarbetReview, CarbetReviewStats } from "@/lib/reviews"; import { getCarbetReviewStats, listCarbetReviews, } from "@/lib/reviews-server"; +import type { PirogueProvider } from "@/lib/pirogue-providers"; export type PublicCarbetMedia = { id: string; @@ -30,6 +31,8 @@ export type PublicCarbetDetail = { maxStayNights: number | null; minCapacity: number | null; seasonalConstraints: unknown; + transportMode: TransportMode | null; + pirogueProvider: PirogueProvider | null; latitude: string; longitude: string; ownerId: string; @@ -61,10 +64,24 @@ export const getPublicCarbet = cache( maxStayNights: true, minCapacity: true, seasonalConstraints: true, + transportMode: true, + pirogueProviderId: true, latitude: true, longitude: true, ownerId: true, owner: { select: { firstName: true } }, + pirogueProvider: { + select: { + id: true, + name: true, + contactEmail: true, + contactPhone: true, + rivers: true, + pricingNote: true, + description: true, + active: true, + }, + }, media: { orderBy: { sortOrder: "asc" }, select: { id: true, type: true, s3Url: true }, @@ -97,6 +114,19 @@ export const getPublicCarbet = cache( maxStayNights: carbet.maxStayNights, minCapacity: carbet.minCapacity, seasonalConstraints: carbet.seasonalConstraints, + transportMode: carbet.transportMode, + pirogueProvider: carbet.pirogueProvider + ? { + id: carbet.pirogueProvider.id, + name: carbet.pirogueProvider.name, + contactEmail: carbet.pirogueProvider.contactEmail, + contactPhone: carbet.pirogueProvider.contactPhone, + rivers: carbet.pirogueProvider.rivers, + pricingNote: carbet.pirogueProvider.pricingNote, + description: carbet.pirogueProvider.description, + active: carbet.pirogueProvider.active, + } + : null, latitude: carbet.latitude.toString(), longitude: carbet.longitude.toString(), ownerId: carbet.ownerId, diff --git a/src/lib/pirogue-providers.ts b/src/lib/pirogue-providers.ts new file mode 100644 index 0000000..923cdd0 --- /dev/null +++ b/src/lib/pirogue-providers.ts @@ -0,0 +1,50 @@ +/** + * Helpers Plugin pirogue-providers. + */ + +import { prisma } from "@/lib/prisma"; + +export type PirogueProvider = { + id: string; + name: string; + contactEmail: string | null; + contactPhone: string | null; + rivers: string[]; + pricingNote: string | null; + description: string | null; + active: boolean; +}; + +export async function listActiveProviders(): Promise { + const rows = await prisma.pirogueProvider.findMany({ + where: { active: true }, + orderBy: { name: "asc" }, + }); + return rows.map((r) => ({ + id: r.id, + name: r.name, + contactEmail: r.contactEmail, + contactPhone: r.contactPhone, + rivers: r.rivers, + pricingNote: r.pricingNote, + description: r.description, + active: r.active, + })); +} + +export async function listProvidersForRiver(river: string): Promise { + const all = await listActiveProviders(); + return all.filter((p) => p.rivers.some((r) => r.toLowerCase() === river.toLowerCase())); +} + +export const TRANSPORT_MODE_LABEL: Record = { + OWNER_PROVIDES: "Le loueur fournit le passeur", + SELF_ARRANGE: "À organiser par le voyageur", + PARTNER_PROVIDER: "Partenaire référencé", +}; + +export const TRANSPORT_MODE_EMOJI: Record = { + OWNER_PROVIDES: "👤", + SELF_ARRANGE: "🗺️", + PARTNER_PROVIDER: "🤝", +}; diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts index 53c5bc3..d9cccb7 100644 --- a/src/lib/plugins/hooks.ts +++ b/src/lib/plugins/hooks.ts @@ -25,6 +25,10 @@ import { seedLegalPages, unpublishLegalPages, } from "./seeds/legal-pages-default"; +import { + deactivatePirogueProviders, + seedPirogueProviders, +} from "./seeds/pirogue-providers-default"; export const pluginHooks: Record = { "demo-carbets-seed": { @@ -67,4 +71,16 @@ export const pluginHooks: Record = { console.log(`[plugin legal-pages] disable: ${unpub} pages dépubliées`); }, }, + "pirogue-providers": { + onEnable: async () => { + const { providers, carbets } = await seedPirogueProviders(); + console.log( + `[plugin pirogue-providers] seed: ${providers} partenaires, ${carbets} carbets attachés`, + ); + }, + onDisable: async () => { + const count = await deactivatePirogueProviders(); + console.log(`[plugin pirogue-providers] disable: ${count} partenaires désactivés`); + }, + }, }; diff --git a/src/lib/plugins/seeds/pirogue-providers-default.ts b/src/lib/plugins/seeds/pirogue-providers-default.ts new file mode 100644 index 0000000..9360325 --- /dev/null +++ b/src/lib/plugins/seeds/pirogue-providers-default.ts @@ -0,0 +1,104 @@ +/** + * Seed du plugin `pirogue-providers` : 3 prestataires partenaires fictifs + * réalistes pour la démo, attachés à 4 fleuves majeurs de Guyane. + */ + +import { prisma } from "@/lib/prisma"; + +const PROVIDERS = [ + { + id: "demo-provider-maroni", + name: "Pirogues du Maroni", + contactEmail: "contact@pirogues-maroni.demo", + contactPhone: "+594-694-100200", + rivers: ["Maroni", "Lawa"], + pricingNote: "≈ 150 € aller-retour depuis Apatou (selon distance carbet)", + description: + "Coopérative de piroguiers bushinengués. Aller-retour vers les carbets en aval d'Apatou, départs matin. Capacité 6-8 passagers, vestes de pluie fournies.", + }, + { + id: "demo-provider-approuague", + name: "Approuague Aventures", + contactEmail: "info@approuague-aventures.demo", + contactPhone: "+594-694-300400", + rivers: ["Approuague"], + pricingNote: "≈ 250 € aller-retour Régina, ≈ 320 € jusqu'au saut Mapaou", + description: + "Prestataire historique de l'Approuague, basé à Régina. Pirogues 4 passagers + matériel. Possibilité de jour blanc (pêche, observation) en option.", + }, + { + id: "demo-provider-oyapock", + name: "Oyapock Frontière", + contactEmail: "contact@oyapock-frontiere.demo", + contactPhone: "+594-694-500600", + rivers: ["Oyapock"], + pricingNote: "≈ 300 € aller-retour Saint-Georges (haute eau), tarif majoré en étiage", + description: + "Trajet vers les carbets côté Guyane et côté Brésilien (Vila Brasil). En étiage (oct-nov), prévoir une marge horaire — fond du fleuve parfois imprévisible.", + }, +] as const; + +const CARBET_PROVIDER_LINKS = [ + { slug: "demo-karbe-awara-maroni", providerId: "demo-provider-maroni", mode: "PARTNER_PROVIDER" as const }, + { slug: "demo-karbe-maripa-approuague", providerId: "demo-provider-approuague", mode: "PARTNER_PROVIDER" as const }, + { slug: "demo-karbe-paripou-oyapock", providerId: "demo-provider-oyapock", mode: "PARTNER_PROVIDER" as const }, + { slug: "demo-karbe-wapa-comte", providerId: null, mode: "OWNER_PROVIDES" as const }, + { slug: "demo-karbe-mahury-ce-hopital", providerId: null, mode: "OWNER_PROVIDES" as const }, + { slug: "demo-karbe-kourou-couleuvre", providerId: null, mode: "SELF_ARRANGE" as const }, +]; + +export async function seedPirogueProviders(): Promise<{ providers: number; carbets: number }> { + let providers = 0; + for (const p of PROVIDERS) { + await prisma.pirogueProvider.upsert({ + where: { id: p.id }, + update: { + name: p.name, + contactEmail: p.contactEmail, + contactPhone: p.contactPhone, + rivers: [...p.rivers], + pricingNote: p.pricingNote, + description: p.description, + active: true, + }, + create: { + id: p.id, + name: p.name, + contactEmail: p.contactEmail, + contactPhone: p.contactPhone, + rivers: [...p.rivers], + pricingNote: p.pricingNote, + description: p.description, + active: true, + }, + }); + providers += 1; + } + + let carbets = 0; + for (const link of CARBET_PROVIDER_LINKS) { + const updated = await prisma.carbet.updateMany({ + where: { slug: link.slug }, + data: { + pirogueProviderId: link.providerId, + transportMode: link.mode, + }, + }); + carbets += updated.count; + } + + return { providers, carbets }; +} + +export async function deactivatePirogueProviders(): Promise { + const result = await prisma.pirogueProvider.updateMany({ + where: { id: { startsWith: "demo-provider-" } }, + data: { active: false }, + }); + // Détache aussi les carbets démo + await prisma.carbet.updateMany({ + where: { pirogueProviderId: { startsWith: "demo-provider-" } }, + data: { pirogueProviderId: null, transportMode: null }, + }); + return result.count; +}