batchCooking/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx
Nicolas 766d48eaa5 feat(recipes): préférences de sources par foyer + distinction officielle/non-officielle
Répond à deux besoins : permettre à chaque foyer de choisir quelles
sources apparaissent dans ses onglets de recettes, et distinguer les
sources à API officielle des sources scrapées.

- RecipeSourceAdapter.official (booléen, sans défaut — chaque
  adaptateur doit le déclarer explicitement) synchronisé sur
  Source.official par syncRecipeSources.
- HouseSource : table de jointure opt-in (House <-> Source) — aucune
  ligne = source masquée. Un foyer nouvellement créé ne voit aucune
  source tant qu'il ne les active pas explicitement.
- GET /reference/sources (catalogue des sources implémentées, avec le
  flag officiel).
- GET/PATCH /house/current/sources (lecture/remplacement complet des
  sources activées par le foyer courant).
- recipe.service.ts : sourceVisibilityWhere() filtre désormais TOUS
  les onglets (perso/foyer/publique/favoris) — une recette sans
  source reste toujours visible ; une recette importée ne l'est que
  si sa source est activée pour le foyer du viewer. Un viewer sans
  foyer ne voit aucune recette sourcée.

Côté web :
- Nouvelle étape /onboarding/sources dans le wizard d'inscription,
  atteinte uniquement si un foyer vient d'être créé/rejoint (sinon on
  saute direct aux allergènes) ; s'auto-saute aussi si aucune source
  n'est encore implémentée (catalogue vide aujourd'hui).
- Nouvelle section « Sources de recettes » dans /parametres/foyer
  (masquée dans les mêmes conditions), avec sauvegarde à la volée
  (même pattern que les autres préférences hot-saved).
- SourceSelect (features/house/), grille de cases à cocher avec badge
  officiel/non-officielle, sur le même principe qu'AllergySelect.

172 tests backend passent (dont 25 nouveaux). Build et lint propres.
Vérifié manuellement en navigateur : le parcours d'onboarding saute
bien l'étape sources (catalogue vide) et affiche « 4 sur 4 » quand un
foyer a été créé ; la section paramètres reste invisible tant
qu'aucune source n'existe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:22:54 +02:00

104 lines
3.8 KiB
TypeScript

import type { AllergyView } from "@batch-cooking/shared";
import { ErrorCode } 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 { AllergySelect } from "../../features/profile/AllergySelect";
import { errorMessageService } from "../../services/error-message.service";
import "./onboarding.scss";
/**
* Last step of the post-signup onboarding wizard, routed at
* `/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 `/parametres/preferences` settings page is the
* always-fetch source of truth for editing an existing selection later).
*
* Also fetches the current household on mount, only to know whether the
* `/onboarding/sources` step ran before this one (household step created
* one and it had a source to configure) — the step count shown reflects
* 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() {
const { t } = useTranslation();
const navigate = useNavigate();
const [allergies, setAllergies] = useState<AllergyView[]>([]);
const [allergyIds, setAllergyIds] = useState<number[]>([]);
const [totalSteps, setTotalSteps] = useState(3);
const [isLoading, setIsLoading] = useState(true);
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
let cancelled = false;
Promise.all([apiClient.getAllergies(), apiClient.getCurrentHouse()])
.then(([allergiesResult, house]) => {
if (cancelled) return;
setAllergies(allergiesResult);
setTotalSteps(house !== null ? 4 : 3);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
/** Finishes the wizard — the profile journey is complete, back to the app itself. */
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
setIsSubmitting(true);
try {
await apiClient.updateAllergyIds(allergyIds);
navigate("/");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
} finally {
setIsSubmitting(false);
}
}
return (
<main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">
{t("onboarding.step", { current: totalSteps, total: totalSteps })}
</p>
<h1>{t("onboarding.allergens.title")}</h1>
{isLoading ? (
<p>{t("onboarding.loading")}</p>
) : (
<>
<AllergySelect
legend={t("preferences.form.allergiesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
value={allergyIds}
onChange={setAllergyIds}
/>
<AllergySelect
legend={t("preferences.form.intolerancesLabel")}
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
value={allergyIds}
onChange={setAllergyIds}
/>
</>
)}
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.finish")}
</button>
</form>
</main>
);
}