diff --git a/package-lock.json b/package-lock.json index eb5b2bd..c1f89be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,15 @@ "@aws-sdk/client-s3": "^3.1056.0", "@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", "stripe": "^18.3.0" }, @@ -2757,6 +2760,17 @@ "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", @@ -3609,6 +3623,12 @@ "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", @@ -3623,6 +3643,15 @@ "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", @@ -7597,6 +7626,12 @@ "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", @@ -9054,6 +9089,20 @@ "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", diff --git a/package.json b/package.json index 6a33258..000a852 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,15 @@ "@aws-sdk/client-s3": "^3.1056.0", "@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", "stripe": "^18.3.0" }, @@ -39,4 +42,4 @@ "typescript": "^5.9.3", "vitest": "^3.2.4" } -} \ No newline at end of file +} diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index e51590a..53544fd 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -14,6 +14,7 @@ import { formatAverageRating } from "@/lib/reviews"; import { BookingForm } from "../_components/booking-form"; 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"; @@ -144,6 +145,25 @@ export default async function PublicCarbetPage({ params }: PageProps) { provider={carbet.pirogueProvider} /> +
+

+ Où se trouve ce carbet +

+

+ Fleuve {carbet.river} · embarquement à{" "} + {carbet.embarkPoint} +

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

diff --git a/src/app/carbets/_components/booking-form.tsx b/src/app/carbets/_components/booking-form.tsx index c2d85a2..522a017 100644 --- a/src/app/carbets/_components/booking-form.tsx +++ b/src/app/carbets/_components/booking-form.tsx @@ -4,6 +4,8 @@ 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; @@ -38,8 +40,8 @@ export function BookingForm({ isAuthenticated, }: Props) { const router = useRouter(); - const [startDate, setStartDate] = useState(todayPlus(7)); - const [endDate, setEndDate] = useState(todayPlus(7 + (minStayNights ?? 2))); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); const [guestCount, setGuestCount] = useState(Math.min(2, capacity)); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -64,34 +66,18 @@ export function BookingForm({ return () => ctrl.abort(); }, [carbetId]); - const nights = useMemo(() => Math.max(0, diffDays(startDate, endDate)), [startDate, endDate]); + const nights = useMemo( + () => (startDate && endDate ? Math.max(0, diffDays(startDate, endDate)) : 0), + [startDate, endDate], + ); const total = nights * nightlyPrice; const minN = minStayNights ?? 1; const maxN = maxStayNights ?? 365; - const nightsOk = nights >= minN && nights <= maxN; + const datesSelected = Boolean(startDate && endDate); + const nightsOk = datesSelected && nights >= minN && nights <= maxN; const guestOk = guestCount >= 1 && guestCount <= capacity; - // 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]); + const canSubmit = nightsOk && guestOk && !busy; async function submit() { if (!isAuthenticated) { @@ -129,28 +115,34 @@ export function BookingForm({ jusqu'à {capacity} voyageurs -
- - -
+ { + setStartDate(s); + setEndDate(e); + setError(null); + }} + /> + + {datesSelected ? ( +
+ + {startDate}{endDate} + + +
+ ) : null} -
-
- - {nightlyPrice.toFixed(0)} € × {nights} nuit{nights > 1 ? "s" : ""} - - {(nightlyPrice * nights).toFixed(2)} € + {datesSelected ? ( +
+
+ + {nightlyPrice.toFixed(0)} € × {nights} nuit{nights > 1 ? "s" : ""} + + {(nightlyPrice * nights).toFixed(2)} € +
+
+ Total + {total.toFixed(2)} € +
-
- Total - {total.toFixed(2)} € -
-
+ ) : null} - {!nightsOk && nights > 0 ? ( + {datesSelected && !nightsOk ? (
Séjour entre {minN} et {maxN} nuits requis.
) : null} - {hasConflict ? ( -
- 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. -
- ) : null} - - {upcomingBlocked.length > 0 && !hasConflict ? ( -
- Voir les prochaines dates indisponibles -
- {upcomingBlocked.map((d) => ( - - {d} - - ))} - {blockedDates.size > upcomingBlocked.length ? ( - + {blockedDates.size - upcomingBlocked.length} autres - ) : null} -
-
- ) : null} - {error ? (
{error}
) : null} diff --git a/src/app/carbets/_components/carbet-map-inner.tsx b/src/app/carbets/_components/carbet-map-inner.tsx new file mode 100644 index 0000000..63d1ab6 --- /dev/null +++ b/src/app/carbets/_components/carbet-map-inner.tsx @@ -0,0 +1,74 @@ +"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: ` +
+ + + + +
+ `, + 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 ( +
+ + + + + {title} +
+ Fleuve {river} +
+ Embarquement : {embarkPoint} +
+ + Ouvrir dans OpenStreetMap ↗ + +
+
+
+
+ ); +} diff --git a/src/app/carbets/_components/carbet-map.tsx b/src/app/carbets/_components/carbet-map.tsx new file mode 100644 index 0000000..31b9718 --- /dev/null +++ b/src/app/carbets/_components/carbet-map.tsx @@ -0,0 +1,31 @@ +"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: () => ( +
+ ), + }, +); + +type Props = { + latitude: number; + longitude: number; + title: string; + river: string; + embarkPoint: string; +}; + +export function CarbetMap(props: Props) { + return ; +} diff --git a/src/app/carbets/_components/mini-calendar.tsx b/src/app/carbets/_components/mini-calendar.tsx new file mode 100644 index 0000000..bdcbb0d --- /dev/null +++ b/src/app/carbets/_components/mini-calendar.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useMemo, useState } from "react"; + +type Props = { + startDate: string | null; + endDate: string | null; + blockedDates: Set; + 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(() => { + 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 ( +
+
+ + + {MONTH_LABEL[viewMonth.getUTCMonth()]} {viewMonth.getUTCFullYear()} + + +
+ +
+ {DOW_LABEL.map((d, i) => ( +
+ {d} +
+ ))} +
+ +
+ {cells.map((cell, i) => { + if (!cell) return
; + 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 ( + + ); + })} +
+ +

+ {!startISO + ? "Choisissez votre date d'arrivée." + : !endISO + ? "Choisissez votre date de départ." + : ""} +

+
+ ); +}