fix(mobile): Sprint R — burger menu + cart badge visible mobile
All checks were successful
CI / test (pull_request) Successful in 2m39s
All checks were successful
CI / test (pull_request) Successful in 2m39s
Issues critiques identifiées par audit screenshot iPhone 14 (390×844) : 1. SiteHeader cachait TOUTE la navigation derrière `sm:` → utilisateur connecté mobile sans aucun lien accessible (Favoris, Mes réservations, Mes locations, Mon compte, Espace hôte/CE/etc.). Même Connexion/Créer un compte étaient inaccessibles sur mobile anonyme. 2. CartBadge `hidden sm:inline` → panier complètement invisible sur mobile même quand des items y sont. Le user perdait la trace de ses ajouts. src/components/MobileMenuButton.tsx (NEW) — client component avec : - Bouton hamburger 9x9 visible uniquement sm:hidden - Drawer right side, overlay sombre, scroll body bloqué quand ouvert - 3 sections : Découvrir (publics), Mon compte (si auth), Espaces pro (hôte/prestataire/CE/admin selon role + plugins activés) - Sign out via signOut() de next-auth/react (côté client — évite d'importer SignOutButton qui tirerait @/auth donc pg dans le bundle) - Lien actif highlighted en emerald - Ferme automatiquement sur changement de pathname (via useRef pour éviter setState-in-effect) SiteHeader.tsx : - Tous les liens « auth » deviennent explicitement `hidden sm:inline` + Connexion/Créer un compte aussi (étaient toujours visibles avant, surchargeaient le mobile) - SignOutButton wrap `hidden sm:inline` pour ne pas dupliquer - MobileMenuButton ajouté à la fin de la zone droite CartBadge.tsx : `inline` au lieu de `hidden sm:inline` → visible quel que soit le viewport. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5845a6b950
commit
62833ee4e6
3 changed files with 242 additions and 4 deletions
|
|
@ -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)`}
|
||||
>
|
||||
🛒
|
||||
|
|
|
|||
224
src/components/MobileMenuButton.tsx
Normal file
224
src/components/MobileMenuButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue