273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { extendDemoSession, destroyDemoSession } from '../../../api/services/demo';
|
|
import { apiClient } from '../../../api/client';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useDemoTour, getTourState, trackTourEvent } from '../../../features/demo-onboarding';
|
|
import { BookOpen, Clock, Sparkles, X } from 'lucide-react';
|
|
|
|
export const DemoBanner: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const { startTour, resumeTour } = useDemoTour();
|
|
const [isDemo, setIsDemo] = useState(() => localStorage.getItem('demo_mode') === 'true');
|
|
const [expiresAt, setExpiresAt] = useState<string | null>(() => localStorage.getItem('demo_expires_at'));
|
|
const [timeRemaining, setTimeRemaining] = useState<string>('');
|
|
const [canExtend, setCanExtend] = useState(true);
|
|
const [extending, setExtending] = useState(false);
|
|
const [showExitModal, setShowExitModal] = useState(false);
|
|
|
|
// Memoize tour state to prevent re-renders
|
|
const tourState = useMemo(() => {
|
|
try {
|
|
return getTourState();
|
|
} catch (error) {
|
|
console.error('Error getting tour state:', error);
|
|
return null;
|
|
}
|
|
}, []); // Only get tour state on initial render
|
|
|
|
useEffect(() => {
|
|
const demoMode = localStorage.getItem('demo_mode') === 'true';
|
|
const expires = localStorage.getItem('demo_expires_at');
|
|
|
|
console.log('[DemoBanner] Demo mode from localStorage:', demoMode);
|
|
console.log('[DemoBanner] Expires at:', expires);
|
|
|
|
setIsDemo(demoMode);
|
|
setExpiresAt(expires);
|
|
|
|
if (demoMode && expires) {
|
|
const interval = setInterval(async () => {
|
|
const now = new Date().getTime();
|
|
const expiryTime = new Date(expires).getTime();
|
|
const diff = expiryTime - now;
|
|
|
|
if (diff <= 0) {
|
|
setTimeRemaining('Sesión expirada');
|
|
await handleExpiration();
|
|
} else {
|
|
const minutes = Math.floor(diff / 60000);
|
|
const seconds = Math.floor((diff % 60000) / 1000);
|
|
setTimeRemaining(`${minutes}:${seconds.toString().padStart(2, '0')}`);
|
|
}
|
|
}, 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}
|
|
}, [expiresAt]);
|
|
|
|
const handleExpiration = async () => {
|
|
// Clear demo-specific localStorage keys
|
|
localStorage.removeItem('demo_mode');
|
|
localStorage.removeItem('demo_session_id');
|
|
localStorage.removeItem('demo_account_type');
|
|
localStorage.removeItem('demo_expires_at');
|
|
localStorage.removeItem('demo_tenant_id');
|
|
|
|
// Clear API client demo session ID and tenant ID
|
|
apiClient.setDemoSessionId(null);
|
|
apiClient.setTenantId(null);
|
|
|
|
// Clear tenant store to remove cached demo tenant data
|
|
const { useTenantStore } = await import('../../../stores/tenant.store');
|
|
useTenantStore.getState().clearTenants();
|
|
|
|
// Clear notification storage to ensure notifications don't persist across sessions
|
|
const { clearNotificationStorage } = await import('../../../hooks/useNotifications');
|
|
clearNotificationStorage();
|
|
|
|
navigate('/demo');
|
|
};
|
|
|
|
const handleExtendSession = async () => {
|
|
const sessionId = apiClient.getDemoSessionId();
|
|
if (!sessionId) return;
|
|
|
|
setExtending(true);
|
|
try {
|
|
const updatedSession = await extendDemoSession({ session_id: sessionId });
|
|
localStorage.setItem('demo_expires_at', updatedSession.expires_at);
|
|
setExpiresAt(updatedSession.expires_at);
|
|
|
|
if (updatedSession.remaining_extensions === 0) {
|
|
setCanExtend(false);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error extending session:', error);
|
|
alert('No se pudo extender la sesión');
|
|
} finally {
|
|
setExtending(false);
|
|
}
|
|
};
|
|
|
|
const handleEndSession = async () => {
|
|
setShowExitModal(true);
|
|
};
|
|
|
|
const confirmEndSession = async () => {
|
|
const sessionId = apiClient.getDemoSessionId();
|
|
if (!sessionId) return;
|
|
|
|
try {
|
|
await destroyDemoSession({ session_id: sessionId });
|
|
} catch (error) {
|
|
console.error('Error destroying session:', error);
|
|
} finally {
|
|
await handleExpiration();
|
|
}
|
|
};
|
|
|
|
const handleCreateAccount = () => {
|
|
trackTourEvent({
|
|
event: 'conversion_cta_clicked',
|
|
timestamp: Date.now(),
|
|
});
|
|
navigate('/register?from=demo_banner');
|
|
};
|
|
|
|
const handleStartTour = () => {
|
|
if (tourState && tourState.currentStep > 0 && !tourState.completed) {
|
|
resumeTour();
|
|
} else {
|
|
startTour();
|
|
}
|
|
};
|
|
|
|
if (!isDemo) {
|
|
console.log('[DemoBanner] Not demo mode, returning null');
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
data-tour="demo-banner"
|
|
className="bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-lg sticky top-[var(--header-height)] z-[1100]"
|
|
style={{ minHeight: '60px' }}
|
|
>
|
|
<div className="max-w-7xl mx-auto px-4 py-3">
|
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
|
|
{/* Left section - Demo info */}
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
<div className="flex items-center gap-2">
|
|
<Sparkles className="w-5 h-5" />
|
|
<span className="font-bold text-base">Sesión Demo Activa</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 text-sm bg-white/20 rounded-md px-3 py-1">
|
|
<Clock className="w-4 h-4" />
|
|
<span className="font-mono font-semibold">{timeRemaining}</span>
|
|
</div>
|
|
|
|
{tourState && !tourState.completed && tourState.currentStep > 0 && (
|
|
<div className="hidden md:flex items-center gap-2 text-sm bg-white/20 rounded-md px-3 py-1">
|
|
<span>Tutorial pausado en paso {tourState.currentStep}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right section - Actions */}
|
|
<div data-tour="demo-banner-actions" className="flex items-center gap-2 flex-wrap">
|
|
{/* Tour button */}
|
|
<button
|
|
onClick={handleStartTour}
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-white text-amber-600 rounded-lg text-sm font-semibold hover:bg-amber-50 transition-all shadow-sm hover:shadow-md"
|
|
>
|
|
<BookOpen className="w-4 h-4" />
|
|
<span className="hidden sm:inline">
|
|
{tourState && tourState.currentStep > 0 && !tourState.completed
|
|
? 'Continuar Tutorial'
|
|
: 'Ver Tutorial'}
|
|
</span>
|
|
<span className="sm:hidden">Tutorial</span>
|
|
</button>
|
|
|
|
{/* Create account CTA */}
|
|
<button
|
|
onClick={handleCreateAccount}
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-white text-orange-600 rounded-lg text-sm font-bold hover:bg-orange-50 transition-all shadow-sm hover:shadow-md animate-pulse hover:animate-none"
|
|
>
|
|
<Sparkles className="w-4 h-4" />
|
|
<span className="hidden lg:inline">¡Crear Cuenta Gratis!</span>
|
|
<span className="lg:hidden">Crear Cuenta</span>
|
|
</button>
|
|
|
|
{/* Extend session */}
|
|
{canExtend && (
|
|
<button
|
|
onClick={handleExtendSession}
|
|
disabled={extending}
|
|
className="px-3 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 hidden sm:block"
|
|
>
|
|
{extending ? 'Extendiendo...' : '+30 min'}
|
|
</button>
|
|
)}
|
|
|
|
{/* End session */}
|
|
<button
|
|
onClick={handleEndSession}
|
|
className="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-sm font-medium transition-colors"
|
|
>
|
|
<span className="hidden sm:inline">Salir</span>
|
|
<X className="w-4 h-4 sm:hidden" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Exit confirmation modal */}
|
|
{showExitModal && (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999] flex items-center justify-center p-4">
|
|
<div className="bg-[var(--bg-primary)] rounded-2xl shadow-2xl max-w-md w-full p-6 border border-[var(--border-default)]">
|
|
<div className="flex items-start gap-4 mb-4">
|
|
<div className="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-xl">
|
|
<Sparkles className="w-6 h-6 text-amber-600 dark:text-amber-400" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-2">
|
|
¿Seguro que quieres salir?
|
|
</h3>
|
|
<p className="text-[var(--text-secondary)] text-sm leading-relaxed">
|
|
Aún te quedan <span className="font-bold text-amber-600">{timeRemaining}</span> de sesión demo.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/30 rounded-xl p-4 mb-6 border border-amber-200 dark:border-amber-800">
|
|
<p className="text-sm font-semibold text-[var(--text-primary)] mb-3">
|
|
¿Te gusta lo que ves?
|
|
</p>
|
|
<p className="text-sm text-[var(--text-secondary)] mb-4 leading-relaxed">
|
|
Crea una cuenta <span className="font-bold">gratuita</span> para acceder a todas las funcionalidades sin límites de tiempo y guardar tus datos de forma permanente.
|
|
</p>
|
|
<button
|
|
onClick={handleCreateAccount}
|
|
className="w-full py-3 bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-lg font-bold hover:from-amber-600 hover:to-orange-600 transition-all shadow-md hover:shadow-lg"
|
|
>
|
|
Crear Mi Cuenta Gratis
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => setShowExitModal(false)}
|
|
className="flex-1 px-4 py-2.5 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)] rounded-lg font-semibold transition-colors border border-[var(--border-default)]"
|
|
>
|
|
Seguir en Demo
|
|
</button>
|
|
<button
|
|
onClick={confirmEndSession}
|
|
className="flex-1 px-4 py-2.5 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 rounded-lg font-semibold transition-colors border border-red-200 dark:border-red-800"
|
|
>
|
|
Salir de Demo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default DemoBanner;
|