Improve the UX and UI of the onboarding steps

This commit is contained in:
Urtzi Alfaro
2025-08-11 07:50:24 +02:00
parent c4d4aeb449
commit 652a850d0f
4 changed files with 703 additions and 408 deletions

View 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>
);
}

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect, useCallback, useRef } 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 { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check, Brain, Clock, CheckCircle, AlertTriangle, Loader, TrendingUp } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import EnhancedTrainingProgress from '../../components/EnhancedTrainingProgress'; import SimplifiedTrainingProgress from '../../components/SimplifiedTrainingProgress';
import { import {
useTenant, useTenant,
@@ -109,10 +109,9 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
// Only initialize from progress if we haven't manually navigated // Only initialize from progress if we haven't manually navigated
try { try {
const { step } = await OnboardingRouter.getOnboardingStepFromProgress(); const { step } = await OnboardingRouter.getOnboardingStepFromProgress();
console.log('🎯 Initializing step from progress:', step);
setCurrentStep(step); setCurrentStep(step);
} catch (error) { } 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(() => { useEffect(() => {
const fetchTenantIdFromBackend = async () => { const fetchTenantIdFromBackend = async () => {
if (!tenantId && user) { if (!tenantId && user) {
console.log('🏢 No tenant ID in localStorage, fetching from backend...');
try { try {
const userTenants = await getUserTenants(); const userTenants = await getUserTenants();
console.log('📋 User tenants:', userTenants);
if (userTenants.length > 0) { if (userTenants.length > 0) {
// Use the first (most recent) tenant for onboarding // Use the first (most recent) tenant for onboarding
const primaryTenant = userTenants[0]; const primaryTenant = userTenants[0];
console.log('✅ Found tenant from backend:', primaryTenant.id);
setTenantId(primaryTenant.id); setTenantId(primaryTenant.id);
storeTenantId(primaryTenant.id); storeTenantId(primaryTenant.id);
} else {
console.log(' No tenants found for user - will be created in step 1');
} }
} catch (error) { } 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 // 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, user_id: user?.id,
tenant_id: tenantId tenant_id: tenantId
}).catch(error => { }).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(() => { setTimeout(() => {
manualNavigation.current = true; manualNavigation.current = true;
setCurrentStep(4); setCurrentStep(4);
}, 2000); }, 3000);
} else if (messageType === 'failed' || messageType === 'training_failed' || messageType === 'training_error') { } else if (messageType === 'failed' || messageType === 'training_failed' || messageType === 'training_error') {
setTrainingProgress(prev => ({ setTrainingProgress(prev => ({
@@ -276,17 +275,13 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
last_updated: new Date().toISOString() last_updated: new Date().toISOString()
})); }));
console.log('✅ Tenant ID stored successfully:', tenantId);
} catch (error) { } catch (error) {
console.error('❌ Failed to store tenant ID:', error); // Handle storage error silently
} }
}; };
const handleNext = async () => { const handleNext = async () => {
console.log(`🚀 HandleNext called for step ${currentStep}`);
if (!validateCurrentStep()) { if (!validateCurrentStep()) {
console.log('❌ Validation failed for current step');
return; return;
} }
@@ -294,20 +289,14 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
try { try {
if (currentStep === 1) { if (currentStep === 1) {
console.log('📝 Processing step 1: Register bakery');
await createBakeryAndTenant(); await createBakeryAndTenant();
console.log('✅ Step 1 completed successfully');
} else if (currentStep === 2) { } else if (currentStep === 2) {
console.log('📂 Processing step 2: Upload sales data');
await uploadAndValidateSalesData(); await uploadAndValidateSalesData();
console.log('✅ Step 2 completed successfully');
} }
} catch (error) { } catch (error) {
console.error('❌ Error in step:', error);
toast.error('Error al procesar el paso. Por favor, inténtalo de nuevo.'); toast.error('Error al procesar el paso. Por favor, inténtalo de nuevo.');
} finally { } finally {
setIsLoading(false); 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 // Create bakery and tenant for step 1
const createBakeryAndTenant = async () => { const createBakeryAndTenant = async () => {
try { try {
console.log('🏪 Creating bakery tenant...');
let currentTenantId = tenantId; let currentTenantId = tenantId;
// Check if user already has a tenant // Check if user already has a tenant
if (currentTenantId) { 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 = { const tenantData: TenantCreate = {
name: bakeryData.name, name: bakeryData.name,
address: bakeryData.address, address: bakeryData.address,
@@ -341,7 +324,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
}; };
const newTenant = await createTenant(tenantData); const newTenant = await createTenant(tenantData);
console.log('✅ Tenant created:', newTenant.id);
currentTenantId = newTenant.id; currentTenantId = newTenant.id;
setTenantId(newTenant.id); setTenantId(newTenant.id);
@@ -357,45 +339,32 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
tenant_id: currentTenantId, // Use the correct tenant ID tenant_id: currentTenantId, // Use the correct tenant ID
user_id: user?.id user_id: user?.id
}); });
console.log('✅ Step marked as completed');
} catch (stepError) { } 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 // Don't throw here - step completion is not critical for UI flow
} }
toast.success('Panadería registrada correctamente'); toast.success('Panadería registrada correctamente');
console.log('🎯 Advancing to step 2');
manualNavigation.current = true; manualNavigation.current = true;
setCurrentStep(2); setCurrentStep(2);
} catch (error) { } catch (error) {
console.error('❌ Error creating tenant:', error);
throw new Error('Error al registrar la panadería'); throw new Error('Error al registrar la panadería');
} }
}; };
// Validate sales data without importing // Validate sales data without importing
const validateSalesFile = async () => { const validateSalesFile = async () => {
console.log('🔍 validateSalesFile called');
console.log('tenantId:', tenantId);
console.log('bakeryData.csvFile:', bakeryData.csvFile);
if (!tenantId || !bakeryData.csvFile) { if (!tenantId || !bakeryData.csvFile) {
console.log('❌ Missing tenantId or csvFile');
toast.error('Falta información del tenant o archivo'); toast.error('Falta información del tenant o archivo');
return; return;
} }
console.log('⏳ Setting validation status to validating');
setValidationStatus({ status: 'validating' }); setValidationStatus({ status: 'validating' });
try { try {
console.log('📤 Calling validateSalesData API');
const validationResult = await validateSalesData(tenantId, bakeryData.csvFile); const validationResult = await validateSalesData(tenantId, bakeryData.csvFile);
console.log('📥 Validation result:', validationResult);
if (validationResult.is_valid) { if (validationResult.is_valid) {
console.log('✅ Validation successful');
setValidationStatus({ setValidationStatus({
status: 'valid', status: 'valid',
message: validationResult.message, message: validationResult.message,
@@ -403,7 +372,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
}); });
toast.success('¡Archivo validado correctamente!'); toast.success('¡Archivo validado correctamente!');
} else { } else {
console.log('❌ Validation failed');
setValidationStatus({ setValidationStatus({
status: 'invalid', status: 'invalid',
message: validationResult.message message: validationResult.message
@@ -411,7 +379,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
toast.error(`Error en validación: ${validationResult.message}`); toast.error(`Error en validación: ${validationResult.message}`);
} }
} catch (error: any) { } catch (error: any) {
console.error('❌ Error validating sales data:', error);
setValidationStatus({ setValidationStatus({
status: 'invalid', status: 'invalid',
message: 'Error al validar el archivo' message: 'Error al validar el archivo'
@@ -455,7 +422,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
// Automatically start training after successful upload // Automatically start training after successful upload
await startTraining(); await startTraining();
} catch (error: any) { } 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'; const message = error?.response?.data?.detail || error.message || 'Error al subir datos de ventas';
throw new Error(message); throw new Error(message);
} }
@@ -498,22 +465,16 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
}; };
const validateCurrentStep = (): boolean => { const validateCurrentStep = (): boolean => {
console.log(`🔍 Validating step ${currentStep}`);
console.log('Bakery data:', { name: bakeryData.name, address: bakeryData.address });
switch (currentStep) { switch (currentStep) {
case 1: case 1:
if (!bakeryData.name.trim()) { if (!bakeryData.name.trim()) {
console.log('❌ Bakery name is empty');
toast.error('El nombre de la panadería es obligatorio'); toast.error('El nombre de la panadería es obligatorio');
return false; return false;
} }
if (!bakeryData.address.trim()) { if (!bakeryData.address.trim()) {
console.log('❌ Bakery address is empty');
toast.error('La dirección es obligatoria'); toast.error('La dirección es obligatoria');
return false; return false;
} }
console.log('✅ Step 1 validation passed');
return true; return true;
case 2: case 2:
if (!bakeryData.csvFile) { if (!bakeryData.csvFile) {
@@ -591,7 +552,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
toast.success('Entrenamiento iniciado correctamente'); toast.success('Entrenamiento iniciado correctamente');
} catch (error) { } catch (error) {
console.error('Training start error:', error); // Training start error
toast.error('Error al iniciar el entrenamiento'); toast.error('Error al iniciar el entrenamiento');
setTrainingProgress(prev => ({ setTrainingProgress(prev => ({
...prev, ...prev,
@@ -623,7 +584,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
toast.success('¡Configuración completada exitosamente!'); toast.success('¡Configuración completada exitosamente!');
onComplete(); onComplete();
} catch (error) { } catch (error) {
console.warn('Failed to mark final step as completed:', error); // Failed to mark final step as completed
// Continue anyway for better UX // Continue anyway for better UX
toast.success('¡Configuración completada exitosamente!'); toast.success('¡Configuración completada exitosamente!');
onComplete(); onComplete();
@@ -663,19 +624,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
// setLimitedAccess(true); // 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 = () => { const renderStep = () => {
switch (currentStep) { switch (currentStep) {
@@ -827,7 +775,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
type="file" type="file"
accept=".csv,.xlsx,.xls,.json" accept=".csv,.xlsx,.xls,.json"
required required
onChange={(e) => { onChange={async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
// Validate file size (10MB limit) // Validate file size (10MB limit)
@@ -837,14 +785,47 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
return; return;
} }
// Update bakery data with the selected file
setBakeryData(prev => ({ setBakeryData(prev => ({
...prev, ...prev,
csvFile: file, csvFile: file,
hasHistoricalData: true hasHistoricalData: true
})); }));
// Reset validation status when new file is selected
setValidationStatus({ status: 'idle' });
toast.success(`Archivo ${file.name} seleccionado correctamente`); 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" className="hidden"
@@ -911,9 +892,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-gray-500">
Debug: validationStatus = "{validationStatus.status}", tenantId = "{tenantId || 'not set'}"
</p>
{!tenantId ? ( {!tenantId ? (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg"> <div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800"> <p className="text-sm text-yellow-800">
@@ -1052,68 +1030,156 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
); );
case 3: case 3:
return ( return (
<EnhancedTrainingProgress <SimplifiedTrainingProgress
progress={{ progress={{
progress: trainingProgress.progress, progress: trainingProgress.progress,
status: trainingProgress.status, status: trainingProgress.status,
currentStep: trainingProgress.currentStep, currentStep: trainingProgress.currentStep,
productsCompleted: trainingProgress.productsCompleted, productsCompleted: trainingProgress.productsCompleted,
productsTotal: trainingProgress.productsTotal, productsTotal: trainingProgress.productsTotal,
estimatedTimeRemaining: trainingProgress.estimatedTimeRemaining, estimatedTimeRemaining: trainingProgress.estimatedTimeRemaining,
error: trainingProgress.error error: trainingProgress.error
}} }}
onTimeout={() => { onTimeout={() => {
// Handle timeout - either navigate to dashboard or show limited access toast.success('El entrenamiento continuará en segundo plano. ¡Puedes empezar a explorar!');
console.log('Training timeout - user wants to continue to dashboard'); onComplete(); // Navigate to dashboard
// You can add your custom timeout logic here }}
}} 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: case 4:
return ( return (
<div className="text-center space-y-6"> <div className="max-w-3xl mx-auto">
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center"> {/* Celebration Header */}
<Check className="h-8 w-8 text-green-600" /> <div className="text-center mb-8">
</div> <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>
<div> <h2 className="text-3xl font-bold text-gray-900 mb-4">
<h3 className="text-xl font-semibold text-gray-900 mb-2"> ¡Todo listo! 🎉
¡Configuración Completada! 🎉 </h2>
</h3> <p className="text-xl text-gray-600 mb-8">
<p className="text-gray-600"> Tu asistente de IA para panadería está listo para ayudarte a predecir la demanda y hacer crecer tu negocio
Tu panadería está lista para usar PanIA. Comenzarás a recibir predicciones precisas de demanda.
</p> </p>
</div> </div>
<div className="bg-gradient-to-r from-primary-50 to-blue-50 p-6 rounded-xl"> {/* Setup Summary Card */}
<h4 className="font-medium text-gray-900 mb-3">Resumen de configuración:</h4> <div className="bg-gradient-to-r from-green-50 to-blue-50 rounded-3xl p-8 mb-8">
<div className="text-left space-y-2 text-sm"> <h3 className="text-lg font-semibold text-gray-900 mb-6 text-center">
<div className="flex justify-between"> Lo que hemos configurado para ti
<span className="text-gray-600">Panadería:</span> </h3>
<span className="font-medium">{bakeryData.name}</span>
<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>
<div className="flex justify-between">
<span className="text-gray-600">Productos:</span> <div className="space-y-4">
<span className="font-medium">{bakeryData.products.length} seleccionados</span> <div className="flex items-center space-x-3">
</div> <div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
<div className="flex justify-between"> <Brain className="w-4 h-4 text-white" />
<span className="text-gray-600">Datos históricos:</span> </div>
<span className="font-medium text-green-600"> <div>
{bakeryData.csvFile?.name.split('.').pop()?.toUpperCase()} subido <div className="font-medium text-gray-900">Modelo de IA</div>
</span> <div className="text-sm text-gray-600">Entrenado y listo</div>
</div> </div>
<div className="flex justify-between"> </div>
<span className="text-gray-600">Modelo IA:</span>
<span className="font-medium text-green-600"> Entrenado</span> <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>
</div> </div>
<div className="bg-yellow-50 border border-yellow-200 p-4 rounded-lg"> {/* Next Steps Preview */}
<p className="text-sm text-yellow-800"> <div className="bg-white border-2 border-blue-200 rounded-3xl p-8 mb-8">
💡 <strong>Próximo paso:</strong> Explora tu dashboard para ver las primeras predicciones y configurar alertas personalizadas. <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> </p>
</div> </div>
</div> </div>
@@ -1139,169 +1205,172 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-blue-50 py-8"> <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"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */} {/* Enhanced Header with Accessibility */}
<div className="text-center mb-8"> <header className="text-center mb-12" role="banner">
<h1 className="text-3xl font-bold text-gray-900 mb-2"> <div
Configuración de PanIA 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> </h1>
<p className="text-gray-600"> <p className="text-xl text-gray-600 max-w-2xl mx-auto mb-6">
Configuremos tu panadería para obtener predicciones precisas de demanda Configuremos tu asistente de IA para panadería en unos sencillos pasos
</p> </p>
{/* Progress Summary */} {/* Simplified Progress Summary with Accessibility */}
{progress && ( {progress && currentStep < 4 && (
<div className="mt-4 p-3 bg-blue-50 rounded-lg"> <div className="max-w-md mx-auto">
<div className="flex items-center justify-center space-x-4"> <div
<div className="text-sm text-blue-700"> className="bg-white rounded-2xl shadow-lg p-6"
<span className="font-semibold">Progreso:</span> {Math.round(progress.completion_percentage)}% 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>
<div className="text-sm text-blue-600"> <div className="bg-gray-200 rounded-full h-2 overflow-hidden" aria-hidden="true">
({progress.steps.filter((s: any) => s.completed).length}/{progress.steps.length} pasos completados) <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>
</div> </div>
)} )}
</div> </header>
{/* Progress Indicator */} {/* Modern Step Indicator with Accessibility - Only show for steps 1-2, hide during training */}
<div className="mb-8"> {currentStep < 3 && (
<div className="flex justify-center items-center space-x-4 mb-6"> <nav className="mb-12" aria-label="Onboarding progress steps" role="navigation">
{steps.map((step, index) => ( <div className="max-w-2xl mx-auto">
<div key={step.id} className="flex flex-col items-center"> <div className="flex justify-between items-center relative">
<div {/* Connection Line */}
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 transition-all duration-300 ${ <div className="absolute top-6 left-6 right-6 h-0.5 bg-gray-200" aria-hidden="true">
currentStep > step.id <div
? 'bg-green-500 border-green-500 text-white' className="h-full bg-gradient-to-r from-primary-500 to-primary-600 rounded-full transition-all duration-700"
: currentStep === step.id style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
? '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" />
)}
</div> </div>
<span className="mt-2 text-xs text-gray-500 text-center max-w-20">
{step.title} {steps.map((step, index) => {
</span> 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>
</div> </nav>
<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>
{/* Step Content */} {/* Step Content with Accessibility */}
<div className="bg-white rounded-2xl shadow-soft p-6 mb-8"> <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()} {renderStep()}
</div> </main>
{/* Navigation */} {/* Navigation with Enhanced Accessibility - Hidden during training (step 3) and completion (step 4) */}
<div className="flex justify-between"> {currentStep < 3 && (
<button <nav
onClick={handlePrevious} className="flex justify-between items-center bg-white rounded-3xl shadow-sm p-6"
disabled={currentStep === 1} role="navigation"
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" aria-label="Onboarding navigation"
> >
<ChevronLeft className="h-5 w-5 mr-1" /> <button
Anterior onClick={handlePrevious}
</button> 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 */} {/* Step info */}
{currentStep < 3 ? ( <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 <button
onClick={handleNext} onClick={handleNext}
disabled={isLoading} disabled={isLoading || (currentStep === 2 && validationStatus?.status !== 'valid')}
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" 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 ? ( {isLoading ? (
<> <>
<Loader className="h-5 w-5 mr-2 animate-spin" /> <Loader className="h-5 w-5 mr-2 animate-spin" aria-hidden="true" />
Procesando... <span aria-live="polite">
{currentStep === 1 ? "Creando panadería..." : "Procesando..."}
</span>
</> </>
) : ( ) : (
<> <>
Siguiente {currentStep === 1 && "Guardar y Continuar"}
<ChevronRight className="h-5 w-5 ml-1" /> {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> </button>
) : currentStep === 3 ? ( </nav>
// 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>
{/* Help Section */} {/* Help Section */}
<div className="mt-8 text-center"> <div className="mt-8 text-center">

View File

@@ -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

View File

@@ -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')