batchCooking/apps/web/src/layouts/AppLayout.tsx
Nicolas 3df24fe71c
All checks were successful
CI / lint (push) Successful in 3m9s
CI / build (push) Successful in 3m50s
CI / intent-service-test (push) Successful in 7m32s
CI / e2e (push) Successful in 10m13s
CI / test (push) Successful in 33m6s
CI / build-android (push) Has been skipped
fix(mobile): lance la vérification de mise à jour à chaque ouverture (#106)
`useAppUpdateCheck` tournait au montage de `<UpdateBanner />`, mais celui-ci
n'était monté que dans `AppLayout`, derrière `RequireAuth` : un utilisateur
non connecté sur l'app native voit `LandingPage`, `AppLayout` n'est jamais
monté, donc aucune vérification tant qu'on n'est pas connecté — alors que
le back suppose l'inverse (`mobile.routes.ts` : « runs on every app start,
session or not »).

- `<UpdateBanner />` monté dans `App.tsx`, hors `<Routes>`, donc rendu pour
  toute route quel que soit l'état d'auth. Garde `!isNativePlatform()` et
  throttle 6 h inchangés ; rend toujours `null` sur le web.
- `update-banner.scss` : passe en `position: fixed` (bandeau haut,
  `env(safe-area-inset-top)`, `z-index: 100`) pour ne pas entrer dans la
  mise en page / le calcul de hauteur d'une page routée (`.app-layout` est
  `height: 100vh; overflow: hidden`).

Pas de test e2e : `Capacitor.isNativePlatform()` n'a pas de couture pour
simuler le natif depuis un run web (posture déjà assumée pour
`useAppUpdateCheck` / `UpdateBanner`, voir `app-settings-page.cy.ts`). Suite
e2e existante (layout / smoke / app-settings) revérifiée : aucun régression.

Closes #106

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:04:48 +02:00

404 lines
15 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { BottomSheet } from "../components/ui/BottomSheet";
import { useAuth } from "../features/auth/AuthContext";
import { useIsMobileViewport } from "../lib/useIsMobileViewport";
import "./AppLayout.scss";
import {
AccountIcon,
AppSettingsIcon,
ChevronLeftIcon,
CreditsIcon,
DietPreferencesIcon,
HouseholdIcon,
MoreIcon,
PlanningIcon,
RecipesIcon,
SettingsIcon,
ShoppingListIcon,
UserPreferencesIcon,
} from "./nav-icons";
/** `localStorage` key persisting {@link AppLayout}'s collapsed/expanded sidebar state across reloads. Desktop-only — the mobile bottom nav has nothing to collapse. */
const SIDEBAR_COLLAPSED_KEY = "batchcooking:sidebarCollapsed";
/**
* One entry in the app's main nav. `key` maps to `layout.nav.<key>` in
* `locales/fr/translation.json` — adding a section is one array entry plus
* one locale key, no other file to touch. Shared verbatim between the
* desktop sidebar and the mobile bottom tab bar (see {@link MobileBottomNav})
* — same items, same routing, just a different container.
*/
const NAV_ITEMS = [
{ to: "/", key: "planning", Icon: PlanningIcon, tourTarget: "nav-planning" },
{ to: "/recettes", key: "recipes", Icon: RecipesIcon, tourTarget: "nav-recipes" },
{
to: "/liste-de-courses",
key: "shoppingList",
Icon: ShoppingListIcon,
tourTarget: "nav-shopping-list",
},
] as const;
/**
* One entry in the "Paramètres" nav — revealed by {@link SettingsMenu} on
* desktop, listed inside {@link MobileMoreSheet} on mobile. `key` maps to
* `layout.settings.nav.<key>`.
*/
const SETTINGS_ITEMS = [
{ to: "/parametres/compte", key: "account", Icon: AccountIcon },
{ to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon },
{ to: "/parametres/foyer", key: "household", Icon: HouseholdIcon },
{ to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon },
{ to: "/parametres/application", key: "application", Icon: AppSettingsIcon },
{ to: "/parametres/credits", key: "credits", Icon: CreditsIcon },
] as const;
/**
* Shell for every authenticated page: on desktop/tablet, a sidebar (brand,
* section nav, a collapsible "Paramètres" nav, and the account menu at the
* bottom); on a phone, a bottom tab bar instead (see {@link MobileBottomNav})
* — a genuinely different component tree, not a CSS-only reflow of the same
* one. Either way, a main content area renders the matched child route via
* `<Outlet />`.
*
* Which shell renders is decided once, here, via
* {@link useIsMobileViewport} — the mobile/tablet UI chantier's own audit
* found this app had *zero* precedent for branching actual markup by
* viewport before this component; every other consumer of that hook (the
* planning popovers, in a later ticket of the same chantier) should follow
* this same "check once at the shell/container level" pattern rather than
* re-checking deep inside a leaf component.
*
* The desktop sidebar itself can still be collapsed to an icon-only rail
* (see `isCollapsed`/`SIDEBAR_COLLAPSED_KEY`) — every nav item keeps its
* icon and gains a `title` tooltip, its label just hides via CSS
* (`.app-sidebar.collapsed .label`, see AppLayout.scss), so collapsing is a
* single class toggle on the `<aside>`, not something each child component
* needs to know about. Nothing on the mobile bottom bar collapses — there's
* no room saved by doing so on an already-narrow screen.
*
* Mounted once as the parent element of the whole authenticated route
* group, itself wrapped in {@link RequireAuth} (see `App.tsx`) — `user` is
* therefore guaranteed non-null by the time this renders.
*/
export function AppLayout() {
const { t } = useTranslation();
const isMobile = useIsMobileViewport();
const [isCollapsed, setIsCollapsed] = useState(
() => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true",
);
function toggleCollapsed() {
setIsCollapsed((collapsed) => {
const next = !collapsed;
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
return next;
});
}
return (
<div className="app-layout">
{!isMobile && (
<aside className={isCollapsed ? "app-sidebar collapsed" : "app-sidebar"}>
<div className="app-sidebar__top">
<div className="app-sidebar__brand">
{/* Collapsed: a short monogram instead of the full wordmark — clearer than an arbitrary icon standing in for the brand itself. */}
<span className="app-sidebar__brand-full">batchCooking</span>
<span className="app-sidebar__brand-mark">bC</span>
</div>
<button
type="button"
className="app-sidebar__collapse-toggle"
onClick={toggleCollapsed}
title={t(isCollapsed ? "layout.sidebar.expand" : "layout.sidebar.collapse")}
>
<ChevronLeftIcon aria-hidden="true" />
</button>
</div>
<nav className="app-sidebar__nav">
{NAV_ITEMS.map(({ to, key, Icon, tourTarget }) => (
<NavLink
key={to}
to={to}
// Only the home ("/") entry needs exact matching — every
// other path is a leaf with nothing nested under it (yet),
// so NavLink's default prefix matching already behaves the
// same way `end` would.
end={to === "/"}
className={({ isActive }) => (isActive ? "active" : undefined)}
title={t(`layout.nav.${key}`)}
data-tour={tourTarget}
>
<Icon aria-hidden="true" />
<span className="label">{t(`layout.nav.${key}`)}</span>
</NavLink>
))}
</nav>
<SettingsMenu />
<AccountMenu />
<p className="app-sidebar__version" aria-hidden="true">
v{__APP_VERSION__}
</p>
</aside>
)}
<main className={isMobile ? "app-content app-content--with-bottom-nav" : "app-content"}>
{/* `UpdateBanner` used to sit here, but the native update check
(issue #66) must run session or not — it's mounted app-wide in
`App.tsx` now, as a `position: fixed` strip. */}
<Outlet />
</main>
{isMobile && <MobileBottomNav />}
</div>
);
}
/**
* Ends the session and returns to `/` — `RequireAuth` shows the public
* {@link LandingPage} there for a logged-out visitor (issue #41). Shared by
* {@link AccountMenu} (desktop dropdown) and {@link MobileMoreSheet}
* (mobile sheet) — both need the exact same behavior, only how they close
* their own menu/sheet beforehand differs, which is why this doesn't also
* take care of that step itself.
*/
function useLogout(): () => Promise<void> {
const { logout } = useAuth();
const navigate = useNavigate();
return async function handleLogout() {
try {
await logout();
void navigate("/");
} catch (err) {
// Rethrown as-is — `AuthContext`'s `logout` already documents itself
// as throwing `ApiError` on failure; this handler just isn't allowed
// a bare `await` per the repo's async/try-catch convention.
throw err;
}
};
}
/**
* Collapsible "Paramètres" section revealing the settings pages (Compte /
* Préférences alimentaires / Foyer / Préférences utilisateur — see
* `pages/settings/`). Desktop/tablet only — see {@link MobileMoreSheet} for
* the mobile equivalent. Starts open whenever the current route is already
* under `/parametres`, so following a link there (e.g. from
* {@link AccountMenu}) doesn't land on a collapsed menu; otherwise starts
* closed to keep the sidebar's main focus on the primary nav above it.
*
* Independent of the whole-sidebar collapse toggle in {@link AppLayout} —
* this menu's own open/closed state persists across that (a rail-collapsed
* sidebar can still have "Paramètres" expanded, just showing icons).
*/
function SettingsMenu() {
const { t } = useTranslation();
const location = useLocation();
const [isOpen, setIsOpen] = useState(location.pathname.startsWith("/parametres"));
return (
<div className="app-sidebar__settings">
<button
type="button"
className="app-sidebar__settings-toggle"
aria-expanded={isOpen}
onClick={() => setIsOpen((open) => !open)}
title={t("layout.settings.toggle")}
>
<span className="app-sidebar__settings-toggle-left">
<SettingsIcon aria-hidden="true" />
<span className="label">{t("layout.settings.toggle")}</span>
</span>
<span className="chevron" aria-hidden="true">
{isOpen ? "▾" : "▸"}
</span>
</button>
{isOpen && (
<nav className="app-sidebar__nav app-sidebar__settings-nav">
{SETTINGS_ITEMS.map(({ to, key, Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) => (isActive ? "active" : undefined)}
title={t(`layout.settings.nav.${key}`)}
>
<Icon aria-hidden="true" />
<span className="label">{t(`layout.settings.nav.${key}`)}</span>
</NavLink>
))}
</nav>
)}
</div>
);
}
/**
* Account menu — a small dropdown opened from the signed-in user's name at
* the very bottom of the sidebar, replacing what used to be a plain
* greeting + logout button. Offers a shortcut straight to `/parametres/compte`
* plus the logout action; closes itself after either action so it never
* lingers open across a navigation. Desktop/tablet only — see
* {@link MobileMoreSheet} for the mobile equivalent.
*/
function AccountMenu() {
const { user } = useAuth();
const handleLogout = useLogout();
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
async function onLogoutClick() {
setIsOpen(false);
try {
await handleLogout();
} catch (err) {
throw err;
}
}
const initial = user?.firstName?.charAt(0).toUpperCase() ?? "";
return (
<div className="app-sidebar__footer">
<button
type="button"
className="app-sidebar__account-toggle"
aria-expanded={isOpen}
onClick={() => setIsOpen((open) => !open)}
title={t("layout.greeting", { firstName: user?.firstName })}
>
<span className="app-sidebar__avatar" aria-hidden="true">
{initial}
</span>
<span className="label">{t("layout.greeting", { firstName: user?.firstName })}</span>
</button>
{isOpen && (
<div className="app-sidebar__account-menu">
<NavLink to="/parametres/compte" onClick={() => setIsOpen(false)}>
{t("layout.accountMenu.myAccount")}
</NavLink>
<button type="button" onClick={() => void onLogoutClick()}>
{t("layout.logout")}
</button>
</div>
)}
</div>
);
}
/**
* Mobile shell's nav — a fixed bottom tab bar (the 3 {@link NAV_ITEMS},
* same as desktop) plus a 4th "Plus" tab opening {@link MobileMoreSheet}.
* Replaces the old ≤640px treatment (the desktop sidebar CSS-squashed into
* a horizontal scrolling top bar) — that approach had no room left for
* Paramètres/Compte except as two separate floating dropdowns with no
* viewport-edge handling at all (see the mobile/tablet UI chantier's own
* audit). A bottom tab bar is also the platform-conventional place for
* primary nav on both Android and iOS, unlike a top bar.
*
* Paramètres and Compte are deliberately combined into ONE "Plus" sheet
* rather than kept as two separate triggers (which is what the desktop
* sidebar still does) — simpler mobile IA, and avoids needing a second tab
* slot on an already-tight 4-tab bar.
*/
function MobileBottomNav() {
const { t } = useTranslation();
const [isMoreOpen, setIsMoreOpen] = useState(false);
return (
<>
<nav className="app-bottom-nav">
{NAV_ITEMS.map(({ to, key, Icon, tourTarget }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) => (isActive ? "active" : undefined)}
data-tour={tourTarget}
>
<Icon aria-hidden="true" />
<span className="label">{t(`layout.nav.${key}`)}</span>
</NavLink>
))}
<button
type="button"
className="app-bottom-nav__more"
aria-haspopup="dialog"
aria-expanded={isMoreOpen}
onClick={() => setIsMoreOpen(true)}
>
<MoreIcon aria-hidden="true" />
<span className="label">{t("layout.nav.more")}</span>
</button>
</nav>
{isMoreOpen && <MobileMoreSheet onClose={() => setIsMoreOpen(false)} />}
</>
);
}
/**
* The mobile "Plus" tab's content — {@link BottomSheet} wrapping the same
* account greeting/logout and Paramètres links as the desktop
* {@link AccountMenu}/{@link SettingsMenu}, just laid out as one scrollable
* list instead of two separate dropdowns. Every link/button here calls
* `onClose` itself (in addition to whatever navigation/logout it triggers)
* — the same "the trigger closes its own menu" responsibility the desktop
* dropdowns already have, just routed through a prop here since this
* content is `BottomSheet`'s `children`, not code that shares a closure
* with the `<dialog>` ref.
*/
function MobileMoreSheet({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const { user } = useAuth();
const handleLogout = useLogout();
async function onLogoutClick() {
onClose();
try {
await handleLogout();
} catch (err) {
throw err;
}
}
const initial = user?.firstName?.charAt(0).toUpperCase() ?? "";
return (
<BottomSheet onClose={onClose} title={t("layout.settings.toggle")}>
<div className="mobile-more-sheet__account">
<span className="mobile-more-sheet__avatar" aria-hidden="true">
{initial}
</span>
<span>{t("layout.greeting", { firstName: user?.firstName })}</span>
</div>
<nav className="mobile-more-sheet__nav">
{SETTINGS_ITEMS.map(({ to, key, Icon }) => (
<NavLink
key={to}
to={to}
onClick={onClose}
className={({ isActive }) => (isActive ? "active" : undefined)}
>
<Icon aria-hidden="true" />
<span>{t(`layout.settings.nav.${key}`)}</span>
</NavLink>
))}
</nav>
<button
type="button"
className="mobile-more-sheet__logout"
onClick={() => void onLogoutClick()}
>
{t("layout.logout")}
</button>
</BottomSheet>
);
}