Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 66 additions & 49 deletions src/components/ProfileWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -61,16 +60,14 @@ const profileSchema = z.object({

export type ProfileFormData = z.infer<typeof profileSchema>;

// 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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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 (
<Container maxWidth="md" sx={{ py: 4 }}>
<Paper
Expand All @@ -358,19 +374,41 @@ export function ProfileWizard({
>
{/* Header */}
<Box sx={{ mb: 4 }}>
{/* Top row: close (exit to app) on the left, sign out on the right */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
{onExit ? (
<Tooltip title="Close and go back" arrow>
{/* Top row (hidden on the completion step, which has Cancel/Done):
close on the left, sign out on the right. */}
{activeStep < steps.length - 1 && (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
{onExit ? (
<Tooltip title="Close and go back" arrow>
<IconButton
onClick={onExit}
size="small"
sx={{
color: brandColors.charcoal,
opacity: 0.7,
'&:hover': {
opacity: 1,
backgroundColor: 'rgba(0, 0, 0, 0.04)',
},
}}
aria-label="Close"
>
<CloseIcon fontSize="small" />
</IconButton>
</Tooltip>
) : (
<Box />
)}
<Tooltip title="Sign out" arrow>
<IconButton
onClick={onExit}
onClick={() => signOut({ callbackUrl: '/auth/signin' })}
size="small"
sx={{
color: brandColors.charcoal,
Expand All @@ -380,32 +418,13 @@ export function ProfileWizard({
backgroundColor: 'rgba(0, 0, 0, 0.04)',
},
}}
aria-label="Close"
aria-label="Sign out"
>
<CloseIcon fontSize="small" />
<LogoutIcon fontSize="small" />
</IconButton>
</Tooltip>
) : (
<Box />
)}
<Tooltip title="Sign out" arrow>
<IconButton
onClick={() => 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"
>
<LogoutIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
)}

{/* Centered header content */}
<Box sx={{ textAlign: 'center' }}>
Expand Down Expand Up @@ -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). */}
<Box sx={{ mb: 4 }}>
<ProfileCompleteness
items={cardStatus}
currentKey={CURRENT_KEY_BY_STEP[activeStep] ?? 'basics'}
/>
<ProfileCompleteness items={cardStatus} currentKey={currentCardKey} />
<Typography
variant="caption"
sx={{
Expand Down Expand Up @@ -488,6 +504,7 @@ export function ProfileWizard({
profilePicturePreviewUrl={profilePicturePreviewUrl}
onMinimalSubmit={handleMinimalSubmit}
isSubmittingMinimal={Boolean(isSubmittingMinimal)}
onCancel={handleCancel}
/>
)}
</Box>
Expand Down
1 change: 1 addition & 0 deletions src/components/profile-wizard/ProfileStep1.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface ProfileStep1Props {
profilePicturePreviewUrl?: string | null;
onMinimalSubmit?: () => void;
isSubmittingMinimal?: boolean;
onCancel?: () => void;
}

export function ProfileStep1({
Expand Down
106 changes: 75 additions & 31 deletions src/components/profile-wizard/ProfileStep2.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';

Expand All @@ -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 {
Expand All @@ -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<ProfileFormData>();

const fileInputRef = useRef<HTMLInputElement>(null);
Expand All @@ -60,11 +57,21 @@ export function ProfileStep2({ onNext, onBack }: ProfileStep2Props) {
const [_verificationResult, setVerificationResult] =
useState<ImageVerificationResult | null>(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<ImageVerificationResult> => {
Expand Down Expand Up @@ -449,39 +456,71 @@ export function ProfileStep2({ onNext, onBack }: ProfileStep2Props) {
a pet peeve is leaving tools outside overnight!&quot;
</Typography>
</Box>

{/* Address */}
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 600, color: brandColors.charcoal, mb: 1 }}
>
Your address
</Typography>
<Controller
name="address"
control={control}
render={({ field }) => (
<AddressAutocomplete
value={field.value}
onChange={(value, addr) => {
field.onChange(value);
if (addr) {
setValue('parsedAddress', addr);
}
}}
error={!!errors.address}
helperText={errors.address?.message || undefined}
placeholder="123 Main Street, City, State"
/>
)}
/>
</Box>
</Stack>
</Box>

{/* Navigation */}
<Box sx={{ pt: 2 }}>
{/* Back link */}
{/* Navigation — a focused "provide the info and you're done" pair */}
<Box
sx={{
pt: 2,
display: 'flex',
flexDirection: { xs: 'column-reverse', sm: 'row' },
gap: 2,
}}
>
<Button
variant="text"
onClick={onBack}
startIcon={<ArrowBack />}
onClick={onCancel}
sx={{
mb: 2,
px: 0,
py: 1,
fontSize: '0.875rem',
py: 1.5,
px: 3,
fontSize: '1rem',
fontWeight: 500,
textTransform: 'none',
color: brandColors.charcoal,
opacity: 0.7,
'&:hover': {
opacity: 1,
backgroundColor: 'transparent',
},
opacity: 0.8,
'&:hover': { opacity: 1, backgroundColor: 'rgba(0,0,0,0.04)' },
}}
>
Back
Cancel
</Button>

{/* Full-width primary button */}
{/* Primary: finish + submit the full profile */}
<Button
type="submit"
variant="contained"
onClick={onNext}
endIcon={<ArrowForward />}
disabled={!canFinish || isSubmitting}
endIcon={
isSubmitting ? <CircularProgress size={16} /> : <CheckCircle />
}
fullWidth
sx={{
py: 1.5,
Expand All @@ -494,10 +533,15 @@ export function ProfileStep2({ onNext, onBack }: ProfileStep2Props) {
boxShadow: '0 6px 20px 0 rgba(30, 58, 95, 0.3)',
transform: 'translateY(-1px)',
},
'&:disabled': {
backgroundColor: brandColors.softGray,
color: brandColors.charcoal,
opacity: 0.6,
},
transition: 'all 0.2s ease',
}}
>
Continue
{isSubmitting ? 'Saving…' : 'Done!'}
</Button>
</Box>
</Box>
Expand Down
Loading
Loading