Web: parcours d'inscription réordonné — régime, foyer (optionnel), allergènes (step 7/8)

- Ordre: régime (1) → foyer (2) → allergènes (3), au lieu de
  foyer → régime → allergènes
- L'étape foyer est désormais optionnelle: créer, rejoindre par code
  d'invitation, ou passer (aucun foyer n'est créé au signup)
This commit is contained in:
Nicolas 2026-08-17 10:48:31 +02:00
parent 7abe030bb9
commit 60ca850042
5 changed files with 155 additions and 45 deletions

View file

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

View file

@ -13,8 +13,8 @@ import "./onboarding.scss";
* `/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).
* would require (the `/parametres/preferences` settings page is the
* always-fetch source of truth for editing an existing selection later).
*/
export function OnboardingAllergensPage() {
const { t } = useTranslation();
@ -68,13 +68,13 @@ export function OnboardingAllergensPage() {
) : (
<>
<AllergySelect
legend={t("household.form.allergiesLabel")}
legend={t("preferences.form.allergiesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
value={allergyIds}
onChange={setAllergyIds}
/>
<AllergySelect
legend={t("household.form.intolerancesLabel")}
legend={t("preferences.form.intolerancesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
value={allergyIds}
onChange={setAllergyIds}

View file

@ -10,7 +10,7 @@ import { errorMessageService } from "../../services/error-message.service";
import "./onboarding.scss";
/**
* Second step of the post-signup onboarding wizard, routed at
* First 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
@ -48,7 +48,7 @@ export function OnboardingDietPage() {
setIsSubmitting(true);
try {
await apiClient.updateDiet(dietId);
navigate("/onboarding/allergenes");
navigate("/onboarding/foyer");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
@ -60,7 +60,7 @@ export function OnboardingDietPage() {
return (
<main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">{t("onboarding.step", { current: 2, total: 3 })}</p>
<p className="onboarding-step">{t("onboarding.step", { current: 1, total: 3 })}</p>
<h1>{t("onboarding.diet.title")}</h1>
{isLoading ? (

View file

@ -1,4 +1,4 @@
import { ErrorCode, renameHouseSchema } from "@batch-cooking/shared";
import { ErrorCode, type HouseView, renameHouseSchema } from "@batch-cooking/shared";
import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@ -9,30 +9,32 @@ 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.
* Second step of the post-signup onboarding wizard, routed at
* `/onboarding/foyer` unlike the regime/allergens steps, this one is
* genuinely optional rather than just "skippable via an unedited default":
* no household is created at signup anymore (see the API's
* `auth.service.ts`), so there's nothing to prefill or implicitly keep by
* pressing "Continuer" the visitor explicitly creates one, joins one by
* invite code, or skips the step outright via {@link SkipButton}.
*
* Still checks `getCurrentHouse()` on mount and skips straight to the
* "already have one" state if it finds one defensive against revisiting
* this step (e.g. browser back) after already creating/joining a
* household earlier in the same wizard run.
*/
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);
const [house, setHouse] = useState<HouseView | null>(null);
useEffect(() => {
let cancelled = false;
apiClient
.getCurrentHouse()
.then((house) => {
if (!cancelled && house) setName(house.name);
.then((result) => {
if (!cancelled) setHouse(result);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
@ -42,21 +44,58 @@ export function OnboardingHouseholdPage() {
};
}, []);
function goToNextStep() {
navigate("/onboarding/allergenes");
}
return (
<main className="onboarding-page">
<div className="onboarding-card">
<p className="onboarding-step">{t("onboarding.step", { current: 2, total: 3 })}</p>
<h1>{t("onboarding.household.title")}</h1>
{isLoading ? (
<p>{t("onboarding.loading")}</p>
) : house !== null ? (
<>
<p>{t("onboarding.household.alreadyHasHouse", { name: house.name })}</p>
<button type="button" onClick={goToNextStep}>
{t("onboarding.continue")}
</button>
</>
) : (
<>
<p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p>
<CreateHouseholdForm onDone={goToNextStep} />
<JoinHouseholdForm onDone={goToNextStep} />
<SkipButton onSkip={goToNextStep} />
</>
)}
</div>
</main>
);
}
function CreateHouseholdForm({ onDone }: { onDone: () => void }) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
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");
await apiClient.createHouse(result.data.name);
onDone();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
@ -66,23 +105,62 @@ export function OnboardingHouseholdPage() {
}
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>
<form onSubmit={handleSubmit} noValidate>
<h2>{t("household.noHouse.createTitle")}</h2>
<HouseNameField value={name} onChange={setName} error={fieldErrors.name} />
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting}>
{t("household.noHouse.createButton")}
</button>
</form>
);
}
function JoinHouseholdForm({ onDone }: { onDone: () => void }) {
const { t } = useTranslation();
const [inviteCode, setInviteCode] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
setIsSubmitting(true);
try {
await apiClient.joinHouse(inviteCode.trim());
onDone();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<h2>{t("household.noHouse.joinTitle")}</h2>
<label htmlFor="inviteCode">{t("household.noHouse.joinLabel")}</label>
<input
id="inviteCode"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value.toUpperCase())}
autoComplete="off"
/>
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || inviteCode.trim().length === 0}>
{t("household.noHouse.joinButton")}
</button>
</form>
);
}
/** No API call — skipping just moves on, same "nothing to persist" idea as the regime/allergens steps' own skippability. */
function SkipButton({ onSkip }: { onSkip: () => void }) {
const { t } = useTranslation();
return (
<button type="button" className="onboarding-skip" onClick={onSkip}>
{t("onboarding.household.skip")}
</button>
);
}

View file

@ -36,6 +36,11 @@
margin-bottom: var(--space-sm);
}
h2 {
margin: var(--space-md) 0 var(--space-xs);
font-size: var(--font-size-base);
}
button {
margin-top: var(--space-md);
padding: 0.6rem;
@ -59,6 +64,24 @@
}
}
// The household step's "skip" action — a plain text link, not another
// filled button, so it doesn't visually compete with "Créer"/"Rejoindre"
// right above it (this is the one step of the wizard offering three
// distinct actions instead of one). The extra specificity of
// `.onboarding-card .onboarding-skip` (a class, not just `button`) is what
// lets this win over `.onboarding-card button`'s filled style above.
.onboarding-card .onboarding-skip {
background: none;
color: var(--color-text-muted);
font-weight: 600;
text-decoration: underline;
&:hover:not(:disabled) {
color: var(--color-text);
background: none;
}
}
.onboarding-step {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
@ -68,6 +91,15 @@
margin: 0 0 var(--space-sm);
}
// The household step's "this is optional" hint — same muted tone as
// `.onboarding-step` above, but a normal sentence (no uppercase transform).
.onboarding-subtitle {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
text-align: center;
margin: 0 0 var(--space-sm);
}
.form-error {
color: var(--color-error);
font-size: var(--font-size-sm);