Add onboardin steps improvements

This commit is contained in:
Urtzi Alfaro
2025-08-11 07:01:08 +02:00
parent c721575cd3
commit c4d4aeb449
16 changed files with 2015 additions and 103 deletions

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Toaster } from 'react-hot-toast';
import toast from 'react-hot-toast';
// Components
import LoadingSpinner from './components/ui/LoadingSpinner';
@@ -19,6 +20,9 @@ import Layout from './components/layout/Layout';
import { store } from './store';
import { Provider } from 'react-redux';
// Onboarding utilities
import { OnboardingRouter, type NextAction, type RoutingDecision } from './utils/onboardingRouter';
// i18n
import './i18n';
@@ -33,6 +37,7 @@ interface User {
fullName: string;
role: string;
isOnboardingComplete: boolean;
tenant_id?: string;
}
interface AppState {
@@ -40,6 +45,7 @@ interface AppState {
isLoading: boolean;
user: User | null;
currentPage: CurrentPage;
routingDecision: RoutingDecision | null;
}
const LoadingFallback = () => (
@@ -56,9 +62,25 @@ const App: React.FC = () => {
isAuthenticated: false,
isLoading: true,
user: null,
currentPage: 'landing' // 👈 Start with landing page
currentPage: 'landing',
routingDecision: null
});
// Helper function to map NextAction to CurrentPage
const mapActionToPage = (action: NextAction): CurrentPage => {
const actionPageMap: Record<NextAction, CurrentPage> = {
'register': 'register',
'login': 'login',
'onboarding_bakery': 'onboarding',
'onboarding_data': 'onboarding',
'onboarding_training': 'onboarding',
'dashboard': 'dashboard',
'landing': 'landing'
};
return actionPageMap[action] || 'landing';
};
// Initialize app and check authentication
useEffect(() => {
const initializeApp = async () => {
@@ -69,17 +91,43 @@ const App: React.FC = () => {
if (token && userData) {
const user = JSON.parse(userData);
setAppState({
isAuthenticated: true,
isLoading: false,
user,
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding'
});
try {
// Use enhanced onboarding router to determine next action
const routingDecision = await OnboardingRouter.getNextActionForUser();
const nextPage = mapActionToPage(routingDecision.nextAction);
setAppState({
isAuthenticated: true,
isLoading: false,
user,
currentPage: nextPage,
routingDecision
});
// Show welcome message with progress
if (routingDecision.message && routingDecision.completionPercentage > 0) {
toast.success(`Welcome back! ${routingDecision.message} (${Math.round(routingDecision.completionPercentage)}% complete)`);
}
} catch (onboardingError) {
// Fallback to legacy logic if onboarding API fails
console.warn('Onboarding API failed, using legacy logic:', onboardingError);
setAppState({
isAuthenticated: true,
isLoading: false,
user,
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding',
routingDecision: null
});
}
} else {
// Unauthenticated user
const routingDecision = OnboardingRouter.getNextActionForGuest();
setAppState(prev => ({
...prev,
isLoading: false,
currentPage: 'landing' // 👈 Show landing page for non-authenticated users
currentPage: 'landing',
routingDecision
}));
}
} catch (error) {
@@ -87,7 +135,8 @@ const App: React.FC = () => {
setAppState(prev => ({
...prev,
isLoading: false,
currentPage: 'landing' // 👈 Fallback to landing page
currentPage: 'landing',
routingDecision: null
}));
}
};
@@ -95,16 +144,45 @@ const App: React.FC = () => {
initializeApp();
}, []);
const handleLogin = (user: User, token: string) => {
const handleLogin = async (user: User, token: string) => {
localStorage.setItem('auth_token', token);
localStorage.setItem('user_data', JSON.stringify(user));
setAppState({
isAuthenticated: true,
isLoading: false,
user,
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding'
});
try {
// Mark user registration as complete
await OnboardingRouter.completeStep('user_registered', {
user_id: user.id,
email: user.email,
login_type: 'existing_user'
});
// Determine next action based on current progress
const routingDecision = await OnboardingRouter.getNextActionForUser();
const nextPage = mapActionToPage(routingDecision.nextAction);
setAppState({
isAuthenticated: true,
isLoading: false,
user,
currentPage: nextPage,
routingDecision
});
// Show progress message
if (routingDecision.message) {
toast.success(routingDecision.message);
}
} catch (error) {
console.warn('Enhanced login routing failed, using fallback:', error);
// Fallback to legacy logic
setAppState({
isAuthenticated: true,
isLoading: false,
user,
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding',
routingDecision: null
});
}
};
const handleLogout = () => {
@@ -119,15 +197,42 @@ const App: React.FC = () => {
});
};
const handleOnboardingComplete = () => {
const updatedUser = { ...appState.user!, isOnboardingComplete: true };
localStorage.setItem('user_data', JSON.stringify(updatedUser));
setAppState(prev => ({
...prev,
user: updatedUser,
currentPage: 'dashboard'
}));
const handleOnboardingComplete = async () => {
try {
// Mark all onboarding steps as complete
await OnboardingRouter.completeStep('dashboard_accessible', {
completion_time: new Date().toISOString(),
user_id: appState.user?.id
});
const updatedUser = { ...appState.user!, isOnboardingComplete: true };
localStorage.setItem('user_data', JSON.stringify(updatedUser));
setAppState(prev => ({
...prev,
user: updatedUser,
currentPage: 'dashboard',
routingDecision: {
nextAction: 'dashboard',
currentStep: 'dashboard_accessible',
completionPercentage: 100,
message: 'Welcome to your PanIA dashboard!'
}
}));
toast.success('¡Configuración completada! Bienvenido a tu dashboard de PanIA 🎉');
} catch (error) {
console.warn('Enhanced onboarding completion failed, using fallback:', error);
// Fallback logic
const updatedUser = { ...appState.user!, isOnboardingComplete: true };
localStorage.setItem('user_data', JSON.stringify(updatedUser));
setAppState(prev => ({
...prev,
user: updatedUser,
currentPage: 'dashboard'
}));
}
};
const navigateTo = (page: CurrentPage) => {

View File

@@ -9,6 +9,16 @@ export { useData } from './useData';
export { useTraining } from './useTraining';
export { useForecast } from './useForecast';
export { useNotification } from './useNotification';
export { useOnboarding, useOnboardingStep } from './useOnboarding';
// Import hooks for combined usage
import { useAuth } from './useAuth';
import { useTenant } from './useTenant';
import { useData } from './useData';
import { useTraining } from './useTraining';
import { useForecast } from './useForecast';
import { useNotification } from './useNotification';
import { useOnboarding } from './useOnboarding';
// Combined hook for common operations
export const useApiHooks = () => {
@@ -18,6 +28,7 @@ export const useApiHooks = () => {
const training = useTraining();
const forecast = useForecast();
const notification = useNotification();
const onboarding = useOnboarding();
return {
auth,
@@ -26,5 +37,6 @@ export const useApiHooks = () => {
training,
forecast,
notification,
onboarding,
};
};

View File

@@ -0,0 +1,194 @@
// frontend/src/api/hooks/useOnboarding.ts
/**
* Onboarding Hook
* React hook for managing user onboarding flow and progress
*/
import { useState, useEffect } from 'react';
import { onboardingService } from '../services/onboarding.service';
import type { UserProgress, UpdateStepRequest } from '../services/onboarding.service';
export interface UseOnboardingReturn {
progress: UserProgress | null;
isLoading: boolean;
error: string | null;
currentStep: string | null;
nextStep: string | null;
completionPercentage: number;
isFullyComplete: boolean;
// Actions
updateStep: (data: UpdateStepRequest) => Promise<void>;
completeStep: (stepName: string, data?: Record<string, any>) => Promise<void>;
resetStep: (stepName: string) => Promise<void>;
getNextStep: () => Promise<string>;
completeOnboarding: () => Promise<void>;
canAccessStep: (stepName: string) => Promise<boolean>;
refreshProgress: () => Promise<void>;
}
export const useOnboarding = (): UseOnboardingReturn => {
const [progress, setProgress] = useState<UserProgress | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Derived state
const currentStep = progress?.current_step || null;
const nextStep = progress?.next_step || null;
const completionPercentage = progress?.completion_percentage || 0;
const isFullyComplete = progress?.fully_completed || false;
// Load initial progress
const loadProgress = async () => {
setIsLoading(true);
setError(null);
try {
const userProgress = await onboardingService.getUserProgress();
setProgress(userProgress);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load onboarding progress';
setError(message);
console.error('Onboarding progress load error:', err);
} finally {
setIsLoading(false);
}
};
// Update step
const updateStep = async (data: UpdateStepRequest) => {
setIsLoading(true);
setError(null);
try {
const updatedProgress = await onboardingService.updateStep(data);
setProgress(updatedProgress);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update step';
setError(message);
throw err; // Re-throw so calling component can handle it
} finally {
setIsLoading(false);
}
};
// Complete step with data
const completeStep = async (stepName: string, data?: Record<string, any>) => {
await updateStep({
step_name: stepName,
completed: true,
data
});
};
// Reset step
const resetStep = async (stepName: string) => {
await updateStep({
step_name: stepName,
completed: false
});
};
// Get next step
const getNextStep = async (): Promise<string> => {
try {
const result = await onboardingService.getNextStep();
return result.step;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to get next step';
setError(message);
throw err;
}
};
// Complete entire onboarding
const completeOnboarding = async () => {
setIsLoading(true);
setError(null);
try {
await onboardingService.completeOnboarding();
await loadProgress(); // Refresh progress after completion
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to complete onboarding';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
// Check if user can access step
const canAccessStep = async (stepName: string): Promise<boolean> => {
try {
const result = await onboardingService.canAccessStep(stepName);
return result.can_access;
} catch (err) {
console.error('Can access step check failed:', err);
return false;
}
};
// Refresh progress
const refreshProgress = async () => {
await loadProgress();
};
// Load progress on mount
useEffect(() => {
loadProgress();
}, []);
return {
progress,
isLoading,
error,
currentStep,
nextStep,
completionPercentage,
isFullyComplete,
updateStep,
completeStep,
resetStep,
getNextStep,
completeOnboarding,
canAccessStep,
refreshProgress,
};
};
// Helper hook for specific steps
export const useOnboardingStep = (stepName: string) => {
const onboarding = useOnboarding();
const stepStatus = onboarding.progress?.steps.find(
step => step.step_name === stepName
);
const isCompleted = stepStatus?.completed || false;
const stepData = stepStatus?.data || {};
const completedAt = stepStatus?.completed_at;
const completeThisStep = async (data?: Record<string, any>) => {
await onboarding.completeStep(stepName, data);
};
const resetThisStep = async () => {
await onboarding.resetStep(stepName);
};
const canAccessThisStep = async (): Promise<boolean> => {
return await onboarding.canAccessStep(stepName);
};
return {
...onboarding,
stepName,
isCompleted,
stepData,
completedAt,
completeThisStep,
resetThisStep,
canAccessThisStep,
};
};

View File

@@ -28,6 +28,7 @@ export {
useForecast,
useNotification,
useApiHooks,
useOnboarding,
} from './hooks';
// Export WebSocket functionality

View File

@@ -11,6 +11,7 @@ import { DataService } from './data.service';
import { TrainingService } from './training.service';
import { ForecastingService } from './forecasting.service';
import { NotificationService } from './notification.service';
import { OnboardingService } from './onboarding.service';
// Create service instances
export const authService = new AuthService();
@@ -19,9 +20,10 @@ export const dataService = new DataService();
export const trainingService = new TrainingService();
export const forecastingService = new ForecastingService();
export const notificationService = new NotificationService();
export const onboardingService = new OnboardingService();
// Export the classes as well
export { AuthService, TenantService, DataService, TrainingService, ForecastingService, NotificationService };
export { AuthService, TenantService, DataService, TrainingService, ForecastingService, NotificationService, OnboardingService };
// Import base client
export { apiClient } from '../client';
@@ -37,6 +39,7 @@ export const api = {
training: trainingService,
forecasting: forecastingService,
notification: notificationService,
onboarding: onboardingService,
} as const;
// Service status checking

View File

@@ -0,0 +1,92 @@
// frontend/src/api/services/onboarding.service.ts
/**
* Onboarding Service
* Handles user progress tracking and onboarding flow management
*/
import { apiClient } from '../client';
export interface OnboardingStepStatus {
step_name: string;
completed: boolean;
completed_at?: string;
data?: Record<string, any>;
}
export interface UserProgress {
user_id: string;
steps: OnboardingStepStatus[];
current_step: string;
next_step?: string;
completion_percentage: number;
fully_completed: boolean;
last_updated: string;
}
export interface UpdateStepRequest {
step_name: string;
completed: boolean;
data?: Record<string, any>;
}
export class OnboardingService {
private baseEndpoint = '/users/me/onboarding';
/**
* Get user's current onboarding progress
*/
async getUserProgress(): Promise<UserProgress> {
return apiClient.get(`${this.baseEndpoint}/progress`);
}
/**
* Update a specific onboarding step
*/
async updateStep(data: UpdateStepRequest): Promise<UserProgress> {
return apiClient.put(`${this.baseEndpoint}/step`, data);
}
/**
* Mark step as completed with optional data
*/
async completeStep(stepName: string, data?: Record<string, any>): Promise<UserProgress> {
return this.updateStep({
step_name: stepName,
completed: true,
data
});
}
/**
* Reset a step (mark as incomplete)
*/
async resetStep(stepName: string): Promise<UserProgress> {
return this.updateStep({
step_name: stepName,
completed: false
});
}
/**
* Get next required step for user
*/
async getNextStep(): Promise<{ step: string; data?: Record<string, any> }> {
return apiClient.get(`${this.baseEndpoint}/next-step`);
}
/**
* Complete entire onboarding process
*/
async completeOnboarding(): Promise<{ success: boolean; message: string }> {
return apiClient.post(`${this.baseEndpoint}/complete`);
}
/**
* Check if user can access a specific step
*/
async canAccessStep(stepName: string): Promise<{ can_access: boolean; reason?: string }> {
return apiClient.get(`${this.baseEndpoint}/can-access/${stepName}`);
}
}
export const onboardingService = new OnboardingService();

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check, Brain, Clock, CheckCircle, AlertTriangle, Loader } from 'lucide-react';
import toast from 'react-hot-toast';
@@ -9,10 +9,13 @@ import {
useTraining,
useData,
useTrainingWebSocket,
useOnboarding,
TenantCreate,
TrainingJobRequest
} from '../../api';
import { OnboardingRouter } from '../../utils/onboardingRouter';
interface OnboardingPageProps {
user: any;
onComplete: () => void;
@@ -47,6 +50,15 @@ const MADRID_PRODUCTS = [
const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) => {
const [currentStep, setCurrentStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const manualNavigation = useRef(false);
// Enhanced onboarding with progress tracking
const {
progress,
isLoading: onboardingLoading,
completeStep,
refreshProgress
} = useOnboarding();
const [bakeryData, setBakeryData] = useState<BakeryData>({
name: '',
address: '',
@@ -56,9 +68,12 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
});
// Training progress state
const [tenantId, setTenantId] = useState<string>('');
const [tenantId, setTenantId] = useState<string>(() => {
// Initialize from localStorage
return localStorage.getItem('current_tenant_id') || '';
});
const [trainingJobId, setTrainingJobId] = useState<string>('');
const { createTenant, isLoading: tenantLoading } = useTenant();
const { createTenant, getUserTenants, isLoading: tenantLoading } = useTenant();
const { startTrainingJob } = useTraining({ disablePolling: true });
const { uploadSalesHistory, validateSalesData } = useData();
@@ -78,6 +93,60 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
estimatedTimeRemaining: 0
});
// Sales data validation state
const [validationStatus, setValidationStatus] = useState<{
status: 'idle' | 'validating' | 'valid' | 'invalid';
message?: string;
records?: number;
}>({
status: 'idle'
});
// Initialize current step from onboarding progress (only when not manually navigating)
useEffect(() => {
const initializeStepFromProgress = async () => {
if (progress && !manualNavigation.current) {
// Only initialize from progress if we haven't manually navigated
try {
const { step } = await OnboardingRouter.getOnboardingStepFromProgress();
console.log('🎯 Initializing step from progress:', step);
setCurrentStep(step);
} catch (error) {
console.warn('Failed to get step from progress, using default:', error);
}
}
};
initializeStepFromProgress();
}, [progress]);
// Fetch tenant ID from backend if not available locally
useEffect(() => {
const fetchTenantIdFromBackend = async () => {
if (!tenantId && user) {
console.log('🏢 No tenant ID in localStorage, fetching from backend...');
try {
const userTenants = await getUserTenants();
console.log('📋 User tenants:', userTenants);
if (userTenants.length > 0) {
// Use the first (most recent) tenant for onboarding
const primaryTenant = userTenants[0];
console.log('✅ Found tenant from backend:', primaryTenant.id);
setTenantId(primaryTenant.id);
storeTenantId(primaryTenant.id);
} else {
console.log(' No tenants found for user - will be created in step 1');
}
} catch (error) {
console.warn('⚠️ Failed to fetch user tenants:', error);
// This is not a critical error - tenant will be created in step 1 if needed
}
}
};
fetchTenantIdFromBackend();
}, [tenantId, user, getUserTenants]);
// WebSocket connection for real-time training updates
const {
@@ -118,9 +187,19 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
currentStep: 'Entrenamiento completado',
estimatedTimeRemaining: 0
}));
// Mark training step as completed in onboarding API
completeStep('training_completed', {
training_completed_at: new Date().toISOString(),
user_id: user?.id,
tenant_id: tenantId
}).catch(error => {
console.warn('Failed to mark training as completed in API:', error);
});
// Auto-advance to final step after 2 seconds
setTimeout(() => {
manualNavigation.current = true;
setCurrentStep(4);
}, 2000);
@@ -203,32 +282,238 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
}
};
const handleNext = () => {
if (validateCurrentStep()) {
if (currentStep === 2) {
// Always proceed to training step after CSV upload
startTraining();
} else {
setCurrentStep(prev => Math.min(prev + 1, steps.length));
const handleNext = async () => {
console.log(`🚀 HandleNext called for step ${currentStep}`);
if (!validateCurrentStep()) {
console.log('❌ Validation failed for current step');
return;
}
setIsLoading(true);
try {
if (currentStep === 1) {
console.log('📝 Processing step 1: Register bakery');
await createBakeryAndTenant();
console.log('✅ Step 1 completed successfully');
} else if (currentStep === 2) {
console.log('📂 Processing step 2: Upload sales data');
await uploadAndValidateSalesData();
console.log('✅ Step 2 completed successfully');
}
} catch (error) {
console.error('❌ Error in step:', error);
toast.error('Error al procesar el paso. Por favor, inténtalo de nuevo.');
} finally {
setIsLoading(false);
console.log(`🔄 Loading state set to false, current step: ${currentStep}`);
}
};
const handlePrevious = () => {
manualNavigation.current = true;
setCurrentStep(prev => Math.max(prev - 1, 1));
};
// Create bakery and tenant for step 1
const createBakeryAndTenant = async () => {
try {
console.log('🏪 Creating bakery tenant...');
let currentTenantId = tenantId;
// Check if user already has a tenant
if (currentTenantId) {
console.log(' User already has tenant ID:', currentTenantId);
console.log('✅ Using existing tenant, skipping creation');
} else {
console.log('📝 Creating new tenant');
const tenantData: TenantCreate = {
name: bakeryData.name,
address: bakeryData.address,
business_type: "bakery",
postal_code: "28010",
phone: "+34655334455",
coordinates: bakeryData.coordinates,
products: bakeryData.products,
has_historical_data: bakeryData.hasHistoricalData,
};
const newTenant = await createTenant(tenantData);
console.log('✅ Tenant created:', newTenant.id);
currentTenantId = newTenant.id;
setTenantId(newTenant.id);
storeTenantId(newTenant.id);
}
// Mark step as completed in onboarding API (non-blocking)
try {
await completeStep('bakery_registered', {
bakery_name: bakeryData.name,
bakery_address: bakeryData.address,
business_type: bakeryData.businessType,
tenant_id: currentTenantId, // Use the correct tenant ID
user_id: user?.id
});
console.log('✅ Step marked as completed');
} catch (stepError) {
console.warn('⚠️ Failed to mark step as completed, but continuing:', stepError);
// Don't throw here - step completion is not critical for UI flow
}
toast.success('Panadería registrada correctamente');
console.log('🎯 Advancing to step 2');
manualNavigation.current = true;
setCurrentStep(2);
} catch (error) {
console.error('❌ Error creating tenant:', error);
throw new Error('Error al registrar la panadería');
}
};
// Validate sales data without importing
const validateSalesFile = async () => {
console.log('🔍 validateSalesFile called');
console.log('tenantId:', tenantId);
console.log('bakeryData.csvFile:', bakeryData.csvFile);
if (!tenantId || !bakeryData.csvFile) {
console.log('❌ Missing tenantId or csvFile');
toast.error('Falta información del tenant o archivo');
return;
}
console.log('⏳ Setting validation status to validating');
setValidationStatus({ status: 'validating' });
try {
console.log('📤 Calling validateSalesData API');
const validationResult = await validateSalesData(tenantId, bakeryData.csvFile);
console.log('📥 Validation result:', validationResult);
if (validationResult.is_valid) {
console.log('✅ Validation successful');
setValidationStatus({
status: 'valid',
message: validationResult.message,
records: validationResult.details?.total_records || 0
});
toast.success('¡Archivo validado correctamente!');
} else {
console.log('❌ Validation failed');
setValidationStatus({
status: 'invalid',
message: validationResult.message
});
toast.error(`Error en validación: ${validationResult.message}`);
}
} catch (error: any) {
console.error('❌ Error validating sales data:', error);
setValidationStatus({
status: 'invalid',
message: 'Error al validar el archivo'
});
toast.error('Error al validar el archivo');
}
};
// Upload and validate sales data for step 2
const uploadAndValidateSalesData = async () => {
if (!tenantId || !bakeryData.csvFile) {
throw new Error('Falta información del tenant o archivo');
}
try {
// If not already validated, validate first
if (validationStatus.status !== 'valid') {
const validationResult = await validateSalesData(tenantId, bakeryData.csvFile);
if (!validationResult.is_valid) {
toast.error(`Error en validación: ${validationResult.message}`);
return;
}
}
// If validation passes, upload the data
const uploadResult = await uploadSalesHistory(tenantId, bakeryData.csvFile);
// Mark step as completed
await completeStep('sales_data_uploaded', {
file_name: bakeryData.csvFile.name,
file_size: bakeryData.csvFile.size,
records_imported: uploadResult.records_imported || 0,
validation_status: 'success',
tenant_id: tenantId,
user_id: user?.id
});
toast.success(`Datos importados correctamente (${uploadResult.records_imported} registros)`);
// Automatically start training after successful upload
await startTraining();
} catch (error: any) {
console.error('Error uploading sales data:', error);
const message = error?.response?.data?.detail || error.message || 'Error al subir datos de ventas';
throw new Error(message);
}
};
// Helper function to mark current step as completed
const markStepCompleted = async () => {
const stepMap: Record<number, string> = {
1: 'bakery_registered',
2: 'sales_data_uploaded',
3: 'training_completed',
4: 'dashboard_accessible'
};
const stepName = stepMap[currentStep];
if (stepName) {
const stepData: Record<string, any> = {
completed_at: new Date().toISOString(),
user_id: user?.id
};
// Add step-specific data
if (currentStep === 1) {
stepData.bakery_name = bakeryData.name;
stepData.bakery_address = bakeryData.address;
stepData.business_type = bakeryData.businessType;
stepData.products_count = bakeryData.products.length;
} else if (currentStep === 2) {
stepData.file_name = bakeryData.csvFile?.name;
stepData.file_size = bakeryData.csvFile?.size;
stepData.file_type = bakeryData.csvFile?.type;
stepData.has_historical_data = bakeryData.hasHistoricalData;
}
await completeStep(stepName, stepData);
// Note: Not calling refreshProgress() here to avoid step reset
toast.success(`✅ Paso ${currentStep} completado`);
}
};
const validateCurrentStep = (): boolean => {
console.log(`🔍 Validating step ${currentStep}`);
console.log('Bakery data:', { name: bakeryData.name, address: bakeryData.address });
switch (currentStep) {
case 1:
if (!bakeryData.name.trim()) {
console.log('❌ Bakery name is empty');
toast.error('El nombre de la panadería es obligatorio');
return false;
}
if (!bakeryData.address.trim()) {
console.log('❌ Bakery address is empty');
toast.error('La dirección es obligatoria');
return false;
}
console.log('✅ Step 1 validation passed');
return true;
case 2:
if (!bakeryData.csvFile) {
@@ -252,6 +537,12 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
toast.error('El archivo es demasiado grande. Máximo 10MB');
return false;
}
// Check if validation was successful
if (validationStatus.status !== 'valid') {
toast.error('Por favor, valida el archivo antes de continuar');
return false;
}
return true;
default:
@@ -260,6 +551,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
};
const startTraining = async () => {
manualNavigation.current = true;
setCurrentStep(3);
setIsLoading(true);
@@ -270,48 +562,9 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
return;
}
// Create tenant first
const tenantData: TenantCreate = {
name: bakeryData.name,
address: bakeryData.address,
business_type: "bakery",
postal_code: "28010",
phone: "+34655334455",
coordinates: bakeryData.coordinates,
products: bakeryData.products,
has_historical_data: bakeryData.hasHistoricalData,
};
const tenant = await createTenant(tenantData);
setTenantId(tenant.id);
storeTenantId(tenant.id);
// Step 2: Validate and Upload CSV file if provided
if (bakeryData.csvFile) {
try {
const validationResult = await validateSalesData(tenant.id, bakeryData.csvFile);
if (!validationResult.is_valid) {
toast.error(`Error en los datos: ${validationResult.message}`);
setTrainingProgress(prev => ({
...prev,
status: 'failed',
error: 'Error en la validación de datos históricos'
}));
return;
}
await uploadSalesHistory(tenant.id, bakeryData.csvFile);
toast.success('Datos históricos validados y subidos correctamente');
} catch (error) {
console.error('CSV validation/upload error:', error);
toast.error('Error al procesar los datos históricos');
setTrainingProgress(prev => ({
...prev,
status: 'failed',
error: 'Error al procesar los datos históricos'
}));
return;
}
if (!tenantId) {
toast.error('Error: No se ha registrado la panadería correctamente.');
return;
}
// Prepare training job request - always use uploaded data since CSV is required
@@ -323,7 +576,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
};
// Start training job using the proper API
const trainingJob = await startTrainingJob(tenant.id, trainingRequest);
const trainingJob = await startTrainingJob(tenantId, trainingRequest);
setTrainingJobId(trainingJob.job_id);
setTrainingProgress({
@@ -357,9 +610,24 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
// Start training process
await startTraining();
} else {
// Complete onboarding
toast.success('¡Configuración completada exitosamente!');
onComplete();
try {
// Mark final step as completed
await completeStep('dashboard_accessible', {
completion_time: new Date().toISOString(),
user_id: user?.id,
tenant_id: tenantId,
final_step: true
});
// Complete onboarding
toast.success('¡Configuración completada exitosamente!');
onComplete();
} catch (error) {
console.warn('Failed to mark final step as completed:', error);
// Continue anyway for better UX
toast.success('¡Configuración completada exitosamente!');
onComplete();
}
}
};
@@ -380,6 +648,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
icon: '',
duration: 4000
});
manualNavigation.current = true;
setCurrentStep(4);
};
@@ -573,6 +842,8 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
csvFile: file,
hasHistoricalData: true
}));
// Reset validation status when new file is selected
setValidationStatus({ status: 'idle' });
toast.success(`Archivo ${file.name} seleccionado correctamente`);
}
}}
@@ -582,25 +853,130 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
</div>
{bakeryData.csvFile ? (
<div className="mt-4 p-4 bg-green-50 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center">
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
<div>
<p className="text-sm font-medium text-green-700">
{bakeryData.csvFile.name}
</p>
<p className="text-xs text-green-600">
{(bakeryData.csvFile.size / 1024).toFixed(1)} KB {bakeryData.csvFile.type || 'Archivo de datos'}
</p>
<div className="mt-4 space-y-3">
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center">
<CheckCircle className="h-5 w-5 text-gray-500 mr-2" />
<div>
<p className="text-sm font-medium text-gray-700">
{bakeryData.csvFile.name}
</p>
<p className="text-xs text-gray-600">
{(bakeryData.csvFile.size / 1024).toFixed(1)} KB {bakeryData.csvFile.type || 'Archivo de datos'}
</p>
</div>
</div>
<button
onClick={() => {
setBakeryData(prev => ({ ...prev, csvFile: undefined }));
setValidationStatus({ status: 'idle' });
}}
className="text-red-600 hover:text-red-800 text-sm"
>
Quitar
</button>
</div>
<button
onClick={() => setBakeryData(prev => ({ ...prev, csvFile: undefined }))}
className="text-red-600 hover:text-red-800 text-sm"
>
Quitar
</button>
</div>
{/* Validation Section */}
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-900">
Validación de datos
</h4>
{validationStatus.status === 'validating' ? (
<Loader className="h-4 w-4 animate-spin text-blue-500" />
) : validationStatus.status === 'valid' ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : validationStatus.status === 'invalid' ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<Clock className="h-4 w-4 text-gray-400" />
)}
</div>
{validationStatus.status === 'idle' && tenantId ? (
<div className="space-y-2">
<p className="text-sm text-gray-600">
Valida tu archivo para verificar que tiene el formato correcto.
</p>
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar archivo
</button>
</div>
) : (
<div className="space-y-2">
<p className="text-sm text-gray-500">
Debug: validationStatus = "{validationStatus.status}", tenantId = "{tenantId || 'not set'}"
</p>
{!tenantId ? (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No se ha encontrado la panadería registrada.
</p>
<p className="text-xs text-yellow-600 mt-1">
Ve al paso anterior para registrar tu panadería o espera mientras se carga desde el servidor.
</p>
</div>
) : validationStatus.status !== 'idle' ? (
<button
onClick={() => setValidationStatus({ status: 'idle' })}
className="px-4 py-2 bg-gray-500 text-white text-sm rounded-lg hover:bg-gray-600"
>
Resetear validación
</button>
) : null}
</div>
)}
{validationStatus.status === 'validating' && (
<p className="text-sm text-blue-600">
Validando archivo... Por favor espera.
</p>
)}
{validationStatus.status === 'valid' && (
<div className="space-y-2">
<p className="text-sm text-green-700">
Archivo validado correctamente
</p>
{validationStatus.records && (
<p className="text-xs text-green-600">
{validationStatus.records} registros encontrados
</p>
)}
{validationStatus.message && (
<p className="text-xs text-green-600">
{validationStatus.message}
</p>
)}
</div>
)}
{validationStatus.status === 'invalid' && (
<div className="space-y-2">
<p className="text-sm text-red-700">
Error en validación
</p>
{validationStatus.message && (
<p className="text-xs text-red-600">
{validationStatus.message}
</p>
)}
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar de nuevo
</button>
</div>
)}
</div>
</div>
) : (
@@ -748,6 +1124,18 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
}
};
// Show loading while onboarding data is being fetched
if (onboardingLoading && !progress) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 via-white to-blue-50">
<div className="text-center">
<Loader className="h-8 w-8 animate-spin mx-auto mb-4 text-primary-500" />
<p className="text-gray-600">Cargando progreso de configuración...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-blue-50 py-8">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -759,6 +1147,20 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
<p className="text-gray-600">
Configuremos tu panadería para obtener predicciones precisas de demanda
</p>
{/* Progress Summary */}
{progress && (
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<div className="flex items-center justify-center space-x-4">
<div className="text-sm text-blue-700">
<span className="font-semibold">Progreso:</span> {Math.round(progress.completion_percentage)}%
</div>
<div className="text-sm text-blue-600">
({progress.steps.filter((s: any) => s.completed).length}/{progress.steps.length} pasos completados)
</div>
</div>
</div>
)}
</div>
{/* Progress Indicator */}
@@ -790,7 +1192,9 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
<div className="mt-4 bg-gray-200 rounded-full h-2">
<div
className="bg-primary-500 h-2 rounded-full transition-all duration-500"
style={{ width: `${(currentStep / steps.length) * 100}%` }}
style={{
width: `${progress ? progress.completion_percentage : (currentStep / steps.length) * 100}%`
}}
/>
</div>
</div>
@@ -857,7 +1261,10 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
</>
) : trainingProgress.status === 'completed' ? (
<button
onClick={() => setCurrentStep(4)}
onClick={() => {
manualNavigation.current = true;
setCurrentStep(4);
}}
className="flex items-center px-6 py-2 bg-green-500 text-white rounded-xl hover:bg-green-600 transition-all hover:shadow-lg"
>
Continuar

View File

@@ -0,0 +1,39 @@
# Onboarding Router Test Cases
## Dashboard Access for Completed Users
### Test Case 1: Training Completed
**Given**: User has completed training (`current_step: "training_completed"`)
**When**: User logs in
**Expected**: `nextAction: "dashboard"`
**Message**: "Great! Your AI model is ready. Welcome to your dashboard!"
### Test Case 2: Dashboard Accessible
**Given**: User has reached dashboard accessible step (`current_step: "dashboard_accessible"`)
**When**: User logs in
**Expected**: `nextAction: "dashboard"`
**Message**: "Welcome back! You're ready to use your AI-powered dashboard."
### Test Case 3: High Completion Percentage
**Given**: User has `completion_percentage >= 80` but step may not be final
**When**: User logs in
**Expected**: `nextAction: "dashboard"`
### Test Case 4: Fully Completed
**Given**: User has `fully_completed: true`
**When**: User logs in
**Expected**: `nextAction: "dashboard"`
### Test Case 5: In Progress User
**Given**: User has `current_step: "sales_data_uploaded"`
**When**: User logs in
**Expected**: `nextAction: "onboarding_data"`
**Should**: Continue onboarding flow
## Manual Testing
1. Complete onboarding flow for a user
2. Log out
3. Log back in
4. Verify user goes directly to dashboard (not onboarding)
5. Check welcome message shows completion status

View File

@@ -0,0 +1,215 @@
// frontend/src/utils/onboardingRouter.ts
/**
* Onboarding Router Utility
* Determines user's next step based on their current progress
*/
import { onboardingService } from '../api/services/onboarding.service';
import type { UserProgress } from '../api/services/onboarding.service';
export type OnboardingStep =
| 'user_registered'
| 'bakery_registered'
| 'sales_data_uploaded'
| 'training_completed'
| 'dashboard_accessible';
export type NextAction =
| 'register'
| 'login'
| 'onboarding_bakery'
| 'onboarding_data'
| 'onboarding_training'
| 'dashboard'
| 'landing';
export interface RoutingDecision {
nextAction: NextAction;
currentStep: OnboardingStep | null;
message?: string;
canSkip?: boolean;
completionPercentage: number;
}
export class OnboardingRouter {
/**
* Determine next action for authenticated user
*/
static async getNextActionForUser(userProgress?: UserProgress): Promise<RoutingDecision> {
try {
const progress = userProgress || await onboardingService.getUserProgress();
// Check if user has fully completed onboarding or has high completion percentage
const isFullyCompleted = progress.fully_completed || progress.completion_percentage >= 80;
const currentStep = progress.current_step as OnboardingStep;
let nextAction: NextAction;
if (isFullyCompleted || currentStep === 'training_completed' || currentStep === 'dashboard_accessible') {
// User can access dashboard
nextAction = 'dashboard';
} else {
// Use step-based routing
nextAction = this.mapStepToAction(currentStep);
}
return {
nextAction,
currentStep,
completionPercentage: progress.completion_percentage,
message: this.getStepMessage(currentStep),
canSkip: this.canSkipStep(currentStep)
};
} catch (error) {
console.error('Error getting user progress:', error);
// Fallback logic when API fails
return {
nextAction: 'onboarding_bakery',
currentStep: 'bakery_registered',
completionPercentage: 0,
message: 'Let\'s set up your bakery information'
};
}
}
/**
* Determine next action for unauthenticated user
*/
static getNextActionForGuest(): RoutingDecision {
return {
nextAction: 'landing',
currentStep: null,
completionPercentage: 0,
message: 'Welcome to PanIA - AI for your bakery'
};
}
/**
* Check if user can access dashboard with current progress
*/
static async canAccessDashboard(): Promise<boolean> {
try {
const progress = await onboardingService.getUserProgress();
return progress.fully_completed || progress.completion_percentage >= 80;
} catch (error) {
console.error('Error checking dashboard access:', error);
return false;
}
}
/**
* Get dynamic onboarding step within the flow
*/
static async getOnboardingStepFromProgress(): Promise<{
step: number;
totalSteps: number;
canProceed: boolean;
}> {
try {
const progress = await onboardingService.getUserProgress();
const currentStep = progress.current_step as OnboardingStep;
const stepMap: Record<OnboardingStep, number> = {
'user_registered': 1,
'bakery_registered': 2,
'sales_data_uploaded': 3,
'training_completed': 4,
'dashboard_accessible': 5
};
const currentStepNumber = stepMap[currentStep] || 1;
return {
step: Math.max(currentStepNumber - 1, 1), // Convert to 1-based onboarding steps
totalSteps: 4, // We have 4 onboarding steps (excluding user registration)
canProceed: progress.completion_percentage > 0
};
} catch (error) {
console.error('Error getting onboarding step:', error);
return {
step: 1,
totalSteps: 4,
canProceed: true
};
}
}
/**
* Update progress when user completes an action
*/
static async completeStep(
step: OnboardingStep,
data?: Record<string, any>
): Promise<UserProgress> {
return await onboardingService.completeStep(step, data);
}
/**
* Check if user has completed a specific step
*/
static async hasCompletedStep(step: OnboardingStep): Promise<boolean> {
try {
const progress = await onboardingService.getUserProgress();
const stepStatus = progress.steps.find((s: any) => s.step_name === step);
return stepStatus?.completed || false;
} catch (error) {
console.error(`Error checking step completion for ${step}:`, error);
return false;
}
}
/**
* Get user-friendly message for current step
*/
private static getStepMessage(step: OnboardingStep): string {
const messages: Record<OnboardingStep, string> = {
'user_registered': 'Welcome! Your account has been created successfully.',
'bakery_registered': 'Let\'s set up your bakery information to get started.',
'sales_data_uploaded': 'Upload your historical sales data for better predictions.',
'training_completed': 'Great! Your AI model is ready. Welcome to your dashboard!',
'dashboard_accessible': 'Welcome back! You\'re ready to use your AI-powered dashboard.'
};
return messages[step] || 'Continue setting up your account';
}
/**
* Map step to corresponding frontend action
*/
private static mapStepToAction(step: OnboardingStep): NextAction {
const actionMap: Record<OnboardingStep, NextAction> = {
'user_registered': 'onboarding_bakery',
'bakery_registered': 'onboarding_bakery',
'sales_data_uploaded': 'onboarding_data',
'training_completed': 'dashboard', // ✅ Users can access dashboard when training is completed
'dashboard_accessible': 'dashboard'
};
return actionMap[step] || 'onboarding_bakery';
}
/**
* Check if user can skip a specific step
*/
private static canSkipStep(step: OnboardingStep): boolean {
// Define which steps can be skipped
const skippableSteps: OnboardingStep[] = [
'training_completed' // Users can access dashboard even if training is still in progress
];
return skippableSteps.includes(step);
}
}
// Helper functions for components
export const useOnboardingRouter = () => {
return {
getNextActionForUser: OnboardingRouter.getNextActionForUser,
getNextActionForGuest: OnboardingRouter.getNextActionForGuest,
canAccessDashboard: OnboardingRouter.canAccessDashboard,
getOnboardingStepFromProgress: OnboardingRouter.getOnboardingStepFromProgress,
completeStep: OnboardingRouter.completeStep,
hasCompletedStep: OnboardingRouter.hasCompletedStep,
};
};