Web: wizard d'inscription (foyer/régime/allergènes) (step 4/6)
- SignupPage: après signup(), navigate("/onboarding/foyer") au lieu de
"/" — la home reste inchangée, seule la destination change.
- pages/onboarding/: 3 routes top-level RequireAuth-gated (PAS nichées
sous AppLayout — wizard plein écran sans sidebar, même langage visuel
que /login|/signup) :
- /onboarding/foyer — HouseNameField, préremplie avec le nom
auto-généré du foyer (continuer sans éditer = skip implicite).
- /onboarding/regime — DietSelect, valeur initiale depuis
useAuth().user.dietId (pas de fetch supplémentaire nécessaire).
- /onboarding/allergenes — AllergySelect, termine sur navigate("/").
- Bug trouvé et corrigé en testant dans le navigateur : RedirectIfAuthenticated
redirigeait vers "/" en course avec le navigate() explicite de
SignupPage — `user` devient non-null (via signup()) pendant que
SignupPage est encore monté sous ce guard, qui réagit et redirige
avant que le navigate("/onboarding/foyer") ne prenne effet. Latent
depuis le début (invisible avant car l'ancien SignupPage naviguait
aussi vers "/", donc les deux redirections concordaient). Fix : la
décision de redirection est verrouillée une seule fois, au moment où
la vérification initiale (`isLoading`) se termine, plutôt que
réévaluée à chaque changement de `user`.
Vérifié de bout en bout dans le navigateur (inscription → 3 étapes →
home), données confirmées en base (foyer renommé, régime + 2 allergènes
enregistrés), puis nettoyage des comptes de test.
This commit is contained in:
parent
e512c33ffc
commit
7bc81f51fb
8 changed files with 393 additions and 5 deletions
|
|
@ -8,6 +8,9 @@ import { LoginPage } from "./pages/LoginPage";
|
||||||
import { RecipesPage } from "./pages/RecipesPage";
|
import { RecipesPage } from "./pages/RecipesPage";
|
||||||
import { ShoppingListPage } from "./pages/ShoppingListPage";
|
import { ShoppingListPage } from "./pages/ShoppingListPage";
|
||||||
import { SignupPage } from "./pages/SignupPage";
|
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
|
* 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
|
* `/signup` redirect an already-logged-in visitor to `/` instead (see
|
||||||
* {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which
|
* {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which
|
||||||
* itself redirects to `/login` if needed.
|
* 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() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -32,6 +41,30 @@ export function App() {
|
||||||
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
||||||
<Route path="/foyer" element={<HouseholdPage />} />
|
<Route path="/foyer" element={<HouseholdPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route
|
||||||
|
path="/onboarding/foyer"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<OnboardingHouseholdPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/onboarding/regime"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<OnboardingDietPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/onboarding/allergenes"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<OnboardingAllergensPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ReactNode } from "react";
|
import { type ReactNode, useRef } from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { useAuth } from "./AuthContext";
|
import { useAuth } from "./AuthContext";
|
||||||
|
|
||||||
|
|
@ -6,14 +6,32 @@ import { useAuth } from "./AuthContext";
|
||||||
* Route guard for pages that make no sense to an already-authenticated
|
* Route guard for pages that make no sense to an already-authenticated
|
||||||
* visitor (`/login`, `/signup`). Mirrors {@link RequireAuth}'s waiting
|
* visitor (`/login`, `/signup`). Mirrors {@link RequireAuth}'s waiting
|
||||||
* behavior while the initial session check is pending.
|
* behavior while the initial session check is pending.
|
||||||
|
*
|
||||||
|
* The redirect decision is latched exactly once, the first moment the
|
||||||
|
* initial `isLoading` check resolves — deliberately *not* reactive to
|
||||||
|
* `user` changing afterward. Without this, a guarded page's own action
|
||||||
|
* that authenticates the visitor (e.g. `SignupPage`'s submit handler
|
||||||
|
* calling `signup()`, which sets `user`, then explicitly navigating
|
||||||
|
* elsewhere) races this guard: `user` becoming truthy while still mounted
|
||||||
|
* fires this component's own `<Navigate to="/">`, 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 }) {
|
export function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
|
||||||
const { user, isLoading } = useAuth();
|
const { user, isLoading } = useAuth();
|
||||||
|
const shouldRedirect = useRef<boolean | null>(null);
|
||||||
|
|
||||||
if (isLoading) {
|
if (!isLoading && shouldRedirect.current === null) {
|
||||||
|
shouldRedirect.current = user !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || shouldRedirect.current === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (user) {
|
if (shouldRedirect.current) {
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,21 @@
|
||||||
"loginLink": "Se connecter"
|
"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": {
|
"layout": {
|
||||||
"nav": {
|
"nav": {
|
||||||
"planning": "Planning",
|
"planning": "Planning",
|
||||||
|
|
|
||||||
|
|
@ -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; 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) {
|
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("/");
|
navigate("/onboarding/foyer");
|
||||||
} 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
|
||||||
|
|
|
||||||
80
apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx
Normal file
80
apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx
Normal file
|
|
@ -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<AllergyView[]>([]);
|
||||||
|
const [allergyIds, setAllergyIds] = useState<number[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [formError, setFormError] = useState<string | null>(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 (
|
||||||
|
<main className="onboarding-page">
|
||||||
|
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
||||||
|
<p className="onboarding-step">{t("onboarding.step", { current: 3, total: 3 })}</p>
|
||||||
|
<h1>{t("onboarding.allergens.title")}</h1>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p>{t("onboarding.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<AllergySelect allergies={allergies} value={allergyIds} onChange={setAllergyIds} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={isSubmitting || isLoading}>
|
||||||
|
{t("onboarding.finish")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
apps/web/src/pages/onboarding/OnboardingDietPage.tsx
Normal file
80
apps/web/src/pages/onboarding/OnboardingDietPage.tsx
Normal file
|
|
@ -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<DietView[]>([]);
|
||||||
|
const [dietId, setDietId] = useState<number | null>(user?.dietId ?? null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [formError, setFormError] = useState<string | null>(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 (
|
||||||
|
<main className="onboarding-page">
|
||||||
|
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
||||||
|
<p className="onboarding-step">{t("onboarding.step", { current: 2, total: 3 })}</p>
|
||||||
|
<h1>{t("onboarding.diet.title")}</h1>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p>{t("onboarding.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<DietSelect diets={diets} value={dietId} onChange={setDietId} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={isSubmitting || isLoading}>
|
||||||
|
{t("onboarding.continue")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx
Normal file
88
apps/web/src/pages/onboarding/OnboardingHouseholdPage.tsx
Normal file
|
|
@ -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<Record<string, string>>({});
|
||||||
|
const [formError, setFormError] = useState<string | null>(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 (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/web/src/pages/onboarding/onboarding.scss
Normal file
74
apps/web/src/pages/onboarding/onboarding.scss
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue