Compare commits

..

No commits in common. "36d26c2739a2559c825fbeffbdbed4c0bc1fc60d" and "61079b3a2ad67d1f29999ff65ceb09dbca2aaab6" have entirely different histories.

10 changed files with 128 additions and 204 deletions

View file

@ -1,54 +1,41 @@
Feature: Onboarding wizard Feature: Onboarding wizard
As a newly signed-up user As a newly signed-up user
I want to set my preferences (diet + allergens), then my household I want to set my diet, household, and allergens
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 every step, creating a household (which surfaces the sources step), and lands on the home page Scenario: Walks through the wizard, creating a household on the way (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/allergenes"
And I should see "Étape 2 sur 3"
And I should see the section "Allergies"
And I should see the section "Intolérances"
When I check the checkbox "Arachides"
And I click the button "Continuer"
Then the allergies update request should have been made with allergy id 1
And the URL should include "/onboarding/foyer" And the URL should include "/onboarding/foyer"
And I should see "Étape 3 sur 3" And I should see "Étape 2 sur 3"
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 include "/onboarding/allergenes"
And I should see "Étape 4 sur 4" And I should see "Étape 4 sur 4"
And I should see the section "Sources disponibles" And I should see the section "Allergies"
And I should see "TheMealDB" And I should see the section "Intolérances"
When I check the checkbox "TheMealDB" When I check the checkbox "Arachides"
And I click the button "Terminer" And I click the button "Terminer"
Then the source selection update request should have been made with source id 1 Then the allergies update request should have been made with allergy 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 preferences steps be skipped without changing anything, then skips the household step too Scenario: Lets the regime and allergens steps be skipped without changing anything
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
@ -58,32 +45,39 @@ 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 be the home page Then the URL should include "/onboarding/allergenes"
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: Creating a household with no sources implemented ends the wizard through the silently-skipped sources step Scenario: Shows and saves the sources step instead of skipping it, when sources are available
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 is empty And the sources reference list has options
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 be the home page And the URL should include "/onboarding/sources"
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: Joining an existing household skips the sources step entirely and ends the wizard Scenario: Lets the household step be completed by joining an existing household instead of creating one
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
@ -93,10 +87,8 @@ 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 be the home page And the URL should include "/onboarding/allergenes"

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: [] });
}); });
// An onboarding scenario whose household step *creates* a household then // Every onboarding scenario that reaches the household step also reaches
// reaches `/onboarding/sources` (see `OnboardingHouseholdPage`'s // `/onboarding/sources` right after (when a household got created/joined —
// `goToNextStep`), which reads this before self-skipping straight to the // see `OnboardingHouseholdPage`'s `goToNextStep`), which reads this before
// home page if it comes back empty. // self-skipping to `/onboarding/allergenes` 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,16 +51,14 @@ 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/allergenes/foyer/sources, in that order) is also * `/onboarding/*` (regime/foyer/sources/allergens, 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`).
* `regime` + `allergenes` are the two "préférences" screens; `/onboarding/sources` * `/onboarding/sources` is conditional — only reached when the `foyer` step
* is conditional — only reached when the `foyer` step *created* a household * created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
* (see `OnboardingHouseholdPage`'s `goToNextStep`), every other path ending * skipped otherwise, straight to `/onboarding/allergenes`.
* 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
@ -140,14 +138,6 @@ export function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/onboarding/allergenes"
element={
<RequireAuth>
<OnboardingAllergensPage />
</RequireAuth>
}
/>
<Route <Route
path="/onboarding/foyer" path="/onboarding/foyer"
element={ element={
@ -164,6 +154,14 @@ 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 régime → allergènes → foyer onboarding wizard rather than going straight to the home. */ /** Validates, then submits the form; on success, starts the regime/household/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);

View file

@ -6,23 +6,22 @@ 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";
/** /**
* Second step of the post-signup onboarding wizard, routed at * Last step of the post-signup onboarding wizard, routed at
* `/onboarding/allergenes` — the allergens half of "préférences" (régime * `/onboarding/allergenes`. Starts from an empty selection — a freshly
* is the first half), now ahead of the household step rather than the * signed-up profile has none yet, so there's no need for the extra
* 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).
* *
* No household fetch anymore: this step no longer needs to retroactively * Also fetches the current household on mount, only to know whether the
* work out whether a sources step ran, since it now comes before the * `/onboarding/sources` step ran before this one (household step created
* household step entirely — the "sur 3" vs "4 sur 4" split lives in * one and it had a source to configure) — the step count shown reflects
* {@link onboardingStepIndicator}. * whichever path the visitor actually took, "3 sur 3" or "4 sur 4"
* (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();
@ -30,22 +29,27 @@ 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;
apiClient Promise.all([apiClient.getAllergies(), apiClient.getCurrentHouse()])
.getAllergies() .then(([allergiesResult, house]) => {
.then((allergiesResult) => { if (cancelled) return;
if (!cancelled) setAllergies(allergiesResult); 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);
}); });
@ -54,14 +58,14 @@ export function OnboardingAllergensPage() {
}; };
}, []); }, []);
/** Moves on to the household step — preferences (régime + allergènes) are done. */ /** Finishes the wizard — the profile journey is complete, back to the app itself. */
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("/onboarding/foyer"); 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));
@ -74,7 +78,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", onboardingStepIndicator("allergens"))} {t("onboarding.step", { current: totalSteps, total: totalSteps })}
</p> </p>
<h1>{t("onboarding.allergens.title")}</h1> <h1>{t("onboarding.allergens.title")}</h1>
@ -100,7 +104,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.continue")} {t("onboarding.finish")}
</button> </button>
</form> </form>
</main> </main>

View file

@ -7,7 +7,6 @@ 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";
/** /**
@ -16,9 +15,6 @@ 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();
@ -57,7 +53,7 @@ export function OnboardingDietPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await apiClient.updateDiet(dietId); await apiClient.updateDiet(dietId);
void navigate("/onboarding/allergenes"); 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));
@ -69,7 +65,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", onboardingStepIndicator("regime"))}</p> <p className="onboarding-step">{t("onboarding.step", { current: 1, total: 3 })}</p>
<h1>{t("onboarding.diet.title")}</h1> <h1>{t("onboarding.diet.title")}</h1>
{isLoading ? ( {isLoading ? (

View file

@ -6,24 +6,16 @@ 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";
/** /**
* Third step of the post-signup onboarding wizard, routed at * Second step of the post-signup onboarding wizard, routed at
* `/onboarding/foyer` — after the régime + allergènes "préférences" steps * `/onboarding/foyer` — unlike the regime/allergens steps, this one is
* (see #129). Unlike those, this one is genuinely optional rather than * genuinely optional rather than just "skippable via an unedited default":
* just "skippable via an unedited default": no household is created at * no household is created at signup anymore (see the API's
* signup anymore (see the API's `auth.service.ts`), so there's nothing to * `auth.service.ts`), so there's nothing to prefill or implicitly keep by
* prefill or implicitly keep by pressing "Continuer" — the visitor * pressing "Continuer" — the visitor explicitly creates one, joins one by
* explicitly creates one, joins one by invite code, or skips the step * invite code, or skips the step outright via {@link SkipButton}.
* 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
@ -58,22 +50,19 @@ export function OnboardingHouseholdPage() {
}, []); }, []);
/** /**
* `/onboarding/sources` only makes sense for a household the visitor just * A household is what the next step (`/onboarding/sources`) needs to
* *created* — that's the sole caller passing `true`. Joining one, skipping * attach a preference to — created/joined/already-there goes on to it,
* the step, or finding one already there (browser back — can't tell * skipped goes straight to `/onboarding/allergenes` instead (nothing to
* created from joined, and the sources step self-skips when there's * configure sources for without a household).
* nothing to configure anyway) all end the wizard at `/`.
*/ */
function goToNextStep(createdHousehold: boolean) { function goToNextStep(hasHousehold: boolean) {
void navigate(createdHousehold ? "/onboarding/sources" : "/"); void navigate(hasHousehold ? "/onboarding/sources" : "/onboarding/allergenes");
} }
return ( return (
<main className="onboarding-page"> <main className="onboarding-page">
<div className="onboarding-card"> <div className="onboarding-card">
<p className="onboarding-step"> <p className="onboarding-step">{t("onboarding.step", { current: 2, total: 3 })}</p>
{t("onboarding.step", onboardingStepIndicator("household"))}
</p>
<h1>{t("onboarding.household.title")}</h1> <h1>{t("onboarding.household.title")}</h1>
{isLoading ? ( {isLoading ? (
@ -81,15 +70,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(false)}> <button type="button" onClick={() => goToNextStep(true)}>
{t("onboarding.finish")} {t("onboarding.continue")}
</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(false)} /> <JoinHouseholdForm onDone={() => goToNextStep(true)} />
<SkipButton onSkip={() => goToNextStep(false)} /> <SkipButton onSkip={() => goToNextStep(false)} />
</> </>
)} )}

View file

@ -6,23 +6,22 @@ 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";
/** /**
* Final step of the post-signup onboarding wizard, routed at * Third step of the post-signup onboarding wizard, routed at
* `/onboarding/sources` — reached only when the previous step *created* a * `/onboarding/sources` — only reached when a household now exists (see
* household (see `OnboardingHouseholdPage`'s `goToNextStep`, which ends the * `OnboardingHouseholdPage`'s `goToStep`, which skips straight to
* wizard at `/` on every other path): enabled sources are a per-household * `/onboarding/allergenes` when the previous step was skipped): enabled
* preference (`HouseSource`, schema.prisma), so there's nothing to attach * sources are a per-household preference (`HouseSource`, schema.prisma),
* a selection to otherwise. * so there's nothing to attach a selection to without one.
* *
* Starts from an empty selection — a newly created household has nothing * Starts from an empty selection — a newly created/joined household has
* enabled yet by design (opt-in, see `house.service.ts`'s * nothing 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 finishes the wizard (straight to `/`) * from either, so this step silently steps itself over rather than showing
* rather than showing an empty screen. * an empty screen.
*/ */
export function OnboardingSourcesPage() { export function OnboardingSourcesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -41,7 +40,7 @@ export function OnboardingSourcesPage() {
.then((result) => { .then((result) => {
if (cancelled) return; if (cancelled) return;
if (result.length === 0) { if (result.length === 0) {
void navigate("/", { replace: true }); void navigate("/onboarding/allergenes", { replace: true });
return; return;
} }
setSources(result); setSources(result);
@ -51,7 +50,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("/", { replace: true }); if (!cancelled) void navigate("/onboarding/allergenes", { replace: true });
}); });
return () => { return () => {
cancelled = true; cancelled = true;
@ -64,7 +63,7 @@ export function OnboardingSourcesPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await apiClient.updateHouseSourceIds(sourceIds); await apiClient.updateHouseSourceIds(sourceIds);
void navigate("/"); 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));
@ -76,9 +75,7 @@ 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"> <p className="onboarding-step">{t("onboarding.step", { current: 3, total: 4 })}</p>
{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>
@ -96,7 +93,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.finish")} {t("onboarding.continue")}
</button> </button>
</form> </form>
</main> </main>

View file

@ -1,45 +0,0 @@
/**
* 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 → allergènes → foyer → [sources]), voir plus bas │ └── onboarding/ # wizard d'inscription (régime → foyer → [sources] → allergènes), 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,11 +156,9 @@ 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` → `allergenes` sous `AppLayout` — wizard plein écran sans sidebar) : `regime` → `foyer` →
(les deux « préférences ») → `foyer` → `sources` (conditionnelle — seulement `sources` (conditionnelle — seulement si l'étape `foyer` a créé/rejoint un
si l'étape `foyer` a **créé** un foyer ; rejoindre/passer termine le wizard foyer, sinon on saute directement à l'étape suivante) → `allergenes`.
vers `/`). L'ordre et les compteurs « sur 3 » / « 4 sur 4 » vivent dans
`pages/onboarding/onboarding-steps.ts`.
--- ---
@ -228,11 +226,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/allergenes"] OB1 --> OB2["/onboarding/foyer"]
OB2 --> OB3["/onboarding/foyer"] OB2 -->|"foyer créé/rejoint"| OB3["/onboarding/sources"]
OB3 -->|"foyer créé"| OB4["/onboarding/sources"] OB2 -->|"pas de foyer"| OB4["/onboarding/allergenes"]
OB3 -->|"rejoint / passé"| HOME["/ (PlanningPage)"] OB3 --> OB4
OB4 --> HOME OB4 --> HOME["/ (PlanningPage)"]
SETTINGSMENU["Sidebar : Paramètres"] --> S1["/parametres/compte"] SETTINGSMENU["Sidebar : Paramètres"] --> S1["/parametres/compte"]
SETTINGSMENU --> S2["/parametres/preferences"] SETTINGSMENU --> S2["/parametres/preferences"]
@ -242,23 +240,18 @@ flowchart LR
SETTINGSMENU --> S6["/parametres/credits"] SETTINGSMENU --> S6["/parametres/credits"]
``` ```
- **`pages/onboarding/`** — wizard de **3 écrans** (régime → allergènes → - **`pages/onboarding/`** — wizard de **4 écrans** (régime → foyer → sources →
foyer), + un **4ᵉ conditionnel** (sources), lancé une seule fois juste après allergènes), lancé une seule fois juste après l'inscription. Routes
l'inscription. Routes top-level `RequireAuth`, **pas** nichées sous top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein
`AppLayout` : wizard plein écran sans sidebar (`onboarding.scss`, même écran sans sidebar (`onboarding.scss`, même langage visuel que
langage visuel que `/login`/`/signup`). Régime et allergènes forment les `/login`/`/signup`). Chaque étape a un unique bouton "Continuer" qui envoie
deux « préférences » ; chacun a un unique bouton "Continuer" qui envoie la la valeur courante — pas de bouton "Passer" séparé, une valeur
valeur courante — une valeur "aucune"/vide *est* le skip. L'étape `foyer`, "aucune"/vide *est* le skip. `/onboarding/sources` est **conditionnelle** :
elle, est explicitement optionnelle (créer / rejoindre / "Passer cette seulement atteinte si l'étape `foyer` vient de créer/rejoindre un foyer
étape"). `/onboarding/sources` est **conditionnelle** : seulement atteinte (`OnboardingHouseholdPage`'s `goToNextStep`) — sautée sinon, directement vers
si l'étape `foyer` vient de **créer** un foyer `/onboarding/allergenes`. `OnboardingSourcesPage` elle-même redirige en
(`OnboardingHouseholdPage`'s `goToNextStep`) — rejoindre ou passer termine silence vers l'étape suivante si `GET /reference/sources` revient vide ou en
le wizard vers `/`. `OnboardingSourcesPage` elle-même redirige en silence erreur, plutôt que d'afficher une étape sans rien à choisir.
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