Improve the UX and UI of the onboarding steps
This commit is contained in:
372
frontend/src/components/SimplifiedTrainingProgress.tsx
Normal file
372
frontend/src/components/SimplifiedTrainingProgress.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Sparkles, CheckCircle, Clock, ArrowRight, Coffee,
|
||||
TrendingUp, Target, Loader, AlertTriangle, Mail,
|
||||
ChevronDown, ChevronUp, HelpCircle, ExternalLink
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SimplifiedTrainingProgressProps {
|
||||
progress: {
|
||||
progress: number;
|
||||
status: string;
|
||||
currentStep: string;
|
||||
productsCompleted: number;
|
||||
productsTotal: number;
|
||||
estimatedTimeRemaining: number;
|
||||
error?: string;
|
||||
};
|
||||
onTimeout?: () => void;
|
||||
onBackgroundMode?: () => void;
|
||||
onEmailNotification?: (email: string) => void;
|
||||
}
|
||||
|
||||
// Proceso simplificado de entrenamiento en 3 etapas
|
||||
const TRAINING_STAGES = [
|
||||
{
|
||||
id: 'preparing',
|
||||
title: 'Preparando Tus Datos',
|
||||
description: 'Estamos organizando tu historial de ventas para encontrar patrones',
|
||||
userFriendly: 'Piensa en esto como ordenar tus recibos para entender tus días de mayor venta',
|
||||
progressRange: [0, 30],
|
||||
icon: Coffee,
|
||||
color: 'blue',
|
||||
celebration: '📊 ¡Los datos están listos!'
|
||||
},
|
||||
{
|
||||
id: 'learning',
|
||||
title: 'Construyendo Tu Modelo',
|
||||
description: 'Tu IA está aprendiendo de tus patrones de ventas',
|
||||
userFriendly: 'Como enseñar a un asistente inteligente a reconocer cuándo vendes más pan',
|
||||
progressRange: [30, 80],
|
||||
icon: Sparkles,
|
||||
color: 'purple',
|
||||
celebration: '🧠 ¡Tu IA se está volviendo inteligente!'
|
||||
},
|
||||
{
|
||||
id: 'finalizing',
|
||||
title: 'Casi Listo',
|
||||
description: 'Ajustando las predicciones para tu panadería',
|
||||
userFriendly: 'Nos aseguramos de que las predicciones funcionen perfectamente para tu negocio específico',
|
||||
progressRange: [80, 100],
|
||||
icon: Target,
|
||||
color: 'green',
|
||||
celebration: '🎉 ¡Tus predicciones están listas!'
|
||||
}
|
||||
];
|
||||
|
||||
const BENEFITS_PREVIEW = [
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: 'Pronósticos Inteligentes',
|
||||
description: 'Saber exactamente cuánto hornear cada día',
|
||||
example: 'Nunca te quedes sin croissants los domingos ocupados'
|
||||
},
|
||||
{
|
||||
icon: Target,
|
||||
title: 'Reducir Desperdicios',
|
||||
description: 'Deja de hacer demasiado de productos que se venden poco',
|
||||
example: 'Ahorra dinero horneando las cantidades correctas'
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: 'Detectar Tendencias',
|
||||
description: 'Ve qué productos se están volviendo populares',
|
||||
example: 'Nota cuando los clientes empiezan a amar tu nueva receta'
|
||||
}
|
||||
];
|
||||
|
||||
export default function SimplifiedTrainingProgress({
|
||||
progress,
|
||||
onTimeout,
|
||||
onBackgroundMode,
|
||||
onEmailNotification
|
||||
}: SimplifiedTrainingProgressProps) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showTimeoutOptions, setShowTimeoutOptions] = useState(false);
|
||||
const [emailForNotification, setEmailForNotification] = useState('');
|
||||
const [celebratingStage, setCelebratingStage] = useState<string | null>(null);
|
||||
const [startTime] = useState(Date.now());
|
||||
|
||||
// Show timeout options after 7 minutes for better UX
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (progress.status === 'running' && progress.progress < 90) {
|
||||
setShowTimeoutOptions(true);
|
||||
}
|
||||
}, 420000); // 7 minutes
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [progress.status, progress.progress]);
|
||||
|
||||
// Celebrate stage completions
|
||||
useEffect(() => {
|
||||
TRAINING_STAGES.forEach(stage => {
|
||||
if (progress.progress >= stage.progressRange[1] &&
|
||||
celebratingStage !== stage.id &&
|
||||
progress.progress > 0) {
|
||||
setCelebratingStage(stage.id);
|
||||
setTimeout(() => setCelebratingStage(null), 3000);
|
||||
}
|
||||
});
|
||||
}, [progress.progress, celebratingStage]);
|
||||
|
||||
const getCurrentStage = () => {
|
||||
return TRAINING_STAGES.find(stage =>
|
||||
progress.progress >= stage.progressRange[0] &&
|
||||
progress.progress < stage.progressRange[1]
|
||||
) || TRAINING_STAGES[TRAINING_STAGES.length - 1];
|
||||
};
|
||||
|
||||
const formatTimeRemaining = (timeValue: number): string => {
|
||||
if (!timeValue || timeValue <= 0) return 'Casi terminado';
|
||||
|
||||
// Manejar tanto segundos como minutos del backend
|
||||
// Si el valor es muy grande, probablemente sean segundos; si es pequeño, probablemente sean minutos
|
||||
const minutes = timeValue > 120 ? Math.floor(timeValue / 60) : Math.floor(timeValue);
|
||||
|
||||
if (minutes <= 1) return 'Menos de un minuto';
|
||||
if (minutes < 60) return `Aproximadamente ${minutes} minutos restantes`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return hours === 1
|
||||
? `Aproximadamente 1 hora ${remainingMinutes > 0 ? `${remainingMinutes} minutos` : ''} restantes`.trim()
|
||||
: `Aproximadamente ${hours} horas ${remainingMinutes > 0 ? `${remainingMinutes} minutos` : ''} restantes`.trim();
|
||||
};
|
||||
|
||||
const handleEmailNotification = () => {
|
||||
if (emailForNotification && onEmailNotification) {
|
||||
onEmailNotification(emailForNotification);
|
||||
setShowTimeoutOptions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentStage = getCurrentStage();
|
||||
|
||||
// Error State
|
||||
if (progress.status === 'failed' || progress.error) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<div className="bg-white rounded-3xl shadow-lg p-8 mb-6">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">
|
||||
Algo salió mal
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
No te preocupes - esto pasa a veces. Nuestro equipo ha sido notificado y lo arreglará rápidamente.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-6">
|
||||
<p className="text-sm text-gray-700">
|
||||
Puedes intentar iniciar el entrenamiento de nuevo, o contactar a nuestro equipo de soporte si esto sigue pasando.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="bg-primary-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
Intentar de Nuevo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Main Progress Card */}
|
||||
<div className="bg-white rounded-3xl shadow-lg overflow-hidden mb-6">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-primary-50 to-secondary-50 p-8 text-center">
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-primary-500 to-primary-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<currentStage.icon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{currentStage.title}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 mb-4">
|
||||
{currentStage.description}
|
||||
</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex justify-between text-sm text-gray-500 mb-2">
|
||||
<span>Progreso</span>
|
||||
<span>{Math.round(progress.progress)}%</span>
|
||||
</div>
|
||||
<div className="bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-primary-500 to-primary-600 h-full transition-all duration-500 ease-out rounded-full"
|
||||
style={{ width: `${progress.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<Clock className="w-4 h-4 inline mr-1" />
|
||||
{formatTimeRemaining(progress.estimatedTimeRemaining)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Progress */}
|
||||
<div className="p-6">
|
||||
<div className="flex justify-center space-x-6 mb-6">
|
||||
{TRAINING_STAGES.map((stage, index) => {
|
||||
const isCompleted = progress.progress >= stage.progressRange[1];
|
||||
const isCurrent = stage.id === currentStage.id;
|
||||
const isCelebrating = celebratingStage === stage.id;
|
||||
|
||||
return (
|
||||
<div key={stage.id} className="flex flex-col items-center relative">
|
||||
{isCelebrating && (
|
||||
<div className="absolute -top-8 bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full animate-bounce">
|
||||
{stage.celebration}
|
||||
</div>
|
||||
)}
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center transition-all duration-500 ${
|
||||
isCompleted
|
||||
? 'bg-green-500 text-white shadow-lg scale-110'
|
||||
: isCurrent
|
||||
? 'bg-primary-500 text-white animate-pulse shadow-lg'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
) : (
|
||||
<stage.icon className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 mt-2 text-center">
|
||||
{stage.title.split(' ')[0]}
|
||||
</span>
|
||||
{index < TRAINING_STAGES.length - 1 && (
|
||||
<ArrowRight className={`absolute top-5 -right-3 w-4 h-4 ${
|
||||
isCompleted ? 'text-green-500' : 'text-gray-300'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Current Stage Explanation */}
|
||||
<div className="bg-gray-50 rounded-xl p-4 text-center mb-4">
|
||||
<p className="text-gray-700">
|
||||
{currentStage.userFriendly}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Optional Details */}
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="flex items-center justify-center w-full text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<HelpCircle className="w-4 h-4 mr-1" />
|
||||
{showDetails ? 'Ocultar' : 'Aprende más sobre qué está pasando'}
|
||||
{showDetails ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
|
||||
</button>
|
||||
|
||||
{showDetails && (
|
||||
<div className="mt-4 p-4 bg-blue-50 rounded-xl text-sm text-blue-800">
|
||||
<p className="mb-2">
|
||||
<strong>Detalles técnicos:</strong> Estamos usando aprendizaje automático para analizar tus patrones de ventas,
|
||||
tendencias estacionales y relaciones entre productos para crear predicciones precisas de demanda.
|
||||
</p>
|
||||
<a
|
||||
href="#"
|
||||
className="text-blue-600 hover:text-blue-700 inline-flex items-center"
|
||||
>
|
||||
Aprende más sobre nuestra tecnología de IA
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Benefits Preview */}
|
||||
<div className="bg-white rounded-3xl shadow-lg p-6 mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 text-center">
|
||||
Lo que obtendrás cuando esto termine
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
{BENEFITS_PREVIEW.map((benefit, index) => (
|
||||
<div key={index} className="text-center p-4 rounded-xl bg-gradient-to-b from-gray-50 to-white border">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<benefit.icon className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-gray-900 mb-2">{benefit.title}</h4>
|
||||
<p className="text-gray-600 text-sm mb-2">{benefit.description}</p>
|
||||
<p className="text-xs text-blue-600 italic">ej., {benefit.example}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeout Options */}
|
||||
{showTimeoutOptions && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-3xl p-6">
|
||||
<div className="text-center mb-4">
|
||||
<Clock className="w-8 h-8 text-yellow-600 mx-auto mb-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">¿Tomando más tiempo del esperado?</h3>
|
||||
<p className="text-gray-600">
|
||||
¡No te preocupes! Tienes algunas opciones mientras terminamos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Continue in Background */}
|
||||
<div className="bg-white rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Continuar en segundo plano</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Explora una vista previa de tu panel de control mientras continúa el entrenamiento.
|
||||
</p>
|
||||
<button
|
||||
onClick={onBackgroundMode}
|
||||
className="w-full bg-primary-500 text-white py-2 px-4 rounded-lg hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
Vista Previa del Panel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Email Notification */}
|
||||
<div className="bg-white rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Recibir notificación</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Te enviaremos un correo cuando tu modelo esté listo.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="tu@correo.com"
|
||||
value={emailForNotification}
|
||||
onChange={(e) => setEmailForNotification(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleEmailNotification}
|
||||
disabled={!emailForNotification}
|
||||
className="w-full bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Mail className="w-4 h-4 inline mr-1" />
|
||||
Notificarme
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-4">
|
||||
<button
|
||||
onClick={() => setShowTimeoutOptions(false)}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Esperaré aquí
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check, Brain, Clock, CheckCircle, AlertTriangle, Loader, TrendingUp } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import EnhancedTrainingProgress from '../../components/EnhancedTrainingProgress';
|
||||
import SimplifiedTrainingProgress from '../../components/SimplifiedTrainingProgress';
|
||||
|
||||
import {
|
||||
useTenant,
|
||||
@@ -109,10 +109,9 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
// 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);
|
||||
// Silently use default step on error
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -124,22 +123,16 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -194,14 +187,20 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
user_id: user?.id,
|
||||
tenant_id: tenantId
|
||||
}).catch(error => {
|
||||
console.warn('Failed to mark training as completed in API:', error);
|
||||
// Failed to mark training as completed in API
|
||||
});
|
||||
|
||||
// Show celebration and auto-advance to final step after 3 seconds
|
||||
// Users can still manually navigate to dashboard immediately if they want
|
||||
toast.success('🎉 Training completed! Your AI model is ready to use.', {
|
||||
duration: 5000,
|
||||
icon: '🤖'
|
||||
});
|
||||
|
||||
// Auto-advance to final step after 2 seconds
|
||||
setTimeout(() => {
|
||||
manualNavigation.current = true;
|
||||
setCurrentStep(4);
|
||||
}, 2000);
|
||||
}, 3000);
|
||||
|
||||
} else if (messageType === 'failed' || messageType === 'training_failed' || messageType === 'training_error') {
|
||||
setTrainingProgress(prev => ({
|
||||
@@ -276,17 +275,13 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
last_updated: new Date().toISOString()
|
||||
}));
|
||||
|
||||
console.log('✅ Tenant ID stored successfully:', tenantId);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to store tenant ID:', error);
|
||||
// Handle storage error silently
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
console.log(`🚀 HandleNext called for step ${currentStep}`);
|
||||
|
||||
if (!validateCurrentStep()) {
|
||||
console.log('❌ Validation failed for current step');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -294,20 +289,14 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -319,16 +308,10 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
// 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');
|
||||
if (!currentTenantId) {
|
||||
const tenantData: TenantCreate = {
|
||||
name: bakeryData.name,
|
||||
address: bakeryData.address,
|
||||
@@ -341,7 +324,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
};
|
||||
|
||||
const newTenant = await createTenant(tenantData);
|
||||
console.log('✅ Tenant created:', newTenant.id);
|
||||
|
||||
currentTenantId = newTenant.id;
|
||||
setTenantId(newTenant.id);
|
||||
@@ -357,45 +339,32 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
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,
|
||||
@@ -403,7 +372,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
});
|
||||
toast.success('¡Archivo validado correctamente!');
|
||||
} else {
|
||||
console.log('❌ Validation failed');
|
||||
setValidationStatus({
|
||||
status: 'invalid',
|
||||
message: validationResult.message
|
||||
@@ -411,7 +379,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
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'
|
||||
@@ -455,7 +422,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
// Automatically start training after successful upload
|
||||
await startTraining();
|
||||
} catch (error: any) {
|
||||
console.error('Error uploading sales data:', error);
|
||||
// Error uploading sales data
|
||||
const message = error?.response?.data?.detail || error.message || 'Error al subir datos de ventas';
|
||||
throw new Error(message);
|
||||
}
|
||||
@@ -498,22 +465,16 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
};
|
||||
|
||||
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) {
|
||||
@@ -591,7 +552,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
toast.success('Entrenamiento iniciado correctamente');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Training start error:', error);
|
||||
// Training start error
|
||||
toast.error('Error al iniciar el entrenamiento');
|
||||
setTrainingProgress(prev => ({
|
||||
...prev,
|
||||
@@ -623,7 +584,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
toast.success('¡Configuración completada exitosamente!');
|
||||
onComplete();
|
||||
} catch (error) {
|
||||
console.warn('Failed to mark final step as completed:', error);
|
||||
// Failed to mark final step as completed
|
||||
// Continue anyway for better UX
|
||||
toast.success('¡Configuración completada exitosamente!');
|
||||
onComplete();
|
||||
@@ -663,19 +624,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
// setLimitedAccess(true);
|
||||
};
|
||||
|
||||
// Then update the EnhancedTrainingProgress call:
|
||||
<EnhancedTrainingProgress
|
||||
progress={{
|
||||
progress: trainingProgress.progress,
|
||||
status: trainingProgress.status,
|
||||
currentStep: trainingProgress.currentStep,
|
||||
productsCompleted: trainingProgress.productsCompleted,
|
||||
productsTotal: trainingProgress.productsTotal,
|
||||
estimatedTimeRemaining: trainingProgress.estimatedTimeRemaining,
|
||||
error: trainingProgress.error
|
||||
}}
|
||||
onTimeout={handleTrainingTimeout}
|
||||
/>
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
@@ -827,7 +775,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls,.json"
|
||||
required
|
||||
onChange={(e) => {
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file size (10MB limit)
|
||||
@@ -837,14 +785,47 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
return;
|
||||
}
|
||||
|
||||
// Update bakery data with the selected file
|
||||
setBakeryData(prev => ({
|
||||
...prev,
|
||||
csvFile: file,
|
||||
hasHistoricalData: true
|
||||
}));
|
||||
// Reset validation status when new file is selected
|
||||
setValidationStatus({ status: 'idle' });
|
||||
|
||||
toast.success(`Archivo ${file.name} seleccionado correctamente`);
|
||||
|
||||
// Auto-validate the file after upload if tenantId exists
|
||||
if (tenantId) {
|
||||
setValidationStatus({ status: 'validating' });
|
||||
|
||||
try {
|
||||
const validationResult = await validateSalesData(tenantId, file);
|
||||
|
||||
if (validationResult.is_valid) {
|
||||
setValidationStatus({
|
||||
status: 'valid',
|
||||
message: validationResult.message,
|
||||
records: validationResult.details?.total_records || 0
|
||||
});
|
||||
toast.success('¡Archivo validado correctamente!');
|
||||
} else {
|
||||
setValidationStatus({
|
||||
status: 'invalid',
|
||||
message: validationResult.message
|
||||
});
|
||||
toast.error(`Error en validación: ${validationResult.message}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setValidationStatus({
|
||||
status: 'invalid',
|
||||
message: 'Error al validar el archivo'
|
||||
});
|
||||
toast.error('Error al validar el archivo');
|
||||
}
|
||||
} else {
|
||||
// If no tenantId yet, set to idle and wait for manual validation
|
||||
setValidationStatus({ status: 'idle' });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
@@ -911,9 +892,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
</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">
|
||||
@@ -1052,68 +1030,156 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
);
|
||||
|
||||
case 3:
|
||||
return (
|
||||
<EnhancedTrainingProgress
|
||||
progress={{
|
||||
progress: trainingProgress.progress,
|
||||
status: trainingProgress.status,
|
||||
currentStep: trainingProgress.currentStep,
|
||||
productsCompleted: trainingProgress.productsCompleted,
|
||||
productsTotal: trainingProgress.productsTotal,
|
||||
estimatedTimeRemaining: trainingProgress.estimatedTimeRemaining,
|
||||
error: trainingProgress.error
|
||||
}}
|
||||
onTimeout={() => {
|
||||
// Handle timeout - either navigate to dashboard or show limited access
|
||||
console.log('Training timeout - user wants to continue to dashboard');
|
||||
// You can add your custom timeout logic here
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<SimplifiedTrainingProgress
|
||||
progress={{
|
||||
progress: trainingProgress.progress,
|
||||
status: trainingProgress.status,
|
||||
currentStep: trainingProgress.currentStep,
|
||||
productsCompleted: trainingProgress.productsCompleted,
|
||||
productsTotal: trainingProgress.productsTotal,
|
||||
estimatedTimeRemaining: trainingProgress.estimatedTimeRemaining,
|
||||
error: trainingProgress.error
|
||||
}}
|
||||
onTimeout={() => {
|
||||
toast.success('El entrenamiento continuará en segundo plano. ¡Puedes empezar a explorar!');
|
||||
onComplete(); // Navigate to dashboard
|
||||
}}
|
||||
onBackgroundMode={() => {
|
||||
toast.success('El entrenamiento continúa en segundo plano. ¡Bienvenido a tu panel de control!');
|
||||
onComplete(); // Navigate to dashboard with limited features
|
||||
}}
|
||||
onEmailNotification={(email: string) => {
|
||||
toast.success(`Te enviaremos un correo a ${email} cuando tu modelo esté listo!`);
|
||||
// Here you would typically call an API to register the email notification
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 4:
|
||||
return (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Check className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
¡Configuración Completada! 🎉
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Tu panadería está lista para usar PanIA. Comenzarás a recibir predicciones precisas de demanda.
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Celebration Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 bg-gradient-to-r from-green-400 to-green-600 rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg animate-bounce">
|
||||
<Check className="h-12 w-12 text-white" />
|
||||
</div>
|
||||
{/* Confetti effect */}
|
||||
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 text-2xl animate-pulse">
|
||||
🎉
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
¡Todo listo! 🎉
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Tu asistente de IA para panadería está listo para ayudarte a predecir la demanda y hacer crecer tu negocio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-primary-50 to-blue-50 p-6 rounded-xl">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Resumen de configuración:</h4>
|
||||
<div className="text-left space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Panadería:</span>
|
||||
<span className="font-medium">{bakeryData.name}</span>
|
||||
{/* Setup Summary Card */}
|
||||
<div className="bg-gradient-to-r from-green-50 to-blue-50 rounded-3xl p-8 mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6 text-center">
|
||||
Lo que hemos configurado para ti
|
||||
</h3>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<Store className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{bakeryData.name}</div>
|
||||
<div className="text-sm text-gray-600">Perfil de tu panadería</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<Upload className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Datos históricos</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Archivo {bakeryData.csvFile?.name.split('.').pop()?.toUpperCase()} procesado
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Productos:</span>
|
||||
<span className="font-medium">{bakeryData.products.length} seleccionados</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Datos históricos:</span>
|
||||
<span className="font-medium text-green-600">
|
||||
✅ {bakeryData.csvFile?.name.split('.').pop()?.toUpperCase()} subido
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Modelo IA:</span>
|
||||
<span className="font-medium text-green-600">✅ Entrenado</span>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<Brain className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Modelo de IA</div>
|
||||
<div className="text-sm text-gray-600">Entrenado y listo</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<TrendingUp className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Predicciones inteligentes</div>
|
||||
<div className="text-sm text-gray-600">Listo para usar</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 p-4 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
💡 <strong>Próximo paso:</strong> Explora tu dashboard para ver las primeras predicciones y configurar alertas personalizadas.
|
||||
{/* Next Steps Preview */}
|
||||
<div className="bg-white border-2 border-blue-200 rounded-3xl p-8 mb-8">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<TrendingUp className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
¿Qué sigue ahora?
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Tu panel de control está listo con todo lo que necesitas para tomar decisiones más inteligentes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4 text-center">
|
||||
<div className="p-4">
|
||||
<div className="text-2xl mb-2">📊</div>
|
||||
<div className="font-medium text-gray-900 mb-1">Ver Pronósticos</div>
|
||||
<div className="text-sm text-gray-600">Ve la demanda prevista para cada producto</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-2xl mb-2">🎯</div>
|
||||
<div className="font-medium text-gray-900 mb-1">Planificar Producción</div>
|
||||
<div className="text-sm text-gray-600">Sabe exactamente cuánto hornear</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-2xl mb-2">📈</div>
|
||||
<div className="font-medium text-gray-900 mb-1">Seguir Rendimiento</div>
|
||||
<div className="text-sm text-gray-600">Monitorear precisión y ganancias</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.success('¡Bienvenido a tu nuevo panel de control de panadería con IA! 🚀');
|
||||
setTimeout(() => onComplete(), 1000); // Small delay for better UX
|
||||
}}
|
||||
className="bg-gradient-to-r from-primary-500 to-primary-600 text-white px-12 py-4 rounded-2xl text-lg font-semibold hover:from-primary-600 hover:to-primary-700 transform hover:scale-105 transition-all duration-200 shadow-lg"
|
||||
>
|
||||
Acceder al Panel de Control 🚀
|
||||
</button>
|
||||
<p className="text-sm text-gray-500 mt-3">
|
||||
¡Listo para ver tus primeras predicciones!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1139,169 +1205,172 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
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">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Configuración de PanIA
|
||||
{/* Enhanced Header with Accessibility */}
|
||||
<header className="text-center mb-12" role="banner">
|
||||
<div
|
||||
className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-r from-primary-500 to-primary-600 rounded-full mb-6"
|
||||
aria-label="PanIA logo"
|
||||
>
|
||||
<Brain className="w-10 h-10 text-white" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Bienvenido a PanIA
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Configuremos tu panadería para obtener predicciones precisas de demanda
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-6">
|
||||
Configuremos tu asistente de IA para panadería en unos sencillos pasos
|
||||
</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)}%
|
||||
{/* Simplified Progress Summary with Accessibility */}
|
||||
{progress && currentStep < 4 && (
|
||||
<div className="max-w-md mx-auto">
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-lg p-6"
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(progress.completion_percentage)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Onboarding setup progress"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-700">Progreso de Configuración</span>
|
||||
<span className="text-sm font-bold text-primary-600" aria-live="polite">
|
||||
{Math.round(progress.completion_percentage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">
|
||||
({progress.steps.filter((s: any) => s.completed).length}/{progress.steps.length} pasos completados)
|
||||
<div className="bg-gray-200 rounded-full h-2 overflow-hidden" aria-hidden="true">
|
||||
<div
|
||||
className="bg-gradient-to-r from-primary-500 to-primary-600 h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress.completion_percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500" role="status">
|
||||
Paso {currentStep} de 4 • {progress.steps.filter((s: any) => s.completed).length} completados
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-center items-center space-x-4 mb-6">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 transition-all duration-300 ${
|
||||
currentStep > step.id
|
||||
? 'bg-green-500 border-green-500 text-white'
|
||||
: currentStep === step.id
|
||||
? 'bg-primary-500 border-primary-500 text-white'
|
||||
: 'border-gray-300 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{currentStep > step.id ? (
|
||||
<Check className="h-5 w-5" />
|
||||
) : (
|
||||
<step.icon className="h-5 w-5" />
|
||||
)}
|
||||
{/* Modern Step Indicator with Accessibility - Only show for steps 1-2, hide during training */}
|
||||
{currentStep < 3 && (
|
||||
<nav className="mb-12" aria-label="Onboarding progress steps" role="navigation">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex justify-between items-center relative">
|
||||
{/* Connection Line */}
|
||||
<div className="absolute top-6 left-6 right-6 h-0.5 bg-gray-200" aria-hidden="true">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-primary-500 to-primary-600 rounded-full transition-all duration-700"
|
||||
style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-2 text-xs text-gray-500 text-center max-w-20">
|
||||
{step.title}
|
||||
</span>
|
||||
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = currentStep > step.id;
|
||||
const isCurrent = currentStep === step.id;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex flex-col items-center relative z-10">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all duration-300 shadow-lg ${
|
||||
isCompleted
|
||||
? 'bg-green-500 text-white scale-110'
|
||||
: isCurrent
|
||||
? 'bg-gradient-to-r from-primary-500 to-primary-600 text-white animate-pulse'
|
||||
: 'bg-white border-2 border-gray-300 text-gray-400'
|
||||
}`}
|
||||
aria-label={`Step ${step.id}: ${step.title}${isCompleted ? ' (completed)' : isCurrent ? ' (current)' : ''}`}
|
||||
role="img"
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-6 w-6" aria-hidden="true" />
|
||||
) : (
|
||||
<step.icon className="h-6 w-6" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-3 text-center ${
|
||||
isCurrent ? 'text-primary-600 font-semibold' : 'text-gray-500'
|
||||
}`}
|
||||
aria-current={isCurrent ? 'step' : undefined}
|
||||
>
|
||||
<div className="text-sm font-medium">{step.title}</div>
|
||||
{isCurrent && (
|
||||
<div className="text-xs text-primary-500 mt-1">Paso actual</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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: `${progress ? progress.completion_percentage : (currentStep / steps.length) * 100}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="bg-white rounded-2xl shadow-soft p-6 mb-8">
|
||||
{/* Step Content with Accessibility */}
|
||||
<main
|
||||
className={`${currentStep === 3 ? '' : 'bg-white rounded-3xl shadow-lg p-8'} mb-8`}
|
||||
role="main"
|
||||
aria-live="polite"
|
||||
aria-label={`Step ${currentStep} content`}
|
||||
>
|
||||
{renderStep()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={currentStep === 1}
|
||||
className="flex items-center px-4 py-2 text-gray-600 disabled:text-gray-400 disabled:cursor-not-allowed hover:text-gray-800 transition-colors"
|
||||
{/* Navigation with Enhanced Accessibility - Hidden during training (step 3) and completion (step 4) */}
|
||||
{currentStep < 3 && (
|
||||
<nav
|
||||
className="flex justify-between items-center bg-white rounded-3xl shadow-sm p-6"
|
||||
role="navigation"
|
||||
aria-label="Onboarding navigation"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5 mr-1" />
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={currentStep === 1}
|
||||
className="flex items-center px-6 py-3 text-gray-600 disabled:text-gray-300 disabled:cursor-not-allowed hover:text-gray-800 hover:bg-gray-50 rounded-xl transition-all group focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
aria-label="Go to previous step"
|
||||
tabIndex={currentStep === 1 ? -1 : 0}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5 mr-2 group-hover:-translate-x-0.5 transition-transform" aria-hidden="true" />
|
||||
Paso Anterior
|
||||
</button>
|
||||
|
||||
{/* Dynamic Next/Complete Button */}
|
||||
{currentStep < 3 ? (
|
||||
{/* Step info */}
|
||||
<div className="text-center" role="status" aria-live="polite">
|
||||
<div className="text-sm text-gray-500">Paso {currentStep} de 4</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{currentStep === 1 && "Configura tu panadería"}
|
||||
{currentStep === 2 && "Sube datos de ventas"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Next Button with contextual text */}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={isLoading}
|
||||
className="flex items-center px-6 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isLoading || (currentStep === 2 && validationStatus?.status !== 'valid')}
|
||||
className="flex items-center px-8 py-3 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-xl hover:from-primary-600 hover:to-primary-700 transition-all hover:shadow-lg hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none group focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
aria-label={`${currentStep === 1 ? 'Save bakery information and continue' : currentStep === 2 && validationStatus?.status === 'valid' ? 'Start AI training' : 'Continue to next step'}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader className="h-5 w-5 mr-2 animate-spin" />
|
||||
Procesando...
|
||||
<Loader className="h-5 w-5 mr-2 animate-spin" aria-hidden="true" />
|
||||
<span aria-live="polite">
|
||||
{currentStep === 1 ? "Creando panadería..." : "Procesando..."}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Siguiente
|
||||
<ChevronRight className="h-5 w-5 ml-1" />
|
||||
{currentStep === 1 && "Guardar y Continuar"}
|
||||
{currentStep === 2 && (
|
||||
<>
|
||||
{validationStatus?.status === 'valid' ? "Iniciar Entrenamiento" : "Continuar"}
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="h-5 w-5 ml-2 group-hover:translate-x-0.5 transition-transform" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : currentStep === 3 ? (
|
||||
// Training step - show different buttons based on status
|
||||
<div className="flex space-x-3">
|
||||
{trainingProgress.status === 'failed' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleRetryTraining}
|
||||
disabled={isLoading}
|
||||
className="flex items-center px-4 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader className="h-5 w-5 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Brain className="h-5 w-5 mr-2" />
|
||||
)}
|
||||
Reintentar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSkipTraining}
|
||||
className="flex items-center px-4 py-2 border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 transition-all"
|
||||
>
|
||||
Continuar sin entrenar
|
||||
<ChevronRight className="h-5 w-5 ml-1" />
|
||||
</button>
|
||||
</>
|
||||
) : trainingProgress.status === 'completed' ? (
|
||||
<button
|
||||
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
|
||||
<ChevronRight className="h-5 w-5 ml-1" />
|
||||
</button>
|
||||
) : (
|
||||
// Training in progress - show status
|
||||
<button
|
||||
disabled
|
||||
className="flex items-center px-6 py-2 bg-gray-400 text-white rounded-xl cursor-not-allowed"
|
||||
>
|
||||
<Loader className="h-5 w-5 mr-2 animate-spin" />
|
||||
Entrenando... {trainingProgress.progress}%
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Final step - Complete button
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
disabled={isLoading}
|
||||
className="flex items-center px-6 py-2 bg-success-500 text-white rounded-xl hover:bg-success-600 transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader className="h-5 w-5 mr-2 animate-spin" />
|
||||
Completando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-5 w-5 mr-2" />
|
||||
Ir al Dashboard
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="mt-8 text-center">
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# 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
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Initial onboarding progress tables
|
||||
|
||||
Revision ID: 001_initial_onboarding_tables
|
||||
Revises:
|
||||
Create Date: 2024-12-20 15:30:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '001_initial_onboarding_tables'
|
||||
down_revision = None # No previous migration
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create users table first (if it doesn't exist)
|
||||
op.create_table(
|
||||
'users',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, primary_key=True),
|
||||
sa.Column('email', sa.String(length=255), nullable=False, unique=True, index=True),
|
||||
sa.Column('hashed_password', sa.String(length=255), nullable=False),
|
||||
sa.Column('full_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
|
||||
sa.Column('is_verified', sa.Boolean(), nullable=False, default=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('last_login', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('phone', sa.String(length=20), nullable=True),
|
||||
sa.Column('language', sa.String(length=10), nullable=True, default='es'),
|
||||
sa.Column('timezone', sa.String(length=50), nullable=True, default='Europe/Madrid'),
|
||||
sa.Column('role', sa.String(length=20), nullable=False),
|
||||
)
|
||||
|
||||
# Create refresh_tokens table
|
||||
op.create_table(
|
||||
'refresh_tokens',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, primary_key=True),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('token', sa.String(length=500), nullable=False, unique=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('is_revoked', sa.Boolean(), nullable=False, default=False),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
|
||||
# Foreign key to users table
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
)
|
||||
|
||||
# Create user_onboarding_progress table
|
||||
op.create_table(
|
||||
'user_onboarding_progress',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, primary_key=True),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False, index=True),
|
||||
sa.Column('step_name', sa.String(length=50), nullable=False),
|
||||
sa.Column('completed', sa.Boolean(), nullable=False, default=False),
|
||||
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('step_data', sa.JSON(), nullable=True, default=dict),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
|
||||
# Foreign key to users table
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
|
||||
# Unique constraint to prevent duplicate steps per user
|
||||
sa.UniqueConstraint('user_id', 'step_name', name='uq_user_step'),
|
||||
|
||||
# Indexes for performance
|
||||
sa.Index('ix_user_onboarding_progress_user_id', 'user_id'),
|
||||
sa.Index('ix_user_onboarding_progress_step_name', 'step_name'),
|
||||
sa.Index('ix_user_onboarding_progress_completed', 'completed'),
|
||||
)
|
||||
|
||||
# Create user_onboarding_summary table
|
||||
op.create_table(
|
||||
'user_onboarding_summary',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, primary_key=True),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False, unique=True, index=True),
|
||||
sa.Column('current_step', sa.String(length=50), nullable=False, default='user_registered'),
|
||||
sa.Column('next_step', sa.String(length=50), nullable=True),
|
||||
sa.Column('completion_percentage', sa.String(length=10), nullable=True, default='0.0'),
|
||||
sa.Column('fully_completed', sa.Boolean(), nullable=False, default=False),
|
||||
sa.Column('steps_completed_count', sa.String(length=10), nullable=True, default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('last_activity_at', sa.DateTime(timezone=True), nullable=False),
|
||||
|
||||
# Foreign key to users table
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
|
||||
# Indexes for performance
|
||||
sa.Index('ix_user_onboarding_summary_user_id', 'user_id'),
|
||||
sa.Index('ix_user_onboarding_summary_current_step', 'current_step'),
|
||||
sa.Index('ix_user_onboarding_summary_fully_completed', 'fully_completed'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop tables in reverse order
|
||||
op.drop_table('user_onboarding_summary')
|
||||
op.drop_table('user_onboarding_progress')
|
||||
op.drop_table('refresh_tokens')
|
||||
op.drop_table('users')
|
||||
Reference in New Issue
Block a user