Improve onboarding flow

This commit is contained in:
Urtzi Alfaro
2026-01-04 21:37:44 +01:00
parent 47ccea4900
commit 429e724a2c
13 changed files with 1052 additions and 213 deletions

View File

@@ -2,8 +2,9 @@
* Onboarding React Query hooks
*/
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { useCallback, useRef } from 'react';
import { onboardingService } from '../services/onboarding';
import { UserProgress, UpdateStepRequest } from '../types/onboarding';
import { UserProgress, UpdateStepRequest, StepDraftResponse } from '../types/onboarding';
import { ApiError } from '../client';
// Query Keys
@@ -12,6 +13,7 @@ export const onboardingKeys = {
progress: (userId: string) => [...onboardingKeys.all, 'progress', userId] as const,
steps: () => [...onboardingKeys.all, 'steps'] as const,
stepDetail: (stepName: string) => [...onboardingKeys.steps(), stepName] as const,
stepDraft: (stepName: string) => [...onboardingKeys.all, 'draft', stepName] as const,
} as const;
// Queries
@@ -122,8 +124,6 @@ export const useMarkStepCompleted = (
// Invalidate queries on error to ensure we get fresh data
queryClient.invalidateQueries({ queryKey: onboardingKeys.progress(userId) });
},
// Prevent duplicate requests by using the step name as a mutation key
mutationKey: (variables) => ['markStepCompleted', variables?.userId, variables?.stepName],
...options,
});
};
@@ -132,7 +132,7 @@ export const useResetProgress = (
options?: UseMutationOptions<UserProgress, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<UserProgress, ApiError, string>({
mutationFn: (userId: string) => onboardingService.resetProgress(userId),
onSuccess: (data, userId) => {
@@ -141,4 +141,110 @@ export const useResetProgress = (
},
...options,
});
};
// Draft Queries and Mutations
/**
* Query hook to get draft data for a specific step
*/
export const useStepDraft = (
stepName: string,
options?: Omit<UseQueryOptions<StepDraftResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<StepDraftResponse, ApiError>({
queryKey: onboardingKeys.stepDraft(stepName),
queryFn: () => onboardingService.getStepDraft(stepName),
enabled: !!stepName,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
/**
* Mutation hook to save draft data for a step
*/
export const useSaveStepDraft = (
options?: UseMutationOptions<{ success: boolean }, ApiError, { stepName: string; draftData: Record<string, any> }>
) => {
const queryClient = useQueryClient();
return useMutation<{ success: boolean }, ApiError, { stepName: string; draftData: Record<string, any> }>({
mutationFn: ({ stepName, draftData }) => onboardingService.saveStepDraft(stepName, draftData),
onSuccess: (_, { stepName }) => {
// Invalidate the draft query to get fresh data
queryClient.invalidateQueries({ queryKey: onboardingKeys.stepDraft(stepName) });
},
...options,
});
};
/**
* Mutation hook to delete draft data for a step
*/
export const useDeleteStepDraft = (
options?: UseMutationOptions<{ success: boolean }, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<{ success: boolean }, ApiError, string>({
mutationFn: (stepName: string) => onboardingService.deleteStepDraft(stepName),
onSuccess: (_, stepName) => {
// Invalidate the draft query
queryClient.invalidateQueries({ queryKey: onboardingKeys.stepDraft(stepName) });
},
...options,
});
};
/**
* Custom hook with debounced draft auto-save functionality.
* Automatically saves draft data after a delay when form data changes.
*/
export const useAutoSaveDraft = (stepName: string, debounceMs: number = 2000) => {
const saveStepDraft = useSaveStepDraft();
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const saveDraft = useCallback(
(draftData: Record<string, any>) => {
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set a new timeout for debounced save
timeoutRef.current = setTimeout(() => {
saveStepDraft.mutate({ stepName, draftData });
}, debounceMs);
},
[stepName, debounceMs, saveStepDraft]
);
const saveDraftImmediately = useCallback(
(draftData: Record<string, any>) => {
// Clear any pending debounced save
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Save immediately
saveStepDraft.mutate({ stepName, draftData });
},
[stepName, saveStepDraft]
);
const cancelPendingSave = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
return {
saveDraft,
saveDraftImmediately,
cancelPendingSave,
isSaving: saveStepDraft.isPending,
isError: saveStepDraft.isError,
error: saveStepDraft.error,
};
};

View File

@@ -3,7 +3,7 @@
* Frontend and backend step names now match directly!
*/
import { apiClient } from '../client';
import { UserProgress, UpdateStepRequest } from '../types/onboarding';
import { UserProgress, UpdateStepRequest, SaveStepDraftRequest, StepDraftResponse } from '../types/onboarding';
// Backend onboarding steps (full list from backend - UPDATED to match refactored flow)
// NOTE: poi-detection removed - now happens automatically in background during tenant registration
@@ -155,7 +155,7 @@ export class OnboardingService {
async getResumeStep(): Promise<{ stepId: string; stepIndex: number }> {
try {
const progress = await this.getUserProgress('');
// If fully completed, go to completion
if (progress.fully_completed) {
return { stepId: 'completion', stepIndex: FRONTEND_STEP_ORDER.indexOf('completion') };
@@ -163,10 +163,10 @@ export class OnboardingService {
// Get the current step from backend
const currentStep = progress.current_step;
// If current step is user_registered, start from setup
const resumeStep = currentStep === 'user_registered' ? 'setup' : currentStep;
// Find the step index in our frontend order
let stepIndex = FRONTEND_STEP_ORDER.indexOf(resumeStep);
if (stepIndex === -1) {
@@ -179,6 +179,66 @@ export class OnboardingService {
return { stepId: 'setup', stepIndex: 0 };
}
}
/**
* Save in-progress step data without marking the step as complete.
* This allows users to save their work and resume later.
*/
async saveStepDraft(stepName: string, draftData: Record<string, any>): Promise<{ success: boolean }> {
const requestBody: SaveStepDraftRequest = {
step_name: stepName,
draft_data: draftData,
};
console.log(`💾 Saving draft for step "${stepName}":`, draftData);
try {
const response = await apiClient.put<{ success: boolean }>(`${this.baseUrl}/step-draft`, requestBody);
console.log(`✅ Draft saved for step "${stepName}"`);
return response;
} catch (error) {
console.error(`❌ Error saving draft for step "${stepName}":`, error);
throw error;
}
}
/**
* Get saved draft data for a specific step.
* Returns null if no draft exists or the step is already completed.
*/
async getStepDraft(stepName: string): Promise<StepDraftResponse> {
console.log(`📖 Getting draft for step "${stepName}"`);
try {
const response = await apiClient.get<StepDraftResponse>(`${this.baseUrl}/step-draft/${stepName}`);
if (response.draft_data) {
console.log(`✅ Found draft for step "${stepName}":`, response.draft_data);
} else {
console.log(` No draft found for step "${stepName}"`);
}
return response;
} catch (error) {
console.error(`❌ Error getting draft for step "${stepName}":`, error);
throw error;
}
}
/**
* Delete saved draft data for a specific step.
* Called after step is completed to clean up draft data.
*/
async deleteStepDraft(stepName: string): Promise<{ success: boolean }> {
console.log(`🗑️ Deleting draft for step "${stepName}"`);
try {
const response = await apiClient.delete<{ success: boolean }>(`${this.baseUrl}/step-draft/${stepName}`);
console.log(`✅ Draft deleted for step "${stepName}"`);
return response;
} catch (error) {
console.error(`❌ Error deleting draft for step "${stepName}":`, error);
throw error;
}
}
}
export const onboardingService = new OnboardingService();

View File

@@ -9,6 +9,27 @@ export interface OnboardingStepStatus {
data?: Record<string, any>;
}
/**
* Wizard context state extracted from backend step data.
* Used to restore frontend WizardContext on session restore.
*/
export interface WizardContextState {
bakery_type: string | null;
tenant_id: string | null;
subscription_tier: string | null;
ai_analysis_complete: boolean;
inventory_review_completed: boolean;
stock_entry_completed: boolean;
categorization_completed: boolean;
suppliers_completed: boolean;
inventory_setup_completed: boolean;
recipes_completed: boolean;
quality_completed: boolean;
team_completed: boolean;
child_tenants_completed: boolean;
ml_training_complete: boolean;
}
export interface UserProgress {
user_id: string;
steps: OnboardingStepStatus[];
@@ -17,10 +38,23 @@ export interface UserProgress {
completion_percentage: number;
fully_completed: boolean;
last_updated: string;
context_state?: WizardContextState;
}
export interface UpdateStepRequest {
step_name: string;
completed: boolean;
data?: Record<string, any>;
}
export interface SaveStepDraftRequest {
step_name: string;
draft_data: Record<string, any>;
}
export interface StepDraftResponse {
step_name: string;
draft_data: Record<string, any> | null;
draft_saved_at?: string;
demo_mode?: boolean;
}