template-saas/apps/frontend/src/hooks/useOnboarding.ts
rckrdmrd 4dafffa386 feat: Add superadmin metrics, onboarding and module documentation
- Add MetricsPage and useOnboarding hook
- Update superadmin controller and service
- Add module documentation (docs/01-modulos/)
- Add CONTEXT-MAP.yml and Sprint 5 execution report
- Update project status and task traces

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:40:26 -06:00

300 lines
7.6 KiB
TypeScript

import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import api from '@/services/api';
import { useAuthStore } from '@/stores';
// ==================== Types ====================
export type OnboardingStep = 'company' | 'invite' | 'plan' | 'complete';
export interface OnboardingState {
currentStep: OnboardingStep;
completedSteps: OnboardingStep[];
companyData: CompanyData | null;
invitedUsers: InvitedUser[];
selectedPlanId: string | null;
}
export interface CompanyData {
name: string;
slug: string;
domain?: string;
logo_url?: string;
industry?: string;
size?: string;
timezone?: string;
}
export interface InvitedUser {
email: string;
role: string;
status: 'pending' | 'sent' | 'error';
}
export interface Plan {
id: string;
name: string;
display_name: string;
description: string;
price_monthly: number;
price_yearly: number;
features: string[];
is_popular?: boolean;
}
// ==================== API Functions ====================
const onboardingApi = {
getStatus: async () => {
const response = await api.get('/onboarding/status');
return response.data;
},
updateCompany: async (data: CompanyData) => {
const response = await api.patch('/tenants/current', data);
return response.data;
},
inviteUsers: async (users: { email: string; role: string }[]) => {
const results = await Promise.allSettled(
users.map((user) => api.post('/users/invite', user))
);
return results;
},
getPlans: async (): Promise<Plan[]> => {
const response = await api.get('/plans');
return response.data;
},
selectPlan: async (planId: string) => {
const response = await api.post('/billing/subscription', { plan_id: planId });
return response.data;
},
completeOnboarding: async () => {
const response = await api.post('/onboarding/complete');
return response.data;
},
};
// ==================== Query Keys ====================
export const onboardingKeys = {
all: ['onboarding'] as const,
status: () => [...onboardingKeys.all, 'status'] as const,
plans: () => [...onboardingKeys.all, 'plans'] as const,
};
// ==================== Hooks ====================
const STEPS: OnboardingStep[] = ['company', 'invite', 'plan', 'complete'];
const STORAGE_KEY = 'onboarding_state';
function loadState(): Partial<OnboardingState> {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : {};
} catch {
return {};
}
}
function saveState(state: Partial<OnboardingState>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Ignore storage errors
}
}
function clearState() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore storage errors
}
}
export function useOnboarding() {
const queryClient = useQueryClient();
const { user } = useAuthStore();
const savedState = loadState();
const [state, setState] = useState<OnboardingState>({
currentStep: savedState.currentStep || 'company',
completedSteps: savedState.completedSteps || [],
companyData: savedState.companyData || null,
invitedUsers: savedState.invitedUsers || [],
selectedPlanId: savedState.selectedPlanId || null,
});
// Update state and persist
const updateState = useCallback((updates: Partial<OnboardingState>) => {
setState((prev) => {
const newState = { ...prev, ...updates };
saveState(newState);
return newState;
});
}, []);
// Navigation
const goToStep = useCallback((step: OnboardingStep) => {
updateState({ currentStep: step });
}, [updateState]);
const nextStep = useCallback(() => {
const currentIndex = STEPS.indexOf(state.currentStep);
if (currentIndex < STEPS.length - 1) {
const nextStepName = STEPS[currentIndex + 1];
updateState({
currentStep: nextStepName,
completedSteps: [...new Set([...state.completedSteps, state.currentStep])],
});
}
}, [state.currentStep, state.completedSteps, updateState]);
const prevStep = useCallback(() => {
const currentIndex = STEPS.indexOf(state.currentStep);
if (currentIndex > 0) {
updateState({ currentStep: STEPS[currentIndex - 1] });
}
}, [state.currentStep, updateState]);
const canGoNext = useCallback(() => {
switch (state.currentStep) {
case 'company':
return !!state.companyData?.name && !!state.companyData?.slug;
case 'invite':
return true; // Optional step
case 'plan':
return !!state.selectedPlanId;
case 'complete':
return false;
default:
return false;
}
}, [state]);
const canGoPrev = useCallback(() => {
return STEPS.indexOf(state.currentStep) > 0 && state.currentStep !== 'complete';
}, [state.currentStep]);
const getStepIndex = useCallback(() => {
return STEPS.indexOf(state.currentStep);
}, [state.currentStep]);
const getTotalSteps = useCallback(() => {
return STEPS.length;
}, []);
const isStepCompleted = useCallback((step: OnboardingStep) => {
return state.completedSteps.includes(step);
}, [state.completedSteps]);
// Reset
const resetOnboarding = useCallback(() => {
clearState();
setState({
currentStep: 'company',
completedSteps: [],
companyData: null,
invitedUsers: [],
selectedPlanId: null,
});
}, []);
return {
state,
updateState,
goToStep,
nextStep,
prevStep,
canGoNext,
canGoPrev,
getStepIndex,
getTotalSteps,
isStepCompleted,
resetOnboarding,
steps: STEPS,
};
}
// ==================== Data Hooks ====================
export function usePlans() {
return useQuery({
queryKey: onboardingKeys.plans(),
queryFn: onboardingApi.getPlans,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
export function useUpdateCompany() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: onboardingApi.updateCompany,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tenant'] });
toast.success('Company information saved!');
},
onError: () => {
toast.error('Failed to save company information');
},
});
}
export function useInviteUsers() {
return useMutation({
mutationFn: onboardingApi.inviteUsers,
onSuccess: (results) => {
const successful = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.filter((r) => r.status === 'rejected').length;
if (successful > 0) {
toast.success(`${successful} invitation${successful > 1 ? 's' : ''} sent!`);
}
if (failed > 0) {
toast.error(`${failed} invitation${failed > 1 ? 's' : ''} failed`);
}
},
onError: () => {
toast.error('Failed to send invitations');
},
});
}
export function useSelectPlan() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: onboardingApi.selectPlan,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription'] });
toast.success('Plan selected successfully!');
},
onError: () => {
toast.error('Failed to select plan');
},
});
}
export function useCompleteOnboarding() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: onboardingApi.completeOnboarding,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user'] });
queryClient.invalidateQueries({ queryKey: ['tenant'] });
toast.success('Welcome aboard! Your setup is complete.');
},
onError: () => {
toast.error('Failed to complete onboarding');
},
});
}