batchCooking/apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx
Nicolas 2a3fb0434a
All checks were successful
CI / lint (push) Successful in 3m54s
CI / build (push) Successful in 4m10s
CI / intent-service-test (push) Successful in 5m15s
CI / e2e (push) Successful in 8m32s
CI / test (push) Successful in 23m8s
CI / build-android (push) Has been skipped
feat(onboarding): réordonne le wizard en compte → préférences → foyer → sources (#129)
Nouvel ordre : régime → allergènes (les deux « préférences ») → foyer →
sources. L'étape sources devient la dernière et n'est atteinte que si
l'étape foyer vient de **créer** un foyer — rejoindre ou passer termine le
wizard vers `/` (un foyer rejoint a déjà ses sources ; un foyer passé n'a
rien à quoi les rattacher).

- `onboarding-steps.ts` (nouveau) : séquence des étapes + `onboardingStepIndicator()`
  en un seul endroit. Les compteurs « Étape N sur M » étaient codés en dur
  page par page, ce qu'un réordonnancement cassait. régime/allergènes/foyer
  sont en amont du choix « créer vs rejoindre/passer » donc affichent « sur 3 » ;
  seule l'étape sources, jamais atteinte hors du chemin à 4 étapes, affiche « 4 sur 4 ».
- `OnboardingDietPage` → enchaîne sur `/onboarding/allergenes`.
- `OnboardingAllergensPage` → enchaîne sur `/onboarding/foyer` ; bouton « Continuer »
  (plus « Terminer ») ; suppression du `getCurrentHouse()` + `totalSteps` dynamique,
  devenus inutiles maintenant que cette étape précède le foyer.
- `OnboardingHouseholdPage` → `goToNextStep(createdHousehold)` : `/onboarding/sources`
  seulement si créé, sinon `/`. `JoinHouseholdForm`/`SkipButton`/branche défensive
  « déjà un foyer » terminent vers `/`.
- `OnboardingSourcesPage` → dernière étape : submit, court-circuit « aucune source »
  et catch vont tous vers `/` ; bouton « Terminer ».
- `App.tsx` : blocs `<Route>` réordonnés + commentaire de doc du groupe.
- `specs/frontend-architecture.md` : ordre, diagramme mermaid, condition sources.

Tests : `onboarding.feature` réécrit — 4 parcours (walk-through complet avec
foyer créé + sources ; préférences + foyer passés ; foyer créé sans source
implémentée → étape sources auto-sautée ; foyer rejoint → pas de sources),
compteurs vérifiés sur chaque chemin. `pnpm lint` / `pnpm build` verts ;
Cypress local : onboarding 4/4, régression auth/preferences/household-settings 16/16.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:34:49 +02:00

188 lines
6.9 KiB
TypeScript

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";
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 { onboardingStepIndicator } from "./onboarding-steps";
import "./onboarding.scss";
/**
* Third step of the post-signup onboarding wizard, routed at
* `/onboarding/foyer` — after the régime + allergènes "préférences" steps
* (see #129). Unlike those, 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}.
*
* 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
* "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 [isLoading, setIsLoading] = useState(true);
const [house, setHouse] = useState<HouseView | null>(null);
useEffect(() => {
let cancelled = false;
apiClient
.getCurrentHouse()
.then((result) => {
if (!cancelled) setHouse(result);
})
// Not essential for this step to render — `house` just stays `null`
// (same as a genuinely household-less profile) rather than
// stranding the visitor over a transient failure, same reasoning as
// OnboardingSourcesPage's own catch.
.catch(() => {})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
/**
* `/onboarding/sources` only makes sense for a household the visitor just
* *created* — that's the sole caller passing `true`. Joining one, skipping
* the step, or finding one already there (browser back — can't tell
* created from joined, and the sources step self-skips when there's
* nothing to configure anyway) all end the wizard at `/`.
*/
function goToNextStep(createdHousehold: boolean) {
void navigate(createdHousehold ? "/onboarding/sources" : "/");
}
return (
<main className="onboarding-page">
<div className="onboarding-card">
<p className="onboarding-step">
{t("onboarding.step", onboardingStepIndicator("household"))}
</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(false)}>
{t("onboarding.finish")}
</button>
</>
) : (
<>
<p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p>
<CreateHouseholdForm onDone={() => goToNextStep(true)} />
<JoinHouseholdForm onDone={() => goToNextStep(false)} />
<SkipButton onSkip={() => goToNextStep(false)} />
</>
)}
</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.createHouse(result.data.name);
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.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>
);
}