diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3709ca9..94cc69c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -8,6 +8,9 @@ import { LoginPage } from "./pages/LoginPage"; import { RecipesPage } from "./pages/RecipesPage"; import { ShoppingListPage } from "./pages/ShoppingListPage"; import { SignupPage } from "./pages/SignupPage"; +import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage"; +import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage"; +import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage"; /** * Top-level route table. Every authenticated section is nested under one @@ -16,6 +19,12 @@ import { SignupPage } from "./pages/SignupPage"; * `/signup` redirect an already-logged-in visitor to `/` instead (see * {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which * itself redirects to `/login` if needed. + * + * `/onboarding/*` (household/regime/allergens) is also `RequireAuth`-gated + * — reached right after signup, once a session already exists — but + * deliberately its own top-level route group, *not* nested under + * `AppLayout`: a focused, distraction-free wizard with no sidebar, same + * full-page-card language as `/login`/`/signup` (see `onboarding.scss`). */ export function App() { return ( @@ -32,6 +41,30 @@ export function App() { } /> } /> + + + + } + /> + + + + } + /> + + + + } + /> `, competing with — and + * sometimes winning against — the page's explicit `navigate(...)` call to + * a different destination. Previously invisible only because the old + * `SignupPage` happened to navigate to the same place ("/") this guard + * does; became visible once it needed to route to `/onboarding/foyer` + * instead. */ export function RedirectIfAuthenticated({ children }: { children: ReactNode }) { const { user, isLoading } = useAuth(); + const shouldRedirect = useRef(null); - if (isLoading) { + if (!isLoading && shouldRedirect.current === null) { + shouldRedirect.current = user !== null; + } + + if (isLoading || shouldRedirect.current === null) { return null; } - if (user) { + if (shouldRedirect.current) { return ; } return <>{children}>; diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 22894db..0bd77e8 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -32,6 +32,21 @@ "loginLink": "Se connecter" } }, + "onboarding": { + "step": "Étape {{current}} sur {{total}}", + "continue": "Continuer", + "finish": "Terminer", + "loading": "Chargement…", + "household": { + "title": "Comment s'appelle votre foyer ?" + }, + "diet": { + "title": "Un régime alimentaire particulier ?" + }, + "allergens": { + "title": "Des allergies ou intolérances ?" + } + }, "layout": { "nav": { "planning": "Planning", diff --git a/apps/web/src/pages/SignupPage.tsx b/apps/web/src/pages/SignupPage.tsx index 662d839..56260ba 100644 --- a/apps/web/src/pages/SignupPage.tsx +++ b/apps/web/src/pages/SignupPage.tsx @@ -37,7 +37,7 @@ export function SignupPage() { const [formError, setFormError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - /** Validates, then submits the form; navigates home on success. */ + /** Validates, then submits the form; on success, starts the household/regime/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("/"); + navigate("/onboarding/foyer"); } catch (err) { // ApiError.code is looked up through ErrorMessageService so the // label is centralized and localized — never display err.message diff --git a/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx b/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx new file mode 100644 index 0000000..ff433a2 --- /dev/null +++ b/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx @@ -0,0 +1,80 @@ +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 `/foyer` settings page, task 10, is the always-fetch + * source of truth for editing an existing selection later). + */ +export function OnboardingAllergensPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [allergies, setAllergies] = useState([]); + const [allergyIds, setAllergyIds] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + let cancelled = false; + apiClient + .getAllergies() + .then((result) => { + if (!cancelled) setAllergies(result); + }) + .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 ( + + + {t("onboarding.step", { current: 3, total: 3 })} + {t("onboarding.allergens.title")} + + {isLoading ? ( + {t("onboarding.loading")} + ) : ( + + )} + + {formError && {formError}} + + + {t("onboarding.finish")} + + + + ); +} diff --git a/apps/web/src/pages/onboarding/OnboardingDietPage.tsx b/apps/web/src/pages/onboarding/OnboardingDietPage.tsx new file mode 100644 index 0000000..a29fef9 --- /dev/null +++ b/apps/web/src/pages/onboarding/OnboardingDietPage.tsx @@ -0,0 +1,80 @@ +import type { DietView } 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 { useAuth } from "../../features/auth/AuthContext"; +import { DietSelect } from "../../features/profile/DietSelect"; +import { errorMessageService } from "../../services/error-message.service"; +import "./onboarding.scss"; + +/** + * Second 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 + * the household step which has no such shortcut for the current name. + */ +export function OnboardingDietPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { user } = useAuth(); + + const [diets, setDiets] = useState([]); + const [dietId, setDietId] = useState(user?.dietId ?? null); + const [isLoading, setIsLoading] = useState(true); + const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + let cancelled = false; + apiClient + .getDiets() + .then((result) => { + if (!cancelled) setDiets(result); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setFormError(null); + setIsSubmitting(true); + try { + await apiClient.updateDiet(dietId); + navigate("/onboarding/allergenes"); + } catch (err) { + const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; + setFormError(errorMessageService.getLabel(code)); + } finally { + setIsSubmitting(false); + } + } + + return ( + + + {t("onboarding.step", { current: 2, total: 3 })} + {t("onboarding.diet.title")} + + {isLoading ? ( + {t("onboarding.loading")} + ) : ( + + )} + + {formError && {formError}} + + + {t("onboarding.continue")} + + + + ); +} diff --git a/apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx b/apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx new file mode 100644 index 0000000..eef0d85 --- /dev/null +++ b/apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx @@ -0,0 +1,88 @@ +import { ErrorCode, 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 "./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. + */ +export function OnboardingHouseholdPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [name, setName] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [fieldErrors, setFieldErrors] = useState>({}); + const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + let cancelled = false; + apiClient + .getCurrentHouse() + .then((house) => { + if (!cancelled && house) setName(house.name); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + 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"); + } catch (err) { + const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; + setFormError(errorMessageService.getLabel(code)); + } finally { + setIsSubmitting(false); + } + } + + return ( + + + {t("onboarding.step", { current: 1, total: 3 })} + {t("onboarding.household.title")} + + {isLoading ? ( + {t("onboarding.loading")} + ) : ( + + )} + + {formError && {formError}} + + + {t("onboarding.continue")} + + + + ); +} diff --git a/apps/web/src/pages/onboarding/onboarding.scss b/apps/web/src/pages/onboarding/onboarding.scss new file mode 100644 index 0000000..379c8eb --- /dev/null +++ b/apps/web/src/pages/onboarding/onboarding.scss @@ -0,0 +1,74 @@ +// ============================================================================= +// Styles shared by the three onboarding wizard pages (OnboardingHousehold, +// OnboardingDiet, OnboardingAllergens) — the household/regime/allergens +// steps of the profile journey, run once right after signup. +// +// Deliberately its own file rather than importing features/auth/ +// auth-form.scss: same visual language (centered card) but a different +// feature area — matches the project's existing convention of colocating +// styles per feature rather than sharing a "global card" partial (see +// HomePage.scss, which makes the same call for its own button styling). +// ============================================================================= + +.onboarding-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-md); + background: var(--color-background); +} + +.onboarding-card { + display: flex; + flex-direction: column; + gap: var(--space-xs); + width: 100%; + max-width: var(--max-width-form); + padding: var(--space-xl); + background: var(--color-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + + h1 { + font-size: var(--font-size-lg); + text-align: center; + margin-bottom: var(--space-sm); + } + + button { + margin-top: var(--space-md); + padding: 0.6rem; + font-family: var(--font-body); + font-size: var(--font-size-base); + font-weight: 600; + cursor: pointer; + border-radius: var(--radius-base); + border: none; + background: var(--color-primary); + color: #fff; + + &:hover:not(:disabled) { + background: var(--color-primary-hover); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } +} + +.onboarding-step { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + text-align: center; + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0 0 var(--space-sm); +} + +.form-error { + color: var(--color-error); + font-size: var(--font-size-sm); +}
{t("onboarding.step", { current: 3, total: 3 })}
{t("onboarding.loading")}
{formError}
{t("onboarding.step", { current: 2, total: 3 })}
{t("onboarding.step", { current: 1, total: 3 })}