All checks were successful
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>
188 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|