From b179c71c1992cb4f6277c5bfc500ea261da86186 Mon Sep 17 00:00:00 2001 From: mcull Date: Tue, 30 Jun 2026 17:34:29 -0700 Subject: [PATCH 1/2] fix(onboarding): copy + close-affordance polish from testing (#3-#6) - Step 1 header now reinforces the fast-track ('name + a few basics') rather than 'set up how you'll appear'; subtitle is step-aware. - Just-in-time complete-profile prompts (lend/borrow/create) name the full ask (photo AND verified address) so there's no surprise second step. - Wizard gains a prominent Close affordance that exits back to the app when it was opened from a just-in-time prompt (no longer trapped, Back only walked wizard steps). - Photo-rejection message is warm and on-brand instead of a raw model reason (raw reason still logged for debugging). Co-Authored-By: Claude Opus 4.8 --- .../profile/create/ProfileCreationHandler.tsx | 14 +++++- src/components/ProfileWizard.tsx | 46 +++++++++++++++++-- .../profile-wizard/ProfileStep2.tsx | 7 ++- src/lib/capability-copy.ts | 14 ++++-- 4 files changed, 69 insertions(+), 12 deletions(-) diff --git a/src/app/profile/create/ProfileCreationHandler.tsx b/src/app/profile/create/ProfileCreationHandler.tsx index 0c5d989..10af515 100644 --- a/src/app/profile/create/ProfileCreationHandler.tsx +++ b/src/app/profile/create/ProfileCreationHandler.tsx @@ -214,7 +214,19 @@ export function ProfileCreationHandler({ onComplete={handleProfileComplete} user={user} isSubmittingMinimal={isSubmittingMinimal} - {...(searchParams?.get('continue') ? { initialStep: 1 } : {})} + {...(searchParams?.get('continue') + ? { + initialStep: 1, + // Reached here from the app (a just-in-time prompt), so offer a + // way back instead of trapping the user in the wizard. + onExit: () => { + const returnTo = searchParams?.get('returnTo'); + router.push( + returnTo ? decodeURIComponent(returnTo) : '/stacks' + ); + }, + } + : {})} {...(initialData ? { initialData } : {})} /> )} diff --git a/src/components/ProfileWizard.tsx b/src/components/ProfileWizard.tsx index c3c2aa8..f408225 100644 --- a/src/components/ProfileWizard.tsx +++ b/src/components/ProfileWizard.tsx @@ -1,7 +1,11 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Check, Logout as LogoutIcon } from '@mui/icons-material'; +import { + Check, + Close as CloseIcon, + Logout as LogoutIcon, +} from '@mui/icons-material'; import { Alert, Box, @@ -126,6 +130,8 @@ interface ProfileWizardProps { initialData?: Partial; initialStep?: number; isSubmittingMinimal?: boolean; + /** When provided, shows a "close" affordance that exits back to the app. */ + onExit?: () => void; user?: | { id: string; @@ -172,6 +178,7 @@ export function ProfileWizard({ initialData, initialStep, isSubmittingMinimal, + onExit, user, }: ProfileWizardProps) { const [activeStep, setActiveStep] = useState(initialStep ?? 0); @@ -386,8 +393,36 @@ export function ProfileWizard({ > {/* Header */} - {/* Top row with logout button */} - + {/* Top row: close (exit to app) on the left, sign out on the right */} + + {onExit ? ( + + + + + + ) : ( + + )} signOut({ callbackUrl: '/auth/signin' })} @@ -431,8 +466,9 @@ export function ProfileWizard({ opacity: 0.7, }} > - Let's set up how you'll appear to your friends and - neighbors + {activeStep === 0 + ? 'Just your name and a few community basics — you can be in in under a minute.' + : "Add a photo and your address so neighbors know who they're sharing with."} diff --git a/src/components/profile-wizard/ProfileStep2.tsx b/src/components/profile-wizard/ProfileStep2.tsx index abe41cb..e4264df 100644 --- a/src/components/profile-wizard/ProfileStep2.tsx +++ b/src/components/profile-wizard/ProfileStep2.tsx @@ -125,9 +125,14 @@ export function ProfileStep2({ onNext, onBack }: ProfileStep2Props) { setVerificationStatus('approved'); setValue('profilePicture', file); } else { + // Keep the raw model reason for debugging, but show neighbors a warm, + // on-brand note instead of a technical rejection string. + if (result.reason) { + console.warn('Photo verification rejected:', result.reason); + } setVerificationStatus('rejected'); setUploadError( - result.reason || 'Image does not meet our community guidelines' + "Aw, we can't use that one. A clear, well-lit photo of just you (no pets, logos, or group shots) helps keep the community friendly and safe. Mind trying another?" ); } } catch (error) { diff --git a/src/lib/capability-copy.ts b/src/lib/capability-copy.ts index cb7ea9f..4445b17 100644 --- a/src/lib/capability-copy.ts +++ b/src/lib/capability-copy.ts @@ -25,18 +25,22 @@ export function capabilityCopy(reason: CapabilityReason): CapabilityCopy { cta: 'Review terms', href: PROFILE_HREF, }; + // Lending, borrowing, and creating a library all require a full profile — + // both a photo and a verified address. Even though a single reason names + // the first missing piece, the prompt names the whole ask so the user + // isn't surprised by a second step. case 'NEEDS_PHOTO': return { - title: 'Add a profile photo', - body: 'Neighbors like to know who they are sharing with. Add a photo to lend and borrow.', - cta: 'Add photo', + title: 'Finish your profile first', + body: 'Add a profile photo and verify your address so neighbors know who they are sharing with. It only takes a minute.', + cta: 'Finish profile', href: PROFILE_HREF, }; case 'NEEDS_ADDRESS': return { title: 'Verify your address', - body: 'Verify your address so we can connect you with nearby neighbors before you lend or borrow.', - cta: 'Verify address', + body: 'Add your verified address (and a photo, if you have not yet) so we can connect you with nearby neighbors.', + cta: 'Finish profile', href: PROFILE_HREF, }; case 'NEEDS_TRUST_TIER': From 2ba94f3cc5cbe906efbc3e13c5b5e465a6a7e6f7 Mon Sep 17 00:00:00 2001 From: mcull Date: Tue, 30 Jun 2026 17:47:15 -0700 Subject: [PATCH 2/2] feat(onboarding): replace 1-2-3 stepper with a card-completeness indicator (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linear stepper made minimal-first entry feel like a required 3-step process. Replace it with a completeness indicator that shows the parts of the library card (Name & agreements / Photo / Address) and checks each off as it fills in — so photo/address read as 'add anytime,' not 'mandatory next.' - Pure tested profileCardStatus()/completedCardCount() helpers. - ProfileCompleteness component (done = check, current = filled ring, else hollow), plus a 'you can fill in the rest anytime' caption. - Removed the numbered Stepper + custom step-icon styling. Co-Authored-By: Claude Opus 4.8 --- src/components/ProfileWizard.tsx | 150 ++++++------------ .../__tests__/profile-completeness.test.ts | 39 +++++ .../profile-wizard/ProfileCompleteness.tsx | 78 +++++++++ src/components/profile-wizard/completeness.ts | 30 ++++ 4 files changed, 198 insertions(+), 99 deletions(-) create mode 100644 src/components/__tests__/profile-completeness.test.ts create mode 100644 src/components/profile-wizard/ProfileCompleteness.tsx create mode 100644 src/components/profile-wizard/completeness.ts diff --git a/src/components/ProfileWizard.tsx b/src/components/ProfileWizard.tsx index f408225..8722166 100644 --- a/src/components/ProfileWizard.tsx +++ b/src/components/ProfileWizard.tsx @@ -1,24 +1,15 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { - Check, - Close as CloseIcon, - Logout as LogoutIcon, -} from '@mui/icons-material'; +import { Close as CloseIcon, Logout as LogoutIcon } from '@mui/icons-material'; import { Alert, Box, Container, Paper, - Step, - StepConnector, - StepLabel, - Stepper, Typography, IconButton, Tooltip, - styled, } from '@mui/material'; import { signOut } from 'next-auth/react'; import { useEffect, useState } from 'react'; @@ -28,6 +19,13 @@ import { z } from 'zod'; import { brandColors } from '@/theme/brandTokens'; +import { + profileCardStatus, + completedCardCount, + type CompletenessKey, +} from './profile-wizard/completeness'; +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'; @@ -63,66 +61,16 @@ const profileSchema = z.object({ export type ProfileFormData = z.infer; -const CustomConnector = styled(StepConnector)(() => ({ - '& .MuiStepConnector-line': { - height: 3, - border: 0, - backgroundColor: brandColors.softGray, - borderRadius: 1, - }, - '&.Mui-active .MuiStepConnector-line': { - backgroundImage: `linear-gradient(95deg, ${brandColors.inkBlue} 0%, ${brandColors.inkBlue} 100%)`, - }, - '&.Mui-completed .MuiStepConnector-line': { - backgroundImage: `linear-gradient(95deg, ${brandColors.inkBlue} 0%, ${brandColors.inkBlue} 100%)`, - }, -})); - -const CustomStepIcon = styled('div')<{ - ownerState: { completed?: boolean; active?: boolean }; -}>(({ ownerState }) => ({ - backgroundColor: - ownerState.completed || ownerState.active - ? brandColors.inkBlue - : brandColors.softGray, - zIndex: 1, - color: '#fff', - width: 50, - height: 50, - display: 'flex', - borderRadius: '50%', - justifyContent: 'center', - alignItems: 'center', - fontSize: '1.2rem', - fontWeight: 600, - ...(ownerState.active && { - backgroundImage: `linear-gradient(95deg, ${brandColors.inkBlue} 0%, ${brandColors.inkBlue} 100%)`, - boxShadow: '0 4px 10px 0 rgba(0,0,0,.25)', - }), -})); - -function StepIcon(props: { - icon: React.ReactNode; - completed?: boolean; - active?: boolean; -}) { - const { icon, completed, active } = props; - - return ( - - {completed ? : icon} - - ); -} - const steps = [ { label: 'Get started', component: ProfileStep1 }, { label: 'Add a photo', component: ProfileStep2 }, { label: 'Address & finish', component: ProfileStep3 }, ]; +// 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 { @@ -380,6 +328,23 @@ export function ProfileWizard({ const CurrentStepComponent = steps[activeStep]?.component; + // Derive card completeness for the indicator that replaced the stepper. + const pa = watchedValues.parsedAddress; + const cardStatus = profileCardStatus({ + hasBasics: canSubmitMinimal({ + name: watchedValues.name ?? '', + agreedToHouseholdGoods: !!watchedValues.agreedToHouseholdGoods, + agreedToTrustAndCare: !!watchedValues.agreedToTrustAndCare, + agreedToCommunityValues: !!watchedValues.agreedToCommunityValues, + agreedToAgeRestrictions: !!watchedValues.agreedToAgeRestrictions, + agreedToTerms: !!watchedValues.agreedToTerms, + }), + hasPhoto: + watchedValues.profilePicture instanceof File || + Boolean(watchedValues.profilePictureUrl), + hasAddress: Boolean(pa?.address1 && pa?.city && pa?.state && pa?.zip), + }); + return ( - {/* Progress Stepper */} - } - sx={{ mb: 4 }} - > - {steps.map((step, index) => ( - - ( - - )} - sx={{ - '& .MuiStepLabel-label': { - fontSize: '0.875rem', - fontWeight: 500, - color: - index <= activeStep - ? brandColors.charcoal - : brandColors.softGray, - mt: 1, - // Hide labels on mobile for cleaner UI - display: { xs: 'none', sm: 'block' }, - }, - }} - > - {step.label} - - - ))} - + {/* Card completeness — what the library card holds, filling in as you + go (replaces the linear 1-2-3 stepper). */} + + + + {completedCardCount(cardStatus) < cardStatus.length + ? 'You can fill in the rest anytime.' + : 'Your card is all set!'} + + {/* Form Content */} diff --git a/src/components/__tests__/profile-completeness.test.ts b/src/components/__tests__/profile-completeness.test.ts new file mode 100644 index 0000000..78d8bd6 --- /dev/null +++ b/src/components/__tests__/profile-completeness.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; + +import { + profileCardStatus, + completedCardCount, +} from '../profile-wizard/completeness'; + +describe('profileCardStatus', () => { + it('returns the three card parts in fill-in order', () => { + const items = profileCardStatus({ + hasBasics: true, + hasPhoto: false, + hasAddress: false, + }); + expect(items.map((i) => i.key)).toEqual(['basics', 'photo', 'address']); + }); + + it('reflects the done flags', () => { + const items = profileCardStatus({ + hasBasics: true, + hasPhoto: true, + hasAddress: false, + }); + expect(items.find((i) => i.key === 'photo')?.done).toBe(true); + expect(items.find((i) => i.key === 'address')?.done).toBe(false); + }); + + it('counts completed parts', () => { + expect( + completedCardCount( + profileCardStatus({ + hasBasics: true, + hasPhoto: true, + hasAddress: false, + }) + ) + ).toBe(2); + }); +}); diff --git a/src/components/profile-wizard/ProfileCompleteness.tsx b/src/components/profile-wizard/ProfileCompleteness.tsx new file mode 100644 index 0000000..4f2d105 --- /dev/null +++ b/src/components/profile-wizard/ProfileCompleteness.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { + CheckCircle, + RadioButtonUnchecked, + RadioButtonChecked, +} from '@mui/icons-material'; +import { Box, Typography } from '@mui/material'; + +import { brandColors } from '@/theme/brandTokens'; + +import type { CompletenessItem, CompletenessKey } from './completeness'; + +interface ProfileCompletenessProps { + items: CompletenessItem[]; + /** The part the user is currently filling in (for a gentle you-are-here). */ + currentKey?: CompletenessKey; +} + +/** + * Replaces the linear 1-2-3 stepper. Shows the parts of the library card and + * checks each off as it's added — so photo/address read as "fill in anytime," + * not "mandatory next step." + */ +export function ProfileCompleteness({ + items, + currentKey, +}: ProfileCompletenessProps) { + return ( + + {items.map((item) => { + const isCurrent = item.key === currentKey && !item.done; + const active = item.done || isCurrent; + const color = active ? brandColors.inkBlue : brandColors.softGray; + + return ( + + {item.done ? ( + + ) : isCurrent ? ( + + ) : ( + + )} + + {item.label} + + + ); + })} + + ); +} diff --git a/src/components/profile-wizard/completeness.ts b/src/components/profile-wizard/completeness.ts new file mode 100644 index 0000000..ce1b53b --- /dev/null +++ b/src/components/profile-wizard/completeness.ts @@ -0,0 +1,30 @@ +export type CompletenessKey = 'basics' | 'photo' | 'address'; + +export interface CompletenessItem { + key: CompletenessKey; + label: string; + done: boolean; +} + +/** + * The three things a full "library card" holds, in fill-in order. Drives the + * completeness indicator that replaced the linear 1-2-3 stepper: it shows what + * the card needs (global) and checks each off as it's added (progress), + * without implying a forced multi-step sequence. + */ +export function profileCardStatus(flags: { + hasBasics: boolean; + hasPhoto: boolean; + hasAddress: boolean; +}): CompletenessItem[] { + return [ + { key: 'basics', label: 'Name & agreements', done: flags.hasBasics }, + { key: 'photo', label: 'Photo', done: flags.hasPhoto }, + { key: 'address', label: 'Address', done: flags.hasAddress }, + ]; +} + +/** How many of the card's parts are filled in. */ +export function completedCardCount(items: CompletenessItem[]): number { + return items.filter((i) => i.done).length; +}