Merge pull request 'feat: calendrier visuel + carte Leaflet' (#56) from feat/visual-calendar-and-map into main
All checks were successful
CI / test (push) Successful in 1m53s
All checks were successful
CI / test (push) Successful in 1m53s
This commit is contained in:
commit
0b5e5408e8
7 changed files with 417 additions and 85 deletions
49
package-lock.json
generated
49
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-xl font-semibold text-zinc-900">
|
||||
Où se trouve ce carbet
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
Fleuve <strong>{carbet.river}</strong> · embarquement à{" "}
|
||||
<strong>{carbet.embarkPoint}</strong>
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<CarbetMap
|
||||
latitude={Number(carbet.latitude)}
|
||||
longitude={Number(carbet.longitude)}
|
||||
title={carbet.title}
|
||||
river={carbet.river}
|
||||
embarkPoint={carbet.embarkPoint}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{carbet.amenities.length > 0 ? (
|
||||
<section className="mt-10">
|
||||
<h2 className="text-xl font-semibold text-zinc-900">
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [endDate, setEndDate] = useState<string | null>(null);
|
||||
const [guestCount, setGuestCount] = useState(Math.min(2, capacity));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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({
|
|||
<span className="text-xs text-zinc-500">jusqu'à {capacity} voyageurs</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-500">Arrivée</span>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
min={todayPlus(0)}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-500">Départ</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
min={startDate || todayPlus(1)}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<MiniCalendar
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
blockedDates={blockedDates}
|
||||
onChange={(s, e) => {
|
||||
setStartDate(s);
|
||||
setEndDate(e);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{datesSelected ? (
|
||||
<div className="flex items-center justify-between rounded-md bg-zinc-50 px-3 py-1.5 text-xs text-zinc-700">
|
||||
<span>
|
||||
<strong>{startDate}</strong> → <strong>{endDate}</strong>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStartDate(null);
|
||||
setEndDate(null);
|
||||
}}
|
||||
className="text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-zinc-500">Voyageurs</span>
|
||||
|
|
@ -164,50 +156,27 @@ export function BookingForm({
|
|||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-1 border-t border-zinc-100 pt-3 text-sm text-zinc-700">
|
||||
<div className="flex justify-between">
|
||||
<span>
|
||||
{nightlyPrice.toFixed(0)} € × {nights} nuit{nights > 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="font-mono">{(nightlyPrice * nights).toFixed(2)} €</span>
|
||||
{datesSelected ? (
|
||||
<div className="space-y-1 border-t border-zinc-100 pt-3 text-sm text-zinc-700">
|
||||
<div className="flex justify-between">
|
||||
<span>
|
||||
{nightlyPrice.toFixed(0)} € × {nights} nuit{nights > 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="font-mono">{(nightlyPrice * nights).toFixed(2)} €</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-semibold text-zinc-900">
|
||||
<span>Total</span>
|
||||
<span className="font-mono">{total.toFixed(2)} €</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-semibold text-zinc-900">
|
||||
<span>Total</span>
|
||||
<span className="font-mono">{total.toFixed(2)} €</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!nightsOk && nights > 0 ? (
|
||||
{datesSelected && !nightsOk ? (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800">
|
||||
Séjour entre {minN} et {maxN} nuits requis.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasConflict ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">
|
||||
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.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{upcomingBlocked.length > 0 && !hasConflict ? (
|
||||
<details className="rounded border border-zinc-100 bg-zinc-50 px-3 py-1.5 text-xs text-zinc-600">
|
||||
<summary className="cursor-pointer">Voir les prochaines dates indisponibles</summary>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{upcomingBlocked.map((d) => (
|
||||
<code key={d} className="rounded bg-white px-1.5 py-0.5 text-[10px] text-zinc-700">
|
||||
{d}
|
||||
</code>
|
||||
))}
|
||||
{blockedDates.size > upcomingBlocked.length ? (
|
||||
<span className="text-[10px] text-zinc-500">+ {blockedDates.size - upcomingBlocked.length} autres</span>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">{error}</div>
|
||||
) : null}
|
||||
|
|
|
|||
74
src/app/carbets/_components/carbet-map-inner.tsx
Normal file
74
src/app/carbets/_components/carbet-map-inner.tsx
Normal file
|
|
@ -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: `
|
||||
<div style="
|
||||
width:32px;height:32px;
|
||||
transform:translate(-50%,-100%);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
">
|
||||
<svg viewBox="0 0 32 40" width="32" height="40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 0 C7 0 0 7 0 16 C0 26 16 40 16 40 C16 40 32 26 32 16 C32 7 25 0 16 0 Z"
|
||||
fill="#059669" stroke="#064e3b" stroke-width="1.5"/>
|
||||
<circle cx="16" cy="15" r="5" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
`,
|
||||
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 (
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-200">
|
||||
<MapContainer
|
||||
center={position}
|
||||
zoom={11}
|
||||
scrollWheelZoom={false}
|
||||
style={{ height: 280, width: "100%" }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker position={position} icon={ICON}>
|
||||
<Popup>
|
||||
<strong>{title}</strong>
|
||||
<br />
|
||||
<span className="text-xs">Fleuve {river}</span>
|
||||
<br />
|
||||
<span className="text-xs text-zinc-600">Embarquement : {embarkPoint}</span>
|
||||
<br />
|
||||
<a
|
||||
href={`https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=14/${latitude}/${longitude}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-emerald-700 underline"
|
||||
>
|
||||
Ouvrir dans OpenStreetMap ↗
|
||||
</a>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/app/carbets/_components/carbet-map.tsx
Normal file
31
src/app/carbets/_components/carbet-map.tsx
Normal file
|
|
@ -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: () => (
|
||||
<div className="h-[280px] w-full animate-pulse rounded-lg bg-zinc-100" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
type Props = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
title: string;
|
||||
river: string;
|
||||
embarkPoint: string;
|
||||
};
|
||||
|
||||
export function CarbetMap(props: Props) {
|
||||
return <CarbetMapInner {...props} />;
|
||||
}
|
||||
186
src/app/carbets/_components/mini-calendar.tsx
Normal file
186
src/app/carbets/_components/mini-calendar.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
blockedDates: Set<string>;
|
||||
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<Date>(() => {
|
||||
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 (
|
||||
<div className="rounded-md border border-zinc-200 bg-white p-2">
|
||||
<header className="mb-1 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGoBack}
|
||||
onClick={() => setViewMonth(addMonths(viewMonth, -1))}
|
||||
className="rounded p-1 text-zinc-600 hover:bg-zinc-100 disabled:opacity-30"
|
||||
aria-label="Mois précédent"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M15 6 L9 12 L15 18" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-zinc-900">
|
||||
{MONTH_LABEL[viewMonth.getUTCMonth()]} {viewMonth.getUTCFullYear()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMonth(addMonths(viewMonth, 1))}
|
||||
className="rounded p-1 text-zinc-600 hover:bg-zinc-100"
|
||||
aria-label="Mois suivant"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M9 6 L15 12 L9 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0.5 text-center text-[10px] uppercase tracking-wider text-zinc-400">
|
||||
{DOW_LABEL.map((d, i) => (
|
||||
<div key={i} className="py-0.5">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{cells.map((cell, i) => {
|
||||
if (!cell) return <div key={i} className="h-7" />;
|
||||
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 (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onClick(cell)}
|
||||
className={cls}
|
||||
>
|
||||
{cell.getUTCDate()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-[11px] text-zinc-500">
|
||||
{!startISO
|
||||
? "Choisissez votre date d'arrivée."
|
||||
: !endISO
|
||||
? "Choisissez votre date de départ."
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue