From 7bc81f51fb1ee5a571eda67775c8d7598a60efac Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 16 Aug 2026 23:31:59 +0200 Subject: [PATCH] =?UTF-8?q?Web:=20wizard=20d'inscription=20(foyer/r=C3=A9g?= =?UTF-8?q?ime/allerg=C3=A8nes)=20(step=204/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SignupPage: après signup(), navigate("/onboarding/foyer") au lieu de "/" — la home reste inchangée, seule la destination change. - pages/onboarding/: 3 routes top-level RequireAuth-gated (PAS nichées sous AppLayout — wizard plein écran sans sidebar, même langage visuel que /login|/signup) : - /onboarding/foyer — HouseNameField, préremplie avec le nom auto-généré du foyer (continuer sans éditer = skip implicite). - /onboarding/regime — DietSelect, valeur initiale depuis useAuth().user.dietId (pas de fetch supplémentaire nécessaire). - /onboarding/allergenes — AllergySelect, termine sur navigate("/"). - Bug trouvé et corrigé en testant dans le navigateur : RedirectIfAuthenticated redirigeait vers "/" en course avec le navigate() explicite de SignupPage — `user` devient non-null (via signup()) pendant que SignupPage est encore monté sous ce guard, qui réagit et redirige avant que le navigate("/onboarding/foyer") ne prenne effet. Latent depuis le début (invisible avant car l'ancien SignupPage naviguait aussi vers "/", donc les deux redirections concordaient). Fix : la décision de redirection est verrouillée une seule fois, au moment où la vérification initiale (`isLoading`) se termine, plutôt que réévaluée à chaque changement de `user`. Vérifié de bout en bout dans le navigateur (inscription → 3 étapes → home), données confirmées en base (foyer renommé, régime + 2 allergènes enregistrés), puis nettoyage des comptes de test. --- apps/web/src/App.tsx | 33 +++++++ .../features/auth/RedirectIfAuthenticated.tsx | 24 ++++- apps/web/src/locales/fr/translation.json | 15 ++++ apps/web/src/pages/SignupPage.tsx | 4 +- .../onboarding/OnboardingAllergensPage.tsx | 80 +++++++++++++++++ .../pages/onboarding/OnboardingDietPage.tsx | 80 +++++++++++++++++ .../onboarding/OnboardingHouseholdPage.tsx | 88 +++++++++++++++++++ apps/web/src/pages/onboarding/onboarding.scss | 74 ++++++++++++++++ 8 files changed, 393 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx create mode 100644 apps/web/src/pages/onboarding/OnboardingDietPage.tsx create mode 100644 apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx create mode 100644 apps/web/src/pages/onboarding/onboarding.scss diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3709ca9..94cc69c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -8,6 +8,9 @@ import { LoginPage } from "./pages/LoginPage"; import { RecipesPage } from "./pages/RecipesPage"; import { ShoppingListPage } from "./pages/ShoppingListPage"; import { SignupPage } from "./pages/SignupPage"; +import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage"; +import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage"; +import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage"; /** * Top-level route table. Every authenticated section is nested under one @@ -16,6 +19,12 @@ import { SignupPage } from "./pages/SignupPage"; * `/signup` redirect an already-logged-in visitor to `/` instead (see * {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which * itself redirects to `/login` if needed. + * + * `/onboarding/*` (household/regime/allergens) is also `RequireAuth`-gated + * — reached right after signup, once a session already exists — but + * deliberately its own top-level route group, *not* nested under + * `AppLayout`: a focused, distraction-free wizard with no sidebar, same + * full-page-card language as `/login`/`/signup` (see `onboarding.scss`). */ export function App() { return ( @@ -32,6 +41,30 @@ export function App() { } /> } /> + + + + } + /> + + + + } + /> + + + + } + /> `, competing with — and + * sometimes winning against — the page's explicit `navigate(...)` call to + * a different destination. Previously invisible only because the old + * `SignupPage` happened to navigate to the same place ("/") this guard + * does; became visible once it needed to route to `/onboarding/foyer` + * instead. */ export function RedirectIfAuthenticated({ children }: { children: ReactNode }) { const { user, isLoading } = useAuth(); + const shouldRedirect = useRef(null); - if (isLoading) { + if (!isLoading && shouldRedirect.current === null) { + shouldRedirect.current = user !== null; + } + + if (isLoading || shouldRedirect.current === null) { return null; } - if (user) { + if (shouldRedirect.current) { return ; } return <>{children}; diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 22894db..0bd77e8 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -32,6 +32,21 @@ "loginLink": "Se connecter" } }, + "onboarding": { + "step": "Étape {{current}} sur {{total}}", + "continue": "Continuer", + "finish": "Terminer", + "loading": "Chargement…", + "household": { + "title": "Comment s'appelle votre foyer ?" + }, + "diet": { + "title": "Un régime alimentaire particulier ?" + }, + "allergens": { + "title": "Des allergies ou intolérances ?" + } + }, "layout": { "nav": { "planning": "Planning", diff --git a/apps/web/src/pages/SignupPage.tsx b/apps/web/src/pages/SignupPage.tsx index 662d839..56260ba 100644 --- a/apps/web/src/pages/SignupPage.tsx +++ b/apps/web/src/pages/SignupPage.tsx @@ -37,7 +37,7 @@ export function SignupPage() { const [formError, setFormError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - /** Validates, then submits the form; navigates home on success. */ + /** Validates, then submits the form; on success, starts the household/regime/allergens onboarding wizard rather than going straight to the home. */ async function handleSubmit(e: FormEvent) { e.preventDefault(); setFormError(null); @@ -52,7 +52,7 @@ export function SignupPage() { setIsSubmitting(true); try { await signup(result.data); - navigate("/"); + navigate("/onboarding/foyer"); } catch (err) { // ApiError.code is looked up through ErrorMessageService so the // label is centralized and localized — never display err.message diff --git a/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx b/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx new file mode 100644 index 0000000..ff433a2 --- /dev/null +++ b/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx @@ -0,0 +1,80 @@ +import type { AllergyView } from "@batch-cooking/shared"; +import { ErrorCode } from "@batch-cooking/shared"; +import { type FormEvent, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { ApiError, apiClient } from "../../api/client"; +import { AllergySelect } from "../../features/profile/AllergySelect"; +import { errorMessageService } from "../../services/error-message.service"; +import "./onboarding.scss"; + +/** + * Last step of the post-signup onboarding wizard, routed at + * `/onboarding/allergenes`. Starts from an empty selection — a freshly + * signed-up profile has none yet, so there's no need for the extra + * `GET /profile/allergies` round trip a "resume where I left off" flow + * would require (the `/foyer` settings page, task 10, is the always-fetch + * source of truth for editing an existing selection later). + */ +export function OnboardingAllergensPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [allergies, setAllergies] = useState([]); + const [allergyIds, setAllergyIds] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + let cancelled = false; + apiClient + .getAllergies() + .then((result) => { + if (!cancelled) setAllergies(result); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + /** Finishes the wizard — the profile journey is complete, back to the app itself. */ + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setFormError(null); + setIsSubmitting(true); + try { + await apiClient.updateAllergyIds(allergyIds); + navigate("/"); + } catch (err) { + const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; + setFormError(errorMessageService.getLabel(code)); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+

{t("onboarding.step", { current: 3, total: 3 })}

+

{t("onboarding.allergens.title")}

+ + {isLoading ? ( +

{t("onboarding.loading")}

+ ) : ( + + )} + + {formError &&

{formError}

} + + + +
+ ); +} diff --git a/apps/web/src/pages/onboarding/OnboardingDietPage.tsx b/apps/web/src/pages/onboarding/OnboardingDietPage.tsx new file mode 100644 index 0000000..a29fef9 --- /dev/null +++ b/apps/web/src/pages/onboarding/OnboardingDietPage.tsx @@ -0,0 +1,80 @@ +import type { DietView } from "@batch-cooking/shared"; +import { ErrorCode } from "@batch-cooking/shared"; +import { type FormEvent, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { ApiError, apiClient } from "../../api/client"; +import { useAuth } from "../../features/auth/AuthContext"; +import { DietSelect } from "../../features/profile/DietSelect"; +import { errorMessageService } from "../../services/error-message.service"; +import "./onboarding.scss"; + +/** + * Second step of the post-signup onboarding wizard, routed at + * `/onboarding/regime`. Initial selection comes from `useAuth()`'s + * already-loaded profile (`user.dietId`) — freshly signed-up, this is + * `null` — no extra fetch needed just to know the starting value, unlike + * the household step which has no such shortcut for the current name. + */ +export function OnboardingDietPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { user } = useAuth(); + + const [diets, setDiets] = useState([]); + const [dietId, setDietId] = useState(user?.dietId ?? null); + const [isLoading, setIsLoading] = useState(true); + const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + let cancelled = false; + apiClient + .getDiets() + .then((result) => { + if (!cancelled) setDiets(result); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setFormError(null); + setIsSubmitting(true); + try { + await apiClient.updateDiet(dietId); + navigate("/onboarding/allergenes"); + } catch (err) { + const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; + setFormError(errorMessageService.getLabel(code)); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+

{t("onboarding.step", { current: 2, total: 3 })}

+

{t("onboarding.diet.title")}

+ + {isLoading ? ( +

{t("onboarding.loading")}

+ ) : ( + + )} + + {formError &&

{formError}

} + + + +
+ ); +} diff --git a/apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx b/apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx new file mode 100644 index 0000000..eef0d85 --- /dev/null +++ b/apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx @@ -0,0 +1,88 @@ +import { ErrorCode, renameHouseSchema } from "@batch-cooking/shared"; +import { type FormEvent, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { ApiError, apiClient } from "../../api/client"; +import { HouseNameField } from "../../features/profile/HouseNameField"; +import { fieldErrorsFrom } from "../../lib/zod-errors"; +import { errorMessageService } from "../../services/error-message.service"; +import "./onboarding.scss"; + +/** + * First step of the post-signup onboarding wizard, routed at + * `/onboarding/foyer` — behind {@link RequireAuth} (see `App.tsx`), reached + * right after `POST /auth/signup` creates the account and its household + * (auto-named, see `auth.service.ts`). Prefills the current (default) name + * so continuing without editing it is a valid, implicit "skip" — there's + * no separate skip button anywhere in this wizard, see `DietSelect`/ + * `AllergySelect` for the same choice on the following steps. + */ +export function OnboardingHouseholdPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [name, setName] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [fieldErrors, setFieldErrors] = useState>({}); + const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + let cancelled = false; + apiClient + .getCurrentHouse() + .then((house) => { + if (!cancelled && house) setName(house.name); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setFormError(null); + + const result = renameHouseSchema.safeParse({ name }); + if (!result.success) { + setFieldErrors(fieldErrorsFrom(result.error)); + return; + } + setFieldErrors({}); + + setIsSubmitting(true); + try { + await apiClient.renameHouse(result.data.name); + navigate("/onboarding/regime"); + } catch (err) { + const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; + setFormError(errorMessageService.getLabel(code)); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+

{t("onboarding.step", { current: 1, total: 3 })}

+

{t("onboarding.household.title")}

+ + {isLoading ? ( +

{t("onboarding.loading")}

+ ) : ( + + )} + + {formError &&

{formError}

} + + + +
+ ); +} diff --git a/apps/web/src/pages/onboarding/onboarding.scss b/apps/web/src/pages/onboarding/onboarding.scss new file mode 100644 index 0000000..379c8eb --- /dev/null +++ b/apps/web/src/pages/onboarding/onboarding.scss @@ -0,0 +1,74 @@ +// ============================================================================= +// Styles shared by the three onboarding wizard pages (OnboardingHousehold, +// OnboardingDiet, OnboardingAllergens) — the household/regime/allergens +// steps of the profile journey, run once right after signup. +// +// Deliberately its own file rather than importing features/auth/ +// auth-form.scss: same visual language (centered card) but a different +// feature area — matches the project's existing convention of colocating +// styles per feature rather than sharing a "global card" partial (see +// HomePage.scss, which makes the same call for its own button styling). +// ============================================================================= + +.onboarding-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-md); + background: var(--color-background); +} + +.onboarding-card { + display: flex; + flex-direction: column; + gap: var(--space-xs); + width: 100%; + max-width: var(--max-width-form); + padding: var(--space-xl); + background: var(--color-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + + h1 { + font-size: var(--font-size-lg); + text-align: center; + margin-bottom: var(--space-sm); + } + + button { + margin-top: var(--space-md); + padding: 0.6rem; + font-family: var(--font-body); + font-size: var(--font-size-base); + font-weight: 600; + cursor: pointer; + border-radius: var(--radius-base); + border: none; + background: var(--color-primary); + color: #fff; + + &:hover:not(:disabled) { + background: var(--color-primary-hover); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } +} + +.onboarding-step { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + text-align: center; + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0 0 var(--space-sm); +} + +.form-error { + color: var(--color-error); + font-size: var(--font-size-sm); +}