1035 lines
43 KiB
TypeScript
1035 lines
43 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Button, Card, CardHeader, CardBody, CardFooter, CardTitle, CardDescription, Badge, Alert, AlertDescription, Modal, ModalHeader, ModalBody, ModalFooter } from '../../components/ui';
|
|
import { apiClient } from '../../api/client';
|
|
import { useAuthStore } from '../../stores';
|
|
import { PublicLayout } from '../../components/layout';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Store,
|
|
Network,
|
|
CheckCircle,
|
|
Users,
|
|
Building,
|
|
Package,
|
|
BarChart3,
|
|
ChefHat,
|
|
CreditCard,
|
|
Bell,
|
|
ShoppingBag,
|
|
ShoppingBasket,
|
|
Warehouse,
|
|
Truck,
|
|
TrendingUp,
|
|
DollarSign,
|
|
Clock,
|
|
MapPin,
|
|
Layers,
|
|
Activity,
|
|
ShoppingCart,
|
|
Factory,
|
|
Zap,
|
|
Star,
|
|
PlusCircle,
|
|
Map as MapIcon,
|
|
ShoppingBasket as ShoppingBasketIcon,
|
|
TrendingUp as ChartIcon,
|
|
DollarSign as MoneyIcon,
|
|
ArrowRight,
|
|
Sparkles
|
|
} from 'lucide-react';
|
|
|
|
const DemoPage = () => {
|
|
const navigate = useNavigate();
|
|
const { t } = useTranslation('demo');
|
|
const setDemoAuth = useAuthStore((state) => state.setDemoAuth);
|
|
const [selectedTier, setSelectedTier] = useState(null);
|
|
const [creatingTier, setCreatingTier] = useState<string | null>(null);
|
|
const [cloneProgress, setCloneProgress] = useState({
|
|
parent: 0,
|
|
children: [0, 0, 0],
|
|
distribution: 0,
|
|
overall: 0
|
|
});
|
|
const [creationError, setCreationError] = useState('');
|
|
const [estimatedProgress, setEstimatedProgress] = useState(0);
|
|
const [progressStartTime, setProgressStartTime] = useState<number | null>(null);
|
|
const [estimatedRemainingSeconds, setEstimatedRemainingSeconds] = useState<number | null>(null);
|
|
|
|
// BUG-010 FIX: State for partial status warning
|
|
const [partialWarning, setPartialWarning] = useState<{
|
|
show: boolean;
|
|
message: string;
|
|
failedServices: string[];
|
|
sessionData: any;
|
|
tier: string;
|
|
} | null>(null);
|
|
|
|
// BUG-011 FIX: State for timeout modal
|
|
const [timeoutModal, setTimeoutModal] = useState<{
|
|
show: boolean;
|
|
sessionId: string;
|
|
sessionData: any;
|
|
tier: string;
|
|
} | null>(null);
|
|
|
|
// BUG-009 FIX: Ref to track polling abort controller
|
|
const abortControllerRef = React.useRef<AbortController | null>(null);
|
|
|
|
// Helper function to calculate estimated progress based on elapsed time
|
|
const calculateEstimatedProgress = (tier: string, startTime: number): number => {
|
|
const elapsed = Date.now() - startTime;
|
|
const duration = 5000; // ms (5s for both professional and enterprise)
|
|
const linearProgress = Math.min(95, (elapsed / duration) * 100);
|
|
// Logarithmic curve for natural feel - starts fast, slows down
|
|
return Math.min(95, Math.round(linearProgress * (1 - Math.exp(-elapsed / 1000))));
|
|
};
|
|
|
|
const demoOptions = [
|
|
{
|
|
id: 'professional',
|
|
tier: 'professional',
|
|
icon: Store,
|
|
title: 'Panadería Artesana',
|
|
subtitle: 'Tier Profesional',
|
|
description: 'Panadería profesional moderna que combina calidad artesanal con eficiencia operativa',
|
|
features: [
|
|
'Gestión de inventario y stock',
|
|
'Planificación de producción',
|
|
'Gestión de pedidos y proveedores',
|
|
'Forecasting con IA (92% precisión)',
|
|
'Dashboard de métricas',
|
|
'Alertas inteligentes',
|
|
'Integración con POS',
|
|
'Recetas profesionales estandarizadas',
|
|
'Ventas omnicanal (retail + online + catering)',
|
|
'Reportes de negocio'
|
|
],
|
|
characteristics: {
|
|
locations: '1',
|
|
employees: '12',
|
|
productionModel: 'Local Artesanal',
|
|
salesChannels: 'Retail + Online + Catering'
|
|
},
|
|
accountType: 'professional',
|
|
baseTenantId: 'a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6',
|
|
color: 'primary',
|
|
gradient: 'from-amber-500 to-orange-600'
|
|
},
|
|
{
|
|
id: 'enterprise',
|
|
tier: 'enterprise',
|
|
icon: Network,
|
|
title: 'Cadena Enterprise',
|
|
subtitle: 'Tier Enterprise',
|
|
description: 'Producción centralizada con red de distribución en Madrid, Barcelona, Valencia, Sevilla y Bilbao',
|
|
features: [
|
|
'Todas las funciones Professional +',
|
|
'Gestión multi-ubicación ilimitada',
|
|
'Obrador central (Madrid) + 5 sucursales',
|
|
'Distribución y logística VRP-optimizada',
|
|
'Forecasting agregado de red',
|
|
'Dashboard enterprise consolidado',
|
|
'Transferencias internas automatizadas',
|
|
'Optimización de rutas de entrega',
|
|
'Solicitudes de procurement entre ubicaciones',
|
|
'Reportes consolidados nivel corporativo'
|
|
],
|
|
characteristics: {
|
|
locations: '1 obrador + 5 tiendas',
|
|
employees: '45',
|
|
productionModel: 'Centralizado (Madrid)',
|
|
salesChannels: 'Madrid / Barcelona / Valencia / Sevilla / Bilbao'
|
|
},
|
|
accountType: 'enterprise',
|
|
baseTenantId: 'c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8',
|
|
color: 'secondary',
|
|
gradient: 'from-emerald-500 to-teal-600'
|
|
}
|
|
];
|
|
|
|
const getLoadingMessage = (tier, progress) => {
|
|
if (tier === 'enterprise') {
|
|
if (progress < 20) return 'Iniciando tu demostración...';
|
|
if (progress < 50) return 'Creando tu panadería central...';
|
|
if (progress < 80) return 'Configurando tus sucursales...';
|
|
if (progress < 95) return 'Preparando datos finales...';
|
|
return 'Casi listo...';
|
|
} else {
|
|
if (progress < 25) return 'Iniciando tu panadería...';
|
|
if (progress < 70) return 'Cargando productos y datos...';
|
|
if (progress < 95) return 'Preparando datos finales...';
|
|
return 'Casi listo...';
|
|
}
|
|
};
|
|
|
|
const handleStartDemo = async (accountType, tier) => {
|
|
setCreatingTier(tier);
|
|
setCreationError('');
|
|
setProgressStartTime(Date.now());
|
|
setEstimatedProgress(0);
|
|
|
|
try {
|
|
// Start the demo session first
|
|
const response = await fetch('/api/v1/demo/sessions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`
|
|
},
|
|
body: JSON.stringify({
|
|
demo_account_type: accountType,
|
|
subscription_tier: tier
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.detail || 'Failed to create demo session');
|
|
}
|
|
|
|
const sessionData = await response.json();
|
|
|
|
// Store demo context immediately after session creation
|
|
localStorage.setItem('demo_mode', 'true');
|
|
localStorage.setItem('demo_account_type', accountType);
|
|
localStorage.setItem('subscription_tier', tier); // Store tier for reference
|
|
localStorage.setItem('demo_session_id', sessionData.session_id);
|
|
localStorage.setItem('virtual_tenant_id', sessionData.virtual_tenant_id);
|
|
localStorage.setItem('demo_expires_at', sessionData.expires_at);
|
|
|
|
// BUG FIX: Store the session token as access_token for authentication
|
|
if (sessionData.session_token) {
|
|
console.log('🔐 [DemoPage] Storing session token:', sessionData.session_token.substring(0, 20) + '...');
|
|
localStorage.setItem('access_token', sessionData.session_token);
|
|
|
|
// CRITICAL FIX: Update Zustand auth store with demo auth
|
|
// This ensures the token persists across navigation and page reloads
|
|
setDemoAuth(sessionData.session_token, {
|
|
id: 'demo-user',
|
|
email: 'demo@bakery.com',
|
|
full_name: 'Demo User',
|
|
is_active: true,
|
|
is_verified: true,
|
|
created_at: new Date().toISOString(),
|
|
tenant_id: sessionData.virtual_tenant_id,
|
|
}, tier); // NEW: Pass subscription tier to setDemoAuth
|
|
|
|
console.log('✅ [DemoPage] Demo auth set in store');
|
|
} else {
|
|
console.error('❌ [DemoPage] No session_token in response!', sessionData);
|
|
}
|
|
|
|
// Now poll for status until ready
|
|
await pollForSessionStatus(sessionData.session_id, tier, sessionData);
|
|
|
|
} catch (error) {
|
|
console.error('Error creating demo:', error);
|
|
setCreatingTier(null);
|
|
setProgressStartTime(null);
|
|
setEstimatedProgress(0);
|
|
setCloneProgress({
|
|
parent: 0,
|
|
children: [0, 0, 0],
|
|
distribution: 0,
|
|
overall: 0
|
|
});
|
|
setCreationError('Error al iniciar la demo. Por favor, inténtalo de nuevo.');
|
|
}
|
|
// NOTE: State reset moved to navigation callback and error handlers
|
|
// to prevent modal from disappearing before redirect
|
|
};
|
|
|
|
const pollForSessionStatus = async (sessionId, tier, sessionData) => {
|
|
const maxAttempts = 120; // 120 * 1s = 2 minutes max wait time
|
|
let attempts = 0;
|
|
|
|
// BUG-009 FIX: Create AbortController for cancellation
|
|
const abortController = new AbortController();
|
|
abortControllerRef.current = abortController;
|
|
|
|
// Initially show a higher progress to indicate activity
|
|
setCloneProgress({
|
|
parent: 10,
|
|
children: [0, 0, 0],
|
|
distribution: 0,
|
|
overall: 10
|
|
});
|
|
|
|
// Set up progress estimation interval for smooth UI updates
|
|
const progressInterval = setInterval(() => {
|
|
if (progressStartTime) {
|
|
const estimated = calculateEstimatedProgress(tier, progressStartTime);
|
|
setEstimatedProgress(estimated);
|
|
|
|
// Update displayed progress with combination of estimated and backend
|
|
setCloneProgress(prev => ({
|
|
...prev,
|
|
overall: Math.max(estimated, prev.overall)
|
|
}));
|
|
}
|
|
}, 500); // Update every 500ms for smooth animation
|
|
|
|
try {
|
|
while (attempts < maxAttempts) {
|
|
try {
|
|
// BUG-009 FIX: Pass abort signal to fetch
|
|
const statusResponse = await fetch(
|
|
`/api/v1/demo/sessions/${sessionId}/status`,
|
|
{ signal: abortController.signal }
|
|
);
|
|
|
|
if (!statusResponse.ok) {
|
|
throw new Error('Failed to get session status');
|
|
}
|
|
|
|
const statusData = await statusResponse.json();
|
|
|
|
// Capture estimated remaining time from backend
|
|
if (statusData.estimated_remaining_seconds !== undefined) {
|
|
setEstimatedRemainingSeconds(statusData.estimated_remaining_seconds);
|
|
}
|
|
|
|
// Update progress based on actual backend status
|
|
updateProgressFromBackendStatus(statusData, tier);
|
|
|
|
// BUG-010 FIX: Handle ready status separately from partial
|
|
if (statusData.status === 'ready') {
|
|
// Full success - set to 100% and navigate immediately
|
|
clearInterval(progressInterval);
|
|
setCloneProgress(prev => ({ ...prev, overall: 100 }));
|
|
requestAnimationFrame(() => {
|
|
// Reset state before navigation
|
|
setCreatingTier(null);
|
|
setProgressStartTime(null);
|
|
setEstimatedProgress(0);
|
|
setCloneProgress({
|
|
parent: 0,
|
|
children: [0, 0, 0],
|
|
distribution: 0,
|
|
overall: 0
|
|
});
|
|
// Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier
|
|
navigate('/app/dashboard');
|
|
});
|
|
return;
|
|
} else if (statusData.status === 'PARTIAL' || statusData.status === 'partial') {
|
|
// BUG-010 FIX: Show warning modal for partial status
|
|
clearInterval(progressInterval);
|
|
const failedServices = getFailedServices(statusData);
|
|
setPartialWarning({
|
|
show: true,
|
|
message: 'La demo está parcialmente lista. Algunos servicios no se cargaron completamente.',
|
|
failedServices: failedServices,
|
|
sessionData: sessionData,
|
|
tier: tier
|
|
});
|
|
return;
|
|
} else if (statusData.status === 'FAILED' || statusData.status === 'failed') {
|
|
clearInterval(progressInterval);
|
|
setCreatingTier(null);
|
|
setProgressStartTime(null);
|
|
setEstimatedProgress(0);
|
|
setCloneProgress({
|
|
parent: 0,
|
|
children: [0, 0, 0],
|
|
distribution: 0,
|
|
overall: 0
|
|
});
|
|
setCreationError('Error al clonar los datos de demo. Por favor, inténtalo de nuevo.');
|
|
return;
|
|
}
|
|
|
|
// Wait before next poll - reduced from 2s to 1s
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
attempts++;
|
|
} catch (error: any) {
|
|
// BUG-009 FIX: Handle abort gracefully
|
|
if (error?.name === 'AbortError') {
|
|
console.log('Polling cancelled due to component unmount');
|
|
clearInterval(progressInterval);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// BUG-011 FIX: Show timeout modal instead of auto-navigating
|
|
clearInterval(progressInterval);
|
|
setTimeoutModal({
|
|
show: true,
|
|
sessionId: sessionId,
|
|
sessionData: sessionData,
|
|
tier: tier
|
|
});
|
|
|
|
} catch (error) {
|
|
clearInterval(progressInterval);
|
|
console.error('Error polling for status:', error);
|
|
setCreatingTier(null);
|
|
setProgressStartTime(null);
|
|
setEstimatedProgress(0);
|
|
setCloneProgress({
|
|
parent: 0,
|
|
children: [0, 0, 0],
|
|
distribution: 0,
|
|
overall: 0
|
|
});
|
|
setCreationError('Error verificando el estado de la demo. Por favor, inténtalo de nuevo.');
|
|
} finally {
|
|
// Clean up abort controller reference
|
|
abortControllerRef.current = null;
|
|
}
|
|
};
|
|
|
|
// BUG-009 FIX: Cleanup on unmount
|
|
React.useEffect(() => {
|
|
return () => {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// BUG-010 FIX: Helper to extract failed services
|
|
const getFailedServices = (statusData: any): string[] => {
|
|
const failed: string[] = [];
|
|
if (statusData.progress) {
|
|
if (statusData.progress.parent?.services) {
|
|
Object.entries(statusData.progress.parent.services).forEach(([name, service]: [string, any]) => {
|
|
if (service?.status === 'failed') {
|
|
failed.push(name);
|
|
}
|
|
});
|
|
}
|
|
if (statusData.progress.services) {
|
|
Object.entries(statusData.progress.services).forEach(([name, service]: [string, any]) => {
|
|
if (service?.status === 'failed') {
|
|
failed.push(name);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return failed;
|
|
};
|
|
|
|
const updateProgressFromBackendStatus = (statusData, tier) => {
|
|
// IMPORTANT: Backend only provides progress AFTER cloning completes
|
|
// During cloning (status=PENDING), progress is empty {}
|
|
// So we rely on estimated progress for visual feedback
|
|
|
|
const hasRealProgress = statusData.progress && Object.keys(statusData.progress).length > 0;
|
|
|
|
if (hasRealProgress) {
|
|
if (tier === 'enterprise') {
|
|
// Handle enterprise progress structure which may be different
|
|
// Enterprise demos may have a different progress structure with parent, children, distribution
|
|
handleEnterpriseProgress(statusData.progress);
|
|
} else {
|
|
// Handle individual bakery progress structure
|
|
handleIndividualProgress(statusData.progress);
|
|
}
|
|
} else {
|
|
// No detailed progress available - backend is still cloning
|
|
// Use estimated progress for smooth visual feedback
|
|
// This is NORMAL during the cloning phase
|
|
setCloneProgress(prev => {
|
|
const newProgress = Math.max(
|
|
estimatedProgress,
|
|
prev.overall // Never go backward
|
|
);
|
|
|
|
// For enterprise, also update sub-components based on estimated progress
|
|
if (tier === 'enterprise') {
|
|
return {
|
|
parent: Math.min(95, Math.round(estimatedProgress * 0.4)), // 40% weight
|
|
children: [
|
|
Math.min(95, Math.round(estimatedProgress * 0.35)),
|
|
Math.min(95, Math.round(estimatedProgress * 0.35)),
|
|
Math.min(95, Math.round(estimatedProgress * 0.35))
|
|
],
|
|
distribution: Math.min(95, Math.round(estimatedProgress * 0.25)), // 25% weight
|
|
overall: newProgress
|
|
};
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
overall: newProgress
|
|
};
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleIndividualProgress = (progress) => {
|
|
// Count services that are completed (look for 'completed', 'ready', or 'success' status)
|
|
const services = progress || {};
|
|
const completedServices = Object.values(services).filter((service: any) =>
|
|
service?.status === 'completed' ||
|
|
service?.status === 'ready' ||
|
|
service?.status === 'success' ||
|
|
service?.success === true // Some services might use success flag instead of status
|
|
).length;
|
|
|
|
// Estimate overall progress based on services completed
|
|
const totalServices = Object.keys(services).length || 11; // Default to 11 known services
|
|
const backendProgress = Math.min(95, Math.round((completedServices / totalServices) * 100));
|
|
|
|
// Use the maximum of backend progress and estimated progress to prevent backtracking
|
|
const overallProgress = Math.max(backendProgress, estimatedProgress);
|
|
|
|
setCloneProgress({
|
|
parent: overallProgress,
|
|
children: [0, 0, 0],
|
|
distribution: 0,
|
|
overall: overallProgress
|
|
});
|
|
};
|
|
|
|
const handleEnterpriseProgress = (progress) => {
|
|
// For enterprise demos, the progress might have a more complex structure
|
|
// It may have parent, children, distribution components
|
|
let backendProgress = 0;
|
|
let parentProgress = 0;
|
|
let childrenProgressArray = [0, 0, 0];
|
|
let distributionProgress = 0;
|
|
|
|
// Check if this is the enterprise results structure (with parent/children/distribution)
|
|
if (progress.parent && progress.children && progress.distribution !== undefined) {
|
|
// This looks like an enterprise results structure from the end of cloning
|
|
// Calculate progress based on parent, children, and distribution status
|
|
// FIX 1: Handle both "completed" and "ready" for parent status
|
|
const parentStatus = progress.parent.overall_status;
|
|
if (parentStatus === 'ready' || parentStatus === 'completed' || parentStatus === 'partial') {
|
|
parentProgress = 100;
|
|
} else if (progress.parent.overall_status === 'pending') {
|
|
parentProgress = 50; // Increased from 25 for better perceived progress
|
|
} else {
|
|
// Count parent services for more granular progress
|
|
const parentServices = progress.parent.services || {};
|
|
const completedParent = Object.values(parentServices).filter((s: any) =>
|
|
s?.status === 'completed' || s?.status === 'ready' || s?.status === 'success'
|
|
).length;
|
|
const totalParent = Object.keys(parentServices).length || 1;
|
|
parentProgress = Math.round((completedParent / totalParent) * 100);
|
|
}
|
|
|
|
if (progress.children && progress.children.length > 0) {
|
|
childrenProgressArray = progress.children.map((child: any) => {
|
|
// FIX 2: Handle both status types for children
|
|
const childStatus = child.status || child.overall_status;
|
|
if (childStatus === 'ready' || childStatus === 'completed') return 100;
|
|
if (childStatus === 'partial') return 75;
|
|
if (childStatus === 'pending') return 30;
|
|
return 0;
|
|
});
|
|
const avgChildrenProgress = childrenProgressArray.reduce((a, b) => a + b, 0) / childrenProgressArray.length;
|
|
|
|
// Ensure we have exactly 3 children values for display
|
|
while (childrenProgressArray.length < 3) childrenProgressArray.push(0);
|
|
childrenProgressArray = childrenProgressArray.slice(0, 3);
|
|
|
|
backendProgress = Math.round((parentProgress * 0.4) + (avgChildrenProgress * 0.4));
|
|
} else {
|
|
backendProgress = Math.round(parentProgress * 0.8);
|
|
}
|
|
|
|
if (progress.distribution) {
|
|
// FIX 3: Handle both status types for distribution
|
|
const distStatus = progress.distribution.status || progress.distribution.overall_status;
|
|
if (distStatus === 'ready' || distStatus === 'completed') {
|
|
distributionProgress = 100;
|
|
} else if (distStatus === 'pending') {
|
|
distributionProgress = 50;
|
|
} else {
|
|
distributionProgress = distStatus === 'failed' ? 100 : 75;
|
|
}
|
|
backendProgress = Math.round(backendProgress * 0.8 + distributionProgress * 0.2);
|
|
}
|
|
|
|
// FIX 4: Allow 100% progress when all components complete
|
|
if (parentProgress === 100 && childrenProgressArray.every(p => p === 100) && distributionProgress === 100) {
|
|
backendProgress = 100;
|
|
}
|
|
} else {
|
|
// If it's not the enterprise result structure, fall back to service-based calculation
|
|
const services = progress || {};
|
|
const completedServices = Object.values(services).filter((service: any) =>
|
|
service?.status === 'completed' ||
|
|
service?.status === 'ready' ||
|
|
service?.status === 'success' ||
|
|
service?.success === true
|
|
).length;
|
|
|
|
const totalServices = Object.keys(services).length || 11;
|
|
backendProgress = Math.min(95, Math.round((completedServices / totalServices) * 100));
|
|
parentProgress = backendProgress;
|
|
childrenProgressArray = [backendProgress * 0.7, backendProgress * 0.7, backendProgress * 0.7];
|
|
distributionProgress = backendProgress * 0.8;
|
|
}
|
|
|
|
// FIX 5: Don't cap at 95% when backend reports 100%
|
|
const cappedBackendProgress = backendProgress === 100 ? 100 : Math.min(95, backendProgress);
|
|
const overallProgress = Math.max(cappedBackendProgress, estimatedProgress);
|
|
|
|
setCloneProgress({
|
|
parent: Math.max(parentProgress, estimatedProgress * 0.9),
|
|
children: childrenProgressArray,
|
|
distribution: Math.max(distributionProgress, estimatedProgress * 0.7),
|
|
overall: overallProgress
|
|
});
|
|
};
|
|
|
|
return (
|
|
<PublicLayout
|
|
variant="default"
|
|
contentPadding="md"
|
|
headerProps={{
|
|
showThemeToggle: true,
|
|
showAuthButtons: true,
|
|
showLanguageSelector: true,
|
|
variant: "default"
|
|
}}
|
|
>
|
|
{/* Hero Section with Enhanced Design */}
|
|
<section className="relative overflow-hidden bg-gradient-to-br from-[var(--bg-primary)] via-[var(--bg-secondary)] to-[var(--color-primary)]/5 dark:from-[var(--bg-primary)] dark:via-[var(--bg-secondary)] dark:to-[var(--color-primary)]/10 py-24">
|
|
{/* Background Pattern */}
|
|
<div className="absolute inset-0 bg-pattern opacity-50"></div>
|
|
|
|
{/* Animated Background Elements */}
|
|
<div className="absolute top-20 left-10 w-72 h-72 bg-[var(--color-primary)]/10 rounded-full blur-3xl animate-pulse"></div>
|
|
<div className="absolute bottom-10 right-10 w-96 h-96 bg-[var(--color-secondary)]/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }}></div>
|
|
|
|
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div className="text-center space-y-6 animate-fade-in">
|
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-primary)]/10 dark:bg-[var(--color-primary)]/20 border border-[var(--color-primary)]/20 dark:border-[var(--color-primary)]/30 mb-4">
|
|
<Sparkles className="w-4 h-4 text-[var(--color-primary)]" />
|
|
<span className="text-sm font-medium text-[var(--color-primary)]">Experiencia Demo Gratuita</span>
|
|
</div>
|
|
|
|
<h1 className="text-5xl md:text-6xl font-bold text-[var(--text-primary)] mb-6 leading-tight">
|
|
Prueba Nuestra
|
|
<span className="block bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] bg-clip-text text-transparent">
|
|
Plataforma de Gestión
|
|
</span>
|
|
</h1>
|
|
|
|
<p className="text-xl md:text-2xl text-[var(--text-secondary)] max-w-3xl mx-auto leading-relaxed">
|
|
Elige tu experiencia de demostración ideal y descubre cómo nuestra plataforma puede transformar tu negocio de panadería
|
|
</p>
|
|
|
|
<div className="flex items-center justify-center gap-8 pt-4 text-sm text-[var(--text-tertiary)]">
|
|
<div className="flex items-center gap-2">
|
|
<CheckCircle className="w-5 h-5 text-[var(--color-success)]" />
|
|
<span>Sin tarjeta de crédito</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<CheckCircle className="w-5 h-5 text-[var(--color-success)]" />
|
|
<span>Configuración instantánea</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<CheckCircle className="w-5 h-5 text-[var(--color-success)]" />
|
|
<span>Datos reales de ejemplo</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Main Content */}
|
|
<section className="py-20 bg-[var(--bg-primary)]">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
{/* Demo Options with Improved Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-12">
|
|
{demoOptions.map((option, index) => (
|
|
<div
|
|
key={option.id}
|
|
className={`
|
|
flex flex-col
|
|
bg-[var(--bg-primary)]
|
|
border border-[var(--border-primary)]
|
|
rounded-xl
|
|
shadow-lg
|
|
overflow-hidden
|
|
transition-all duration-300
|
|
hover:shadow-2xl
|
|
cursor-pointer
|
|
${selectedTier === option.id
|
|
? 'ring-2 ring-[var(--color-primary)] shadow-xl'
|
|
: ''
|
|
}
|
|
`}
|
|
onClick={() => setSelectedTier(option.id)}
|
|
>
|
|
{/* Card Header with Gradient */}
|
|
<div className={`bg-gradient-to-r ${option.gradient} p-6`}>
|
|
<div className="flex items-start justify-between w-full text-white mb-4">
|
|
<div className="flex items-start gap-4 flex-1">
|
|
<div className="p-3 bg-white/20 backdrop-blur-sm rounded-xl">
|
|
<option.icon className="w-8 h-8 text-white" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-2xl font-bold text-white mb-2">
|
|
{option.title}
|
|
</h3>
|
|
<Badge
|
|
variant={option.tier === 'enterprise' ? 'secondary' : 'default'}
|
|
className="bg-white/20 backdrop-blur-sm text-white border-white/30 capitalize font-semibold"
|
|
>
|
|
{option.subtitle}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
{selectedTier === option.id && (
|
|
<div className="animate-scale-in">
|
|
<CheckCircle className="w-6 h-6 text-white" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="text-white/90 text-base leading-relaxed">
|
|
{option.description}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Card Body */}
|
|
<div className="p-6 flex-1">
|
|
{/* Features List with Icons */}
|
|
<div className="space-y-3 mb-6">
|
|
<h4 className="font-semibold text-[var(--text-primary)] text-sm uppercase tracking-wide mb-4">
|
|
Características Incluidas
|
|
</h4>
|
|
{option.features.slice(0, 6).map((feature, index) => (
|
|
<div key={index} className="flex items-start gap-3 group">
|
|
<div className="flex-shrink-0 mt-0.5">
|
|
<div className="p-1 rounded-full bg-[var(--color-success)]/10 group-hover:bg-[var(--color-success)]/20 transition-colors">
|
|
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
|
|
</div>
|
|
</div>
|
|
<span className="text-sm text-[var(--text-secondary)] group-hover:text-[var(--text-primary)] transition-colors">
|
|
{feature}
|
|
</span>
|
|
</div>
|
|
))}
|
|
{option.features.length > 6 && (
|
|
<div className="flex items-start gap-3 pt-2">
|
|
<div className="flex-shrink-0 mt-0.5">
|
|
<div className="p-1 rounded-full bg-[var(--color-info)]/10">
|
|
<PlusCircle className="w-4 h-4 text-[var(--color-info)]" />
|
|
</div>
|
|
</div>
|
|
<span className="text-sm font-medium text-[var(--color-info)]">
|
|
+ {option.features.length - 6} funciones más
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Characteristics Grid with Enhanced Design */}
|
|
<div className="grid grid-cols-2 gap-4 p-4 rounded-xl bg-gradient-to-br from-[var(--bg-secondary)] to-[var(--bg-tertiary)] border border-[var(--border-primary)]">
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center gap-2 text-[var(--text-tertiary)]">
|
|
<MapPin className="w-4 h-4" />
|
|
<span className="text-xs font-medium uppercase tracking-wide">Ubicaciones</span>
|
|
</div>
|
|
<p className="text-sm font-semibold text-[var(--text-primary)]">
|
|
{option.characteristics.locations}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center gap-2 text-[var(--text-tertiary)]">
|
|
<Users className="w-4 h-4" />
|
|
<span className="text-xs font-medium uppercase tracking-wide">Empleados</span>
|
|
</div>
|
|
<p className="text-sm font-semibold text-[var(--text-primary)]">
|
|
{option.characteristics.employees}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center gap-2 text-[var(--text-tertiary)]">
|
|
<Factory className="w-4 h-4" />
|
|
<span className="text-xs font-medium uppercase tracking-wide">Producción</span>
|
|
</div>
|
|
<p className="text-sm font-semibold text-[var(--text-primary)]">
|
|
{option.characteristics.productionModel}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center gap-2 text-[var(--text-tertiary)]">
|
|
<ShoppingBag className="w-4 h-4" />
|
|
<span className="text-xs font-medium uppercase tracking-wide">Canales</span>
|
|
</div>
|
|
<p className="text-sm font-semibold text-[var(--text-primary)]">
|
|
{option.characteristics.salesChannels}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card Footer */}
|
|
<div className="px-6 py-4 bg-[var(--bg-secondary)] border-t border-[var(--border-primary)]">
|
|
<Button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleStartDemo(option.accountType, option.tier);
|
|
}}
|
|
disabled={creatingTier !== null}
|
|
size="lg"
|
|
isFullWidth={true}
|
|
variant="gradient"
|
|
className="font-semibold group"
|
|
>
|
|
{creatingTier === option.tier ? (
|
|
<span className="flex items-center gap-3">
|
|
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white/30 border-t-white" />
|
|
<span>{getLoadingMessage(option.tier, cloneProgress.overall)}</span>
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center justify-center gap-2">
|
|
<Zap className="w-5 h-5" />
|
|
<span>Iniciar Demo {option.tier === 'enterprise' ? 'Enterprise' : 'Professional'}</span>
|
|
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
|
</span>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Loading Progress Modal with Enhanced Design */}
|
|
{creatingTier !== null && (
|
|
<Modal
|
|
isOpen={creatingTier !== null}
|
|
onClose={() => { }}
|
|
size="lg"
|
|
>
|
|
<ModalHeader
|
|
title={
|
|
<div className="flex items-center gap-3">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-2 border-[var(--color-primary)]/30 border-t-[var(--color-primary)]"></div>
|
|
<span className="text-xl font-bold text-[var(--text-primary)]">
|
|
Configurando Tu Demo
|
|
</span>
|
|
</div>
|
|
}
|
|
showCloseButton={false}
|
|
/>
|
|
<ModalBody padding="xl">
|
|
<div className="space-y-8">
|
|
{/* Overall Progress Section */}
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-lg font-medium text-[var(--text-primary)]">
|
|
{getLoadingMessage(creatingTier, cloneProgress.overall)}
|
|
</span>
|
|
<span className="text-2xl font-bold text-[var(--color-primary)]">
|
|
{cloneProgress.overall}%
|
|
</span>
|
|
</div>
|
|
|
|
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-3 overflow-hidden">
|
|
<div
|
|
className="bg-[var(--color-primary)] h-3 rounded-full transition-all duration-500"
|
|
style={{ width: `${cloneProgress.overall}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{estimatedRemainingSeconds !== null && estimatedRemainingSeconds > 0 && (
|
|
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
|
<Clock className="w-4 h-4" />
|
|
<span>Aproximadamente {estimatedRemainingSeconds}s restantes</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
|
|
{/* Information Box */}
|
|
<div className="rounded-lg p-4 bg-[var(--bg-secondary)] border border-[var(--border-primary)]">
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|
{creatingTier === 'enterprise'
|
|
? 'Estamos preparando tu panadería con una tienda principal y 3 sucursales conectadas'
|
|
: 'Estamos preparando tu panadería con productos, recetas y ventas de ejemplo'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</ModalBody>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Error Alert with Enhanced Design */}
|
|
{creationError && (
|
|
<div className="max-w-md mx-auto mt-6 animate-shake">
|
|
<Alert variant="destructive" className="shadow-lg border-2">
|
|
<AlertDescription className="flex items-center gap-2">
|
|
<span className="flex-shrink-0 w-2 h-2 bg-[var(--color-error)] rounded-full animate-pulse"></span>
|
|
{creationError}
|
|
</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
)}
|
|
|
|
{/* Partial Status Warning Modal */}
|
|
{partialWarning?.show && (
|
|
<Modal
|
|
isOpen={partialWarning.show}
|
|
onClose={() => setPartialWarning(null)}
|
|
size="md"
|
|
>
|
|
<ModalHeader
|
|
title="Demo Parcialmente Lista"
|
|
showCloseButton={true}
|
|
onClose={() => setPartialWarning(null)}
|
|
/>
|
|
<ModalBody padding="lg">
|
|
<div className="space-y-4">
|
|
<Alert variant="warning" className="mb-4">
|
|
<AlertDescription>
|
|
{partialWarning.message}
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
{partialWarning.failedServices.length > 0 && (
|
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-4 rounded-lg">
|
|
<h4 className="font-semibold text-sm mb-2 text-yellow-900 dark:text-yellow-100">
|
|
Servicios con problemas:
|
|
</h4>
|
|
<ul className="list-disc list-inside space-y-1 text-sm text-yellow-800 dark:text-yellow-200">
|
|
{partialWarning.failedServices.map((service, index) => (
|
|
<li key={index}>{service}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|
Puedes continuar con la demo, pero algunas funcionalidades pueden no estar disponibles.
|
|
También puedes intentar crear una nueva sesión.
|
|
</p>
|
|
</div>
|
|
</ModalBody>
|
|
<ModalFooter justify="end">
|
|
<div className="flex gap-3">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setPartialWarning(null);
|
|
setCreatingTier(null);
|
|
setCreationError('Creación cancelada. Por favor, intenta de nuevo.');
|
|
}}
|
|
>
|
|
Cancelar e Intentar de Nuevo
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => {
|
|
// Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier
|
|
setPartialWarning(null);
|
|
navigate('/app/dashboard');
|
|
}}
|
|
>
|
|
Continuar con Demo Parcial
|
|
</Button>
|
|
</div>
|
|
</ModalFooter>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Timeout Modal */}
|
|
{timeoutModal?.show && (
|
|
<Modal
|
|
isOpen={timeoutModal.show}
|
|
onClose={() => setTimeoutModal(null)}
|
|
size="md"
|
|
>
|
|
<ModalHeader
|
|
title="La Configuración está Tomando más Tiempo de lo Esperado"
|
|
showCloseButton={true}
|
|
onClose={() => setTimeoutModal(null)}
|
|
/>
|
|
<ModalBody padding="lg">
|
|
<div className="space-y-4">
|
|
<Alert variant="warning" className="mb-4">
|
|
<AlertDescription>
|
|
La creación de tu sesión demo está tardando más de lo habitual.
|
|
Esto puede deberse a alta carga en el sistema.
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|
Tienes las siguientes opciones:
|
|
</p>
|
|
|
|
<div className="space-y-3">
|
|
<div className="bg-[var(--color-info)]/10 dark:bg-[var(--color-info)]/20 p-3 rounded-lg">
|
|
<h4 className="font-semibold text-sm text-[var(--color-info-dark)] dark:text-[var(--color-info-light)]">
|
|
1. Seguir Esperando
|
|
</h4>
|
|
<p className="text-xs text-[var(--color-info)] dark:text-[var(--color-info-light)] mt-1">
|
|
La sesión puede completarse en cualquier momento. Mantén esta página abierta.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-[var(--color-success)]/10 dark:bg-[var(--color-success)]/20 p-3 rounded-lg">
|
|
<h4 className="font-semibold text-sm text-[var(--color-success-dark)] dark:text-[var(--color-success-light)]">
|
|
2. Iniciar con Datos Parciales
|
|
</h4>
|
|
<p className="text-xs text-[var(--color-success)] dark:text-[var(--color-success-light)] mt-1">
|
|
Accede a la demo ahora con los servicios que ya estén listos.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-[var(--bg-secondary)] dark:bg-[var(--bg-tertiary)] p-3 rounded-lg">
|
|
<h4 className="font-semibold text-sm text-[var(--text-primary)] dark:text-[var(--text-primary)]">
|
|
3. Cancelar e Intentar de Nuevo
|
|
</h4>
|
|
<p className="text-xs text-[var(--text-secondary)] dark:text-[var(--text-secondary)] mt-1">
|
|
Cancela esta sesión y crea una nueva desde cero.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ModalBody>
|
|
<ModalFooter justify="center">
|
|
<div className="flex flex-col gap-2 w-full max-w-sm">
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => {
|
|
setTimeoutModal(null);
|
|
// Continue polling
|
|
}}
|
|
>
|
|
Seguir Esperando
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => {
|
|
// Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier
|
|
setTimeoutModal(null);
|
|
navigate('/app/dashboard');
|
|
}}
|
|
>
|
|
Iniciar con Datos Parciales
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setTimeoutModal(null);
|
|
setCreatingTier(null);
|
|
setCreationError('Tiempo de espera excedido. Por favor, intenta de nuevo.');
|
|
}}
|
|
>
|
|
Cancelar e Intentar de Nuevo
|
|
</Button>
|
|
</div>
|
|
</ModalFooter>
|
|
</Modal>
|
|
)}
|
|
|
|
</div>
|
|
</section>
|
|
</PublicLayout>
|
|
);
|
|
};
|
|
|
|
export default DemoPage;
|