From 8985044aa95644574a6d93d8953820904cf871dc Mon Sep 17 00:00:00 2001 From: mcull Date: Tue, 30 Jun 2026 17:17:45 -0700 Subject: [PATCH] fix(onboarding): surface why 'Complete Profile' is blocked instead of silent no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Step 3 the navy 'Complete Profile' button could look active yet do nothing: react-hook-form's handleSubmit swallowed zod validation failures, and handleComplete silently returned if an agreement wasn't set. The user got a dead button with no explanation. - Wire an onInvalid handler + testable profileSubmitBlockMessage() that maps the first failing field to friendly guidance (photo / address / name / agreements), shown in an Alert. - Replace the silent agreements return with the same visible message. - Clear the message on step navigation. This makes the failure visible (and, on the next attempt, tells us exactly which field is blocking — see the draft-serialization suspicion in the PR). Co-Authored-By: Claude Opus 4.8 --- src/components/ProfileWizard.tsx | 58 ++++++++++++++++++- .../__tests__/profile-submit-block.test.ts | 31 ++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 src/components/__tests__/profile-submit-block.test.ts diff --git a/src/components/ProfileWizard.tsx b/src/components/ProfileWizard.tsx index e39ae84..c3c2aa8 100644 --- a/src/components/ProfileWizard.tsx +++ b/src/components/ProfileWizard.tsx @@ -3,6 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Check, Logout as LogoutIcon } from '@mui/icons-material'; import { + Alert, Box, Container, Paper, @@ -17,6 +18,7 @@ import { } from '@mui/material'; import { signOut } from 'next-auth/react'; import { useEffect, useState } from 'react'; +import type { FieldErrors } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form'; import { z } from 'zod'; @@ -136,6 +138,35 @@ interface ProfileWizardProps { | undefined; } +/** + * A friendly, field-aware explanation for why a full-profile submit was + * blocked. Prevents the "button looks active but does nothing" dead end by + * turning silent react-hook-form validation failures into visible guidance. + */ +export function profileSubmitBlockMessage( + errors: FieldErrors +): string { + if (errors.profilePicture) { + return 'Please add a profile photo before you finish.'; + } + if (errors.address || errors.parsedAddress) { + return 'Please pick your address from the suggestions so we can verify it.'; + } + if (errors.name) { + return 'Please enter your name.'; + } + if ( + errors.agreedToHouseholdGoods || + errors.agreedToTrustAndCare || + errors.agreedToCommunityValues || + errors.agreedToAgeRestrictions || + errors.agreedToTerms + ) { + return 'Please accept the community agreements to continue.'; + } + return 'Please complete the highlighted fields to continue.'; +} + export function ProfileWizard({ onComplete, initialData, @@ -144,6 +175,7 @@ export function ProfileWizard({ user, }: ProfileWizardProps) { const [activeStep, setActiveStep] = useState(initialStep ?? 0); + const [submitError, setSubmitError] = useState(null); const [profilePicturePreviewUrl, setProfilePicturePreviewUrl] = useState< string | null >(null); @@ -251,11 +283,13 @@ export function ProfileWizard({ const isStepValid = await trigger(fieldsToValidate); if (isStepValid) { + setSubmitError(null); setActiveStep((prev) => prev + 1); } }; const handleBack = () => { + setSubmitError(null); setActiveStep((prev) => prev - 1); }; @@ -268,10 +302,14 @@ export function ProfileWizard({ !data.agreedToAgeRestrictions || !data.agreedToTerms ) { - // This shouldn't happen due to UI logic, but just in case + // The agreements live on Step 1; surface the reason rather than + // silently no-op'ing (which reads as a dead "Complete Profile" button). + setSubmitError('Please accept the community agreements to continue.'); return; } + setSubmitError(null); + // Clear user-specific draft const draftKey = user?.email ? `profile-wizard-draft-${user.email}` @@ -283,6 +321,12 @@ export function ProfileWizard({ onComplete(data, 'full'); }; + // react-hook-form calls this when zod validation blocks the submit. Without + // it the failure is silent and the button appears to do nothing. + const handleInvalid = (errors: FieldErrors) => { + setSubmitError(profileSubmitBlockMessage(errors)); + }; + // Minimal entry: name + agreements only. Bypasses the full zod schema // (which requires address + photo); UI gating in Step 1 enforces the // agreements via canSubmitMinimal. @@ -434,9 +478,19 @@ export function ProfileWizard({ void + handleComplete as (data: ProfileFormData) => void, + handleInvalid )} > + {submitError && ( + setSubmitError(null)} + sx={{ mb: 3, borderRadius: 2 }} + > + {submitError} + + )} {CurrentStepComponent && ( { + it('points at the photo when profilePicture is missing', () => { + expect( + profileSubmitBlockMessage({ + profilePicture: { type: 'required', message: 'x' }, + }) + ).toMatch(/photo/i); + }); + + it('points at the address when it is not verified', () => { + expect( + profileSubmitBlockMessage({ address: { type: 'required', message: 'x' } }) + ).toMatch(/address/i); + }); + + it('points at the agreements when one is unchecked', () => { + expect( + profileSubmitBlockMessage({ + agreedToTerms: { type: 'required', message: 'x' }, + }) + ).toMatch(/agreement/i); + }); + + it('never returns an empty string, even with no known field', () => { + expect(profileSubmitBlockMessage({}).length).toBeGreaterThan(0); + }); +});