batchCooking/apps/web/src/layouts/AppLayout.tsx
Nicolas e1589e63d0
Some checks failed
CI / lint (push) Successful in 2m54s
CI / build (push) Successful in 2m56s
CI / intent-service-test (push) Successful in 4m44s
CI / e2e (push) Successful in 9m40s
CI / test (push) Failing after 17m34s
CI / build-android (push) Has been skipped
feat(settings): onglet "Paramètres d'application" (#86)
Nouvelle route /parametres/application + entrée dans SETTINGS_ITEMS
(desktop et sheet mobile, un seul tableau partagé). Thème et "rejouer
un tutoriel" restent inchangés dans /parametres/preferences-utilisateur
(décision explicite du chantier).

Le contenu affiché diffère réellement selon la plateforme, pas par
simple choix cosmétique — chaque moitié de la page est inerte sur
l'autre plateforme, pas juste hors-sujet :

- Version installée + "Vérifier les mises à jour" (natif seulement) :
  useAppUpdateCheck (déjà utilisé par la bannière silencieuse
  existante) gagne un checkNow() qui relance la même comparaison sans
  attendre le throttle de 6h, réutilisant la logique existante plutôt
  que de la dupliquer (fetchLatestVersionResult extraite en fonction
  partagée). Nouveau hook useInstalledVersion (App.getInfo() natif,
  __APP_VERSION__ web).

- "Toujours rester sur le site web" (web seulement, tout appareil) :
  pilote STAY_ON_WEB_PREFERENCE_KEY (déjà exporté par
  mobile-app-redirect.ts, ticket #84). Volontairement masqué côté natif
  : le WebView Capacitor tourne sur sa propre origine https://localhost,
  un localStorage totalement séparé de celui du vrai site web que lit
  attemptOpenNativeApp — modifier la préférence depuis l'app ne
  changerait donc jamais rien pour un visiteur mobile réel. Documenté
  explicitement dans le commentaire de la page pour ne pas réintroduire
  ce contrôle-fantôme plus tard.

Couverture Cypress : nouveau app-settings-page.cy.ts (contenu web,
persistance du toggle après reload) ; sidebar.cy.ts étendu pour couvrir
le nouveau lien. Le déclenchement natif réel (App.getInfo(), l'appel
réseau de checkNow) n'a pas de point d'entrée testable depuis un run
Cypress web — même posture déjà acceptée pour useAppUpdateCheck/
UpdateBanner, la logique de comparaison pure reste couverte par
version-compare.cy.tsx (inchangé). Suite complète : biome check,
tsc -b --force, build, 146/146 e2e, 71/71 composants — tous verts.

Closes #86

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:19:36 +02:00

405 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 { UpdateBanner } from "../features/app-update/UpdateBanner";
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"}>
{/* Renders nothing on the web build (native-only check) or once no
newer version is known — see UpdateBanner's own doc comment. */}
<UpdateBanner />
<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>
);
}