Merge pull request 'feat(onboarding): réordonne le wizard en compte → préférences → foyer → sources (#129)' (#139) from feat/onboarding-wizard-order into main
Some checks failed
CI / lint (push) Successful in 3m8s
CI / build (push) Successful in 3m22s
CI / intent-service-test (push) Successful in 5m42s
CI / e2e (push) Successful in 10m40s
CI / test (push) Failing after 16m36s
CI / build-android (push) Has been skipped

Closes #129.
This commit is contained in:
kyuno 2026-09-09 16:59:49 +02:00
commit 36d26c2739
10 changed files with 205 additions and 129 deletions

View file

@ -1,41 +1,54 @@
Feature: Onboarding wizard Feature: Onboarding wizard
As a newly signed-up user As a newly signed-up user
I want to set my diet, household, and allergens I want to set my preferences (diet + allergens), then my household
So that my profile is ready before I start planning meals So that my profile is ready before I start planning meals
# Order since #129: compte -> régime -> allergènes -> foyer -> sources,
# and the sources step only when the foyer step *created* a household
# (joining one or skipping ends the wizard at the home page).
Background: Background:
Given the planning request returns nothing Given the planning request returns nothing
Scenario: Walks through the wizard, creating a household on the way (which surfaces the sources step), and lands on the home page Scenario: Walks through every step, creating a household (which surfaces the sources step), and lands on the home page
Given the diets reference list has options Given the diets reference list has options
And selecting the diet will succeed And selecting the diet will succeed
And the household request returns no household And the household request returns no household
And creating a household will succeed
And the sources reference list is empty
And the allergies reference list has options And the allergies reference list has options
And updating allergies will succeed And updating allergies will succeed
And creating a household will succeed
And the sources reference list has options
And the household's enabled sources are empty
And saving the source selection will succeed
And I have signed up And I have signed up
Then the URL should include "/onboarding/regime" Then the URL should include "/onboarding/regime"
And I should see "Étape 1 sur 3" And I should see "Étape 1 sur 3"
When I select "Végétarien" from the "diet" field When I select "Végétarien" from the "diet" field
And I click the button "Continuer" And I click the button "Continuer"
Then the diet update request should have been made with diet id 2 Then the diet update request should have been made with diet id 2
And the URL should include "/onboarding/foyer"
And I should see "Étape 2 sur 3"
When I fill in the "houseName" field with "Chez Alice"
And I click the button "Créer"
Then the household creation request should have been made with name "Chez Alice"
And the URL should include "/onboarding/allergenes" And the URL should include "/onboarding/allergenes"
And I should see "Étape 4 sur 4" And I should see "Étape 2 sur 3"
And I should see the section "Allergies" And I should see the section "Allergies"
And I should see the section "Intolérances" And I should see the section "Intolérances"
When I check the checkbox "Arachides" When I check the checkbox "Arachides"
And I click the button "Terminer" And I click the button "Continuer"
Then the allergies update request should have been made with allergy id 1 Then the allergies update request should have been made with allergy id 1
And the URL should include "/onboarding/foyer"
And I should see "Étape 3 sur 3"
When I fill in the "houseName" field with "Chez Alice"
And I click the button "Créer"
Then the household creation request should have been made with name "Chez Alice"
And the URL should include "/onboarding/sources"
And I should see "Étape 4 sur 4"
And I should see the section "Sources disponibles"
And I should see "TheMealDB"
When I check the checkbox "TheMealDB"
And I click the button "Terminer"
Then the source selection update request should have been made with source id 1
And the URL should be the home page And the URL should be the home page
And I should see the heading "Planning de la semaine" And I should see the heading "Planning de la semaine"
Scenario: Lets the regime and allergens steps be skipped without changing anything Scenario: Lets the preferences steps be skipped without changing anything, then skips the household step too
Given the diets reference list is empty Given the diets reference list is empty
And selecting the diet will succeed And selecting the diet will succeed
And the household request returns no household And the household request returns no household
@ -45,39 +58,32 @@ Feature: Onboarding wizard
Then the URL should include "/onboarding/regime" Then the URL should include "/onboarding/regime"
When I click the button "Continuer" When I click the button "Continuer"
Then the diet update request should have been made with no diet id Then the diet update request should have been made with no diet id
And the URL should include "/onboarding/allergenes"
When I click the button "Continuer"
Then the allergies update request should have been made with no allergy ids
And the URL should include "/onboarding/foyer" And the URL should include "/onboarding/foyer"
When I click the button "Passer cette étape" When I click the button "Passer cette étape"
Then the URL should include "/onboarding/allergenes" Then the URL should be the home page
When I click the button "Terminer"
Then the allergies update request should have been made with no allergy ids
And the URL should be the home page
Scenario: Shows and saves the sources step instead of skipping it, when sources are available Scenario: Creating a household with no sources implemented ends the wizard through the silently-skipped sources step
Given the diets reference list is empty Given the diets reference list is empty
And selecting the diet will succeed And selecting the diet will succeed
And the household request returns no household And the household request returns no household
And creating a household will succeed And creating a household will succeed
And the sources reference list has options And the sources reference list is empty
And the household's enabled sources are empty
And saving the source selection will succeed
And the allergies reference list is empty And the allergies reference list is empty
And updating allergies will succeed And updating allergies will succeed
And I have signed up And I have signed up
When I click the button "Continuer" When I click the button "Continuer"
Then the URL should include "/onboarding/allergenes"
When I click the button "Continuer"
Then the URL should include "/onboarding/foyer" Then the URL should include "/onboarding/foyer"
When I fill in the "houseName" field with "Chez Alice" When I fill in the "houseName" field with "Chez Alice"
And I click the button "Créer" And I click the button "Créer"
Then the household creation request should have been made with name "Chez Alice" Then the household creation request should have been made with name "Chez Alice"
And the URL should include "/onboarding/sources" And the URL should be the home page
And I should see "Étape 3 sur 4"
And I should see the section "Sources disponibles"
And I should see "TheMealDB"
When I check the checkbox "TheMealDB"
And I click the button "Continuer"
Then the source selection update request should have been made with source id 1
And the URL should include "/onboarding/allergenes"
Scenario: Lets the household step be completed by joining an existing household instead of creating one Scenario: Joining an existing household skips the sources step entirely and ends the wizard
Given the diets reference list is empty Given the diets reference list is empty
And selecting the diet will succeed And selecting the diet will succeed
And the household request returns no household And the household request returns no household
@ -87,8 +93,10 @@ Feature: Onboarding wizard
And updating allergies will succeed And updating allergies will succeed
And I have signed up And I have signed up
When I click the button "Continuer" When I click the button "Continuer"
Then the URL should include "/onboarding/allergenes"
When I click the button "Continuer"
Then the URL should include "/onboarding/foyer" Then the URL should include "/onboarding/foyer"
When I fill in the "inviteCode" field with "abcd2345" When I fill in the "inviteCode" field with "abcd2345"
And I click the button "Rejoindre" And I click the button "Rejoindre"
Then the household join request should have been made with invite code "ABCD2345" Then the household join request should have been made with invite code "ABCD2345"
And the URL should include "/onboarding/allergenes" And the URL should be the home page

View file

@ -32,10 +32,10 @@ Given("the allergies reference list is empty", () => {
cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] }); cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
}); });
// Every onboarding scenario that reaches the household step also reaches // An onboarding scenario whose household step *creates* a household then
// `/onboarding/sources` right after (when a household got created/joined — // reaches `/onboarding/sources` (see `OnboardingHouseholdPage`'s
// see `OnboardingHouseholdPage`'s `goToNextStep`), which reads this before // `goToNextStep`), which reads this before self-skipping straight to the
// self-skipping to `/onboarding/allergenes` if it comes back empty. // home page if it comes back empty.
Given("the sources reference list is empty", () => { Given("the sources reference list is empty", () => {
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] }); cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
}); });

View file

@ -51,14 +51,16 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
* just redirects to `/parametres/foyer` so an existing bookmark/link keeps * just redirects to `/parametres/foyer` so an existing bookmark/link keeps
* working. * working.
* *
* `/onboarding/*` (regime/foyer/sources/allergens, in that order) is also * `/onboarding/*` (regime/allergenes/foyer/sources, in that order) is also
* `RequireAuth`-gated — reached right after signup, once a session already * `RequireAuth`-gated — reached right after signup, once a session already
* exists — but deliberately its own top-level route group, *not* nested * exists — but deliberately its own top-level route group, *not* nested
* under `AppLayout`: a focused, distraction-free wizard with no sidebar, * under `AppLayout`: a focused, distraction-free wizard with no sidebar,
* same full-page-card language as `/login`/`/signup` (see `onboarding.scss`). * same full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
* `/onboarding/sources` is conditional — only reached when the `foyer` step * `regime` + `allergenes` are the two "préférences" screens; `/onboarding/sources`
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`); * is conditional — only reached when the `foyer` step *created* a household
* skipped otherwise, straight to `/onboarding/allergenes`. * (see `OnboardingHouseholdPage`'s `goToNextStep`), every other path ending
* the wizard straight to `/`. Step order and the "sur 3" / "4 sur 4"
* indicators live in `pages/onboarding/onboarding-steps.ts`.
* *
* `/telecharger` is the mobile-app-redirect chantier's fallback landing spot * `/telecharger` is the mobile-app-redirect chantier's fallback landing spot
* (see `lib/mobile-app-redirect.ts`'s own doc comment) — public like * (see `lib/mobile-app-redirect.ts`'s own doc comment) — public like
@ -138,6 +140,14 @@ export function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/onboarding/allergenes"
element={
<RequireAuth>
<OnboardingAllergensPage />
</RequireAuth>
}
/>
<Route <Route
path="/onboarding/foyer" path="/onboarding/foyer"
element={ element={
@ -154,14 +164,6 @@ export function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/onboarding/allergenes"
element={
<RequireAuth>
<OnboardingAllergensPage />
</RequireAuth>
}
/>
<Route <Route
path="/login" path="/login"
element={ element={

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; on success, starts the regime/household/allergens onboarding wizard rather than going straight to the home. */ /** Validates, then submits the form; on success, starts the régime → allergènes → foyer 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);

View file

@ -6,22 +6,23 @@ import { useNavigate } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client"; import { ApiError, apiClient } from "../../api/client";
import { AllergySelect } from "../../features/profile/AllergySelect"; import { AllergySelect } from "../../features/profile/AllergySelect";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import { onboardingStepIndicator } from "./onboarding-steps";
import "./onboarding.scss"; import "./onboarding.scss";
/** /**
* Last step of the post-signup onboarding wizard, routed at * Second step of the post-signup onboarding wizard, routed at
* `/onboarding/allergenes`. Starts from an empty selection — a freshly * `/onboarding/allergenes` — the allergens half of "préférences" (régime
* signed-up profile has none yet, so there's no need for the extra * is the first half), now ahead of the household step rather than the
* wizard's final screen (see #129). 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 * `GET /profile/allergies` round trip a "resume where I left off" flow
* would require (the `/parametres/preferences` settings page is the * would require (the `/parametres/preferences` settings page is the
* always-fetch source of truth for editing an existing selection later). * always-fetch source of truth for editing an existing selection later).
* *
* Also fetches the current household on mount, only to know whether the * No household fetch anymore: this step no longer needs to retroactively
* `/onboarding/sources` step ran before this one (household step created * work out whether a sources step ran, since it now comes before the
* one and it had a source to configure) — the step count shown reflects * household step entirely — the "sur 3" vs "4 sur 4" split lives in
* whichever path the visitor actually took, "3 sur 3" or "4 sur 4" * {@link onboardingStepIndicator}.
* (there's no shared wizard state to read this from — see
* `OnboardingHouseholdPage`'s doc comment on why each step is independent).
*/ */
export function OnboardingAllergensPage() { export function OnboardingAllergensPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -29,27 +30,22 @@ export function OnboardingAllergensPage() {
const [allergies, setAllergies] = useState<AllergyView[]>([]); const [allergies, setAllergies] = useState<AllergyView[]>([]);
const [allergyIds, setAllergyIds] = useState<number[]>([]); const [allergyIds, setAllergyIds] = useState<number[]>([]);
const [totalSteps, setTotalSteps] = useState(3);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
Promise.all([apiClient.getAllergies(), apiClient.getCurrentHouse()]) apiClient
.then(([allergiesResult, house]) => { .getAllergies()
if (cancelled) return; .then((allergiesResult) => {
setAllergies(allergiesResult); if (!cancelled) setAllergies(allergiesResult);
setTotalSteps(house !== null ? 4 : 3);
})
.catch(() => {
// Neither piece is essential for this last step to render — the
// allergy list just stays empty (nothing to pick from) and the
// step count keeps its 3-of-3 default, same "don't strand the
// visitor over a transient failure" reasoning as
// OnboardingSourcesPage's own catch, just with nothing to
// navigate away to since this already is the last step.
}) })
// Not essential for this step to render — the allergy list just
// stays empty (nothing to pick from) rather than stranding the
// visitor over a transient failure, same reasoning as
// OnboardingSourcesPage's own catch.
.catch(() => {})
.finally(() => { .finally(() => {
if (!cancelled) setIsLoading(false); if (!cancelled) setIsLoading(false);
}); });
@ -58,14 +54,14 @@ export function OnboardingAllergensPage() {
}; };
}, []); }, []);
/** Finishes the wizard — the profile journey is complete, back to the app itself. */ /** Moves on to the household step — preferences (régime + allergènes) are done. */
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
setFormError(null); setFormError(null);
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await apiClient.updateAllergyIds(allergyIds); await apiClient.updateAllergyIds(allergyIds);
void navigate("/"); void navigate("/onboarding/foyer");
} catch (err) { } catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code)); setFormError(errorMessageService.getLabel(code));
@ -78,7 +74,7 @@ export function OnboardingAllergensPage() {
<main className="onboarding-page"> <main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate> <form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step"> <p className="onboarding-step">
{t("onboarding.step", { current: totalSteps, total: totalSteps })} {t("onboarding.step", onboardingStepIndicator("allergens"))}
</p> </p>
<h1>{t("onboarding.allergens.title")}</h1> <h1>{t("onboarding.allergens.title")}</h1>
@ -104,7 +100,7 @@ export function OnboardingAllergensPage() {
{formError && <p className="form-error">{formError}</p>} {formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}> <button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.finish")} {t("onboarding.continue")}
</button> </button>
</form> </form>
</main> </main>

View file

@ -7,6 +7,7 @@ import { ApiError, apiClient } from "../../api/client";
import { useAuth } from "../../features/auth/AuthContext"; import { useAuth } from "../../features/auth/AuthContext";
import { DietSelect } from "../../features/profile/DietSelect"; import { DietSelect } from "../../features/profile/DietSelect";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import { onboardingStepIndicator } from "./onboarding-steps";
import "./onboarding.scss"; import "./onboarding.scss";
/** /**
@ -15,6 +16,9 @@ import "./onboarding.scss";
* already-loaded profile (`user.dietId`) — freshly signed-up, this is * already-loaded profile (`user.dietId`) — freshly signed-up, this is
* `null` — no extra fetch needed just to know the starting value, unlike * `null` — no extra fetch needed just to know the starting value, unlike
* the household step which has no such shortcut for the current name. * the household step which has no such shortcut for the current name.
*
* Followed by the allergens step (both make up "préférences" — see #129);
* the household step comes after those, not between them.
*/ */
export function OnboardingDietPage() { export function OnboardingDietPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -53,7 +57,7 @@ export function OnboardingDietPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await apiClient.updateDiet(dietId); await apiClient.updateDiet(dietId);
void navigate("/onboarding/foyer"); void navigate("/onboarding/allergenes");
} catch (err) { } catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code)); setFormError(errorMessageService.getLabel(code));
@ -65,7 +69,7 @@ export function OnboardingDietPage() {
return ( return (
<main className="onboarding-page"> <main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate> <form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">{t("onboarding.step", { current: 1, total: 3 })}</p> <p className="onboarding-step">{t("onboarding.step", onboardingStepIndicator("regime"))}</p>
<h1>{t("onboarding.diet.title")}</h1> <h1>{t("onboarding.diet.title")}</h1>
{isLoading ? ( {isLoading ? (

View file

@ -6,16 +6,24 @@ import { ApiError, apiClient } from "../../api/client";
import { HouseNameField } from "../../features/profile/HouseNameField"; import { HouseNameField } from "../../features/profile/HouseNameField";
import { fieldErrorsFrom } from "../../lib/zod-errors"; import { fieldErrorsFrom } from "../../lib/zod-errors";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import { onboardingStepIndicator } from "./onboarding-steps";
import "./onboarding.scss"; import "./onboarding.scss";
/** /**
* Second step of the post-signup onboarding wizard, routed at * Third step of the post-signup onboarding wizard, routed at
* `/onboarding/foyer` — unlike the regime/allergens steps, this one is * `/onboarding/foyer` — after the régime + allergènes "préférences" steps
* genuinely optional rather than just "skippable via an unedited default": * (see #129). Unlike those, this one is genuinely optional rather than
* no household is created at signup anymore (see the API's * just "skippable via an unedited default": no household is created at
* `auth.service.ts`), so there's nothing to prefill or implicitly keep by * signup anymore (see the API's `auth.service.ts`), so there's nothing to
* pressing "Continuer" — the visitor explicitly creates one, joins one by * prefill or implicitly keep by pressing "Continuer" — the visitor
* invite code, or skips the step outright via {@link SkipButton}. * explicitly creates one, joins one by invite code, or skips the step
* outright via {@link SkipButton}.
*
* This step's outcome decides whether the wizard has a 4th step: only
* *creating* a household leads on to `/onboarding/sources` (a per-house
* preference to seed while the visitor is here); joining one or skipping
* ends the wizard straight to `/` (a joined household's sources were set
* by whoever created it; a skipped step has no household to attach any to).
* *
* Still checks `getCurrentHouse()` on mount and skips straight to the * Still checks `getCurrentHouse()` on mount and skips straight to the
* "already have one" state if it finds one — defensive against revisiting * "already have one" state if it finds one — defensive against revisiting
@ -50,19 +58,22 @@ export function OnboardingHouseholdPage() {
}, []); }, []);
/** /**
* A household is what the next step (`/onboarding/sources`) needs to * `/onboarding/sources` only makes sense for a household the visitor just
* attach a preference to — created/joined/already-there goes on to it, * *created* — that's the sole caller passing `true`. Joining one, skipping
* skipped goes straight to `/onboarding/allergenes` instead (nothing to * the step, or finding one already there (browser back — can't tell
* configure sources for without a household). * created from joined, and the sources step self-skips when there's
* nothing to configure anyway) all end the wizard at `/`.
*/ */
function goToNextStep(hasHousehold: boolean) { function goToNextStep(createdHousehold: boolean) {
void navigate(hasHousehold ? "/onboarding/sources" : "/onboarding/allergenes"); void navigate(createdHousehold ? "/onboarding/sources" : "/");
} }
return ( return (
<main className="onboarding-page"> <main className="onboarding-page">
<div className="onboarding-card"> <div className="onboarding-card">
<p className="onboarding-step">{t("onboarding.step", { current: 2, total: 3 })}</p> <p className="onboarding-step">
{t("onboarding.step", onboardingStepIndicator("household"))}
</p>
<h1>{t("onboarding.household.title")}</h1> <h1>{t("onboarding.household.title")}</h1>
{isLoading ? ( {isLoading ? (
@ -70,15 +81,15 @@ export function OnboardingHouseholdPage() {
) : house !== null ? ( ) : house !== null ? (
<> <>
<p>{t("onboarding.household.alreadyHasHouse", { name: house.name })}</p> <p>{t("onboarding.household.alreadyHasHouse", { name: house.name })}</p>
<button type="button" onClick={() => goToNextStep(true)}> <button type="button" onClick={() => goToNextStep(false)}>
{t("onboarding.continue")} {t("onboarding.finish")}
</button> </button>
</> </>
) : ( ) : (
<> <>
<p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p> <p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p>
<CreateHouseholdForm onDone={() => goToNextStep(true)} /> <CreateHouseholdForm onDone={() => goToNextStep(true)} />
<JoinHouseholdForm onDone={() => goToNextStep(true)} /> <JoinHouseholdForm onDone={() => goToNextStep(false)} />
<SkipButton onSkip={() => goToNextStep(false)} /> <SkipButton onSkip={() => goToNextStep(false)} />
</> </>
)} )}

View file

@ -6,22 +6,23 @@ import { useNavigate } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client"; import { ApiError, apiClient } from "../../api/client";
import { SourceSelect } from "../../features/house/SourceSelect"; import { SourceSelect } from "../../features/house/SourceSelect";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import { onboardingStepIndicator } from "./onboarding-steps";
import "./onboarding.scss"; import "./onboarding.scss";
/** /**
* Third step of the post-signup onboarding wizard, routed at * Final step of the post-signup onboarding wizard, routed at
* `/onboarding/sources` — only reached when a household now exists (see * `/onboarding/sources` — reached only when the previous step *created* a
* `OnboardingHouseholdPage`'s `goToStep`, which skips straight to * household (see `OnboardingHouseholdPage`'s `goToNextStep`, which ends the
* `/onboarding/allergenes` when the previous step was skipped): enabled * wizard at `/` on every other path): enabled sources are a per-household
* sources are a per-household preference (`HouseSource`, schema.prisma), * preference (`HouseSource`, schema.prisma), so there's nothing to attach
* so there's nothing to attach a selection to without one. * a selection to otherwise.
* *
* Starts from an empty selection — a newly created/joined household has * Starts from an empty selection — a newly created household has nothing
* nothing enabled yet by design (opt-in, see `house.service.ts`'s * enabled yet by design (opt-in, see `house.service.ts`'s
* `getHouseSourceIds`). If no source is implemented yet (`getSources()` * `getHouseSourceIds`). If no source is implemented yet (`getSources()`
* comes back empty — currently always the case), there's nothing to pick * comes back empty — currently always the case), there's nothing to pick
* from either, so this step silently steps itself over rather than showing * from either, so this step silently finishes the wizard (straight to `/`)
* an empty screen. * rather than showing an empty screen.
*/ */
export function OnboardingSourcesPage() { export function OnboardingSourcesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -40,7 +41,7 @@ export function OnboardingSourcesPage() {
.then((result) => { .then((result) => {
if (cancelled) return; if (cancelled) return;
if (result.length === 0) { if (result.length === 0) {
void navigate("/onboarding/allergenes", { replace: true }); void navigate("/", { replace: true });
return; return;
} }
setSources(result); setSources(result);
@ -50,7 +51,7 @@ export function OnboardingSourcesPage() {
// Nothing to configure sources for if we can't even list them — the // Nothing to configure sources for if we can't even list them — the
// wizard shouldn't strand the visitor here over a transient failure // wizard shouldn't strand the visitor here over a transient failure
// fetching an optional step's own data. // fetching an optional step's own data.
if (!cancelled) void navigate("/onboarding/allergenes", { replace: true }); if (!cancelled) void navigate("/", { replace: true });
}); });
return () => { return () => {
cancelled = true; cancelled = true;
@ -63,7 +64,7 @@ export function OnboardingSourcesPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await apiClient.updateHouseSourceIds(sourceIds); await apiClient.updateHouseSourceIds(sourceIds);
void navigate("/onboarding/allergenes"); void navigate("/");
} catch (err) { } catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code)); setFormError(errorMessageService.getLabel(code));
@ -75,7 +76,9 @@ export function OnboardingSourcesPage() {
return ( return (
<main className="onboarding-page"> <main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate> <form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">{t("onboarding.step", { current: 3, total: 4 })}</p> <p className="onboarding-step">
{t("onboarding.step", onboardingStepIndicator("sources", { hasSources: true }))}
</p>
<h1>{t("onboarding.sources.title")}</h1> <h1>{t("onboarding.sources.title")}</h1>
<p className="onboarding-subtitle">{t("onboarding.sources.subtitle")}</p> <p className="onboarding-subtitle">{t("onboarding.sources.subtitle")}</p>
@ -93,7 +96,7 @@ export function OnboardingSourcesPage() {
{formError && <p className="form-error">{formError}</p>} {formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}> <button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.continue")} {t("onboarding.finish")}
</button> </button>
</form> </form>
</main> </main>

View file

@ -0,0 +1,45 @@
/**
* The post-signup onboarding wizard's step order, in one place so the
* per-page "Étape N sur M" indicators can't drift out of sync when the
* sequence changes.
*
* Order: régime → allergènes → foyer → [sources]. `"sources"` is a
* conditional 4th step, reached only when the visitor *creates* a
* household on the foyer step — joining one or skipping the step ends the
* wizard at foyer (a joined household's sources were already configured by
* whoever created it, and there's nothing to attach a source selection to
* without a household at all).
*
* Each page renders its own indicator with no shared runtime wizard state
* to read from (see {@link OnboardingHouseholdPage}'s doc on why the steps
* are independent), so régime/allergènes/foyer — all upstream of the
* "create vs join/skip" decision — can't yet know whether a 4th step is
* coming and show "sur 3"; only the `"sources"` step, which by definition
* is only ever reached on the 4-step path, shows "4 sur 4". This mirrors
* the pre-#129 flow, whose last step likewise only learned the real total
* after the fact.
*/
export type OnboardingStep = "regime" | "allergens" | "household" | "sources";
/** The wizard's two possible step sequences, keyed by whether the sources step runs. */
export const ONBOARDING_STEP_SEQUENCE = {
/** régime → allergènes → foyer: household joined or skipped (or still upstream of that choice). */
withoutSources: ["regime", "allergens", "household"],
/** régime → allergènes → foyer → sources: the visitor created a household. */
withSources: ["regime", "allergens", "household", "sources"],
} as const satisfies Record<string, readonly OnboardingStep[]>;
/**
* `{ current, total }` to feed `t("onboarding.step", …)` for a given step.
* Defaults to the 3-step sequence; the `"sources"` step passes
* `hasSources: true` for its "4 sur 4".
*/
export function onboardingStepIndicator(
step: OnboardingStep,
{ hasSources = false }: { hasSources?: boolean } = {},
): { current: number; total: number } {
const sequence: readonly OnboardingStep[] = hasSources
? ONBOARDING_STEP_SEQUENCE.withSources
: ONBOARDING_STEP_SEQUENCE.withoutSources;
return { current: sequence.indexOf(step) + 1, total: sequence.length };
}

View file

@ -71,7 +71,7 @@ apps/web/src/
│ │ ├── AccountSettingsPage.tsx / HouseholdSettingsPage.tsx / PreferencesPage.tsx │ │ ├── AccountSettingsPage.tsx / HouseholdSettingsPage.tsx / PreferencesPage.tsx
│ │ ├── UserPreferencesPage.tsx / CreditsPage.tsx │ │ ├── UserPreferencesPage.tsx / CreditsPage.tsx
│ │ └── settings-pages.scss │ │ └── settings-pages.scss
│ └── onboarding/ # wizard d'inscription (régime → foyer → [sources] → allergènes), voir plus bas │ └── onboarding/ # wizard d'inscription (régime → allergènes → foyer → [sources]), voir plus bas
│ ├── OnboardingDietPage.tsx / OnboardingHouseholdPage.tsx / OnboardingSourcesPage.tsx / OnboardingAllergensPage.tsx │ ├── OnboardingDietPage.tsx / OnboardingHouseholdPage.tsx / OnboardingSourcesPage.tsx / OnboardingAllergensPage.tsx
│ └── onboarding.scss │ └── onboarding.scss
├── styles/ ├── styles/
@ -156,9 +156,11 @@ flowchart TB
l'ancienne page combinée) redirige vers `/parametres/foyer` pour ne pas casser l'ancienne page combinée) redirige vers `/parametres/foyer` pour ne pas casser
un lien existant. un lien existant.
- **`/onboarding/*`** reste son propre groupe de routes top-level (pas nichées - **`/onboarding/*`** reste son propre groupe de routes top-level (pas nichées
sous `AppLayout` — wizard plein écran sans sidebar) : `regime` → `foyer` → sous `AppLayout` — wizard plein écran sans sidebar) : `regime` → `allergenes`
`sources` (conditionnelle — seulement si l'étape `foyer` a créé/rejoint un (les deux « préférences ») → `foyer` → `sources` (conditionnelle — seulement
foyer, sinon on saute directement à l'étape suivante) → `allergenes`. si l'étape `foyer` a **créé** un foyer ; rejoindre/passer termine le wizard
vers `/`). L'ordre et les compteurs « sur 3 » / « 4 sur 4 » vivent dans
`pages/onboarding/onboarding-steps.ts`.
--- ---
@ -226,11 +228,11 @@ Deux surfaces partagent les mêmes composants de champ (`features/profile/` +
```mermaid ```mermaid
flowchart LR flowchart LR
SIGNUP["SignupPage<br/>(POST /auth/signup)"] --> OB1["/onboarding/regime"] SIGNUP["SignupPage<br/>(POST /auth/signup)"] --> OB1["/onboarding/regime"]
OB1 --> OB2["/onboarding/foyer"] OB1 --> OB2["/onboarding/allergenes"]
OB2 -->|"foyer créé/rejoint"| OB3["/onboarding/sources"] OB2 --> OB3["/onboarding/foyer"]
OB2 -->|"pas de foyer"| OB4["/onboarding/allergenes"] OB3 -->|"foyer créé"| OB4["/onboarding/sources"]
OB3 --> OB4 OB3 -->|"rejoint / passé"| HOME["/ (PlanningPage)"]
OB4 --> HOME["/ (PlanningPage)"] OB4 --> HOME
SETTINGSMENU["Sidebar : Paramètres"] --> S1["/parametres/compte"] SETTINGSMENU["Sidebar : Paramètres"] --> S1["/parametres/compte"]
SETTINGSMENU --> S2["/parametres/preferences"] SETTINGSMENU --> S2["/parametres/preferences"]
@ -240,18 +242,23 @@ flowchart LR
SETTINGSMENU --> S6["/parametres/credits"] SETTINGSMENU --> S6["/parametres/credits"]
``` ```
- **`pages/onboarding/`** — wizard de **4 écrans** (régime → foyer → sources → - **`pages/onboarding/`** — wizard de **3 écrans** (régime → allergènes →
allergènes), lancé une seule fois juste après l'inscription. Routes foyer), + un **4ᵉ conditionnel** (sources), lancé une seule fois juste après
top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein l'inscription. Routes top-level `RequireAuth`, **pas** nichées sous
écran sans sidebar (`onboarding.scss`, même langage visuel que `AppLayout` : wizard plein écran sans sidebar (`onboarding.scss`, même
`/login`/`/signup`). Chaque étape a un unique bouton "Continuer" qui envoie langage visuel que `/login`/`/signup`). Régime et allergènes forment les
la valeur courante — pas de bouton "Passer" séparé, une valeur deux « préférences » ; chacun a un unique bouton "Continuer" qui envoie la
"aucune"/vide *est* le skip. `/onboarding/sources` est **conditionnelle** : valeur courante — une valeur "aucune"/vide *est* le skip. L'étape `foyer`,
seulement atteinte si l'étape `foyer` vient de créer/rejoindre un foyer elle, est explicitement optionnelle (créer / rejoindre / "Passer cette
(`OnboardingHouseholdPage`'s `goToNextStep`) — sautée sinon, directement vers étape"). `/onboarding/sources` est **conditionnelle** : seulement atteinte
`/onboarding/allergenes`. `OnboardingSourcesPage` elle-même redirige en si l'étape `foyer` vient de **créer** un foyer
silence vers l'étape suivante si `GET /reference/sources` revient vide ou en (`OnboardingHouseholdPage`'s `goToNextStep`) — rejoindre ou passer termine
erreur, plutôt que d'afficher une étape sans rien à choisir. le wizard vers `/`. `OnboardingSourcesPage` elle-même redirige en silence
vers `/` si `GET /reference/sources` revient vide ou en erreur, plutôt que
d'afficher une étape sans rien à choisir. L'ordre des étapes et les
compteurs affichés (« Étape 1 sur 3 » … « 4 sur 4 » pour le chemin avec
sources) sont centralisés dans `pages/onboarding/onboarding-steps.ts` —
chaque page les lisait auparavant en dur, ce qu'un réordonnancement cassait.
- **Pages `/parametres/*`** (`pages/settings/`, dans `AppLayout`) — l'ancienne - **Pages `/parametres/*`** (`pages/settings/`, dans `AppLayout`) — l'ancienne
`HouseholdPage` combinée a été **éclatée en 5 pages** dédiées (voir la `HouseholdPage` combinée a été **éclatée en 5 pages** dédiées (voir la
section suivante pour le détail de chacune), toutes en **hot saving** (pas section suivante pour le détail de chacune), toutes en **hot saving** (pas