From 757f758dc0699587990942493abd9455990a4d35 Mon Sep 17 00:00:00 2001 From: mcull Date: Tue, 30 Jun 2026 19:40:01 -0700 Subject: [PATCH] feat(onboarding): single 'complete your card' screen with Cancel / Done Reaching the profile completion from a just-in-time prompt (e.g. Create a library) felt like a confusing multi-step wizard: four ambiguous corner controls (close X, a sign-out icon that reads like 'share', a Back arrow 'to where?', and Continue). Rework it into one focused task: - Merge photo + address into a single 'Complete your card' step (was two). - Exactly two clear actions: a text 'Cancel' and a primary 'Done!' (submits the full profile). Cancel exits to the app when opened from a prompt, otherwise steps back to the minimal entry. - Hide the top close/sign-out icons on the completion step (Cancel covers it). - Wizard is now two steps (basics -> complete); the completeness indicator highlights whichever of photo/address is still outstanding. - Remove the now-orphaned ProfileStep3. Co-Authored-By: Claude Opus 4.8 --- src/components/ProfileWizard.tsx | 115 +++++---- .../profile-wizard/ProfileStep1.tsx | 1 + .../profile-wizard/ProfileStep2.tsx | 106 ++++++--- .../profile-wizard/ProfileStep3.tsx | 219 ------------------ 4 files changed, 142 insertions(+), 299 deletions(-) delete mode 100644 src/components/profile-wizard/ProfileStep3.tsx diff --git a/src/components/ProfileWizard.tsx b/src/components/ProfileWizard.tsx index 8722166..21566af 100644 --- a/src/components/ProfileWizard.tsx +++ b/src/components/ProfileWizard.tsx @@ -28,7 +28,6 @@ import { canSubmitMinimal } from './profile-wizard/minimalEntry'; import { ProfileCompleteness } from './profile-wizard/ProfileCompleteness'; import { ProfileStep1 } from './profile-wizard/ProfileStep1'; import { ProfileStep2 } from './profile-wizard/ProfileStep2'; -import { ProfileStep3 } from './profile-wizard/ProfileStep3'; import { Wordmark } from './Wordmark'; const profileSchema = z.object({ @@ -61,16 +60,14 @@ const profileSchema = z.object({ export type ProfileFormData = z.infer; +// Two steps: minimal entry, then a single "complete your card" screen that +// collects photo + address together (Cancel / Done), so finishing from a +// just-in-time prompt feels like one quick task rather than a multi-step wizard. const steps = [ { label: 'Get started', component: ProfileStep1 }, - { label: 'Add a photo', component: ProfileStep2 }, - { label: 'Address & finish', component: ProfileStep3 }, + { label: 'Complete your card', component: ProfileStep2 }, ]; -// Which card part the user is filling in on each step (for the you-are-here -// highlight on the completeness indicator). -const CURRENT_KEY_BY_STEP: CompletenessKey[] = ['basics', 'photo', 'address']; - export type ProfileSubmitMode = 'minimal' | 'full'; interface ProfileWizardProps { @@ -318,14 +315,24 @@ export function ProfileWizard({ 'agreedToTerms', ]; case 1: - return ['profilePicture']; - case 2: - return ['address']; + // Combined completion step: photo + address together. + return ['profilePicture', 'address']; default: return []; } } + // Cancel out of the wizard: exit to the app when opened from a prompt + // (onExit), otherwise step back to the minimal entry. + const handleCancel = () => { + setSubmitError(null); + if (onExit) { + onExit(); + } else { + setActiveStep(0); + } + }; + const CurrentStepComponent = steps[activeStep]?.component; // Derive card completeness for the indicator that replaced the stepper. @@ -345,6 +352,15 @@ export function ProfileWizard({ hasAddress: Boolean(pa?.address1 && pa?.city && pa?.state && pa?.zip), }); + // You-are-here highlight: basics on step 0; on the combined completion step, + // point at whichever of photo/address is still outstanding. + const currentCardKey: CompletenessKey = + activeStep === 0 + ? 'basics' + : cardStatus.find((i) => i.key === 'photo')?.done + ? 'address' + : 'photo'; + return ( {/* Header */} - {/* Top row: close (exit to app) on the left, sign out on the right */} - - {onExit ? ( - + {/* Top row (hidden on the completion step, which has Cancel/Done): + close on the left, sign out on the right. */} + {activeStep < steps.length - 1 && ( + + {onExit ? ( + + + + + + ) : ( + + )} + signOut({ callbackUrl: '/auth/signin' })} size="small" sx={{ color: brandColors.charcoal, @@ -380,32 +418,13 @@ export function ProfileWizard({ backgroundColor: 'rgba(0, 0, 0, 0.04)', }, }} - aria-label="Close" + aria-label="Sign out" > - + - ) : ( - - )} - - signOut({ callbackUrl: '/auth/signin' })} - size="small" - sx={{ - color: brandColors.charcoal, - opacity: 0.7, - '&:hover': { - opacity: 1, - backgroundColor: 'rgba(0, 0, 0, 0.04)', - }, - }} - aria-label="Sign out" - > - - - - + + )} {/* Centered header content */} @@ -441,10 +460,7 @@ export function ProfileWizard({ {/* Card completeness — what the library card holds, filling in as you go (replaces the linear 1-2-3 stepper). */} - + )} diff --git a/src/components/profile-wizard/ProfileStep1.tsx b/src/components/profile-wizard/ProfileStep1.tsx index c11d6c8..b81cbfe 100644 --- a/src/components/profile-wizard/ProfileStep1.tsx +++ b/src/components/profile-wizard/ProfileStep1.tsx @@ -26,6 +26,7 @@ interface ProfileStep1Props { profilePicturePreviewUrl?: string | null; onMinimalSubmit?: () => void; isSubmittingMinimal?: boolean; + onCancel?: () => void; } export function ProfileStep1({ diff --git a/src/components/profile-wizard/ProfileStep2.tsx b/src/components/profile-wizard/ProfileStep2.tsx index e4264df..0bb9ed5 100644 --- a/src/components/profile-wizard/ProfileStep2.tsx +++ b/src/components/profile-wizard/ProfileStep2.tsx @@ -1,13 +1,6 @@ 'use client'; -import { - ArrowBack, - ArrowForward, - CameraAlt, - Close, - CheckCircle, - Warning, -} from '@mui/icons-material'; +import { CameraAlt, Close, CheckCircle, Warning } from '@mui/icons-material'; import { Avatar, Box, @@ -20,8 +13,9 @@ import { CircularProgress, } from '@mui/material'; import { useState, useRef } from 'react'; -import { useFormContext } from 'react-hook-form'; +import { useFormContext, Controller } from 'react-hook-form'; +import { AddressAutocomplete } from '@/components/AddressAutocomplete'; // import { STUFF_CATEGORIES } from '@/data/stuffCategories'; import { brandColors } from '@/theme/brandTokens'; @@ -35,6 +29,8 @@ interface ProfileStep2Props { profilePicturePreviewUrl?: string | null; onMinimalSubmit?: () => void; isSubmittingMinimal?: boolean; + /** Cancel out of the wizard (exits to the app when opened from a prompt). */ + onCancel?: () => void; } interface ImageVerificationResult { @@ -43,12 +39,13 @@ interface ImageVerificationResult { confidence: 'high' | 'medium' | 'low'; } -export function ProfileStep2({ onNext, onBack }: ProfileStep2Props) { +export function ProfileStep2({ onCancel }: ProfileStep2Props) { const { register, setValue, watch, - formState: { errors }, + control, + formState: { errors, isSubmitting }, } = useFormContext(); const fileInputRef = useRef(null); @@ -60,11 +57,21 @@ export function ProfileStep2({ onNext, onBack }: ProfileStep2Props) { const [_verificationResult, setVerificationResult] = useState(null); - const _profilePicture = watch('profilePicture'); + const profilePicture = watch('profilePicture'); const profilePictureUrl = watch('profilePictureUrl'); + const parsedAddress = watch('parsedAddress'); const currentImageUrl = previewUrl || profilePictureUrl; + // "Done" is enabled once a photo is added and an address is verified. + const hasVerifiedAddress = Boolean( + parsedAddress?.address1 && + parsedAddress?.city && + parsedAddress?.state && + parsedAddress?.zip + ); + const canFinish = Boolean(profilePicture) && hasVerifiedAddress; + const verifyImageWithAI = async ( file: File ): Promise => { @@ -449,39 +456,71 @@ export function ProfileStep2({ onNext, onBack }: ProfileStep2Props) { a pet peeve is leaving tools outside overnight!" + + {/* Address */} + + + Your address + + ( + { + field.onChange(value); + if (addr) { + setValue('parsedAddress', addr); + } + }} + error={!!errors.address} + helperText={errors.address?.message || undefined} + placeholder="123 Main Street, City, State" + /> + )} + /> + - {/* Navigation */} - - {/* Back link */} + {/* Navigation — a focused "provide the info and you're done" pair */} + - {/* Full-width primary button */} + {/* Primary: finish + submit the full profile */} diff --git a/src/components/profile-wizard/ProfileStep3.tsx b/src/components/profile-wizard/ProfileStep3.tsx deleted file mode 100644 index 402de94..0000000 --- a/src/components/profile-wizard/ProfileStep3.tsx +++ /dev/null @@ -1,219 +0,0 @@ -'use client'; - -import { ArrowBack, CameraAlt, CheckCircle } from '@mui/icons-material'; -import { - Avatar, - Box, - Button, - Card, - CardContent, - Stack, - Typography, - CircularProgress, -} from '@mui/material'; -import { useFormContext, Controller } from 'react-hook-form'; - -import { AddressAutocomplete } from '@/components/AddressAutocomplete'; -import { brandColors } from '@/theme/brandTokens'; - -import type { ProfileFormData } from '../ProfileWizard'; - -interface ProfileStep3Props { - onNext: () => void; - onBack: () => void; - isFirstStep: boolean; - isLastStep: boolean; - profilePicturePreviewUrl?: string | null; - onMinimalSubmit?: () => void; - isSubmittingMinimal?: boolean; -} - -export function ProfileStep3({ - onBack, - profilePicturePreviewUrl, -}: ProfileStep3Props) { - const { - watch, - control, - setValue, - formState: { errors, isSubmitting }, - } = useFormContext(); - - const formData = watch(); - const { name, bio, profilePicture, profilePictureUrl, parsedAddress } = - formData; - - const currentImageUrl = profilePicturePreviewUrl || profilePictureUrl; - - // Address must be verified (parsed) and a photo must have been added. - const hasVerifiedAddress = Boolean( - parsedAddress?.address1 && - parsedAddress?.city && - parsedAddress?.state && - parsedAddress?.zip - ); - const canProceed = hasVerifiedAddress && Boolean(profilePicture); - - return ( - - {/* Step Content */} - - - Review and complete - - - Verify your address so we can connect you with nearby neighbors, then - finish up. - - - - {/* Profile Summary */} - - - - Profile Summary - - - - - {!currentImageUrl && } - - - - {name || 'Your Name'} - - {parsedAddress && ( - - {parsedAddress.city}, {parsedAddress.state} - - )} - {bio && ( - - {bio} - - )} - - - - - - {/* Address */} - - - Your address - - ( - { - field.onChange(value); - if (addr) { - setValue('parsedAddress', addr); - } - }} - error={!!errors.address} - helperText={errors.address?.message || undefined} - placeholder="123 Main Street, City, State" - /> - )} - /> - - - - - {/* Navigation */} - - {/* Back link */} - - - {/* Full-width primary button */} - - - - ); -}