feat(frontend): streamline onboarding from 7 steps to 3 with skip option (#701)#746
feat(frontend): streamline onboarding from 7 steps to 3 with skip option (#701)#746ericsocrat merged 3 commits intomainfrom
Conversation
…ion (#701) - Merge Welcome+Region, Diet+Allergens, Goals+Categories into 3 visible steps - Auto-complete on Finish (no separate Done screen) - Add localStorage persistence (tryvit_onboarding_progress key) - Redirect to /app/categories after completion or skip - Add 'finish' i18n key (en/pl/de) - 47 new tests across 4 test files (160 onboarding tests total)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Bundle Size Report
✅ Bundle size is within acceptable limits. |
There was a problem hiding this comment.
Pull request overview
This PR streamlines the frontend onboarding experience by collapsing the previous multi-step wizard into a 3-step flow with skip support and persisted progress, and updates copy/i18n to match the new UI.
Changes:
- Replaces the 7-step onboarding wizard with 3 merged steps (Welcome+Region, Diet+Allergens, Goals+Categories) and updates navigation/progress display.
- Adds localStorage persistence for onboarding progress and clears it on successful completion/skip.
- Adds the new i18n key for the “Finish” CTA in en/pl/de.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/app/onboarding/types.ts | Updates step count constant and introduces a shared localStorage key for onboarding persistence. |
| frontend/src/app/onboarding/steps/WelcomeRegionStep.tsx | New merged step for welcome + region + language selection, with skip/next CTAs. |
| frontend/src/app/onboarding/steps/WelcomeRegionStep.test.tsx | Unit tests covering rendering and interactions for the merged Welcome+Region step. |
| frontend/src/app/onboarding/steps/DietAllergensStep.tsx | New merged step combining diet selection and allergen selection/toggles. |
| frontend/src/app/onboarding/steps/DietAllergensStep.test.tsx | Unit tests for diet/allergen selection behaviors and navigation. |
| frontend/src/app/onboarding/steps/GoalsCategoriesStep.tsx | New merged step combining health goals and category selection with a Finish CTA. |
| frontend/src/app/onboarding/steps/GoalsCategoriesStep.test.tsx | Unit tests for goals/categories selection and finish/back navigation. |
| frontend/src/app/onboarding/OnboardingWizard.tsx | Rewrites wizard flow for 3 steps, adds persistence, updates progress bar behavior, and redirects to /app/categories. |
| frontend/src/app/onboarding/OnboardingWizard.test.tsx | Updates wizard tests for the new 3-step flow and adds localStorage persistence coverage. |
| frontend/messages/en.json | Adds onboarding.finish translation. |
| frontend/messages/pl.json | Adds onboarding.finish translation. |
| frontend/messages/de.json | Adds onboarding.finish translation. |
| function loadPersistedState(): { step: number; data: OnboardingData } | null { | ||
| try { | ||
| const raw = localStorage.getItem(ONBOARDING_STORAGE_KEY); | ||
| if (!raw) return null; | ||
| const parsed = JSON.parse(raw) as { step: number; data: OnboardingData }; | ||
| if (typeof parsed.step !== "number" || !parsed.data) return null; | ||
| return parsed; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } |
There was a problem hiding this comment.
loadPersistedState() returns whatever is in localStorage without clamping the step to a valid range or validating the data shape. A corrupted/stale value (e.g., step > 2 or missing arrays like healthGoals) can lead to an empty wizard render or runtime errors (e.g., calling .includes on undefined). Consider normalizing here: clamp step to [0, TOTAL_STEPS-1] (or discard invalid), and merge persisted data into INITIAL_ONBOARDING_DATA to guarantee required fields/arrays exist.
| const [step, setStep] = useState(() => loadPersistedState()?.step ?? 0); | ||
| const [data, setData] = useState<OnboardingData>( | ||
| () => loadPersistedState()?.data ?? INITIAL_ONBOARDING_DATA, | ||
| ); |
There was a problem hiding this comment.
The component reads and parses persisted onboarding state twice (once for step and once for data). This can produce inconsistent initial state (and does duplicate JSON.parse work). Consider loading persisted state once (e.g., in a single initializer) and deriving both step and data from that single result.
| @@ -74,81 +137,47 @@ export function OnboardingWizard() { | |||
| return; | |||
| } | |||
|
|
|||
| track("onboarding_completed", { skipped: true }); | |||
| router.push("/app/search"); | |||
| router.refresh(); | |||
| } | |||
|
|
|||
| async function handleComplete() { | |||
| setLoading(true); | |||
| const result = await completeOnboarding(supabase, { | |||
| country: data.country, | |||
| language: data.language || undefined, | |||
| diet: data.diet, | |||
| allergens: data.allergens, | |||
| strict_allergen: data.strictAllergen, | |||
| strict_diet: data.strictDiet, | |||
| treat_may_contain_as_unsafe: data.treatMayContain, | |||
| health_goals: data.healthGoals, | |||
| favorite_categories: data.favoriteCategories, | |||
| }); | |||
| setLoading(false); | |||
|
|
|||
| if (!result.ok) { | |||
| showToast({ | |||
| type: "error", | |||
| messageKey: "onboarding.onboardingFailed", | |||
| }); | |||
| return; | |||
| // Clear persisted progress | |||
| try { | |||
| localStorage.removeItem(ONBOARDING_STORAGE_KEY); | |||
| } catch { | |||
| // noop | |||
| } | |||
|
|
|||
| track("onboarding_completed", { | |||
| skipped: false, | |||
| diet: data.diet, | |||
| allergen_count: data.allergens.length, | |||
| health_goal_count: data.healthGoals.length, | |||
| category_count: data.favoriteCategories.length, | |||
| }); | |||
| showToast({ type: "success", messageKey: "onboarding.preferencesSaved" }); | |||
| router.push("/app/search"); | |||
| track("onboarding_completed", { skipped: true }); | |||
| router.push("/app/categories"); | |||
| router.refresh(); | |||
| } | |||
|
|
|||
| const stepProps = { | |||
| data, | |||
| onChange: updateData, | |||
| onNext: goNext, | |||
| onNext: step === TOTAL_STEPS - 1 ? handleComplete : goNext, | |||
| onBack: goBack, | |||
| }; | |||
There was a problem hiding this comment.
handleComplete/handleSkipAll set loading, but the primary action buttons (e.g., Finish in the last step, Skip button on step 0, Next buttons) are not consistently disabled/guarded when loading is true. This allows double-submits (multiple RPC calls + duplicate analytics) from rapid clicks. Consider guarding early in the handlers when loading is already true and/or passing loading down so all navigation/submit controls can be disabled while an RPC is in-flight.
|
|
||
| // ─── Step 1: Welcome + Region ─────────────────────────────────────────────── | ||
| // Combines greeting with country/language selection in a single step. | ||
| // Issue #701: streamline onboarding from 7 steps to 4. |
There was a problem hiding this comment.
The step header comment says this flow is “7 steps to 4”, but this PR implements 3 visible steps. Please update the comment to match the current behavior to avoid future confusion when maintaining onboarding step logic.
| // Issue #701: streamline onboarding from 7 steps to 4. | |
| // Issue #701: streamline onboarding from a 7-step flow to 3 visible steps. |
|
|
||
| // ─── Step 2: Diet + Allergens ─────────────────────────────────────────────── | ||
| // Combines diet preference selection with allergen checklist in a single step. | ||
| // Issue #701: streamline onboarding from 7 steps to 4. |
There was a problem hiding this comment.
The step header comment says this flow is “7 steps to 4”, but the wizard now has 3 visible steps. Please update the comment to reflect the actual 3-step flow so the file doesn’t contradict TOTAL_STEPS = 3.
| // Issue #701: streamline onboarding from 7 steps to 4. | |
| // Issue #701: streamlined onboarding into a 3-step flow (TOTAL_STEPS = 3). |
|
|
||
| // ─── Step 3: Health Goals + Categories ────────────────────────────────────── | ||
| // Combines health goals selection with favorite category selection. | ||
| // Issue #701: streamline onboarding from 7 steps to 4. |
There was a problem hiding this comment.
The step header comment says this flow is “7 steps to 4”, but the wizard now has 3 visible steps. Please update the comment to match the current 3-step onboarding to keep documentation consistent.
| // Issue #701: streamline onboarding from 7 steps to 4. | |
| // Issue #701: part of streamlining onboarding to a 3-step flow. |
| <button | ||
| onClick={onNext} | ||
| className="btn-primary flex-1" | ||
| data-testid="onboarding-complete" | ||
| > | ||
| {t("onboarding.finish")} | ||
| </button> |
There was a problem hiding this comment.
The “Finish” button can trigger an async completion RPC via onNext, but it isn’t disabled while the request is in-flight. This can lead to multiple completion submissions if the user clicks repeatedly. Consider threading a loading/submitting prop from OnboardingWizard and disabling the button (and optionally showing a saving label) while completion is running.
| <div className="mt-8 flex gap-3"> | ||
| <button | ||
| onClick={onSkipAll} | ||
| className="btn-secondary flex-1" | ||
| data-testid="onboarding-skip-all" | ||
| > | ||
| {t("onboarding.skipAll")} | ||
| </button> |
There was a problem hiding this comment.
The step 0 “Skip” button calls an async handler (onSkipAll) but isn’t disabled during the in-flight request. This makes it possible to fire multiple skip RPCs via rapid clicks. Consider passing a loading prop from the wizard and disabling this button while skipping is in progress (or adding a re-entrancy guard in the handler).
| const STEP_NAMES = ["welcome_region", "diet_allergens", "goals_categories"] as const; | ||
|
|
||
| function loadPersistedState(): { step: number; data: OnboardingData } | null { | ||
| try { | ||
| const raw = localStorage.getItem(ONBOARDING_STORAGE_KEY); | ||
| if (!raw) return null; | ||
| const parsed = JSON.parse(raw) as { step: number; data: OnboardingData }; | ||
| if (typeof parsed.step !== "number" || !parsed.data) return null; | ||
| return parsed; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export function OnboardingWizard() { | ||
| const router = useRouter(); | ||
| const supabase = createClient(); | ||
| const { track } = useAnalytics(); | ||
| const { t } = useTranslation(); | ||
| const [step, setStep] = useState(0); // 0-indexed | ||
| const [data, setData] = useState<OnboardingData>(INITIAL_ONBOARDING_DATA); | ||
|
|
||
| const [step, setStep] = useState(() => loadPersistedState()?.step ?? 0); | ||
| const [data, setData] = useState<OnboardingData>( | ||
| () => loadPersistedState()?.data ?? INITIAL_ONBOARDING_DATA, | ||
| ); |
There was a problem hiding this comment.
loadPersistedState()only checksparsed.datatruthiness and doesn’t validate/normalize the shape (arrays/booleans/strings) or clampstepinto the valid range. A corrupted/partial localStorage value can lead to runtime crashes in steps (e.g.,data.healthGoals.includeswhenhealthGoalsis missing) or render no step ifstepis out of bounds. Consider (a) loading once into a singleinitialvalue sostep/datacan’t get out of sync and you don’t parse JSON twice, and (b) normalizing persisted data by merging withINITIAL_ONBOARDING_DATAplus validatingstepto be within[0, TOTAL_STEPS - 1]` before using it.
| async function handleComplete() { | ||
| setLoading(true); | ||
| const result = await completeOnboarding(supabase, { | ||
| country: data.country, | ||
| language: data.language || undefined, | ||
| diet: data.diet, | ||
| allergens: data.allergens, | ||
| strict_allergen: data.strictAllergen, | ||
| strict_diet: data.strictDiet, | ||
| treat_may_contain_as_unsafe: data.treatMayContain, | ||
| health_goals: data.healthGoals, | ||
| favorite_categories: data.favoriteCategories, | ||
| }); | ||
| setLoading(false); |
There was a problem hiding this comment.
handleComplete()can be triggered multiple times (e.g., double-clicking “Finish”) because there’s no re-entrancy guard (and the step components don’t receiveloadingto disable their CTA buttons). This can cause duplicated submissions and duplicated analytics events. Consider short-circuiting early whenloadingis already true and/or passingloading` into steps to disable Next/Finish while a request is in-flight.
| {COUNTRIES.map((country) => ( | ||
| <button | ||
| key={country.code} | ||
| onClick={() => handleCountrySelect(country.code)} | ||
| className={`flex w-full items-center gap-4 rounded-xl border-2 p-4 text-left transition-colors ${ | ||
| data.country === country.code | ||
| ? "border-brand bg-brand-subtle" | ||
| : "border bg-surface hover:border-strong" | ||
| }`} | ||
| data-testid={`country-${country.code}`} | ||
| > |
There was a problem hiding this comment.
The country selection is a “single-select” control but is implemented as plain buttons without any selection semantics for assistive tech. Consider adding aria-pressed for a toggle-button pattern or switching to a radiogroup/radio pattern (with appropriate labels) so screen readers can announce the current selection state. The same applies to the language selector buttons.
| // ─── Step 1: Welcome + Region ─────────────────────────────────────────────── | ||
| // Combines greeting with country/language selection in a single step. | ||
| // Issue #701: streamline onboarding from 7 steps to 4. |
There was a problem hiding this comment.
The header comment is out of date/inconsistent with the PR’s 3-step flow: it says “Step 1” and “7 steps to 4”. Please update to reflect the actual step index naming used by the wizard (Step 0) and the correct “7 → 3” change to avoid future confusion.
| // ─── Step 1: Welcome + Region ─────────────────────────────────────────────── | |
| // Combines greeting with country/language selection in a single step. | |
| // Issue #701: streamline onboarding from 7 steps to 4. | |
| // ─── Step 0: Welcome + Region ─────────────────────────────────────────────── | |
| // Combines greeting with country/language selection in a single step. | |
| // Issue #701: streamline onboarding from 7 steps to 3. |
| // ─── Step 2: Diet + Allergens ─────────────────────────────────────────────── | ||
| // Combines diet preference selection with allergen checklist in a single step. | ||
| // Issue #701: streamline onboarding from 7 steps to 4. |
There was a problem hiding this comment.
The header comment is inconsistent with the new 3-step flow: this is Step 1 in the wizard, and the issue note should say “7 → 3” (not “7 → 4”). Updating these comments will keep the code aligned with the current onboarding model.
| // ─── Step 2: Diet + Allergens ─────────────────────────────────────────────── | |
| // Combines diet preference selection with allergen checklist in a single step. | |
| // Issue #701: streamline onboarding from 7 steps to 4. | |
| // ─── Step 1: Diet + Allergens ─────────────────────────────────────────────── | |
| // Combines diet preference selection with allergen checklist in a single step. | |
| // Issue #701: streamline onboarding from 7 steps to 3. |
| // ─── Step 3: Health Goals + Categories ────────────────────────────────────── | ||
| // Combines health goals selection with favorite category selection. | ||
| // Issue #701: streamline onboarding from 7 steps to 4. |
There was a problem hiding this comment.
The header comment is inconsistent with the new 3-step flow: this is Step 2 (final step), and the issue note should say “7 → 3” (not “7 → 4”).
| // ─── Step 3: Health Goals + Categories ────────────────────────────────────── | |
| // Combines health goals selection with favorite category selection. | |
| // Issue #701: streamline onboarding from 7 steps to 4. | |
| // ─── Step 2: Health Goals + Categories ────────────────────────────────────── | |
| // Combines health goals selection with favorite category selection. | |
| // Issue #701: streamline onboarding from 7 steps to 3. |
|
|
||
| it("shows checkmark for selected country", () => { | ||
| renderStep({ country: "PL", language: "en" }); | ||
| const checkSpan = document.querySelector(".text-brand svg"); |
There was a problem hiding this comment.
This checkmark assertion is brittle because it queries the entire document by a CSS class that could match unrelated elements. Prefer scoping the query to the selected country button (e.g., within(screen.getByTestId("country-PL"))) and/or adding a stable data-testid/accessible label for the checkmark to make the test less sensitive to styling changes.
| const checkSpan = document.querySelector(".text-brand svg"); | |
| const selectedCountry = screen.getByTestId("country-PL"); | |
| const checkSpan = selectedCountry.querySelector("svg"); |
feat(frontend): streamline onboarding from 7 steps to 3 with skip option
Closes #701
Summary
Merges the 7-step onboarding flow into 3 visible steps with auto-complete, reducing friction and improving completion rates.
New 3-Step Flow
On "Finish" → auto-saves preferences → success toast → redirect to
/app/categories.Key Changes
WelcomeRegionStep,DietAllergensStep,GoalsCategoriesSteptryvit_onboarding_progress), auto-complete on final step/app/search→/app/categoriesFiles Changed
12 files changed, +979 / -158 lines
Test Coverage
WelcomeRegionStep.test.tsx— 16 testsDietAllergensStep.test.tsx— 14 testsGoalsCategoriesStep.test.tsx— 12 testsOnboardingWizard.test.tsx— 17 tests (rewritten for 3-step flow + localStorage)Verification