Onboarding Flow
@doc-version: 1.0.0
@last-updated: 2026-01-10
The onboarding flow guides new users through the essential setup steps: establishing identity, connecting to their ward, and choosing their civic role.
Overview
Core Principle: “You’re not joining a platform — you’re stepping into your place.” Onboarding should feel like returning home, not signing up for another social network.
The User Journey
Sign Up (Auth)
↓
┌─────────────────────────────────┐
│ ONBOARDING WIZARD │
│ │
│ Step 1: Identity │
│ - Display name │
│ - Avatar (optional) │
│ │
│ Step 2: Ward Selection │
│ - Geolocation or search │
│ - Confirm home ward │
│ │
│ Step 3: Role Selection │
│ - Choose civic role │
│ - (Fixer, Watcher, Organizer) │
└─────────────────────────────────┘
↓
Ward Dashboard
Architecture
Route Structure
/onboarding → Main onboarding page
└── OnboardingWizard → Client component wizard
├── StepIdentity
├── StepWardSelect
└── StepRole
Components
| Component | Purpose |
|---|
OnboardingWizard | State management and step orchestration |
StepIdentity | Display name and avatar setup |
StepWardSelect | Geolocation + ward confirmation |
StepRole | Civic role selection |
The Onboarding Wizard
// components/onboarding/OnboardingWizard.tsx
export type OnboardingData = {
wardId?: string
wardName?: string
displayName?: string
avatarPath?: string
role?: string
}
export function OnboardingWizard() {
const [step, setStep] = useState(1)
const [data, setData] = useState<OnboardingData>({})
const finish = async () => {
await completeOnboarding({
ward_code: data.wardId,
full_name: data.displayName,
role: data.role
})
}
// Step rendering with AnimatePresence for smooth transitions
}
Step 1: Identity
Users establish their display identity:
// components/onboarding/steps/StepIdentity.tsx
export function StepIdentity({ data, updateData, onNext }) {
return (
<motion.div>
<h2>Who are you?</h2>
{/* Display Name Input */}
<Input
value={data.displayName}
onChange={(e) => updateData({ displayName: e.target.value })}
placeholder="Your name"
/>
{/* Avatar Upload (Optional) */}
<AvatarUpload
onUpload={(path) => updateData({ avatarPath: path })}
/>
<Button onClick={onNext}>Continue</Button>
</motion.div>
)
}
Design Choices:
- Display name is optional but encouraged
- Avatar is fully optional — no gatekeeping
- Skip button available for quick setup
Step 2: Ward Selection
Users connect to their physical ward:
// components/onboarding/steps/StepWardSelect.tsx
export function StepWardSelect({ data, updateData, onNext, onBack }) {
// Uses LocationPicker component for geolocation
return (
<motion.div>
<h2>Where do you live?</h2>
<LocationPicker
onWardSelected={(ward) => updateData({
wardId: ward.ward_code,
wardName: ward.name
})}
/>
{/* Alternative: Search by area */}
<WardSearchInput />
<div className="flex gap-4">
<Button variant="outline" onClick={onBack}>Back</Button>
<Button onClick={onNext}>Confirm Ward</Button>
</div>
</motion.div>
)
}
Ward Detection Flow:
- Request geolocation permission
- Call
findWardByLocation(lat, lng) RPC
- Display ward name and municipality
- Allow manual search as fallback
Step 3: Role Selection
Users choose their civic identity:
// components/onboarding/steps/StepRole.tsx
const ROLES = [
{
id: 'fixer',
title: 'Fixer',
description: 'I want to help solve problems in my community',
icon: Wrench
},
{
id: 'watcher',
title: 'Watcher',
description: 'I want to stay informed about what happens locally',
icon: Eye
},
{
id: 'organizer',
title: 'Organizer',
description: 'I want to coordinate community action',
icon: Users
}
]
export function StepRole({ data, updateData, onNext, onBack, isLoading }) {
return (
<motion.div>
<h2>How will you participate?</h2>
<div className="grid gap-4">
{ROLES.map(role => (
<RoleCard
key={role.id}
role={role}
selected={data.role === role.id}
onClick={() => updateData({ role: role.id })}
/>
))}
</div>
<Button onClick={onNext} disabled={isLoading}>
{isLoading ? 'Setting up...' : 'Enter My Ward'}
</Button>
</motion.div>
)
}
Role Implications:
- Roles affect default notification preferences
- Roles influence dashboard widget ordering
- Roles are displayed on profile badges
- Roles can be changed later in settings
Server Action: Complete Onboarding
// app/actions/onboarding.ts
export async function completeOnboarding(data: {
ward_code: string;
full_name?: string;
role: string;
}) {
const user = await getCurrentUser();
if (!user) throw new Error('Unauthorized');
const supabase = await createClient();
// Update profile with onboarding data
const { error } = await supabase
.from('profiles')
.update({
ward_code: data.ward_code,
full_name: data.full_name,
role: data.role,
onboarded_at: new Date().toISOString()
})
.eq('id', user.id);
if (error) throw error;
// Redirect to ward dashboard
redirect(`/ward/${data.ward_code}`);
}
Onboarding Guard
Incomplete profiles are redirected to onboarding:
// middleware.ts or layout checks
if (!profile.onboarded_at && !pathname.startsWith('/onboarding')) {
redirect('/onboarding');
}
Animation & UX
The wizard uses Framer Motion for step transitions:
<AnimatePresence mode="wait">
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{/* Step content */}
</motion.div>
</AnimatePresence>
Progress Indicator:
- Visual progress bar showing completion %
- Step counter “Step 2 of 3”
- Animated bar width transition
Status: Active
Last Updated: January 2026