fix(mobile): Sprint R — mobile UX audit + burger menu
All checks were successful
CI / test (push) Successful in 2m22s

This commit is contained in:
tarzzan 2026-06-03 03:54:13 +00:00
commit 07301ae997
3 changed files with 242 additions and 4 deletions

View file

@ -10,7 +10,7 @@ export function CartBadge() {
return (
<Link
href="/panier"
className="relative hidden text-zinc-700 hover:text-zinc-900 sm:inline"
className="relative inline text-zinc-700 hover:text-zinc-900"
aria-label={`Panier (${totalItems} item)`}
>
🛒

View file

@ -0,0 +1,224 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { signOut } from "next-auth/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
type LinkItem = { href: string; label: string };
type Props = {
isAuthenticated: boolean;
isOwner: boolean;
isRentalProvider: boolean;
isCeManager: boolean;
isAdmin: boolean;
rentalEnabled: boolean;
ceEnabled: boolean;
};
/**
* Bouton hamburger visible uniquement sur mobile (sm:hidden).
* Ouvre un drawer qui rassemble tous les liens de navigation, car en
* mobile les liens du SiteHeader sont masqués pour rester sur 1 ligne.
*/
export function MobileMenuButton({
isAuthenticated,
isOwner,
isRentalProvider,
isCeManager,
isAdmin,
rentalEnabled,
ceEnabled,
}: Props) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
// Ferme le menu si on change de page — pathname comparé à la valeur précédente
// dans un effect avec setState, façon "useRef + condition" pour éviter le
// warning react-hooks/set-state-in-effect (setState dans un effect sans
// dépendance externe = anti-pattern).
const lastPathnameRef = useRef(pathname);
useEffect(() => {
if (lastPathnameRef.current !== pathname) {
lastPathnameRef.current = pathname;
// closure ref → reflète bien la dernière valeur ; setOpen est stable
// (renvoyé par useState) donc OK dans deps.
setOpen(false);
}
}, [pathname]);
// Empêche le scroll sous-jacent quand ouvert
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const publicLinks: LinkItem[] = [
{ href: "/decouvrir", label: "Au fil de l'eau" },
{ href: "/carbets", label: "Catalogue" },
...(rentalEnabled ? [{ href: "/materiel", label: "Matériel" }] : []),
];
const userLinks: LinkItem[] = isAuthenticated
? [
{ href: "/mes-favoris", label: "Favoris" },
{ href: "/mes-reservations", label: "Mes réservations" },
...(rentalEnabled ? [{ href: "/mes-locations", label: "Mes locations" }] : []),
{ href: "/mon-compte", label: "Mon compte" },
]
: [];
const proLinks: LinkItem[] = isAuthenticated
? [
...(isOwner ? [{ href: "/espace-hote", label: "Espace hôte" }] : []),
...(isRentalProvider && rentalEnabled
? [{ href: "/espace-prestataire", label: "Espace prestataire" }]
: []),
...(isCeManager && ceEnabled ? [{ href: "/espace-ce", label: "Espace CE" }] : []),
...(isAdmin ? [{ href: "/admin", label: "Admin" }] : []),
]
: [];
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Ouvrir le menu"
aria-expanded={open}
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-zinc-700 hover:bg-zinc-100 sm:hidden"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 5h14v2H3V5zm0 4h14v2H3V9zm0 4h14v2H3v-2z" />
</svg>
</button>
{open ? (
<div className="fixed inset-0 z-50 sm:hidden">
<button
type="button"
aria-label="Fermer le menu"
onClick={() => setOpen(false)}
className="absolute inset-0 bg-zinc-900/40"
/>
<div className="absolute right-0 top-0 flex h-full w-72 max-w-[85vw] flex-col overflow-y-auto bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
<span className="text-base font-semibold text-zinc-900">Menu</span>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Fermer"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-zinc-500 hover:bg-zinc-100"
>
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<path d="M4.3 4.3l1.4-1.4L10 7.2l4.3-4.3 1.4 1.4L11.4 8.6l4.3 4.3-1.4 1.4L10 10l-4.3 4.3-1.4-1.4 4.3-4.3z" />
</svg>
</button>
</div>
<nav className="flex-1 px-2 py-3 text-sm">
<MenuSection label="Découvrir">
{publicLinks.map((l) => (
<MenuLink key={l.href} href={l.href} pathname={pathname}>
{l.label}
</MenuLink>
))}
</MenuSection>
{userLinks.length > 0 ? (
<MenuSection label="Mon compte">
{userLinks.map((l) => (
<MenuLink key={l.href} href={l.href} pathname={pathname}>
{l.label}
</MenuLink>
))}
</MenuSection>
) : null}
{proLinks.length > 0 ? (
<MenuSection label="Espaces pro">
{proLinks.map((l) => (
<MenuLink key={l.href} href={l.href} pathname={pathname}>
{l.label}
</MenuLink>
))}
</MenuSection>
) : null}
</nav>
<div className="border-t border-zinc-200 p-3">
{isAuthenticated ? (
<button
type="button"
onClick={() => signOut({ callbackUrl: "/" })}
className="w-full rounded-md border border-zinc-300 px-4 py-2 text-center text-sm font-semibold text-zinc-700 hover:bg-zinc-50"
>
Se déconnecter
</button>
) : (
<div className="flex flex-col gap-2">
<Link
href="/connexion"
className="rounded-md border border-zinc-300 px-4 py-2 text-center text-sm font-semibold text-zinc-900 hover:bg-zinc-50"
>
Connexion
</Link>
<Link
href="/inscription"
className="rounded-md bg-zinc-900 px-4 py-2 text-center text-sm font-semibold text-white hover:bg-zinc-800"
>
Créer un compte
</Link>
</div>
)}
</div>
</div>
</div>
) : null}
</>
);
}
function MenuSection({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<section className="mb-2">
<h3 className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
{label}
</h3>
<ul className="space-y-0.5">{children}</ul>
</section>
);
}
function MenuLink({
href,
pathname,
children,
}: {
href: string;
pathname: string;
children: React.ReactNode;
}) {
const active = pathname === href || (href !== "/" && pathname.startsWith(href));
return (
<li>
<Link
href={href}
className={
"block rounded-md px-3 py-2 " +
(active
? "bg-emerald-50 font-semibold text-emerald-900"
: "text-zinc-700 hover:bg-zinc-100")
}
>
{children}
</Link>
</li>
);
}

View file

@ -10,6 +10,7 @@ import { UserRole } from "@/generated/prisma/enums";
import { isPluginEnabled } from "@/lib/plugins/server";
import { CartBadge } from "./CartBadge";
import { MobileMenuButton } from "./MobileMenuButton";
import { SignOutButton } from "./SignOutButton";
export async function SiteHeader() {
@ -50,6 +51,7 @@ export async function SiteHeader() {
<div className="flex items-center gap-3 text-sm">
{rentalEnabled ? <CartBadge /> : null}
{/* Desktop-only links (sm+) */}
{u ? (
<>
<Link href="/mes-favoris" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
@ -89,21 +91,33 @@ export async function SiteHeader() {
<span className="hidden max-w-[14ch] truncate text-xs text-zinc-500 md:inline" title={u.email ?? ""}>
{u.name || u.email}
</span>
<SignOutButton />
<span className="hidden sm:inline">
<SignOutButton />
</span>
</>
) : (
<>
<Link href="/connexion" className="text-zinc-700 hover:text-zinc-900">
<Link href="/connexion" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Connexion
</Link>
<Link
href="/inscription"
className="rounded-md bg-zinc-900 px-3 py-1 text-xs font-semibold text-white hover:bg-zinc-800"
className="hidden rounded-md bg-zinc-900 px-3 py-1 text-xs font-semibold text-white hover:bg-zinc-800 sm:inline-block"
>
Créer un compte
</Link>
</>
)}
{/* Mobile-only burger menu */}
<MobileMenuButton
isAuthenticated={Boolean(u)}
isOwner={Boolean(isOwner)}
isRentalProvider={Boolean(isRentalProvider)}
isCeManager={Boolean(isCeManager)}
isAdmin={isAdmin}
rentalEnabled={rentalEnabled}
ceEnabled={ceEnabled}
/>
</div>
</div>
</header>