feat(p1): calendrier dispo + emails Resend + amount calculé + best-effort welcome/confirmation/refund

This commit is contained in:
Claude Integration 2026-06-01 02:20:38 +00:00
parent 4e14854245
commit b59b8a0af2
7 changed files with 585 additions and 6 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@ -43,6 +43,26 @@ export function BookingForm({
const [guestCount, setGuestCount] = useState(Math.min(2, capacity));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [blockedDates, setBlockedDates] = useState<Set<string>>(new Set());
// Fetch availability sur les 90 prochains jours pour griser/avertir.
useEffect(() => {
const ctrl = new AbortController();
const from = todayPlus(0);
const to = todayPlus(90);
fetch(`/api/carbets/${carbetId}/availability?from=${from}&to=${to}`, { signal: ctrl.signal })
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
if (!j?.calendar) return;
const blocked = new Set<string>();
for (const d of j.calendar as { date: string; isAvailable: boolean }[]) {
if (!d.isAvailable) blocked.add(d.date);
}
setBlockedDates(blocked);
})
.catch(() => {});
return () => ctrl.abort();
}, [carbetId]);
const nights = useMemo(() => Math.max(0, diffDays(startDate, endDate)), [startDate, endDate]);
const total = nights * nightlyPrice;
@ -50,7 +70,28 @@ export function BookingForm({
const maxN = maxStayNights ?? 365;
const nightsOk = nights >= minN && nights <= maxN;
const guestOk = guestCount >= 1 && guestCount <= capacity;
const canSubmit = nightsOk && guestOk && !busy;
// 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]);
async function submit() {
if (!isAuthenticated) {
@ -142,6 +183,31 @@ export function BookingForm({
</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}