feat: Implement CRM and External Integrations

This commit is contained in:
gpt-engineer-app[bot]
2025-10-11 16:13:55 +00:00
parent b22adcbf34
commit ecc24cd7ed
6 changed files with 1477 additions and 2 deletions

View File

@@ -60,6 +60,11 @@ import ContentLibrary from "./pages/dashboard/guides/ContentLibrary";
import CommissionsDashboard from "./pages/dashboard/commissions/CommissionsDashboard"; import CommissionsDashboard from "./pages/dashboard/commissions/CommissionsDashboard";
import CommissionRules from "./pages/dashboard/commissions/CommissionRules"; import CommissionRules from "./pages/dashboard/commissions/CommissionRules";
import PaymentHistory from "./pages/dashboard/commissions/PaymentHistory"; import PaymentHistory from "./pages/dashboard/commissions/PaymentHistory";
// CRM pages
import CRMDashboard from "./pages/dashboard/crm/CRMDashboard";
import CRMContacts from "./pages/dashboard/crm/Contacts";
import CRMCampaigns from "./pages/dashboard/crm/Campaigns";
import CRMAnalytics from "./pages/dashboard/crm/Analytics";
// Tourist App // Tourist App
import TouristApp from "./pages/TouristApp"; import TouristApp from "./pages/TouristApp";
// Commerce pages (for retail stores) // Commerce pages (for retail stores)
@@ -655,6 +660,39 @@ const AppRouter = () => (
</ProtectedRoute> </ProtectedRoute>
} /> } />
{/* CRM Routes */}
<Route path="/dashboard/crm/dashboard" element={
<ProtectedRoute>
<DashboardLayout>
<CRMDashboard />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/crm/contacts" element={
<ProtectedRoute>
<DashboardLayout>
<CRMContacts />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/crm/campaigns" element={
<ProtectedRoute>
<DashboardLayout>
<CRMCampaigns />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/crm/analytics" element={
<ProtectedRoute>
<DashboardLayout>
<CRMAnalytics />
</DashboardLayout>
</ProtectedRoute>
} />
{/* Catch-all route */} {/* Catch-all route */}
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>

View File

@@ -59,7 +59,8 @@ import {
Store, Store,
Server, Server,
ShieldAlert, ShieldAlert,
UserCircle UserCircle,
Mail
} from 'lucide-react'; } from 'lucide-react';
const DashboardLayout = ({ children }: { children: React.ReactNode }) => { const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
@@ -128,6 +129,17 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
{ icon: FileText, label: 'Historial', path: '/dashboard/commissions/payments' } { icon: FileText, label: 'Historial', path: '/dashboard/commissions/payments' }
] ]
}, },
{
icon: Users,
label: 'CRM',
path: '/dashboard/crm',
subItems: [
{ icon: BarChart3, label: 'Dashboard', path: '/dashboard/crm/dashboard' },
{ icon: Users, label: 'Contactos', path: '/dashboard/crm/contacts' },
{ icon: Mail, label: 'Campañas', path: '/dashboard/crm/campaigns' },
{ icon: BarChart3, label: 'Analytics', path: '/dashboard/crm/analytics' }
]
},
{ {
icon: Settings, icon: Settings,
label: 'Configuración', label: 'Configuración',

View File

@@ -0,0 +1,286 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
TrendingUp,
TrendingDown,
Users,
DollarSign,
Activity,
Calendar,
Download,
ArrowUpRight,
ArrowDownRight
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
const Analytics = () => {
const [timeRange, setTimeRange] = useState('30d');
const metrics = [
{
title: 'Nuevos Clientes',
value: '342',
change: '+18.2%',
trend: 'up',
icon: Users,
color: 'text-blue-500'
},
{
title: 'Tasa de Retención',
value: '84.2%',
change: '-2.1%',
trend: 'down',
icon: Activity,
color: 'text-green-500'
},
{
title: 'Ingresos por Cliente',
value: '$1,234',
change: '+12.5%',
trend: 'up',
icon: DollarSign,
color: 'text-orange-500'
},
{
title: 'Engagement Score',
value: '7.8/10',
change: '+0.5',
trend: 'up',
icon: TrendingUp,
color: 'text-purple-500'
}
];
const segmentPerformance = [
{
segment: 'VIP Travelers',
customers: 342,
revenue: '$145,230',
avgSpent: '$425',
retention: '92%',
growth: '+18%'
},
{
segment: 'Business',
customers: 456,
revenue: '$187,900',
avgSpent: '$412',
retention: '88%',
growth: '+25%'
},
{
segment: 'Familias',
customers: 891,
revenue: '$98,450',
avgSpent: '$110',
retention: '76%',
growth: '+12%'
},
{
segment: 'Aventureros',
customers: 623,
revenue: '$76,340',
avgSpent: '$123',
retention: '81%',
growth: '+8%'
}
];
const customerJourney = [
{ stage: 'Descubrimiento', count: 5420, conversion: '100%', color: 'bg-blue-500' },
{ stage: 'Consideración', count: 3845, conversion: '71%', color: 'bg-green-500' },
{ stage: 'Decisión', count: 2156, conversion: '56%', color: 'bg-orange-500' },
{ stage: 'Compra', count: 1284, conversion: '60%', color: 'bg-purple-500' },
{ stage: 'Fidelización', count: 956, conversion: '74%', color: 'bg-pink-500' }
];
const topActions = [
{ action: 'Abrir emails', count: 4523, percentage: 85 },
{ action: 'Visitar sitio web', count: 3891, percentage: 73 },
{ action: 'Ver productos', count: 2456, percentage: 61 },
{ action: 'Agregar al carrito', count: 1234, percentage: 50 },
{ action: 'Completar compra', count: 789, percentage: 64 }
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Analytics CRM</h1>
<p className="text-gray-600 mt-1">Análisis detallado del comportamiento de clientes</p>
</div>
<div className="flex gap-3">
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-48">
<Calendar className="h-4 w-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7d">Últimos 7 días</SelectItem>
<SelectItem value="30d">Últimos 30 días</SelectItem>
<SelectItem value="90d">Últimos 90 días</SelectItem>
<SelectItem value="1y">Último año</SelectItem>
</SelectContent>
</Select>
<Button variant="outline">
<Download className="h-4 w-4 mr-2" />
Exportar Reporte
</Button>
</div>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{metrics.map((metric) => {
const Icon = metric.icon;
return (
<Card key={metric.title}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">
{metric.title}
</CardTitle>
<Icon className={`h-5 w-5 ${metric.color}`} />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">{metric.value}</div>
<div className="flex items-center mt-1">
{metric.trend === 'up' ? (
<ArrowUpRight className="h-4 w-4 text-green-500 mr-1" />
) : (
<ArrowDownRight className="h-4 w-4 text-red-500 mr-1" />
)}
<span className={`text-sm ${metric.trend === 'up' ? 'text-green-600' : 'text-red-600'}`}>
{metric.change}
</span>
<span className="text-sm text-gray-500 ml-2">vs período anterior</span>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Segment Performance */}
<Card>
<CardHeader>
<CardTitle>Rendimiento por Segmento</CardTitle>
<CardDescription>Métricas clave de cada grupo de clientes</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{segmentPerformance.map((segment, index) => (
<div key={index} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-gradient-to-r from-orange-500 to-red-500 flex items-center justify-center text-white font-bold">
{index + 1}
</div>
<div>
<h3 className="font-semibold text-gray-900">{segment.segment}</h3>
<p className="text-sm text-gray-600">{segment.customers} clientes</p>
</div>
</div>
<Badge variant={segment.growth.startsWith('+') ? 'default' : 'secondary'}>
{segment.growth} crecimiento
</Badge>
</div>
<div className="grid grid-cols-4 gap-4">
<div>
<p className="text-sm text-gray-600">Ingresos Totales</p>
<p className="text-lg font-semibold text-gray-900">{segment.revenue}</p>
</div>
<div>
<p className="text-sm text-gray-600">Gasto Promedio</p>
<p className="text-lg font-semibold text-gray-900">{segment.avgSpent}</p>
</div>
<div>
<p className="text-sm text-gray-600">Retención</p>
<p className="text-lg font-semibold text-gray-900">{segment.retention}</p>
</div>
<div>
<p className="text-sm text-gray-600">Clientes</p>
<p className="text-lg font-semibold text-gray-900">{segment.customers}</p>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Customer Journey */}
<Card>
<CardHeader>
<CardTitle>Embudo de Conversión</CardTitle>
<CardDescription>Journey del cliente desde descubrimiento hasta fidelización</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{customerJourney.map((stage, index) => (
<div key={index}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={`w-3 h-3 rounded-full ${stage.color}`}></span>
<span className="font-medium text-gray-900">{stage.stage}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600">{stage.count.toLocaleString()}</span>
<Badge variant="outline">{stage.conversion}</Badge>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${stage.color}`}
style={{ width: stage.conversion }}
></div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Top Actions */}
<Card>
<CardHeader>
<CardTitle>Acciones Principales</CardTitle>
<CardDescription>Actividades más comunes de los clientes</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{topActions.map((action, index) => (
<div key={index} className="space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">{action.action}</span>
<span className="text-sm text-gray-600">{action.count.toLocaleString()}</span>
</div>
<div className="relative w-full bg-gray-200 rounded-full h-2">
<div
className="absolute h-2 rounded-full bg-gradient-to-r from-orange-500 to-red-500"
style={{ width: `${action.percentage}%` }}
></div>
</div>
<div className="flex justify-end">
<span className="text-xs text-gray-500">{action.percentage}% de conversión</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
};
export default Analytics;

View File

@@ -0,0 +1,327 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Users,
TrendingUp,
Mail,
Phone,
Target,
DollarSign,
Calendar,
ArrowUpRight,
ArrowDownRight,
MoreVertical
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
const CRMDashboard = () => {
const [timeRange, setTimeRange] = useState('30d');
const metrics = [
{
title: 'Total Clientes',
value: '2,847',
change: '+12.5%',
trend: 'up',
icon: Users,
color: 'text-blue-500'
},
{
title: 'Clientes Activos',
value: '1,923',
change: '+8.2%',
trend: 'up',
icon: TrendingUp,
color: 'text-green-500'
},
{
title: 'Valor de Vida (LTV)',
value: '$4,325',
change: '+15.3%',
trend: 'up',
icon: DollarSign,
color: 'text-orange-500'
},
{
title: 'Tasa de Retención',
value: '84.2%',
change: '-2.1%',
trend: 'down',
icon: Target,
color: 'text-purple-500'
}
];
const recentActivities = [
{
id: 1,
customer: 'María González',
action: 'Completó reserva',
value: '$450',
time: 'Hace 5 min',
type: 'booking'
},
{
id: 2,
customer: 'Juan Pérez',
action: 'Solicitó información',
value: null,
time: 'Hace 12 min',
type: 'inquiry'
},
{
id: 3,
customer: 'Ana Martínez',
action: 'Renovó suscripción',
value: '$89/mes',
time: 'Hace 25 min',
type: 'subscription'
},
{
id: 4,
customer: 'Carlos López',
action: 'Dejó review',
value: '5 estrellas',
time: 'Hace 1 hora',
type: 'review'
}
];
const topSegments = [
{ name: 'VIP Travelers', count: 342, revenue: '$145,230', growth: '+18%' },
{ name: 'Familias', count: 891, revenue: '$98,450', growth: '+12%' },
{ name: 'Business', count: 456, revenue: '$187,900', growth: '+25%' },
{ name: 'Aventureros', count: 623, revenue: '$76,340', growth: '+8%' }
];
const activeCampaigns = [
{
id: 1,
name: 'Promoción Verano 2024',
status: 'active',
sent: 2847,
opened: 1423,
clicked: 456,
conversions: 89
},
{
id: 2,
name: 'Newsletter Mensual',
status: 'scheduled',
sent: 0,
opened: 0,
clicked: 0,
conversions: 0
},
{
id: 3,
name: 'Reactivación Clientes',
status: 'active',
sent: 1245,
opened: 623,
clicked: 187,
conversions: 34
}
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">CRM Dashboard</h1>
<p className="text-gray-600 mt-1">Gestión de relaciones con clientes</p>
</div>
<div className="flex gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Calendar className="h-4 w-4 mr-2" />
{timeRange === '7d' ? 'Últimos 7 días' : timeRange === '30d' ? 'Últimos 30 días' : 'Último año'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setTimeRange('7d')}>Últimos 7 días</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTimeRange('30d')}>Últimos 30 días</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTimeRange('1y')}>Último año</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button>
<Mail className="h-4 w-4 mr-2" />
Nueva Campaña
</Button>
</div>
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{metrics.map((metric) => {
const Icon = metric.icon;
return (
<Card key={metric.title}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">
{metric.title}
</CardTitle>
<Icon className={`h-5 w-5 ${metric.color}`} />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">{metric.value}</div>
<div className="flex items-center mt-1">
{metric.trend === 'up' ? (
<ArrowUpRight className="h-4 w-4 text-green-500 mr-1" />
) : (
<ArrowDownRight className="h-4 w-4 text-red-500 mr-1" />
)}
<span className={`text-sm ${metric.trend === 'up' ? 'text-green-600' : 'text-red-600'}`}>
{metric.change}
</span>
<span className="text-sm text-gray-500 ml-2">vs período anterior</span>
</div>
</CardContent>
</Card>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Activities */}
<Card>
<CardHeader>
<CardTitle>Actividad Reciente</CardTitle>
<CardDescription>Últimas interacciones con clientes</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentActivities.map((activity) => (
<div key={activity.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-orange-500 to-red-500 flex items-center justify-center text-white font-semibold">
{activity.customer.split(' ').map(n => n[0]).join('')}
</div>
<div>
<p className="font-medium text-gray-900">{activity.customer}</p>
<p className="text-sm text-gray-600">{activity.action}</p>
</div>
</div>
<div className="text-right">
{activity.value && (
<p className="font-semibold text-gray-900">{activity.value}</p>
)}
<p className="text-xs text-gray-500">{activity.time}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Top Segments */}
<Card>
<CardHeader>
<CardTitle>Segmentos Principales</CardTitle>
<CardDescription>Grupos de clientes de mayor valor</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{topSegments.map((segment, index) => (
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-gray-900">{segment.name}</p>
<Badge variant="secondary" className="text-xs">
{segment.count} clientes
</Badge>
</div>
<div className="flex items-center gap-4 mt-2">
<p className="text-sm text-gray-600">Ingresos: {segment.revenue}</p>
<span className="text-xs text-green-600 font-medium">{segment.growth}</span>
</div>
</div>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Active Campaigns */}
<Card>
<CardHeader>
<CardTitle>Campañas Activas</CardTitle>
<CardDescription>Estado de campañas de marketing</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{activeCampaigns.map((campaign) => (
<div key={campaign.id} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-gray-900">{campaign.name}</h3>
<Badge variant={campaign.status === 'active' ? 'default' : 'secondary'}>
{campaign.status === 'active' ? 'Activa' : 'Programada'}
</Badge>
</div>
<Button variant="ghost" size="sm">
Ver Detalles
</Button>
</div>
<div className="grid grid-cols-4 gap-4">
<div>
<p className="text-sm text-gray-600">Enviados</p>
<p className="text-lg font-semibold text-gray-900">{campaign.sent.toLocaleString()}</p>
</div>
<div>
<p className="text-sm text-gray-600">Abiertos</p>
<p className="text-lg font-semibold text-gray-900">
{campaign.opened.toLocaleString()}
{campaign.sent > 0 && (
<span className="text-sm text-gray-500 ml-1">
({((campaign.opened / campaign.sent) * 100).toFixed(1)}%)
</span>
)}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Clicks</p>
<p className="text-lg font-semibold text-gray-900">
{campaign.clicked.toLocaleString()}
{campaign.opened > 0 && (
<span className="text-sm text-gray-500 ml-1">
({((campaign.clicked / campaign.opened) * 100).toFixed(1)}%)
</span>
)}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Conversiones</p>
<p className="text-lg font-semibold text-gray-900">
{campaign.conversions.toLocaleString()}
{campaign.sent > 0 && (
<span className="text-sm text-gray-500 ml-1">
({((campaign.conversions / campaign.sent) * 100).toFixed(1)}%)
</span>
)}
</p>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
};
export default CRMDashboard;

View File

@@ -0,0 +1,419 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Mail,
Plus,
Send,
Clock,
CheckCircle,
XCircle,
BarChart3,
Users,
Eye,
MousePointer,
ShoppingCart,
Calendar
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { z } from 'zod';
const campaignSchema = z.object({
name: z.string().trim().min(1, 'Nombre requerido').max(100, 'Nombre muy largo'),
subject: z.string().trim().min(1, 'Asunto requerido').max(200, 'Asunto muy largo'),
segment: z.string().min(1, 'Selecciona un segmento'),
message: z.string().trim().min(10, 'Mensaje muy corto').max(2000, 'Mensaje muy largo'),
});
const Campaigns = () => {
const { toast } = useToast();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [formData, setFormData] = useState({
name: '',
subject: '',
segment: '',
message: ''
});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const campaigns = [
{
id: 1,
name: 'Promoción Verano 2024',
status: 'sent',
segment: 'VIP Travelers',
sentDate: '2024-03-10',
recipients: 2847,
opened: 1423,
clicked: 456,
conversions: 89,
revenue: '$12,450'
},
{
id: 2,
name: 'Newsletter Mensual',
status: 'scheduled',
segment: 'Todos',
sentDate: '2024-03-20',
recipients: 5234,
opened: 0,
clicked: 0,
conversions: 0,
revenue: '$0'
},
{
id: 3,
name: 'Reactivación Clientes',
status: 'sending',
segment: 'Inactivos',
sentDate: '2024-03-15',
recipients: 1245,
opened: 623,
clicked: 187,
conversions: 34,
revenue: '$4,230'
},
{
id: 4,
name: 'Ofertas Especiales Familias',
status: 'draft',
segment: 'Familias',
sentDate: null,
recipients: 891,
opened: 0,
clicked: 0,
conversions: 0,
revenue: '$0'
}
];
const segments = ['Todos', 'VIP Travelers', 'Business', 'Familias', 'Aventureros', 'Inactivos'];
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
try {
campaignSchema.parse(formData);
setFormErrors({});
toast({
title: 'Campaña creada',
description: `La campaña "${formData.name}" ha sido creada exitosamente.`,
});
setIsCreateDialogOpen(false);
setFormData({ name: '', subject: '', segment: '', message: '' });
} catch (error) {
if (error instanceof z.ZodError) {
const errors: Record<string, string> = {};
error.errors.forEach((err) => {
if (err.path[0]) {
errors[err.path[0].toString()] = err.message;
}
});
setFormErrors(errors);
}
}
};
const getStatusBadge = (status: string) => {
const statusConfig = {
sent: { label: 'Enviada', variant: 'default' as const, icon: CheckCircle, color: 'text-green-600' },
scheduled: { label: 'Programada', variant: 'secondary' as const, icon: Clock, color: 'text-blue-600' },
sending: { label: 'Enviando', variant: 'outline' as const, icon: Send, color: 'text-orange-600' },
draft: { label: 'Borrador', variant: 'outline' as const, icon: Mail, color: 'text-gray-600' },
failed: { label: 'Fallida', variant: 'destructive' as const, icon: XCircle, color: 'text-red-600' }
};
return statusConfig[status as keyof typeof statusConfig] || statusConfig.draft;
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Campañas</h1>
<p className="text-gray-600 mt-1">Gestiona tus campañas de marketing</p>
</div>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Nueva Campaña
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Crear Nueva Campaña</DialogTitle>
<DialogDescription>
Configura tu campaña de email marketing
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="name">Nombre de la Campaña *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Ej: Promoción Verano 2024"
className={formErrors.name ? 'border-red-500' : ''}
/>
{formErrors.name && (
<p className="text-sm text-red-500 mt-1">{formErrors.name}</p>
)}
</div>
<div>
<Label htmlFor="subject">Asunto del Email *</Label>
<Input
id="subject"
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
placeholder="Asunto atractivo para tus clientes"
className={formErrors.subject ? 'border-red-500' : ''}
/>
{formErrors.subject && (
<p className="text-sm text-red-500 mt-1">{formErrors.subject}</p>
)}
</div>
<div>
<Label htmlFor="segment">Segmento Objetivo *</Label>
<Select
value={formData.segment}
onValueChange={(value) => setFormData({ ...formData, segment: value })}
>
<SelectTrigger className={formErrors.segment ? 'border-red-500' : ''}>
<SelectValue placeholder="Selecciona el público objetivo" />
</SelectTrigger>
<SelectContent>
{segments.map((segment) => (
<SelectItem key={segment} value={segment}>
{segment}
</SelectItem>
))}
</SelectContent>
</Select>
{formErrors.segment && (
<p className="text-sm text-red-500 mt-1">{formErrors.segment}</p>
)}
</div>
<div>
<Label htmlFor="message">Mensaje *</Label>
<Textarea
id="message"
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
placeholder="Escribe el contenido de tu email..."
rows={6}
className={formErrors.message ? 'border-red-500' : ''}
/>
{formErrors.message && (
<p className="text-sm text-red-500 mt-1">{formErrors.message}</p>
)}
<p className="text-sm text-gray-500 mt-1">
{formData.message.length}/2000 caracteres
</p>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button type="button" variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
Cancelar
</Button>
<Button type="button" variant="outline">
Guardar Borrador
</Button>
<Button type="submit">
Crear Campaña
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">
Total Enviados
</CardTitle>
<Mail className="h-5 w-5 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">9,326</div>
<p className="text-xs text-gray-500 mt-1">Este mes</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">
Tasa de Apertura
</CardTitle>
<Eye className="h-5 w-5 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">47.3%</div>
<p className="text-xs text-gray-500 mt-1">Promedio</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">
Click Rate
</CardTitle>
<MousePointer className="h-5 w-5 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">18.6%</div>
<p className="text-xs text-gray-500 mt-1">Promedio</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">
Ingresos Generados
</CardTitle>
<ShoppingCart className="h-5 w-5 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">$16,680</div>
<p className="text-xs text-gray-500 mt-1">Este mes</p>
</CardContent>
</Card>
</div>
{/* Campaigns List */}
<Card>
<CardHeader>
<CardTitle>Lista de Campañas</CardTitle>
<CardDescription>Todas tus campañas de email marketing</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{campaigns.map((campaign) => {
const statusConfig = getStatusBadge(campaign.status);
const StatusIcon = statusConfig.icon;
return (
<div key={campaign.id} className="border rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg bg-gradient-to-r from-orange-500 to-red-500 flex items-center justify-center`}>
<Mail className="h-5 w-5 text-white" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{campaign.name}</h3>
<Badge variant={statusConfig.variant} className="flex items-center gap-1">
<StatusIcon className={`h-3 w-3 ${statusConfig.color}`} />
{statusConfig.label}
</Badge>
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-gray-600">
<div className="flex items-center gap-1">
<Users className="h-4 w-4" />
<span>{campaign.segment}</span>
</div>
{campaign.sentDate && (
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>{campaign.sentDate}</span>
</div>
)}
</div>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<BarChart3 className="h-4 w-4 mr-2" />
Ver Resultados
</Button>
{campaign.status === 'draft' && (
<Button size="sm">
<Send className="h-4 w-4 mr-2" />
Enviar
</Button>
)}
</div>
</div>
{campaign.status !== 'draft' && (
<div className="grid grid-cols-5 gap-4 pt-4 border-t">
<div>
<p className="text-sm text-gray-600">Destinatarios</p>
<p className="text-lg font-semibold text-gray-900">
{campaign.recipients.toLocaleString()}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Abiertos</p>
<p className="text-lg font-semibold text-gray-900">
{campaign.opened.toLocaleString()}
{campaign.recipients > 0 && (
<span className="text-sm text-gray-500 ml-1">
({((campaign.opened / campaign.recipients) * 100).toFixed(1)}%)
</span>
)}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Clicks</p>
<p className="text-lg font-semibold text-gray-900">
{campaign.clicked.toLocaleString()}
{campaign.opened > 0 && (
<span className="text-sm text-gray-500 ml-1">
({((campaign.clicked / campaign.opened) * 100).toFixed(1)}%)
</span>
)}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Conversiones</p>
<p className="text-lg font-semibold text-gray-900">
{campaign.conversions.toLocaleString()}
{campaign.recipients > 0 && (
<span className="text-sm text-gray-500 ml-1">
({((campaign.conversions / campaign.recipients) * 100).toFixed(1)}%)
</span>
)}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Ingresos</p>
<p className="text-lg font-semibold text-green-600">
{campaign.revenue}
</p>
</div>
</div>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
);
};
export default Campaigns;

View File

@@ -0,0 +1,393 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Users,
Search,
Filter,
Plus,
Mail,
Phone,
MapPin,
Calendar,
Star,
DollarSign,
MoreVertical,
Download,
Upload
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useToast } from '@/hooks/use-toast';
import { z } from 'zod';
const contactSchema = z.object({
firstName: z.string().trim().min(1, 'Nombre requerido').max(50, 'Nombre muy largo'),
lastName: z.string().trim().min(1, 'Apellido requerido').max(50, 'Apellido muy largo'),
email: z.string().trim().email('Email inválido').max(255, 'Email muy largo'),
phone: z.string().trim().max(20, 'Teléfono muy largo').optional(),
segment: z.string().min(1, 'Selecciona un segmento'),
});
const Contacts = () => {
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState('');
const [selectedSegment, setSelectedSegment] = useState('all');
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
segment: ''
});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const contacts = [
{
id: 1,
name: 'María González',
email: 'maria.gonzalez@email.com',
phone: '+1 234 567 8900',
segment: 'VIP Travelers',
location: 'Madrid, España',
lastContact: '2024-03-15',
totalSpent: '$2,450',
rating: 5,
status: 'active'
},
{
id: 2,
name: 'Juan Pérez',
email: 'juan.perez@email.com',
phone: '+1 234 567 8901',
segment: 'Business',
location: 'Barcelona, España',
lastContact: '2024-03-14',
totalSpent: '$5,890',
rating: 4,
status: 'active'
},
{
id: 3,
name: 'Ana Martínez',
email: 'ana.martinez@email.com',
phone: '+1 234 567 8902',
segment: 'Familias',
location: 'Valencia, España',
lastContact: '2024-03-10',
totalSpent: '$1,230',
rating: 5,
status: 'inactive'
},
{
id: 4,
name: 'Carlos López',
email: 'carlos.lopez@email.com',
phone: '+1 234 567 8903',
segment: 'Aventureros',
location: 'Sevilla, España',
lastContact: '2024-03-12',
totalSpent: '$890',
rating: 4,
status: 'active'
}
];
const segments = ['VIP Travelers', 'Business', 'Familias', 'Aventureros'];
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
try {
contactSchema.parse(formData);
setFormErrors({});
toast({
title: 'Contacto agregado',
description: `${formData.firstName} ${formData.lastName} ha sido agregado exitosamente.`,
});
setIsAddDialogOpen(false);
setFormData({ firstName: '', lastName: '', email: '', phone: '', segment: '' });
} catch (error) {
if (error instanceof z.ZodError) {
const errors: Record<string, string> = {};
error.errors.forEach((err) => {
if (err.path[0]) {
errors[err.path[0].toString()] = err.message;
}
});
setFormErrors(errors);
}
}
};
const filteredContacts = contacts.filter(contact => {
const matchesSearch = contact.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
contact.email.toLowerCase().includes(searchTerm.toLowerCase());
const matchesSegment = selectedSegment === 'all' || contact.segment === selectedSegment;
return matchesSearch && matchesSegment;
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Contactos</h1>
<p className="text-gray-600 mt-1">Gestiona tu base de datos de clientes</p>
</div>
<div className="flex gap-3">
<Button variant="outline">
<Download className="h-4 w-4 mr-2" />
Exportar
</Button>
<Button variant="outline">
<Upload className="h-4 w-4 mr-2" />
Importar
</Button>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Nuevo Contacto
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Agregar Nuevo Contacto</DialogTitle>
<DialogDescription>
Completa la información del cliente
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="firstName">Nombre *</Label>
<Input
id="firstName"
value={formData.firstName}
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
className={formErrors.firstName ? 'border-red-500' : ''}
/>
{formErrors.firstName && (
<p className="text-sm text-red-500 mt-1">{formErrors.firstName}</p>
)}
</div>
<div>
<Label htmlFor="lastName">Apellido *</Label>
<Input
id="lastName"
value={formData.lastName}
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
className={formErrors.lastName ? 'border-red-500' : ''}
/>
{formErrors.lastName && (
<p className="text-sm text-red-500 mt-1">{formErrors.lastName}</p>
)}
</div>
</div>
<div>
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className={formErrors.email ? 'border-red-500' : ''}
/>
{formErrors.email && (
<p className="text-sm text-red-500 mt-1">{formErrors.email}</p>
)}
</div>
<div>
<Label htmlFor="phone">Teléfono</Label>
<Input
id="phone"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className={formErrors.phone ? 'border-red-500' : ''}
/>
{formErrors.phone && (
<p className="text-sm text-red-500 mt-1">{formErrors.phone}</p>
)}
</div>
<div>
<Label htmlFor="segment">Segmento *</Label>
<Select
value={formData.segment}
onValueChange={(value) => setFormData({ ...formData, segment: value })}
>
<SelectTrigger className={formErrors.segment ? 'border-red-500' : ''}>
<SelectValue placeholder="Selecciona un segmento" />
</SelectTrigger>
<SelectContent>
{segments.map((segment) => (
<SelectItem key={segment} value={segment}>
{segment}
</SelectItem>
))}
</SelectContent>
</Select>
{formErrors.segment && (
<p className="text-sm text-red-500 mt-1">{formErrors.segment}</p>
)}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button type="button" variant="outline" onClick={() => setIsAddDialogOpen(false)}>
Cancelar
</Button>
<Button type="submit">
Agregar Contacto
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</div>
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Buscar por nombre o email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Select value={selectedSegment} onValueChange={setSelectedSegment}>
<SelectTrigger className="w-48">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Filtrar por segmento" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los segmentos</SelectItem>
{segments.map((segment) => (
<SelectItem key={segment} value={segment}>
{segment}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Contacts List */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Lista de Contactos</CardTitle>
<CardDescription>
{filteredContacts.length} contacto{filteredContacts.length !== 1 ? 's' : ''} encontrado{filteredContacts.length !== 1 ? 's' : ''}
</CardDescription>
</div>
<Badge variant="secondary">
<Users className="h-4 w-4 mr-1" />
Total: {contacts.length}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{filteredContacts.map((contact) => (
<div key={contact.id} className="border rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-gradient-to-r from-orange-500 to-red-500 flex items-center justify-center text-white font-semibold text-lg">
{contact.name.split(' ').map(n => n[0]).join('')}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{contact.name}</h3>
<Badge variant={contact.status === 'active' ? 'default' : 'secondary'}>
{contact.status === 'active' ? 'Activo' : 'Inactivo'}
</Badge>
<Badge variant="outline">{contact.segment}</Badge>
</div>
<div className="flex items-center gap-4 mt-2 text-sm text-gray-600">
<div className="flex items-center gap-1">
<Mail className="h-4 w-4" />
<span>{contact.email}</span>
</div>
<div className="flex items-center gap-1">
<Phone className="h-4 w-4" />
<span>{contact.phone}</span>
</div>
<div className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
<span>{contact.location}</span>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-right">
<div className="flex items-center gap-1 text-yellow-500">
<Star className="h-4 w-4 fill-current" />
<span className="font-semibold">{contact.rating}</span>
</div>
<p className="text-sm text-gray-600 mt-1">Rating</p>
</div>
<div className="text-right">
<p className="font-semibold text-gray-900">{contact.totalSpent}</p>
<p className="text-sm text-gray-600">Total gastado</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-900">{contact.lastContact}</p>
<p className="text-sm text-gray-600">Último contacto</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Ver Perfil</DropdownMenuItem>
<DropdownMenuItem>Enviar Email</DropdownMenuItem>
<DropdownMenuItem>Editar</DropdownMenuItem>
<DropdownMenuItem className="text-red-600">Eliminar</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
};
export default Contacts;