Skip to main content

Onboarding Flows

Complete guide to implementing custom onboarding experiences and integrating them with ValkyrAI workflows.

Overview

The Valhalla Suite onboarding system provides:

  • Signup Wizard: Multi-step user registration with customizable steps
  • Workflow Integration: Automatic post-signup workflows (welcome email, data collection, etc.)
  • Personalization: Step-specific configurations, conditional steps, custom branding
  • Analytics: Track completion rates, drop-off points, user engagement

Architecture:

User Starts Signup

OnboardingWizard Component
├─ Step 1: Account Creation
├─ Step 2: Profile Setup
├─ Step 3: Company Details
├─ Step 4: Preferences
└─ Step 5: Confirmation

Account Created (Database)

Post-Signup Workflows Triggered
├─ Send Welcome Email
├─ Initialize Default Workspace
├─ Create Starter Templates
└─ Schedule Onboarding Reminders

Frontend: Signup Wizard Component

Basic Setup

import React, { useState } from 'react';
import { OnboardingWizard } from '@valkyr/components/onboarding/OnboardingWizard';
import { useCreateUserMutation } from './backend/api/UserService';

export const SignupPage = () => {
const [createUser] = useCreateUserMutation();
const [completed, setCompleted] = useState(false);

const handleComplete = async (formData: SignupFormData) => {
try {
// Create user account
const user = await createUser({
email: formData.email,
password: formData.password,
firstName: formData.firstName,
lastName: formData.lastName,
company: formData.companyName,
}).unwrap();

setCompleted(true);
// Redirect after 2 seconds
setTimeout(() => {
window.location.href = '/dashboard';
}, 2000);
} catch (error) {
toast.error('Signup failed: ' + error.message);
}
};

return (
<div className="signup-container">
<OnboardingWizard
title="Welcome to Valkyr"
onComplete={handleComplete}
showProgress={true}
/>
</div>
);
};

Wizard Configuration

interface WizardStep {
id: string;
label: string;
title: string;
description: string;
fields: FormField[];
conditional?: (formData: any) => boolean; // Show step if true
optional?: boolean;
helpText?: string;
}

interface FormField {
name: string;
label: string;
type: 'text' | 'email' | 'password' | 'select' | 'checkbox' | 'textarea';
required?: boolean;
placeholder?: string;
options?: { label: string; value: string }[];
validation?: (value: any) => string | null; // Error message or null
helpText?: string;
}

// Example: Custom configuration
const customSteps: WizardStep[] = [
{
id: 'account',
label: 'Account',
title: 'Create Your Account',
description: 'Let\'s get started with your account setup',
fields: [
{
name: 'email',
label: 'Email Address',
type: 'email',
required: true,
placeholder: 'you@company.com',
validation: (value) => {
if (!value.includes('@')) return 'Invalid email';
return null;
},
},
{
name: 'password',
label: 'Password',
type: 'password',
required: true,
validation: (value) => {
if (value.length < 8) return 'Min 8 characters';
if (!/[A-Z]/.test(value)) return 'Need uppercase letter';
if (!/[0-9]/.test(value)) return 'Need number';
return null;
},
},
],
},
{
id: 'profile',
label: 'Profile',
title: 'Tell Us About Yourself',
fields: [
{
name: 'firstName',
label: 'First Name',
type: 'text',
required: true,
},
{
name: 'lastName',
label: 'Last Name',
type: 'text',
required: true,
},
],
},
{
id: 'company',
label: 'Company',
title: 'Company Information',
conditional: (data) => data.accountType === 'business', // Only for business
fields: [
{
name: 'companyName',
label: 'Company Name',
type: 'text',
required: true,
},
{
name: 'industry',
label: 'Industry',
type: 'select',
required: true,
options: [
{ label: 'Technology', value: 'tech' },
{ label: 'Finance', value: 'finance' },
{ label: 'Healthcare', value: 'healthcare' },
// ...
],
},
],
},
];

// Use custom steps
<OnboardingWizard steps={customSteps} onComplete={handleComplete} />

Error Handling & Validation

const handleStepError = (error: any) => {
// Show field-level errors
if (error.fieldErrors) {
Object.entries(error.fieldErrors).forEach(([field, message]) => {
setFieldError(field, message);
});
}

// Show general error
if (error.message) {
toast.error(error.message);
}

// Prevent step advance
return false;
};

const validateStep = async (stepId: string, formData: any) => {
try {
// Call backend validation
const validation = await validateSignupStep(stepId, formData);

if (!validation.isValid) {
setFieldErrors(validation.errors);
return false;
}

return true;
} catch (error) {
return handleStepError(error);
}
};

<OnboardingWizard
steps={customSteps}
onStepChange={validateStep}
onComplete={handleComplete}
/>

Backend: Post-Signup Workflows

Setup Workflow Triggers

@Service
public class OnboardingService {

@Autowired
private WorkflowService workflowService;

@Autowired
private UserRepository userRepo;

/**
* Triggered after user completes signup
*/
@Transactional
@EventListener(condition = "#event.eventType == 'user.registered'")
public void onUserRegistered(UserRegisteredEvent event) {
User user = event.getUser();

logger.info("User registered: {}", user.getEmail());

// Queue post-signup workflows
executePostSignupWorkflows(user);
}

private void executePostSignupWorkflows(User user) {
// Workflow 1: Send welcome email
triggerWorkflow("welcome-email", user);

// Workflow 2: Initialize workspace
triggerWorkflow("init-workspace", user);

// Workflow 3: Send onboarding sequence
triggerWorkflow("onboarding-sequence", user);
}

private void triggerWorkflow(String workflowId, User user) {
Workflow workflow = workflowService.getWorkflow(workflowId);

// Set initial state with user info
Map<String, Object> initialState = Map.of(
"userId", user.getId(),
"userEmail", user.getEmail(),
"userName", user.getFirstName() + " " + user.getLastName(),
"companyId", user.getCompany().getId()
);

// Execute workflow
workflowService.executeWorkflow(workflow, initialState);
}
}

Welcome Email Workflow Example

workflows:
- id: welcome-email
name: Welcome Email
description: Send welcome email to new users

tasks:
# Task 1: Prepare email content
- id: prepare-email
moduleId: transformModule
inputMapping:
userName: workflow.state.userName
userEmail: workflow.state.userEmail
outputMapping:
emailBody: workflow.state.emailContent

# Task 2: Send email
- id: send-email
moduleId: emailModule
inputMapping:
to: workflow.state.userEmail
subject: "Welcome to Valkyr!"
body: workflow.state.emailContent
html: true
outputMapping:
emailId: workflow.state.sentEmailId
status: workflow.state.emailStatus

# Task 3: Update user profile
- id: update-profile
moduleId: updateUserModule
inputMapping:
userId: workflow.state.userId
onboardingStep: "welcome_sent"
welcomeEmailSentAt: workflow.state.timestamp
outputMapping:
success: workflow.state.profileUpdated

ExecModule: Update User Onboarding Status

@Component("updateUserModule")
@VModuleAware
public class UpdateUserModule extends VModule {

@Autowired
private UserRepository userRepo;

@Override
public Map<String, Object> execute(
Workflow workflow,
Task task,
ExecModule module,
Map<String, Object> input
) {
try {
UUID userId = UUID.fromString((String) input.get("userId"));
String onboardingStep = (String) input.get("onboardingStep");
Instant timestamp = Instant.now();

// Get user
User user = userRepo.findById(userId)
.orElseThrow(() -> new NotFoundException("User not found"));

// Update onboarding status
user.setOnboardingStep(onboardingStep);
user.setOnboardingUpdatedAt(timestamp);

// If all steps complete, mark as onboarded
if ("completed".equals(onboardingStep)) {
user.setOnboardingCompletedAt(timestamp);
}

User updated = userRepo.save(user);

return Map.of(
"status", "success",
"userId", updated.getId(),
"onboardingStep", updated.getOnboardingStep()
);
} catch (Exception e) {
logger.error("Update user failed", e);
return Map.of("status", "error", "error", e.getMessage());
}
}
}

Onboarding Checklist Workflow

Implementation Example

workflows:
- id: onboarding-checklist
name: Onboarding Checklist
description: Track user onboarding completion

tasks:
- id: get-checklist
moduleId: getChecklistModule
inputMapping:
userId: workflow.state.userId
outputMapping:
items: workflow.state.checklistItems
completed: workflow.state.completedCount
total: workflow.state.totalCount

- id: check-completion
moduleId: conditionalModule
inputMapping:
completed: workflow.state.completedCount
total: workflow.state.totalCount
outputMapping:
isComplete: workflow.state.onboardingComplete

- id: send-reminder
moduleId: emailModule
conditional:
# Only send if not complete
path: workflow.state.onboardingComplete
value: false
inputMapping:
to: workflow.state.userEmail
subject: "Complete Your Onboarding"

Checklist Items

public enum OnboardingChecklistItem {
COMPLETE_PROFILE("Complete Profile", "Add photo and bio", 1),
CONNECT_CALENDAR("Connect Calendar", "Integrate with Outlook/Google", 2),
INVITE_TEAM("Invite Team", "Add team members", 3),
SETUP_INTEGRATIONS("Setup Integrations", "Connect CRM or tools", 4),
CREATE_FIRST_WORKFLOW("Create First Workflow", "Build automation", 5),
COMPLETE_TRAINING("Complete Training", "Watch onboarding videos", 6);

public final String title;
public final String description;
public final int order;

OnboardingChecklistItem(String title, String description, int order) {
this.title = title;
this.description = description;
this.order = order;
}
}

Analytics: Track Onboarding Metrics

Tracking Implementation

@Service
public class OnboardingAnalyticsService {

@Autowired
private OnboardingEventRepository eventRepo;

/**
* Track step progression
*/
public void recordStepCompleted(UUID userId, String stepId) {
OnboardingEvent event = new OnboardingEvent();
event.setUserId(userId);
event.setEventType(OnboardingEventType.STEP_COMPLETED);
event.setStepId(stepId);
event.setTimestamp(Instant.now());

eventRepo.save(event);
}

/**
* Track step abandonment
*/
public void recordStepAbandoned(UUID userId, String stepId) {
OnboardingEvent event = new OnboardingEvent();
event.setUserId(userId);
event.setEventType(OnboardingEventType.STEP_ABANDONED);
event.setStepId(stepId);
event.setTimestamp(Instant.now());

eventRepo.save(event);
}

/**
* Track onboarding completion
*/
public void recordOnboardingCompleted(UUID userId) {
OnboardingEvent event = new OnboardingEvent();
event.setUserId(userId);
event.setEventType(OnboardingEventType.COMPLETED);
event.setTimestamp(Instant.now());

eventRepo.save(event);
}

/**
* Get completion rate
*/
@Query("""
SELECT COUNT(DISTINCT oe.userId) as completedUsers,
COUNT(DISTINCT u.id) as totalUsers,
(COUNT(DISTINCT oe.userId) * 100.0 / COUNT(DISTINCT u.id)) as completionRate
FROM OnboardingEvent oe
RIGHT JOIN User u ON oe.userId = u.id
WHERE oe.eventType = 'COMPLETED'
AND u.createdAt >= :startDate
""")
OnboardingMetrics getCompletionRate(@Param("startDate") Instant startDate);
}

Metrics Endpoint

@RestController
@RequestMapping("/v1/analytics/onboarding")
@PreAuthorize("hasRole('ADMIN')")
public class OnboardingAnalyticsController {

@Autowired
private OnboardingAnalyticsService analyticsService;

@GetMapping("/metrics")
public ResponseEntity<OnboardingMetrics> getMetrics(
@RequestParam(required = false) String period // day, week, month
) {
Instant startDate = getStartDate(period);
OnboardingMetrics metrics = analyticsService.getCompletionRate(startDate);

return ResponseEntity.ok(metrics);
}

@GetMapping("/completion-by-step")
public ResponseEntity<List<StepMetrics>> getStepMetrics() {
List<StepMetrics> metrics = analyticsService.getMetricsByStep();
return ResponseEntity.ok(metrics);
}

@GetMapping("/funnel")
public ResponseEntity<OnboardingFunnel> getFunnel() {
OnboardingFunnel funnel = analyticsService.buildFunnel();
return ResponseEntity.ok(funnel);
}
}

Funnel Visualization

import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts';

export const OnboardingFunnel = ({ funnel }: { funnel: OnboardingFunnel }) => {
const data = [
{ step: 'Signup', users: funnel.started },
{ step: 'Account', users: funnel.accountCompleted },
{ step: 'Profile', users: funnel.profileCompleted },
{ step: 'Company', users: funnel.companyCompleted },
{ step: 'Preferences', users: funnel.preferencesCompleted },
{ step: 'Completed', users: funnel.completed },
];

return (
<div className="funnel-chart">
<h2>Onboarding Funnel</h2>
<AreaChart width={600} height={400} data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="step" />
<YAxis />
<Tooltip />
<Area
type="monotone"
dataKey="users"
stroke="#8884d8"
fill="#8884d8"
animationDuration={1000}
/>
</AreaChart>
</div>
);
};

Personalization: Conditional Steps

Dynamic Step Logic

// Example: Show company details only for business accounts
const getStepsForUser = (accountType: string): WizardStep[] => {
const baseSteps = [accountStep, profileStep];

if (accountType === "business") {
baseSteps.push(companyStep, integrationStep);
}

return baseSteps;
};

// Example: Update wizard steps based on previous choices
const handleAccountTypeChange = (type: string) => {
const updatedSteps = getStepsForUser(type);
setWizardSteps(updatedSteps);
};

Conditional Workflows

workflows:
- id: post-signup
name: Post-Signup Workflow
description: Route based on user type

tasks:
# Branch: Personal account vs Business
- id: route-by-type
moduleId: branchingModule
inputMapping:
accountType: workflow.state.accountType
outputMapping:
# Output determines which branch executes next
branch: workflow.state.routedBranch

# Personal account path
- id: personal-welcome
moduleId: emailModule
conditional:
path: workflow.state.accountType
value: personal
inputMapping:
to: workflow.state.userEmail
template: "welcome-personal"

# Business account path
- id: business-setup
moduleId: companySetupModule
conditional:
path: workflow.state.accountType
value: business
inputMapping:
companyId: workflow.state.companyId
adminUserId: workflow.state.userId

Error Handling & Recovery

Session Persistence

// Save form progress to localStorage
const saveProgress = (currentStep: number, formData: any) => {
const progress = {
step: currentStep,
data: formData,
savedAt: new Date().toISOString(),
};

localStorage.setItem("onboarding_progress", JSON.stringify(progress));
};

// Recover from saved progress
const getProgress = () => {
const saved = localStorage.getItem("onboarding_progress");
return saved ? JSON.parse(saved) : null;
};

// Resume onboarding from where user left off
const { data: currentProgress } = useEffect(() => {
const saved = getProgress();
if (saved) {
setCurrentStep(saved.step);
setFormData(saved.data);
}
}, []);

Timeout & Recovery

@Service
public class OnboardingTimeoutService {

@Scheduled(fixedRate = 3600000) // Check hourly
public void cleanupExpiredSessions() {
Instant oneHourAgo = Instant.now().minus(1, ChronoUnit.HOURS);

List<OnboardingSession> expired = sessionRepo
.findByUpdatedAtBefore(oneHourAgo);

expired.forEach(session -> {
logger.info("Expiring session: {}", session.getId());
sessionRepo.delete(session);
});
}

@Transactional
public void recordSessionTimeout(UUID userId) {
OnboardingEvent event = new OnboardingEvent();
event.setUserId(userId);
event.setEventType(OnboardingEventType.SESSION_TIMEOUT);
event.setTimestamp(Instant.now());

eventRepo.save(event);

// Send reminder email
sendTimeoutReminder(userId);
}
}

Deployment Checklist

  • Onboarding wizard component tested with all step types
  • Form validation working for all field types
  • Backend endpoints validated and secured
  • Post-signup workflows created and tested
  • ExecModules for onboarding tasks working
  • Analytics tracking enabled
  • Email templates for onboarding created
  • Error pages for failed signup displayed
  • Session recovery implemented
  • Mobile-responsive wizard UI verified
  • A/B testing framework set up
  • Performance optimized (< 3s page load)

ONBOARDING INTEGRATION COMPLETE