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>
104 lines
3.8 KiB
TypeScript
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>
|
|
);
|
|
}
|