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:
parent
e512c33ffc
commit
7bc81f51fb
8 changed files with 393 additions and 5 deletions
|
|
@ -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() {
|
|||
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
||||
<Route path="/foyer" element={<HouseholdPage />} />
|
||||
</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
|
||||
path="/login"
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "./AuthContext";
|
||||
|
||||
|
|
@ -6,14 +6,32 @@ import { useAuth } from "./AuthContext";
|
|||
* Route guard for pages that make no sense to an already-authenticated
|
||||
* visitor (`/login`, `/signup`). Mirrors {@link RequireAuth}'s waiting
|
||||
* 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 }) {
|
||||
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;
|
||||
}
|
||||
if (user) {
|
||||
if (shouldRedirect.current) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function SignupPage() {
|
|||
const [formError, setFormError] = useState<string | null>(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
|
||||
|
|
|
|||
80
apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx
Normal file
80
apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
apps/web/src/pages/onboarding/OnboardingDietPage.tsx
Normal file
80
apps/web/src/pages/onboarding/OnboardingDietPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx
Normal file
88
apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
apps/web/src/pages/onboarding/onboarding.scss
Normal file
74
apps/web/src/pages/onboarding/onboarding.scss
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue