karbe/src/app/panier/_components/CartReview.tsx
Ubuntu 06b01f65e2
All checks were successful
CI / test (pull_request) Successful in 2m36s
fix(mobile): Sprint S — tap targets 44px + iOS safe-area
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>
2026-06-03 04:20:05 +00:00

256 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}