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 [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) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
|
@ -52,7 +52,7 @@ export function SignupPage() {
|
|||
setIsSubmitting(true);
|
||||
try {
|
||||
await signup(result.data);
|
||||
navigate("/onboarding/foyer");
|
||||
navigate("/onboarding/regime");
|
||||
} catch (err) {
|
||||
// ApiError.code is looked up through ErrorMessageService so the
|
||||
// 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
|
||||
* 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).
|
||||
* would require (the `/parametres/preferences` settings page is the
|
||||
* always-fetch source of truth for editing an existing selection later).
|
||||
*/
|
||||
export function OnboardingAllergensPage() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -68,13 +68,13 @@ export function OnboardingAllergensPage() {
|
|||
) : (
|
||||
<>
|
||||
<AllergySelect
|
||||
legend={t("household.form.allergiesLabel")}
|
||||
legend={t("preferences.form.allergiesLabel")}
|
||||
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
|
||||
value={allergyIds}
|
||||
onChange={setAllergyIds}
|
||||
/>
|
||||
<AllergySelect
|
||||
legend={t("household.form.intolerancesLabel")}
|
||||
legend={t("preferences.form.intolerancesLabel")}
|
||||
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
|
||||
value={allergyIds}
|
||||
onChange={setAllergyIds}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { errorMessageService } from "../../services/error-message.service";
|
|||
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
|
||||
* already-loaded profile (`user.dietId`) — freshly signed-up, this is
|
||||
* `null` — no extra fetch needed just to know the starting value, unlike
|
||||
|
|
@ -48,7 +48,7 @@ export function OnboardingDietPage() {
|
|||
setIsSubmitting(true);
|
||||
try {
|
||||
await apiClient.updateDiet(dietId);
|
||||
navigate("/onboarding/allergenes");
|
||||
navigate("/onboarding/foyer");
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||
setFormError(errorMessageService.getLabel(code));
|
||||
|
|
@ -60,7 +60,7 @@ export function OnboardingDietPage() {
|
|||
return (
|
||||
<main className="onboarding-page">
|
||||
<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>
|
||||
|
||||
{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 { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
|
@ -9,30 +9,32 @@ 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.
|
||||
* Second step of the post-signup onboarding wizard, routed at
|
||||
* `/onboarding/foyer` — unlike the regime/allergens steps, 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}.
|
||||
*
|
||||
* 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 [name, setName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [house, setHouse] = useState<HouseView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
apiClient
|
||||
.getCurrentHouse()
|
||||
.then((house) => {
|
||||
if (!cancelled && house) setName(house.name);
|
||||
.then((result) => {
|
||||
if (!cancelled) setHouse(result);
|
||||
})
|
||||
.finally(() => {
|
||||
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) {
|
||||
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");
|
||||
await apiClient.createHouse(result.data.name);
|
||||
onDone();
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||
setFormError(errorMessageService.getLabel(code));
|
||||
|
|
@ -66,23 +105,62 @@ export function OnboardingHouseholdPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<main className="onboarding-page">
|
||||
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
||||
<p className="onboarding-step">{t("onboarding.step", { current: 1, total: 3 })}</p>
|
||||
<h1>{t("onboarding.household.title")}</h1>
|
||||
|
||||
{isLoading ? (
|
||||
<p>{t("onboarding.loading")}</p>
|
||||
) : (
|
||||
<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 || isLoading}>
|
||||
{t("onboarding.continue")}
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{t("household.noHouse.createButton")}
|
||||
</button>
|
||||
</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);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: var(--space-md) 0 var(--space-xs);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: var(--space-md);
|
||||
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 {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
|
|
@ -68,6 +91,15 @@
|
|||
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 {
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-sm);
|
||||
|
|
|
|||
Loading…
Reference in a new issue