Web: parcours d'inscription réordonné — régime, foyer (optionnel), allergènes (step 7/8)
- Ordre: régime (1) → foyer (2) → allergènes (3), au lieu de foyer → régime → allergènes - L'étape foyer est désormais optionnelle: créer, rejoindre par code d'invitation, ou passer (aucun foyer n'est créé au signup)
This commit is contained in:
parent
7abe030bb9
commit
60ca850042
5 changed files with 155 additions and 45 deletions
|
|
@ -37,7 +37,7 @@ export function SignupPage() {
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
/** Validates, then submits the form; on success, starts the household/regime/allergens onboarding wizard rather than going straight to the home. */
|
/** Validates, then submits the form; on success, starts the regime/household/allergens onboarding wizard rather than going straight to the home. */
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
@ -52,7 +52,7 @@ export function SignupPage() {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await signup(result.data);
|
await signup(result.data);
|
||||||
navigate("/onboarding/foyer");
|
navigate("/onboarding/regime");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// ApiError.code is looked up through ErrorMessageService so the
|
// ApiError.code is looked up through ErrorMessageService so the
|
||||||
// label is centralized and localized — never display err.message
|
// label is centralized and localized — never display err.message
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ import "./onboarding.scss";
|
||||||
* `/onboarding/allergenes`. Starts from an empty selection — a freshly
|
* `/onboarding/allergenes`. Starts from an empty selection — a freshly
|
||||||
* signed-up profile has none yet, so there's no need for the extra
|
* 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
|
* `GET /profile/allergies` round trip a "resume where I left off" flow
|
||||||
* would require (the `/foyer` settings page, task 10, is the always-fetch
|
* would require (the `/parametres/preferences` settings page is the
|
||||||
* source of truth for editing an existing selection later).
|
* always-fetch source of truth for editing an existing selection later).
|
||||||
*/
|
*/
|
||||||
export function OnboardingAllergensPage() {
|
export function OnboardingAllergensPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -68,13 +68,13 @@ export function OnboardingAllergensPage() {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AllergySelect
|
<AllergySelect
|
||||||
legend={t("household.form.allergiesLabel")}
|
legend={t("preferences.form.allergiesLabel")}
|
||||||
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
|
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
|
||||||
value={allergyIds}
|
value={allergyIds}
|
||||||
onChange={setAllergyIds}
|
onChange={setAllergyIds}
|
||||||
/>
|
/>
|
||||||
<AllergySelect
|
<AllergySelect
|
||||||
legend={t("household.form.intolerancesLabel")}
|
legend={t("preferences.form.intolerancesLabel")}
|
||||||
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
|
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
|
||||||
value={allergyIds}
|
value={allergyIds}
|
||||||
onChange={setAllergyIds}
|
onChange={setAllergyIds}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { errorMessageService } from "../../services/error-message.service";
|
||||||
import "./onboarding.scss";
|
import "./onboarding.scss";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Second step of the post-signup onboarding wizard, routed at
|
* First step of the post-signup onboarding wizard, routed at
|
||||||
* `/onboarding/regime`. Initial selection comes from `useAuth()`'s
|
* `/onboarding/regime`. Initial selection comes from `useAuth()`'s
|
||||||
* already-loaded profile (`user.dietId`) — freshly signed-up, this is
|
* already-loaded profile (`user.dietId`) — freshly signed-up, this is
|
||||||
* `null` — no extra fetch needed just to know the starting value, unlike
|
* `null` — no extra fetch needed just to know the starting value, unlike
|
||||||
|
|
@ -48,7 +48,7 @@ export function OnboardingDietPage() {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.updateDiet(dietId);
|
await apiClient.updateDiet(dietId);
|
||||||
navigate("/onboarding/allergenes");
|
navigate("/onboarding/foyer");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
setFormError(errorMessageService.getLabel(code));
|
setFormError(errorMessageService.getLabel(code));
|
||||||
|
|
@ -60,7 +60,7 @@ export function OnboardingDietPage() {
|
||||||
return (
|
return (
|
||||||
<main className="onboarding-page">
|
<main className="onboarding-page">
|
||||||
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
||||||
<p className="onboarding-step">{t("onboarding.step", { current: 2, total: 3 })}</p>
|
<p className="onboarding-step">{t("onboarding.step", { current: 1, total: 3 })}</p>
|
||||||
<h1>{t("onboarding.diet.title")}</h1>
|
<h1>{t("onboarding.diet.title")}</h1>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ErrorCode, renameHouseSchema } from "@batch-cooking/shared";
|
import { ErrorCode, type HouseView, renameHouseSchema } from "@batch-cooking/shared";
|
||||||
import { type FormEvent, useEffect, useState } from "react";
|
import { type FormEvent, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
@ -9,30 +9,32 @@ import { errorMessageService } from "../../services/error-message.service";
|
||||||
import "./onboarding.scss";
|
import "./onboarding.scss";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First step of the post-signup onboarding wizard, routed at
|
* Second step of the post-signup onboarding wizard, routed at
|
||||||
* `/onboarding/foyer` — behind {@link RequireAuth} (see `App.tsx`), reached
|
* `/onboarding/foyer` — unlike the regime/allergens steps, this one is
|
||||||
* right after `POST /auth/signup` creates the account and its household
|
* genuinely optional rather than just "skippable via an unedited default":
|
||||||
* (auto-named, see `auth.service.ts`). Prefills the current (default) name
|
* no household is created at signup anymore (see the API's
|
||||||
* so continuing without editing it is a valid, implicit "skip" — there's
|
* `auth.service.ts`), so there's nothing to prefill or implicitly keep by
|
||||||
* no separate skip button anywhere in this wizard, see `DietSelect`/
|
* pressing "Continuer" — the visitor explicitly creates one, joins one by
|
||||||
* `AllergySelect` for the same choice on the following steps.
|
* invite code, or skips the step outright via {@link SkipButton}.
|
||||||
|
*
|
||||||
|
* 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() {
|
export function OnboardingHouseholdPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
const [house, setHouse] = useState<HouseView | null>(null);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
apiClient
|
apiClient
|
||||||
.getCurrentHouse()
|
.getCurrentHouse()
|
||||||
.then((house) => {
|
.then((result) => {
|
||||||
if (!cancelled && house) setName(house.name);
|
if (!cancelled) setHouse(result);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setIsLoading(false);
|
if (!cancelled) setIsLoading(false);
|
||||||
|
|
@ -42,21 +44,58 @@ export function OnboardingHouseholdPage() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function goToNextStep() {
|
||||||
|
navigate("/onboarding/allergenes");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="onboarding-page">
|
||||||
|
<div className="onboarding-card">
|
||||||
|
<p className="onboarding-step">{t("onboarding.step", { current: 2, total: 3 })}</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}>
|
||||||
|
{t("onboarding.continue")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p>
|
||||||
|
<CreateHouseholdForm onDone={goToNextStep} />
|
||||||
|
<JoinHouseholdForm onDone={goToNextStep} />
|
||||||
|
<SkipButton onSkip={goToNextStep} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
||||||
const result = renameHouseSchema.safeParse({ name });
|
const result = renameHouseSchema.safeParse({ name });
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setFieldErrors(fieldErrorsFrom(result.error));
|
setFieldErrors(fieldErrorsFrom(result.error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.renameHouse(result.data.name);
|
await apiClient.createHouse(result.data.name);
|
||||||
navigate("/onboarding/regime");
|
onDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
setFormError(errorMessageService.getLabel(code));
|
setFormError(errorMessageService.getLabel(code));
|
||||||
|
|
@ -66,23 +105,62 @@ export function OnboardingHouseholdPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="onboarding-page">
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
<h2>{t("household.noHouse.createTitle")}</h2>
|
||||||
<p className="onboarding-step">{t("onboarding.step", { current: 1, total: 3 })}</p>
|
|
||||||
<h1>{t("onboarding.household.title")}</h1>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<p>{t("onboarding.loading")}</p>
|
|
||||||
) : (
|
|
||||||
<HouseNameField value={name} onChange={setName} error={fieldErrors.name} />
|
<HouseNameField value={name} onChange={setName} error={fieldErrors.name} />
|
||||||
)}
|
|
||||||
|
|
||||||
{formError && <p className="form-error">{formError}</p>}
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
<button type="submit" disabled={isSubmitting}>
|
||||||
<button type="submit" disabled={isSubmitting || isLoading}>
|
{t("household.noHouse.createButton")}
|
||||||
{t("onboarding.continue")}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,11 @@
|
||||||
margin-bottom: var(--space-sm);
|
margin-bottom: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: var(--space-md) 0 var(--space-xs);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
margin-top: var(--space-md);
|
margin-top: var(--space-md);
|
||||||
padding: 0.6rem;
|
padding: 0.6rem;
|
||||||
|
|
@ -59,6 +64,24 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The household step's "skip" action — a plain text link, not another
|
||||||
|
// filled button, so it doesn't visually compete with "Créer"/"Rejoindre"
|
||||||
|
// right above it (this is the one step of the wizard offering three
|
||||||
|
// distinct actions instead of one). The extra specificity of
|
||||||
|
// `.onboarding-card .onboarding-skip` (a class, not just `button`) is what
|
||||||
|
// lets this win over `.onboarding-card button`'s filled style above.
|
||||||
|
.onboarding-card .onboarding-skip {
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: underline;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: var(--color-text);
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.onboarding-step {
|
.onboarding-step {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
|
|
@ -68,6 +91,15 @@
|
||||||
margin: 0 0 var(--space-sm);
|
margin: 0 0 var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The household step's "this is optional" hint — same muted tone as
|
||||||
|
// `.onboarding-step` above, but a normal sentence (no uppercase transform).
|
||||||
|
.onboarding-subtitle {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue