From 4bdc9c04707ec926baa8bb04982c492183fa0882 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 15:56:12 +0200 Subject: [PATCH] =?UTF-8?q?Web:=20UserPreferencesPage=20(th=C3=A8me)=20+?= =?UTF-8?q?=20renommage=20"Pr=C3=A9f=C3=A9rences=20alimentaires"=20(step?= =?UTF-8?q?=203/4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThemeContext (features/theme/) : charge/applique le thème du profil connecté via l'attribut data-theme (SYSTEM = pas d'attribut, laisse la media query prefers-color-scheme décider) ; échec réseau non bloquant (worst case reste au thème courant, pas d'unhandled rejection) - UserPreferencesPage, routée /parametres/preferences-utilisateur, hot-save (3 boutons radio Clair/Sombre/Système) - layout.settings.nav.preferences renommé "Préférences alimentaires" (évite la confusion avec ce nouveau concept plus large), nouvelle entrée "Préférences utilisateur" - _theme.scss: commentaires mis à jour (l'attribut data-theme est désormais réellement posé, plus une simple anticipation) --- apps/web/src/App.tsx | 14 +-- apps/web/src/api/client.ts | 12 +++ apps/web/src/features/theme/ThemeContext.tsx | 89 +++++++++++++++++++ apps/web/src/layouts/AppLayout.tsx | 6 +- apps/web/src/locales/fr/translation.json | 14 ++- apps/web/src/main.tsx | 5 +- .../pages/settings/UserPreferencesPage.tsx | 66 ++++++++++++++ .../src/pages/settings/settings-pages.scss | 23 +++++ apps/web/src/styles/_theme.scss | 11 +-- 9 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/features/theme/ThemeContext.tsx create mode 100644 apps/web/src/pages/settings/UserPreferencesPage.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4abe841..eda674e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -13,6 +13,7 @@ import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdP import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage"; import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage"; import { PreferencesPage } from "./pages/settings/PreferencesPage"; +import { UserPreferencesPage } from "./pages/settings/UserPreferencesPage"; /** * Top-level route table. Every authenticated section is nested under one @@ -22,12 +23,12 @@ import { PreferencesPage } from "./pages/settings/PreferencesPage"; * {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which * itself redirects to `/login` if needed. * - * `/parametres/*` (compte/préférences/foyer) are the settings pages, - * reachable from the sidebar's bottom "Paramètres" menu and the account - * menu (see `AppLayout`) — nested under `AppLayout` like every other - * authenticated section. `/foyer` is the old, pre-split combined page's - * path; it now just redirects to `/parametres/foyer` so an existing - * bookmark/link keeps working. + * `/parametres/*` (compte/préférences alimentaires/foyer/préférences + * utilisateur) are the settings pages, reachable from the sidebar's bottom + * "Paramètres" menu and the account menu (see `AppLayout`) — nested under + * `AppLayout` like every other authenticated section. `/foyer` is the old, + * pre-split combined page's path; it now just redirects to + * `/parametres/foyer` so an existing bookmark/link keeps working. * * `/onboarding/*` (regime/foyer/allergens, in that order) is also * `RequireAuth`-gated — reached right after signup, once a session already @@ -51,6 +52,7 @@ export function App() { } /> } /> } /> + } /> } /> { + return this.request("/preferences"); + } + + /** Sets the current user's theme preference. */ + public updatePreferences(theme: ThemePreference): Promise { + return this.request("/preferences", { method: "PATCH", body: JSON.stringify({ theme }) }); + } } /** Single shared instance — this client is stateless, no need for one per caller. */ diff --git a/apps/web/src/features/theme/ThemeContext.tsx b/apps/web/src/features/theme/ThemeContext.tsx new file mode 100644 index 0000000..f8c1021 --- /dev/null +++ b/apps/web/src/features/theme/ThemeContext.tsx @@ -0,0 +1,89 @@ +import type { ThemePreference } from "@batch-cooking/shared"; +import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react"; +import { apiClient } from "../../api/client"; +import { useAuth } from "../auth/AuthContext"; + +/** Shape of the theme state/actions exposed via {@link useTheme}. */ +interface ThemeContextValue { + /** The signed-in user's theme preference — `"SYSTEM"` (the default) for a logged-out visitor too, nothing to load a preference for. */ + theme: ThemePreference; + /** Persists `theme` and applies it immediately. Throws `ApiError` on failure. */ + setTheme: (theme: ThemePreference) => Promise; +} + +/** React context carrying {@link ThemeContextValue} — always accessed through {@link useTheme}, never directly. */ +const ThemeContext = createContext(null); + +/** + * Applies `theme` to the document. `SYSTEM` *removes* the override rather + * than setting a literal `"system"` value `_theme.scss` wouldn't recognize + * — with no `data-theme` attribute at all, its `prefers-color-scheme` + * media query decides instead, exactly the "system" behavior. + */ +function applyTheme(theme: ThemePreference) { + if (theme === "SYSTEM") { + delete document.documentElement.dataset.theme; + } else { + document.documentElement.dataset.theme = theme.toLowerCase(); + } +} + +/** + * Provides the signed-in user's theme preference and keeps the document in + * sync with it. Must be nested inside `AuthProvider` (reads `useAuth()`'s + * `user` to know when a profile is available to load a preference for) — + * see `main.tsx`. + * + * Keyed on `user?.id` rather than `user` itself: `AuthContext`'s `user` + * object is a fresh reference every time it refreshes (e.g. after an + * unrelated `refreshUser()` call elsewhere), which shouldn't re-trigger a + * preferences fetch — only actually signing in/out or switching accounts + * should. + */ +export function ThemeProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + const [theme, setThemeState] = useState("SYSTEM"); + + // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the id on purpose, see the doc comment above. + useEffect(() => { + if (!user) { + setThemeState("SYSTEM"); + applyTheme("SYSTEM"); + return; + } + + let cancelled = false; + apiClient + .getPreferences() + .then((preferences) => { + if (cancelled) return; + setThemeState(preferences.theme); + applyTheme(preferences.theme); + }) + // Failing to load a preference (e.g. a network hiccup) shouldn't break + // the rest of the app — worst case the theme just stays at its + // current/default value, same "not fatal" spirit as AuthContext's own + // `me()` call on mount. + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [user?.id]); + + const setTheme = useCallback(async (newTheme: ThemePreference) => { + await apiClient.updatePreferences(newTheme); + setThemeState(newTheme); + applyTheme(newTheme); + }, []); + + return {children}; +} + +/** Reads the current theme state/actions. Must be called from within a {@link ThemeProvider}. */ +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return ctx; +} diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx index eb58f9a..0033027 100644 --- a/apps/web/src/layouts/AppLayout.tsx +++ b/apps/web/src/layouts/AppLayout.tsx @@ -23,6 +23,7 @@ const SETTINGS_ITEMS = [ { to: "/parametres/compte", key: "account" }, { to: "/parametres/preferences", key: "preferences" }, { to: "/parametres/foyer", key: "household" }, + { to: "/parametres/preferences-utilisateur", key: "userPreferences" }, ] as const; /** @@ -71,8 +72,9 @@ export function AppLayout() { } /** - * Collapsible "Paramètres" section revealing the three settings pages - * (Compte/Préférences/Foyer — see `pages/settings/`). Starts open whenever + * Collapsible "Paramètres" section revealing the settings pages (Compte / + * Préférences alimentaires / Foyer / Préférences utilisateur — see + * `pages/settings/`). 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 diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 35a1e30..ff633b0 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -68,8 +68,9 @@ "toggle": "Paramètres", "nav": { "account": "Compte", - "preferences": "Préférences", - "household": "Foyer" + "preferences": "Préférences alimentaires", + "household": "Foyer", + "userPreferences": "Préférences utilisateur" } }, "accountMenu": { @@ -145,6 +146,15 @@ "intolerancesLabel": "Intolérances" } }, + "userPreferences": { + "title": "Préférences utilisateur", + "themeLabel": "Thème", + "theme": { + "LIGHT": "Clair", + "DARK": "Sombre", + "SYSTEM": "Système" + } + }, "household": { "title": "Foyer", "form": { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 7416923..8f16472 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { App } from "./App"; import { AuthProvider } from "./features/auth/AuthContext"; +import { ThemeProvider } from "./features/theme/ThemeContext"; // Side-effect import: initializes the i18next instance before anything // renders (react-i18next reads it via context under the hood). See i18n/i18n.ts. import "./i18n/i18n"; @@ -19,7 +20,9 @@ createRoot(rootElement).render( - + + + , diff --git a/apps/web/src/pages/settings/UserPreferencesPage.tsx b/apps/web/src/pages/settings/UserPreferencesPage.tsx new file mode 100644 index 0000000..5efb2a7 --- /dev/null +++ b/apps/web/src/pages/settings/UserPreferencesPage.tsx @@ -0,0 +1,66 @@ +import { ErrorCode, THEME_PREFERENCES, type ThemePreference } from "@batch-cooking/shared"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ApiError } from "../../api/client"; +import { useTheme } from "../../features/theme/ThemeContext"; +import { errorMessageService } from "../../services/error-message.service"; +import "./settings-pages.scss"; + +/** Status of the theme choice's own autosave — see `PreferencesPage` for the same hot-saving pattern. */ +type SaveState = "idle" | "saving" | "saved" | "error"; + +/** + * User (personalization) preferences — routed at + * `/parametres/preferences-utilisateur`. Just the theme choice for now + * (light/dark/system — see `features/theme/ThemeContext.tsx`), meant to + * grow. Distinct from `/parametres/preferences` ("Préférences + * alimentaires" — regime/allergies): that page is about the household's + * food constraints, this one is about how the app itself looks, unrelated + * concerns that happened to share a name before this page existed. + */ +export function UserPreferencesPage() { + const { t } = useTranslation(); + const { theme, setTheme } = useTheme(); + const [saveState, setSaveState] = useState("idle"); + const [saveError, setSaveError] = useState(null); + + async function handleChange(newTheme: ThemePreference) { + setSaveState("saving"); + try { + await setTheme(newTheme); + setSaveState("saved"); + } catch (err) { + const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; + setSaveError(errorMessageService.getLabel(code)); + setSaveState("error"); + } + } + + return ( +
+

{t("userPreferences.title")}

+ +
+
+ {t("userPreferences.themeLabel")} + {THEME_PREFERENCES.map((option) => ( + + ))} +
+ + {saveState === "saving" &&

{t("common.saving")}

} + {saveState === "saved" &&

{t("common.saved")}

} + {saveState === "error" &&

{saveError}

} +
+
+ ); +} diff --git a/apps/web/src/pages/settings/settings-pages.scss b/apps/web/src/pages/settings/settings-pages.scss index 1338858..9eebd4c 100644 --- a/apps/web/src/pages/settings/settings-pages.scss +++ b/apps/web/src/pages/settings/settings-pages.scss @@ -149,3 +149,26 @@ button.settings-page__link-button { cursor: pointer; text-decoration: underline; } + +// Theme choice (UserPreferencesPage) — a plain radio group, no fieldset/ +// legend styling exists elsewhere yet to reuse (AllergySelect's `.allergy- +// select` in profile-forms.scss is checkbox-grid specific). +.theme-select { + border: none; + padding: 0; + margin: 0; + + legend { + padding: 0 0 var(--space-sm); + font-weight: 600; + font-size: var(--font-size-sm); + } + + &__option { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-xs) 0; + cursor: pointer; + } +} diff --git a/apps/web/src/styles/_theme.scss b/apps/web/src/styles/_theme.scss index b062d73..1afa0c3 100644 --- a/apps/web/src/styles/_theme.scss +++ b/apps/web/src/styles/_theme.scss @@ -107,9 +107,9 @@ // --- Dark mode --------------------------------------------------------- // Follows the OS/browser preference by default. Guarded with -// `:root:not([data-theme="light"])` so that, if a manual theme switch is -// ever added, an explicit "light" choice can override a dark OS setting — -// today nothing sets `data-theme`, so this simply tracks system preference. +// `:root:not([data-theme="light"])` so an explicit "light" choice (see +// apps/web's `ThemeContext`, `SYSTEM` = no `data-theme` attribute at all — +// this block then decides) can override a dark OS setting. @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --color-background: #14181a; @@ -138,8 +138,9 @@ } } -// Mirrors the block above for a future explicit "dark" choice (e.g. a -// theme toggle), so it wins over the OS setting in both directions. +// Mirrors the block above for an explicit "dark" choice (`ThemeContext` +// sets `data-theme="dark"` on ``), so it wins over the OS setting in +// both directions. :root[data-theme="dark"] { --color-background: #14181a; --color-surface: #1c221e;