Web: wizard d'inscription (foyer/régime/allergènes) (step 4/6)

- 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.
This commit is contained in:
Nicolas 2026-08-16 23:31:59 +02:00
parent e512c33ffc
commit 7bc81f51fb
8 changed files with 393 additions and 5 deletions

View file

@ -8,6 +8,9 @@ import { LoginPage } from "./pages/LoginPage";
import { RecipesPage } from "./pages/RecipesPage"; import { RecipesPage } from "./pages/RecipesPage";
import { ShoppingListPage } from "./pages/ShoppingListPage"; import { ShoppingListPage } from "./pages/ShoppingListPage";
import { SignupPage } from "./pages/SignupPage"; 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 * 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 * `/signup` redirect an already-logged-in visitor to `/` instead (see
* {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which * {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which
* itself redirects to `/login` if needed. * 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() { export function App() {
return ( return (
@ -32,6 +41,30 @@ export function App() {
<Route path="/liste-de-courses" element={<ShoppingListPage />} /> <Route path="/liste-de-courses" element={<ShoppingListPage />} />
<Route path="/foyer" element={<HouseholdPage />} /> <Route path="/foyer" element={<HouseholdPage />} />
</Route> </Route>
<Route
path="/onboarding/foyer"
element={
<RequireAuth>
<OnboardingHouseholdPage />
</RequireAuth>
}
/>
<Route
path="/onboarding/regime"
element={
<RequireAuth>
<OnboardingDietPage />
</RequireAuth>
}
/>
<Route
path="/onboarding/allergenes"
element={
<RequireAuth>
<OnboardingAllergensPage />
</RequireAuth>
}
/>
<Route <Route
path="/login" path="/login"
element={ element={

View file

@ -1,4 +1,4 @@
import type { ReactNode } from "react"; import { type ReactNode, useRef } from "react";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { useAuth } from "./AuthContext"; import { useAuth } from "./AuthContext";
@ -6,14 +6,32 @@ import { useAuth } from "./AuthContext";
* Route guard for pages that make no sense to an already-authenticated * Route guard for pages that make no sense to an already-authenticated
* visitor (`/login`, `/signup`). Mirrors {@link RequireAuth}'s waiting * visitor (`/login`, `/signup`). Mirrors {@link RequireAuth}'s waiting
* behavior while the initial session check is pending. * behavior while the initial session check is pending.
*
* The redirect decision is latched exactly once, the first moment the
* initial `isLoading` check resolves deliberately *not* reactive to
* `user` changing afterward. Without this, a guarded page's own action
* that authenticates the visitor (e.g. `SignupPage`'s submit handler
* calling `signup()`, which sets `user`, then explicitly navigating
* elsewhere) races this guard: `user` becoming truthy while still mounted
* fires this component's own `<Navigate to="/">`, 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 }) { export function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
const shouldRedirect = useRef<boolean | null>(null);
if (isLoading) { if (!isLoading && shouldRedirect.current === null) {
shouldRedirect.current = user !== null;
}
if (isLoading || shouldRedirect.current === null) {
return null; return null;
} }
if (user) { if (shouldRedirect.current) {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }
return <>{children}</>; return <>{children}</>;

View file

@ -32,6 +32,21 @@
"loginLink": "Se connecter" "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": { "layout": {
"nav": { "nav": {
"planning": "Planning", "planning": "Planning",

View file

@ -37,7 +37,7 @@ export function SignupPage() {
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); 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) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
setFormError(null); setFormError(null);
@ -52,7 +52,7 @@ export function SignupPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await signup(result.data); await signup(result.data);
navigate("/"); navigate("/onboarding/foyer");
} catch (err) { } catch (err) {
// ApiError.code is looked up through ErrorMessageService so the // ApiError.code is looked up through ErrorMessageService so the
// label is centralized and localized — never display err.message // label is centralized and localized — never display err.message

View file

@ -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<AllergyView[]>([]);
const [allergyIds, setAllergyIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [formError, setFormError] = useState<string | null>(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 (
<main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">{t("onboarding.step", { current: 3, total: 3 })}</p>
<h1>{t("onboarding.allergens.title")}</h1>
{isLoading ? (
<p>{t("onboarding.loading")}</p>
) : (
<AllergySelect allergies={allergies} value={allergyIds} onChange={setAllergyIds} />
)}
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.finish")}
</button>
</form>
</main>
);
}

View file

@ -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<DietView[]>([]);
const [dietId, setDietId] = useState<number | null>(user?.dietId ?? null);
const [isLoading, setIsLoading] = useState(true);
const [formError, setFormError] = useState<string | null>(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 (
<main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">{t("onboarding.step", { current: 2, total: 3 })}</p>
<h1>{t("onboarding.diet.title")}</h1>
{isLoading ? (
<p>{t("onboarding.loading")}</p>
) : (
<DietSelect diets={diets} value={dietId} onChange={setDietId} />
)}
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.continue")}
</button>
</form>
</main>
);
}

View file

@ -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<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(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 (
<main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">{t("onboarding.step", { current: 1, total: 3 })}</p>
<h1>{t("onboarding.household.title")}</h1>
{isLoading ? (
<p>{t("onboarding.loading")}</p>
) : (
<HouseNameField value={name} onChange={setName} error={fieldErrors.name} />
)}
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.continue")}
</button>
</form>
</main>
);
}

View file

@ -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);
}