- 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>
300 lines
7.6 KiB
TypeScript
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');
|
|
},
|
|
});
|
|
}
|