All checks were successful
CI / test (pull_request) Successful in 2m36s
Polish final mobile :
1. /panier sticky cart drawer respecte la safe-area iOS Safari (notch +
home indicator) :
- bottom: max(0.75rem, env(safe-area-inset-bottom, 0.75rem))
- Fallback à 0.75rem (équivalent ancien bottom-3) sur les navigateurs
sans env().
2. AddToCart inputs et boutons remontés à min-h-44px (guideline Apple/
Material) :
- 2 date pickers + qty number : min-h-[44px] px-3 py-2 text-base
- inputMode="numeric" sur qty pour clavier optimisé
- 2 CTA buttons (Ajouter / Voir mon panier) : min-h-[44px] py-3
3. Booking form /carbets/[slug] :
- Guest count input : min-h-[44px] px-3 py-2 text-base + inputMode
- CTA Réserver : min-h-[44px] py-3
Avant : inputs/buttons ~36-40px (sous le seuil 44px iOS), text-sm
(14px). Après : 44px+ partout, text-base (16px) sur les inputs où le
user tape → meilleur contraste tactile et le clavier iOS ne zoom plus
(iOS zoome si font-size < 16px).
Pas de tests vitest dédiés (changements purement CSS), mais 89/89
restent verts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
256 lines
9.3 KiB
TypeScript
256 lines
9.3 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState, useTransition } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
|
||
import { useCart } from "@/components/RentalCartProvider";
|
||
import { diffDays } from "@/lib/rental-cart";
|
||
|
||
type ItemSnapshot = {
|
||
id: string;
|
||
name: string;
|
||
category: string;
|
||
imageUrl: string | null;
|
||
pricePerDay: string;
|
||
deposit: string;
|
||
totalQty: number;
|
||
provider: { id: string; name: string; isSystemD: boolean };
|
||
};
|
||
|
||
type Line = {
|
||
idx: number;
|
||
entry: { itemId: string; qty: number; startDate: string; endDate: string };
|
||
item: ItemSnapshot;
|
||
};
|
||
|
||
export function CartReview({ lines }: { lines: Line[] }) {
|
||
const router = useRouter();
|
||
const { removeEntry, updateEntry, clear } = useCart();
|
||
const [busy, startTransition] = useTransition();
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Groupe par prestataire
|
||
const groups = useMemo(() => {
|
||
const map = new Map<string, { providerName: string; isSystemD: boolean; lines: Line[]; subtotal: number; deposit: number }>();
|
||
for (const l of lines) {
|
||
const nights = Math.max(1, diffDays(l.entry.startDate, l.entry.endDate));
|
||
const lineSub = nights * l.entry.qty * Number(l.item.pricePerDay);
|
||
const lineDeposit = l.entry.qty * Number(l.item.deposit);
|
||
const existing = map.get(l.item.provider.id);
|
||
if (existing) {
|
||
existing.lines.push(l);
|
||
existing.subtotal += lineSub;
|
||
existing.deposit += lineDeposit;
|
||
} else {
|
||
map.set(l.item.provider.id, {
|
||
providerName: l.item.provider.name,
|
||
isSystemD: l.item.provider.isSystemD,
|
||
lines: [l],
|
||
subtotal: lineSub,
|
||
deposit: lineDeposit,
|
||
});
|
||
}
|
||
}
|
||
return Array.from(map.values());
|
||
}, [lines]);
|
||
|
||
const grandTotal = groups.reduce((acc, g) => acc + g.subtotal, 0);
|
||
const grandDeposit = groups.reduce((acc, g) => acc + g.deposit, 0);
|
||
|
||
function checkout() {
|
||
setError(null);
|
||
startTransition(async () => {
|
||
const res = await fetch("/api/rentals/checkout", { method: "POST" });
|
||
const json = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
setError(json?.error || `Erreur ${res.status}`);
|
||
return;
|
||
}
|
||
if (json.checkoutUrl) {
|
||
window.location.assign(json.checkoutUrl);
|
||
return;
|
||
}
|
||
if (json.rentalBookingIds?.length) {
|
||
clear();
|
||
router.push(`/mes-locations?ok=${json.rentalBookingIds[0]}`);
|
||
return;
|
||
}
|
||
router.push("/mes-locations");
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{groups.map((g) => (
|
||
<section key={g.providerName} className="rounded-lg border border-zinc-200 bg-white shadow-sm">
|
||
<header className="border-b border-zinc-100 px-4 py-3">
|
||
<h2 className="text-base font-semibold text-zinc-900">
|
||
{g.providerName}
|
||
{g.isSystemD ? (
|
||
<span className="ml-2 rounded-full bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
|
||
Karbé
|
||
</span>
|
||
) : null}
|
||
</h2>
|
||
</header>
|
||
<ul className="divide-y divide-zinc-100">
|
||
{g.lines.map((l) => (
|
||
<CartLineItem
|
||
key={l.idx}
|
||
line={l}
|
||
onRemove={() => removeEntry(l.idx)}
|
||
onChangeQty={(qty) => updateEntry(l.idx, { qty })}
|
||
onChangeDates={(startDate, endDate) => updateEntry(l.idx, { startDate, endDate })}
|
||
disabled={busy}
|
||
/>
|
||
))}
|
||
</ul>
|
||
<footer className="flex items-center justify-between border-t border-zinc-100 bg-zinc-50 px-4 py-2 text-sm">
|
||
<span className="text-zinc-600">Sous-total prestataire</span>
|
||
<span className="font-mono font-semibold text-zinc-900">{g.subtotal.toFixed(2)} €</span>
|
||
</footer>
|
||
</section>
|
||
))}
|
||
|
||
<aside
|
||
className="sticky z-10 rounded-lg border border-zinc-200 bg-white p-4 shadow-md"
|
||
style={{
|
||
// iOS Safari : tient compte de la safe-area (home indicator).
|
||
// Fallback à 0.75rem (= bottom-3) sur les navigateurs sans env().
|
||
bottom: "max(0.75rem, env(safe-area-inset-bottom, 0.75rem))",
|
||
}}
|
||
>
|
||
<dl className="space-y-1 text-sm">
|
||
<div className="flex justify-between">
|
||
<dt>Total location</dt>
|
||
<dd className="font-mono font-semibold">{grandTotal.toFixed(2)} €</dd>
|
||
</div>
|
||
{grandDeposit > 0 ? (
|
||
<div className="flex justify-between text-xs text-zinc-500">
|
||
<dt>+ Caution récupérable</dt>
|
||
<dd className="font-mono">{grandDeposit.toFixed(2)} €</dd>
|
||
</div>
|
||
) : null}
|
||
<div className="flex justify-between border-t border-zinc-100 pt-2 text-base font-semibold text-zinc-900">
|
||
<dt>À régler</dt>
|
||
<dd className="font-mono">{(grandTotal + grandDeposit).toFixed(2)} €</dd>
|
||
</div>
|
||
</dl>
|
||
|
||
{error ? (
|
||
<div className="mt-2 rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={clear}
|
||
disabled={busy}
|
||
className="text-xs text-zinc-500 hover:text-zinc-900"
|
||
>
|
||
Vider le panier
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={checkout}
|
||
disabled={busy || lines.length === 0}
|
||
className="rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||
>
|
||
{busy ? "Envoi…" : "Valider et payer"}
|
||
</button>
|
||
</div>
|
||
<p className="mt-2 text-center text-[11px] text-zinc-500">
|
||
Vous devez être <Link href="/connexion?next=/panier" className="underline">connecté</Link> pour finaliser.
|
||
</p>
|
||
</aside>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CartLineItem({
|
||
line,
|
||
onRemove,
|
||
onChangeQty,
|
||
onChangeDates,
|
||
disabled,
|
||
}: {
|
||
line: Line;
|
||
onRemove: () => void;
|
||
onChangeQty: (qty: number) => void;
|
||
onChangeDates: (start: string, end: string) => void;
|
||
disabled?: boolean;
|
||
}) {
|
||
const nights = Math.max(1, diffDays(line.entry.startDate, line.entry.endDate));
|
||
const lineTotal = nights * line.entry.qty * Number(line.item.pricePerDay);
|
||
return (
|
||
<li className="flex flex-wrap items-center gap-3 px-4 py-3">
|
||
<div className="hidden h-14 w-20 shrink-0 overflow-hidden rounded bg-zinc-100 sm:block">
|
||
{line.item.imageUrl ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={line.item.imageUrl} alt={line.item.name} className="h-full w-full object-cover" />
|
||
) : (
|
||
<div className="flex h-full items-center justify-center text-2xl text-zinc-300">
|
||
{line.item.category === "SLEEP" ? "💤" :
|
||
line.item.category === "NAVIGATION" ? "🛶" :
|
||
line.item.category === "FISHING" ? "🎣" :
|
||
line.item.category === "COOKING" ? "🍳" : "🦺"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<Link href={`/materiel/${line.item.id}`} className="font-medium text-zinc-900 hover:underline">
|
||
{line.item.name}
|
||
</Link>
|
||
<div className="mt-1 grid grid-cols-2 gap-1 text-xs sm:grid-cols-4">
|
||
<label className="block">
|
||
<span className="text-zinc-500">Du</span>
|
||
<input
|
||
type="date"
|
||
value={line.entry.startDate}
|
||
onChange={(e) => onChangeDates(e.target.value, line.entry.endDate)}
|
||
disabled={disabled}
|
||
className="block w-full rounded border border-zinc-200 px-1.5 py-0.5"
|
||
/>
|
||
</label>
|
||
<label className="block">
|
||
<span className="text-zinc-500">Au</span>
|
||
<input
|
||
type="date"
|
||
value={line.entry.endDate}
|
||
onChange={(e) => onChangeDates(line.entry.startDate, e.target.value)}
|
||
disabled={disabled}
|
||
className="block w-full rounded border border-zinc-200 px-1.5 py-0.5"
|
||
/>
|
||
</label>
|
||
<label className="block">
|
||
<span className="text-zinc-500">Qté</span>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={line.item.totalQty}
|
||
value={line.entry.qty}
|
||
onChange={(e) => onChangeQty(Math.max(1, Math.min(line.item.totalQty, Number(e.target.value) || 1)))}
|
||
disabled={disabled}
|
||
className="block w-full rounded border border-zinc-200 px-1.5 py-0.5"
|
||
/>
|
||
</label>
|
||
<div className="flex flex-col text-right">
|
||
<span className="text-zinc-500">{nights} j × {Number(line.item.pricePerDay).toFixed(0)} €</span>
|
||
<span className="font-mono font-semibold text-zinc-900">{lineTotal.toFixed(2)} €</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onRemove}
|
||
disabled={disabled}
|
||
className="text-xs text-rose-700 hover:text-rose-900 disabled:opacity-50"
|
||
>
|
||
Retirer
|
||
</button>
|
||
</li>
|
||
);
|
||
}
|