Add DEMO feature to the project
This commit is contained in:
@@ -45,6 +45,7 @@ class ApiClient {
|
||||
private baseURL: string;
|
||||
private authToken: string | null = null;
|
||||
private tenantId: string | null = null;
|
||||
private demoSessionId: string | null = null;
|
||||
private refreshToken: string | null = null;
|
||||
private isRefreshing: boolean = false;
|
||||
private refreshAttempts: number = 0;
|
||||
@@ -74,14 +75,31 @@ class ApiClient {
|
||||
// Request interceptor to add auth headers
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
if (this.authToken) {
|
||||
// Public endpoints that don't require authentication
|
||||
const publicEndpoints = [
|
||||
'/demo/accounts',
|
||||
'/demo/session/create',
|
||||
];
|
||||
|
||||
const isPublicEndpoint = publicEndpoints.some(endpoint =>
|
||||
config.url?.includes(endpoint)
|
||||
);
|
||||
|
||||
// Only add auth token for non-public endpoints
|
||||
if (this.authToken && !isPublicEndpoint) {
|
||||
config.headers.Authorization = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
if (this.tenantId) {
|
||||
|
||||
if (this.tenantId && !isPublicEndpoint) {
|
||||
config.headers['X-Tenant-ID'] = this.tenantId;
|
||||
}
|
||||
|
||||
// Check demo session ID from memory OR localStorage
|
||||
const demoSessionId = this.demoSessionId || localStorage.getItem('demo_session_id');
|
||||
if (demoSessionId) {
|
||||
config.headers['X-Demo-Session-Id'] = demoSessionId;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
@@ -317,6 +335,19 @@ class ApiClient {
|
||||
this.tenantId = tenantId;
|
||||
}
|
||||
|
||||
setDemoSessionId(sessionId: string | null) {
|
||||
this.demoSessionId = sessionId;
|
||||
if (sessionId) {
|
||||
localStorage.setItem('demo_session_id', sessionId);
|
||||
} else {
|
||||
localStorage.removeItem('demo_session_id');
|
||||
}
|
||||
}
|
||||
|
||||
getDemoSessionId(): string | null {
|
||||
return this.demoSessionId || localStorage.getItem('demo_session_id');
|
||||
}
|
||||
|
||||
getAuthToken(): string | null {
|
||||
return this.authToken;
|
||||
}
|
||||
|
||||
@@ -111,6 +111,9 @@ export const useSubscription = () => {
|
||||
const analyticsLevel = subscriptionService.getAnalyticsLevelForPlan(planKey);
|
||||
return { hasAccess: true, level: analyticsLevel };
|
||||
}
|
||||
|
||||
// Default fallback when plan is not recognized
|
||||
return { hasAccess: false, level: 'none', reason: 'Unknown plan' };
|
||||
}, [subscriptionInfo.plan]);
|
||||
|
||||
// Check if user can access specific analytics features
|
||||
|
||||
83
frontend/src/api/services/demo.ts
Normal file
83
frontend/src/api/services/demo.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Demo Session API Service
|
||||
* Manages demo session creation, extension, and cleanup
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
|
||||
export interface DemoAccount {
|
||||
account_type: string;
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
description?: string;
|
||||
features?: string[];
|
||||
business_model?: string;
|
||||
}
|
||||
|
||||
export interface DemoSession {
|
||||
session_id: string;
|
||||
virtual_tenant_id: string;
|
||||
base_demo_tenant_id: string;
|
||||
demo_account_type: string;
|
||||
status: 'active' | 'expired' | 'destroyed';
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
remaining_extensions: number;
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
demo_account_type: 'individual_bakery' | 'central_baker';
|
||||
}
|
||||
|
||||
export interface ExtendSessionRequest {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface DestroySessionRequest {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available demo accounts
|
||||
*/
|
||||
export const getDemoAccounts = async (): Promise<DemoAccount[]> => {
|
||||
return await apiClient.get<DemoAccount[]>('/demo/accounts');
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new demo session
|
||||
*/
|
||||
export const createDemoSession = async (
|
||||
request: CreateSessionRequest
|
||||
): Promise<DemoSession> => {
|
||||
return await apiClient.post<DemoSession>('/demo/session/create', request);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extend an existing demo session
|
||||
*/
|
||||
export const extendDemoSession = async (
|
||||
request: ExtendSessionRequest
|
||||
): Promise<DemoSession> => {
|
||||
return await apiClient.post<DemoSession>('/demo/session/extend', request);
|
||||
};
|
||||
|
||||
/**
|
||||
* Destroy a demo session
|
||||
*/
|
||||
export const destroyDemoSession = async (
|
||||
request: DestroySessionRequest
|
||||
): Promise<{ message: string }> => {
|
||||
return await apiClient.post<{ message: string }>(
|
||||
'/demo/session/destroy',
|
||||
request
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get demo session statistics
|
||||
*/
|
||||
export const getDemoStats = async (): Promise<any> => {
|
||||
return await apiClient.get('/demo/stats');
|
||||
};
|
||||
@@ -3,9 +3,11 @@ import { clsx } from 'clsx';
|
||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useTenantInitializer } from '../../../stores/useTenantInitializer';
|
||||
import { useHasAccess } from '../../../hooks/useAccessControl';
|
||||
import { Header } from '../Header';
|
||||
import { Sidebar } from '../Sidebar';
|
||||
import { Footer } from '../Footer';
|
||||
import { DemoBanner } from '../DemoBanner';
|
||||
|
||||
export interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
@@ -74,10 +76,10 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
|
||||
loadingComponent,
|
||||
errorBoundary: ErrorBoundary,
|
||||
}, ref) => {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const authLoading = false; // Since we're in a protected route, auth loading should be false
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const hasAccess = useHasAccess(); // Check both authentication and demo mode
|
||||
|
||||
// Initialize tenant data for authenticated users
|
||||
useTenantInitializer();
|
||||
|
||||
@@ -196,12 +198,12 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
|
||||
return <ErrorBoundary error={error}>{children}</ErrorBoundary>;
|
||||
}
|
||||
|
||||
const shouldShowSidebar = showSidebar && isAuthenticated && !fullScreen;
|
||||
const shouldShowSidebar = showSidebar && hasAccess && !fullScreen;
|
||||
const shouldShowHeader = showHeader && !fullScreen;
|
||||
const shouldShowFooter = showFooter && !fullScreen;
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={clsx(
|
||||
'min-h-screen bg-[var(--bg-primary)] flex flex-col',
|
||||
resolvedTheme,
|
||||
@@ -209,6 +211,9 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
|
||||
)}
|
||||
data-testid="app-shell"
|
||||
>
|
||||
{/* Demo Banner */}
|
||||
<DemoBanner />
|
||||
|
||||
{/* Header */}
|
||||
{shouldShowHeader && (
|
||||
<Header
|
||||
@@ -250,8 +255,8 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
|
||||
// Add header offset
|
||||
shouldShowHeader && 'pt-[var(--header-height)]',
|
||||
// Adjust margins based on sidebar state
|
||||
shouldShowSidebar && isAuthenticated && !isSidebarCollapsed && 'lg:ml-[var(--sidebar-width)]',
|
||||
shouldShowSidebar && isAuthenticated && isSidebarCollapsed && 'lg:ml-[var(--sidebar-collapsed-width)]',
|
||||
shouldShowSidebar && hasAccess && !isSidebarCollapsed && 'lg:ml-[var(--sidebar-width)]',
|
||||
shouldShowSidebar && hasAccess && isSidebarCollapsed && 'lg:ml-[var(--sidebar-collapsed-width)]',
|
||||
// Add padding to content
|
||||
padded && 'px-4 lg:px-6 pb-4 lg:pb-6'
|
||||
)}
|
||||
@@ -269,8 +274,8 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
|
||||
showPrivacyLinks={true}
|
||||
className={clsx(
|
||||
'transition-all duration-300 ease-in-out',
|
||||
shouldShowSidebar && isAuthenticated && !isSidebarCollapsed && 'lg:ml-[var(--sidebar-width)]',
|
||||
shouldShowSidebar && isAuthenticated && isSidebarCollapsed && 'lg:ml-[var(--sidebar-collapsed-width)]'
|
||||
shouldShowSidebar && hasAccess && !isSidebarCollapsed && 'lg:ml-[var(--sidebar-width)]',
|
||||
shouldShowSidebar && hasAccess && isSidebarCollapsed && 'lg:ml-[var(--sidebar-collapsed-width)]'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
146
frontend/src/components/layout/DemoBanner/DemoBanner.tsx
Normal file
146
frontend/src/components/layout/DemoBanner/DemoBanner.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { extendDemoSession, destroyDemoSession } from '../../../api/services/demo';
|
||||
import { apiClient } from '../../../api/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const DemoBanner: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [isDemo, setIsDemo] = useState(false);
|
||||
const [expiresAt, setExpiresAt] = useState<string | null>(null);
|
||||
const [timeRemaining, setTimeRemaining] = useState<string>('');
|
||||
const [canExtend, setCanExtend] = useState(true);
|
||||
const [extending, setExtending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const demoMode = localStorage.getItem('demo_mode') === 'true';
|
||||
const expires = localStorage.getItem('demo_expires_at');
|
||||
|
||||
setIsDemo(demoMode);
|
||||
setExpiresAt(expires);
|
||||
|
||||
if (demoMode && expires) {
|
||||
const interval = setInterval(() => {
|
||||
const now = new Date().getTime();
|
||||
const expiryTime = new Date(expires).getTime();
|
||||
const diff = expiryTime - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
setTimeRemaining('Sesión expirada');
|
||||
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 = () => {
|
||||
localStorage.removeItem('demo_mode');
|
||||
localStorage.removeItem('demo_session_id');
|
||||
localStorage.removeItem('demo_account_type');
|
||||
localStorage.removeItem('demo_expires_at');
|
||||
apiClient.setDemoSessionId(null);
|
||||
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 () => {
|
||||
const sessionId = apiClient.getDemoSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
if (confirm('¿Estás seguro de que quieres terminar la sesión demo?')) {
|
||||
try {
|
||||
await destroyDemoSession({ session_id: sessionId });
|
||||
} catch (error) {
|
||||
console.error('Error destroying session:', error);
|
||||
} finally {
|
||||
handleExpiration();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isDemo) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-amber-500 to-orange-500 text-white px-4 py-2 shadow-md">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">Modo Demo</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:flex items-center text-sm">
|
||||
<svg
|
||||
className="w-4 h-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Tiempo restante: <span className="font-mono ml-1">{timeRemaining}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{canExtend && (
|
||||
<button
|
||||
onClick={handleExtendSession}
|
||||
disabled={extending}
|
||||
className="px-3 py-1 bg-white/20 hover:bg-white/30 rounded-md text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{extending ? 'Extendiendo...' : '+30 min'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleEndSession}
|
||||
className="px-3 py-1 bg-white/20 hover:bg-white/30 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
Terminar Demo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DemoBanner;
|
||||
1
frontend/src/components/layout/DemoBanner/index.ts
Normal file
1
frontend/src/components/layout/DemoBanner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DemoBanner, default } from './DemoBanner';
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useNotifications } from '../../../hooks/useNotifications';
|
||||
import { useHasAccess } from '../../../hooks/useAccessControl';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { TenantSwitcher } from '../../ui/TenantSwitcher';
|
||||
@@ -87,7 +88,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthUser();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const hasAccess = useHasAccess(); // Check both authentication and demo mode
|
||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||
const {
|
||||
notifications,
|
||||
@@ -183,7 +184,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
</div>
|
||||
|
||||
{/* Tenant Switcher - Desktop */}
|
||||
{isAuthenticated && (
|
||||
{hasAccess && (
|
||||
<div className="hidden md:block mx-2 lg:mx-4 flex-shrink-0">
|
||||
<TenantSwitcher
|
||||
showLabel={true}
|
||||
@@ -193,7 +194,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
)}
|
||||
|
||||
{/* Tenant Switcher - Mobile (in title area) */}
|
||||
{isAuthenticated && (
|
||||
{hasAccess && (
|
||||
<div className="md:hidden flex-1 min-w-0 ml-3">
|
||||
<TenantSwitcher
|
||||
showLabel={false}
|
||||
@@ -203,7 +204,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
)}
|
||||
|
||||
{/* Space for potential future content */ }
|
||||
{isAuthenticated && (
|
||||
{hasAccess && (
|
||||
<div className="hidden md:flex items-center flex-1 max-w-md mx-4">
|
||||
{/* Empty space to maintain layout consistency */}
|
||||
</div>
|
||||
@@ -211,12 +212,12 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
</div>
|
||||
|
||||
{/* Right section */}
|
||||
{isAuthenticated && (
|
||||
{hasAccess && (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Placeholder for potential future items */ }
|
||||
|
||||
{/* Language selector */}
|
||||
<CompactLanguageSelector className="w-auto min-w-[60px]" />
|
||||
<CompactLanguageSelector className="w-auto min-w-[50px]" />
|
||||
|
||||
{/* Theme toggle */}
|
||||
{showThemeToggle && (
|
||||
|
||||
@@ -154,10 +154,12 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
</nav>
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Language selector */}
|
||||
<div className="flex items-center gap-2 lg:gap-3">
|
||||
{/* Language selector - More compact */}
|
||||
{showLanguageSelector && (
|
||||
<CompactLanguageSelector className="hidden sm:flex" />
|
||||
<div className="hidden sm:flex">
|
||||
<CompactLanguageSelector className="w-[70px]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme toggle */}
|
||||
@@ -169,22 +171,22 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Authentication buttons */}
|
||||
{/* Authentication buttons - Enhanced */}
|
||||
{showAuthButtons && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 lg:gap-3">
|
||||
<Link to="/login">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden sm:inline-flex"
|
||||
size="md"
|
||||
className="hidden sm:inline-flex font-medium hover:bg-[var(--bg-secondary)] transition-all duration-200"
|
||||
>
|
||||
Iniciar Sesión
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white"
|
||||
size="md"
|
||||
className="bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] hover:opacity-90 text-white font-semibold shadow-lg hover:shadow-xl transition-all duration-200 px-6"
|
||||
>
|
||||
<span className="hidden sm:inline">Comenzar Gratis</span>
|
||||
<span className="sm:hidden">Registro</span>
|
||||
@@ -243,14 +245,21 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
|
||||
{/* Mobile auth buttons */}
|
||||
{showAuthButtons && (
|
||||
<div className="flex flex-col gap-2 pt-4 sm:hidden">
|
||||
<div className="flex flex-col gap-3 pt-4 sm:hidden">
|
||||
<Link to="/login">
|
||||
<Button variant="ghost" size="sm" className="w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
className="w-full font-medium border border-[var(--border-primary)] hover:bg-[var(--bg-secondary)]"
|
||||
>
|
||||
Iniciar Sesión
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button size="sm" className="w-full bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white">
|
||||
<Button
|
||||
size="md"
|
||||
className="w-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] hover:opacity-90 text-white font-semibold shadow-lg"
|
||||
>
|
||||
Comenzar Gratis
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
|
||||
import { useCurrentTenantAccess } from '../../../stores/tenant.store';
|
||||
import { useHasAccess } from '../../../hooks/useAccessControl';
|
||||
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
|
||||
import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes';
|
||||
import { Button } from '../../ui';
|
||||
@@ -138,7 +139,8 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthUser();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const isAuthenticated = useIsAuthenticated(); // Keep for logout check
|
||||
const hasAccess = useHasAccess(); // For UI visibility
|
||||
const currentTenantAccess = useCurrentTenantAccess();
|
||||
const { logout } = useAuthActions();
|
||||
|
||||
@@ -207,37 +209,42 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
// Filter items based on user permissions - memoized to prevent infinite re-renders
|
||||
const visibleItems = useMemo(() => {
|
||||
const filterItemsByPermissions = (items: NavigationItem[]): NavigationItem[] => {
|
||||
if (!isAuthenticated || !user) return [];
|
||||
if (!hasAccess) return [];
|
||||
|
||||
return items.map(item => ({
|
||||
...item, // Create a shallow copy to avoid mutation
|
||||
children: item.children ? filterItemsByPermissions(item.children) : item.children
|
||||
})).filter(item => {
|
||||
// Combine global and tenant roles for comprehensive access control
|
||||
const globalUserRoles = user.role ? [user.role as string] : [];
|
||||
const globalUserRoles = user?.role ? [user.role as string] : [];
|
||||
const tenantRole = currentTenantAccess?.role;
|
||||
const tenantRoles = tenantRole ? [tenantRole as string] : [];
|
||||
const allUserRoles = [...globalUserRoles, ...tenantRoles];
|
||||
const tenantPermissions = currentTenantAccess?.permissions || [];
|
||||
|
||||
const hasAccess = !item.requiredPermissions && !item.requiredRoles ||
|
||||
canAccessRoute(
|
||||
{
|
||||
path: item.path,
|
||||
requiredRoles: item.requiredRoles,
|
||||
requiredPermissions: item.requiredPermissions
|
||||
} as any,
|
||||
isAuthenticated,
|
||||
allUserRoles,
|
||||
tenantPermissions
|
||||
);
|
||||
// If no specific permissions/roles required, allow access
|
||||
if (!item.requiredPermissions && !item.requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasAccess;
|
||||
// Check access based on roles and permissions
|
||||
const canAccessItem = canAccessRoute(
|
||||
{
|
||||
path: item.path,
|
||||
requiredRoles: item.requiredRoles,
|
||||
requiredPermissions: item.requiredPermissions
|
||||
} as any,
|
||||
isAuthenticated,
|
||||
allUserRoles,
|
||||
tenantPermissions
|
||||
);
|
||||
|
||||
return canAccessItem;
|
||||
});
|
||||
};
|
||||
|
||||
return filterItemsByPermissions(navigationItems);
|
||||
}, [navigationItems, isAuthenticated, user, currentTenantAccess]);
|
||||
}, [navigationItems, hasAccess, isAuthenticated, user, currentTenantAccess]);
|
||||
|
||||
// Handle item click
|
||||
const handleItemClick = useCallback((item: NavigationItem) => {
|
||||
@@ -645,7 +652,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
);
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (!hasAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
74
frontend/src/hooks/useAccessControl.ts
Normal file
74
frontend/src/hooks/useAccessControl.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Centralized access control hook
|
||||
* Checks both authentication and demo mode to determine if user has access
|
||||
*/
|
||||
|
||||
import { useIsAuthenticated } from '../stores';
|
||||
|
||||
/**
|
||||
* Check if user is in demo mode
|
||||
*/
|
||||
export const useIsDemoMode = (): boolean => {
|
||||
return localStorage.getItem('demo_mode') === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get demo session ID
|
||||
*/
|
||||
export const useDemoSessionId = (): string | null => {
|
||||
return localStorage.getItem('demo_session_id');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has access (either authenticated OR in valid demo mode)
|
||||
*/
|
||||
export const useHasAccess = (): boolean => {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const isDemoMode = useIsDemoMode();
|
||||
const demoSessionId = useDemoSessionId();
|
||||
|
||||
// User has access if:
|
||||
// 1. They are authenticated, OR
|
||||
// 2. They are in demo mode with a valid session ID
|
||||
return isAuthenticated || (isDemoMode && !!demoSessionId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if current session is demo (not a real authenticated user)
|
||||
*/
|
||||
export const useIsDemo = (): boolean => {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const isDemoMode = useIsDemoMode();
|
||||
const demoSessionId = useDemoSessionId();
|
||||
|
||||
// It's a demo session if demo mode is active but user is not authenticated
|
||||
return !isAuthenticated && isDemoMode && !!demoSessionId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get demo account type
|
||||
*/
|
||||
export const useDemoAccountType = (): string | null => {
|
||||
const isDemoMode = useIsDemoMode();
|
||||
if (!isDemoMode) return null;
|
||||
return localStorage.getItem('demo_account_type');
|
||||
};
|
||||
|
||||
/**
|
||||
* Get demo session expiration
|
||||
*/
|
||||
export const useDemoExpiresAt = (): string | null => {
|
||||
const isDemoMode = useIsDemoMode();
|
||||
if (!isDemoMode) return null;
|
||||
return localStorage.getItem('demo_expires_at');
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear demo session data
|
||||
*/
|
||||
export const clearDemoSession = (): void => {
|
||||
localStorage.removeItem('demo_mode');
|
||||
localStorage.removeItem('demo_session_id');
|
||||
localStorage.removeItem('demo_account_type');
|
||||
localStorage.removeItem('demo_expires_at');
|
||||
};
|
||||
256
frontend/src/pages/public/DemoPage.tsx
Normal file
256
frontend/src/pages/public/DemoPage.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
import { Button } from '../../components/ui';
|
||||
import { getDemoAccounts, createDemoSession, DemoAccount } from '../../api/services/demo';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { Check, Clock, Shield, Play, Zap, ArrowRight, Store, Factory } from 'lucide-react';
|
||||
|
||||
export const DemoPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [demoAccounts, setDemoAccounts] = useState<DemoAccount[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creatingSession, setCreatingSession] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDemoAccounts = async () => {
|
||||
try {
|
||||
const accounts = await getDemoAccounts();
|
||||
setDemoAccounts(accounts);
|
||||
} catch (err) {
|
||||
setError('Error al cargar las cuentas demo');
|
||||
console.error('Error fetching demo accounts:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDemoAccounts();
|
||||
}, []);
|
||||
|
||||
const handleStartDemo = async (accountType: string) => {
|
||||
setCreatingSession(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const session = await createDemoSession({
|
||||
demo_account_type: accountType as 'individual_bakery' | 'central_baker',
|
||||
});
|
||||
|
||||
// Store session ID in API client
|
||||
apiClient.setDemoSessionId(session.session_id);
|
||||
|
||||
// Store session info in localStorage for UI
|
||||
localStorage.setItem('demo_mode', 'true');
|
||||
localStorage.setItem('demo_session_id', session.session_id);
|
||||
localStorage.setItem('demo_account_type', accountType);
|
||||
localStorage.setItem('demo_expires_at', session.expires_at);
|
||||
localStorage.setItem('demo_tenant_id', session.virtual_tenant_id);
|
||||
|
||||
// Navigate to dashboard
|
||||
navigate('/app/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Error al crear sesión demo');
|
||||
console.error('Error creating demo session:', err);
|
||||
} finally {
|
||||
setCreatingSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getAccountIcon = (accountType: string) => {
|
||||
return accountType === 'individual_bakery' ? Store : Factory;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PublicLayout
|
||||
variant="full-width"
|
||||
contentPadding="none"
|
||||
headerProps={{
|
||||
showThemeToggle: true,
|
||||
showAuthButtons: true,
|
||||
showLanguageSelector: true,
|
||||
}}
|
||||
>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[var(--bg-primary)] via-[var(--bg-secondary)] to-[var(--color-primary)]/5">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[var(--color-primary)] mx-auto"></div>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">Cargando cuentas demo...</p>
|
||||
</div>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout
|
||||
variant="full-width"
|
||||
contentPadding="none"
|
||||
headerProps={{
|
||||
showThemeToggle: true,
|
||||
showAuthButtons: true,
|
||||
showLanguageSelector: true,
|
||||
}}
|
||||
>
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-20 lg:py-32 bg-gradient-to-br from-[var(--bg-primary)] via-[var(--bg-secondary)] to-[var(--color-primary)]/5">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<div className="mb-6">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Demo Interactiva
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl tracking-tight font-extrabold text-[var(--text-primary)] sm:text-5xl lg:text-6xl">
|
||||
<span className="block">Prueba BakeryIA</span>
|
||||
<span className="block text-[var(--color-primary)]">sin compromiso</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-3xl mx-auto text-lg text-[var(--text-secondary)] sm:text-xl">
|
||||
Explora nuestro sistema con datos reales de panaderías españolas.
|
||||
Elige el tipo de negocio que mejor se adapte a tu caso.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex items-center justify-center space-x-6 text-sm text-[var(--text-tertiary)]">
|
||||
<div className="flex items-center">
|
||||
<Check className="w-4 h-4 text-green-500 mr-2" />
|
||||
Sin tarjeta de crédito
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Clock className="w-4 h-4 text-green-500 mr-2" />
|
||||
30 minutos de acceso
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Shield className="w-4 h-4 text-green-500 mr-2" />
|
||||
Datos aislados y seguros
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-8 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded-lg max-w-2xl mx-auto">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo Account Cards */}
|
||||
<div className="grid md:grid-cols-2 gap-8 max-w-6xl mx-auto">
|
||||
{demoAccounts.map((account) => {
|
||||
const Icon = getAccountIcon(account.account_type);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.account_type}
|
||||
className="relative bg-[var(--bg-primary)] rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-300 overflow-hidden border border-[var(--border-default)] group"
|
||||
>
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[var(--color-primary)]/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
|
||||
<div className="relative p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-xl bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{account.name}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-1">
|
||||
{account.business_model}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-3 py-1 bg-[var(--color-primary)]/10 text-[var(--color-primary)] rounded-full text-xs font-semibold">
|
||||
DEMO
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[var(--text-secondary)] mb-6">
|
||||
{account.description}
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
{account.features && account.features.length > 0 && (
|
||||
<div className="mb-6 space-y-2">
|
||||
<p className="text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
Funcionalidades incluidas:
|
||||
</p>
|
||||
{account.features.map((feature, idx) => (
|
||||
<div key={idx} className="flex items-center text-sm text-[var(--text-secondary)]">
|
||||
<Zap className="w-4 h-4 mr-2 text-[var(--color-primary)]" />
|
||||
{feature}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo Benefits */}
|
||||
<div className="space-y-2 mb-8 pt-6 border-t border-[var(--border-default)]">
|
||||
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
||||
<Check className="w-4 h-4 mr-2 text-green-500" />
|
||||
Datos reales en español
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
||||
<Check className="w-4 h-4 mr-2 text-green-500" />
|
||||
Sesión aislada de 30 minutos
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
||||
<Check className="w-4 h-4 mr-2 text-green-500" />
|
||||
Sin necesidad de registro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button
|
||||
onClick={() => handleStartDemo(account.account_type)}
|
||||
disabled={creatingSession}
|
||||
size="lg"
|
||||
className="w-full bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
{creatingSession ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Creando sesión...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 w-5 h-5" />
|
||||
Probar Demo Ahora
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer CTA */}
|
||||
<div className="mt-16 text-center">
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
¿Ya tienes una cuenta?
|
||||
</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center text-[var(--color-primary)] hover:text-[var(--color-primary-dark)] font-semibold transition-colors"
|
||||
>
|
||||
Inicia sesión aquí
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default DemoPage;
|
||||
@@ -3,12 +3,12 @@ import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../components/ui';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
Zap,
|
||||
Users,
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
Zap,
|
||||
Users,
|
||||
Award,
|
||||
ChevronRight,
|
||||
Check,
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
Euro,
|
||||
Package,
|
||||
PieChart,
|
||||
Settings
|
||||
Settings,
|
||||
Brain
|
||||
} from 'lucide-react';
|
||||
|
||||
const LandingPage: React.FC = () => {
|
||||
@@ -55,38 +56,57 @@ const LandingPage: React.FC = () => {
|
||||
<section className="relative py-20 lg:py-32 bg-gradient-to-br from-[var(--bg-primary)] via-[var(--bg-secondary)] to-[var(--color-primary)]/5">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-6">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
{t('landing:hero.badge', 'IA Avanzada para Panaderías')}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Reducción de Desperdicio Alimentario
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<h1 className="text-4xl tracking-tight font-extrabold text-[var(--text-primary)] sm:text-5xl lg:text-7xl">
|
||||
<span className="block">{t('landing:hero.title_line1', 'Revoluciona tu')}</span>
|
||||
<span className="block text-[var(--color-primary)]">{t('landing:hero.title_line2', 'Panadería con IA')}</span>
|
||||
<span className="block">{t('landing:hero.title_line1', 'IA que Reduce')}</span>
|
||||
<span className="block text-[var(--color-primary)]">{t('landing:hero.title_line2', 'Desperdicio Alimentario')}</span>
|
||||
</h1>
|
||||
|
||||
|
||||
<p className="mt-6 max-w-3xl mx-auto text-lg text-[var(--text-secondary)] sm:text-xl">
|
||||
{t('landing:hero.subtitle', 'Optimiza automáticamente tu producción, reduce desperdicios hasta un 35%, predice demanda con precisión del 92% y aumenta tus ventas con inteligencia artificial.')}
|
||||
{t('landing:hero.subtitle', 'Tecnología de inteligencia artificial que reduce hasta un 35% el desperdicio alimentario, optimiza tu producción y protege tu información. Tus datos son 100% tuyos.')}
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center">
|
||||
|
||||
{/* Pilot Launch Banner */}
|
||||
<div className="mt-8 inline-block">
|
||||
<div className="bg-gradient-to-r from-amber-500/10 to-orange-500/10 border-2 border-amber-500/30 rounded-xl px-6 py-4">
|
||||
<div className="flex items-center justify-center gap-2 text-amber-600 dark:text-amber-400 font-bold text-lg">
|
||||
<Star className="w-5 h-5 fill-current" />
|
||||
<span>¡Lanzamiento Piloto!</span>
|
||||
<Star className="w-5 h-5 fill-current" />
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)] text-center">
|
||||
<strong className="text-[var(--color-primary)]">3 meses GRATIS</strong> para early adopters que se registren ahora
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link to="/register">
|
||||
<Button size="lg" className="px-8 py-4 text-lg font-semibold bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-200">
|
||||
{t('landing:hero.cta_primary', 'Comenzar Gratis 14 Días')}
|
||||
{t('landing:hero.cta_primary', 'Comenzar GRATIS 3 Meses')}
|
||||
<ArrowRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="px-8 py-4 text-lg font-semibold border-2 border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white transition-all duration-200"
|
||||
onClick={() => scrollToSection('demo')}
|
||||
>
|
||||
<Play className="mr-2 w-5 h-5" />
|
||||
{t('landing:hero.cta_secondary', 'Ver Demo en Vivo')}
|
||||
</Button>
|
||||
<Link to="/demo">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="px-8 py-4 text-lg font-semibold border-2 border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white transition-all duration-200"
|
||||
>
|
||||
<Play className="mr-2 w-5 h-5" />
|
||||
{t('landing:hero.cta_secondary', 'Ver Demo en Vivo')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-center space-x-6 text-sm text-[var(--text-tertiary)]">
|
||||
@@ -137,44 +157,126 @@ const LandingPage: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main Features Section */}
|
||||
{/* Main Features Section - Focus on AI & Food Waste */}
|
||||
<section id="features" className="py-24 bg-[var(--bg-secondary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<span className="inline-flex items-center px-4 py-2 rounded-full text-sm font-medium bg-gradient-to-r from-blue-500/10 to-purple-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
|
||||
<Brain className="w-4 h-4 mr-2" />
|
||||
Tecnología de IA de Última Generación
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-3xl lg:text-5xl font-extrabold text-[var(--text-primary)]">
|
||||
Gestión Completa con
|
||||
<span className="block text-[var(--color-primary)]">Inteligencia Artificial</span>
|
||||
Combate el Desperdicio Alimentario
|
||||
<span className="block text-[var(--color-primary)]">con Inteligencia Artificial</span>
|
||||
</h2>
|
||||
<p className="mt-6 max-w-3xl mx-auto text-lg text-[var(--text-secondary)]">
|
||||
Automatiza procesos, optimiza recursos y toma decisiones inteligentes basadas en datos reales de tu panadería.
|
||||
Sistema de alta tecnología que utiliza algoritmos de IA avanzados para optimizar tu producción, reducir residuos alimentarios y mantener tus datos 100% seguros y bajo tu control.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-20 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* AI Forecasting */}
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border border-[var(--border-primary)] hover:border-[var(--color-primary)]/30">
|
||||
{/* AI Technology */}
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border-2 border-[var(--border-primary)] hover:border-blue-500/50">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-xl flex items-center justify-center shadow-lg">
|
||||
<TrendingUp className="w-6 h-6 text-white" />
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-blue-600 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Brain className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)]">Predicción Inteligente</h3>
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)]">IA Avanzada de Predicción</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Algoritmos de IA analizan patrones históricos, clima, eventos locales y tendencias para predecir la demanda exacta de cada producto.
|
||||
Algoritmos de machine learning de última generación analizan patrones históricos, clima, eventos y tendencias para predecir demanda con precisión quirúrgica.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center text-sm text-[var(--color-primary)]">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Precisión del 92% en predicciones
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<Zap className="w-3 h-3 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">Precisión del 92% en predicciones</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-primary)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Reduce desperdicios hasta 35%
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<TrendingUp className="w-3 h-3 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">Aprendizaje continuo y adaptativo</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-primary)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Aumenta ventas promedio 22%
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<BarChart3 className="w-3 h-3 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">Análisis predictivo en tiempo real</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Food Waste Reduction */}
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border-2 border-[var(--border-primary)] hover:border-green-500/50">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-green-600 to-emerald-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Shield className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)]">Reducción de Desperdicio</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Contribuye al medioambiente y reduce costos eliminando hasta un 35% del desperdicio alimentario mediante producción optimizada e inteligente.
|
||||
</p>
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-green-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<Check className="w-3 h-3 text-green-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">Hasta 35% menos desperdicio</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-green-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<Euro className="w-3 h-3 text-green-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">Ahorro promedio de €800/mes</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-green-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<Award className="w-3 h-3 text-green-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">Elegible para ayudas UE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Ownership & Privacy */}
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border-2 border-[var(--border-primary)] hover:border-amber-500/50">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-amber-600 to-orange-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Shield className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)]">Tus Datos, Tu Propiedad</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
Privacidad y seguridad total. Tus datos operativos, proveedores y analíticas permanecen 100% bajo tu control. Nunca compartidos, nunca vendidos.
|
||||
</p>
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-amber-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<Shield className="w-3 h-3 text-amber-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">100% propiedad de datos</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-amber-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<Settings className="w-3 h-3 text-amber-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">Control total de privacidad</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-amber-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<Award className="w-3 h-3 text-amber-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">Cumplimiento GDPR garantizado</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -874,15 +976,16 @@ const LandingPage: React.FC = () => {
|
||||
<ArrowRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="px-10 py-4 text-lg font-semibold border-2 border-white text-white hover:bg-white hover:text-[var(--color-primary)] transition-all duration-200"
|
||||
onClick={() => scrollToSection('demo')}
|
||||
>
|
||||
<Play className="mr-2 w-5 h-5" />
|
||||
Ver Demo
|
||||
</Button>
|
||||
<Link to="/demo">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="px-10 py-4 text-lg font-semibold border-2 border-white text-white hover:bg-white hover:text-[var(--color-primary)] transition-all duration-200"
|
||||
>
|
||||
<Play className="mr-2 w-5 h-5" />
|
||||
Ver Demo
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid grid-cols-1 sm:grid-cols-3 gap-8 text-center">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as LandingPage } from './LandingPage';
|
||||
export { default as LoginPage } from './LoginPage';
|
||||
export { default as RegisterPage } from './RegisterPage';
|
||||
export { default as RegisterPage } from './RegisterPage';
|
||||
export { default as DemoPage } from './DemoPage';
|
||||
@@ -8,6 +8,7 @@ import { AppShell } from '../components/layout';
|
||||
const LandingPage = React.lazy(() => import('../pages/public/LandingPage'));
|
||||
const LoginPage = React.lazy(() => import('../pages/public/LoginPage'));
|
||||
const RegisterPage = React.lazy(() => import('../pages/public/RegisterPage'));
|
||||
const DemoPage = React.lazy(() => import('../pages/public/DemoPage'));
|
||||
const DashboardPage = React.lazy(() => import('../pages/app/DashboardPage'));
|
||||
|
||||
// Operations pages
|
||||
@@ -58,6 +59,7 @@ export const AppRouter: React.FC = () => {
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/demo" element={<DemoPage />} />
|
||||
|
||||
{/* Protected Routes with AppShell Layout */}
|
||||
<Route
|
||||
|
||||
@@ -6,6 +6,7 @@ import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores';
|
||||
import { useCurrentTenantAccess, useTenantPermissions } from '../stores/tenant.store';
|
||||
import { useHasAccess, useIsDemoMode } from '../hooks/useAccessControl';
|
||||
import { RouteConfig, canAccessRoute, ROUTES } from './routes.config';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
@@ -130,6 +131,8 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
const currentTenantAccess = useCurrentTenantAccess();
|
||||
const { hasPermission } = useTenantPermissions();
|
||||
const location = useLocation();
|
||||
const hasAccess = useHasAccess(); // Check both authentication and demo mode
|
||||
const isDemoMode = useIsDemoMode();
|
||||
|
||||
// Note: Onboarding routes are now properly protected and require authentication
|
||||
// Mock mode only applies to the onboarding flow content, not to route protection
|
||||
@@ -144,15 +147,20 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// If user has access (authenticated OR demo mode), allow access
|
||||
if (hasAccess) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// If not authenticated and route requires auth, redirect to login
|
||||
if (!isAuthenticated) {
|
||||
const redirectPath = redirectTo || ROUTES.LOGIN;
|
||||
const returnUrl = location.pathname + location.search;
|
||||
|
||||
|
||||
return (
|
||||
<Navigate
|
||||
to={`${redirectPath}?returnUrl=${encodeURIComponent(returnUrl)}`}
|
||||
replace
|
||||
<Navigate
|
||||
to={`${redirectPath}?returnUrl=${encodeURIComponent(returnUrl)}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,57 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useIsAuthenticated } from './auth.store';
|
||||
import { useTenantActions, useAvailableTenants } from './tenant.store';
|
||||
import { useTenantActions, useAvailableTenants, useCurrentTenant } from './tenant.store';
|
||||
import { useIsDemoMode, useDemoSessionId, useDemoAccountType } from '../hooks/useAccessControl';
|
||||
|
||||
/**
|
||||
* Hook to automatically initialize tenant data when user is authenticated
|
||||
* Hook to automatically initialize tenant data when user is authenticated or in demo mode
|
||||
* This should be used at the app level to ensure tenant data is loaded
|
||||
*/
|
||||
export const useTenantInitializer = () => {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const isDemoMode = useIsDemoMode();
|
||||
const demoSessionId = useDemoSessionId();
|
||||
const demoAccountType = useDemoAccountType();
|
||||
const availableTenants = useAvailableTenants();
|
||||
const { loadUserTenants } = useTenantActions();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { loadUserTenants, setCurrentTenant } = useTenantActions();
|
||||
|
||||
// Load tenants for authenticated users
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !availableTenants) {
|
||||
// Load user's available tenants when authenticated and not already loaded
|
||||
loadUserTenants();
|
||||
}
|
||||
}, [isAuthenticated, availableTenants, loadUserTenants]);
|
||||
|
||||
// Also load tenants when user becomes authenticated (e.g., after login)
|
||||
// Set up mock tenant for demo mode
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && availableTenants === null) {
|
||||
loadUserTenants();
|
||||
if (isDemoMode && demoSessionId) {
|
||||
const demoTenantId = localStorage.getItem('demo_tenant_id') || 'demo-tenant-id';
|
||||
|
||||
// Check if current tenant is the demo tenant and is properly set
|
||||
const isValidDemoTenant = currentTenant &&
|
||||
typeof currentTenant === 'object' &&
|
||||
currentTenant.id === demoTenantId;
|
||||
|
||||
if (!isValidDemoTenant) {
|
||||
const accountTypeName = demoAccountType === 'individual_bakery'
|
||||
? 'Panadería San Pablo - Demo'
|
||||
: 'Panadería La Espiga - Demo';
|
||||
|
||||
// Create a mock tenant object matching TenantResponse structure
|
||||
const mockTenant = {
|
||||
id: demoTenantId,
|
||||
name: accountTypeName,
|
||||
subdomain: `demo-${demoSessionId.slice(0, 8)}`,
|
||||
plan_type: 'professional', // Use a valid plan type
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Set the demo tenant as current
|
||||
setCurrentTenant(mockTenant);
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, availableTenants, loadUserTenants]);
|
||||
}, [isDemoMode, demoSessionId, demoAccountType, currentTenant, setCurrentTenant]);
|
||||
};
|
||||
Reference in New Issue
Block a user