feat: versão 1.5 - CRM Beta com leads, funis, campanhas e portal do cliente

This commit is contained in:
Erik Silva
2025-12-24 17:36:52 -03:00
parent 99d828869a
commit dfb91c8ba5
98 changed files with 18255 additions and 1465 deletions

View File

@@ -0,0 +1,272 @@
"use client";
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import {
CheckCircleIcon,
ClockIcon,
UserCircleIcon,
EnvelopeIcon,
PhoneIcon,
BuildingOfficeIcon,
CalendarIcon,
ChartBarIcon,
} from '@heroicons/react/24/outline';
interface Lead {
id: string;
name: string;
email: string;
phone: string;
status: string;
source: string;
created_at: string;
}
interface CustomerData {
customer: {
id: string;
name: string;
email: string;
phone: string;
company: string;
portal_last_login: string | null;
portal_created_at: string;
has_portal_access: boolean;
is_active: boolean;
};
leads?: Lead[];
stats?: {
total_leads: number;
active_leads: number;
converted: number;
};
}
export default function CustomerDashboardPage() {
const router = useRouter();
const [data, setData] = useState<CustomerData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDashboard();
}, []);
const fetchDashboard = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/portal/dashboard', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) throw new Error('Erro ao buscar dados');
const result = await response.json();
setData(result);
} catch (error) {
console.error('Error fetching dashboard:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<svg className="animate-spin h-12 w-12 mx-auto text-blue-600" 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>
<p className="mt-4 text-gray-600 dark:text-gray-400">Carregando...</p>
</div>
</div>
);
}
const customer = data?.customer;
const stats = data?.stats;
const leads = data?.leads || [];
const firstName = customer?.name?.split(' ')[0] || 'Cliente';
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
novo: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
qualificado: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
negociacao: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
convertido: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
perdido: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
};
return colors[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300';
};
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-8">
{/* Header - Template Pattern */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">
Olá, {firstName}! 👋
</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Bem-vindo ao seu portal. Acompanhe seus leads e o desempenho da sua conta.
</p>
</div>
<div className="flex gap-2">
<Link
href="/cliente/perfil"
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-200 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<UserCircleIcon className="w-4 h-4" />
Meu Perfil
</Link>
<Link
href="/cliente/leads"
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
style={{ background: 'var(--brand-color, #3B82F6)' }}
>
<ChartBarIcon className="w-4 h-4" />
Ver Todos os Leads
</Link>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Total de Leads</p>
<p className="text-3xl font-bold text-zinc-900 dark:text-white mt-1">{stats?.total_leads || 0}</p>
</div>
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<ChartBarIcon className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Leads Convertidos</p>
<p className="text-3xl font-bold text-zinc-900 dark:text-white mt-1">{stats?.converted || 0}</p>
</div>
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<CheckCircleIcon className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Em Andamento</p>
<p className="text-3xl font-bold text-zinc-900 dark:text-white mt-1">{stats?.active_leads || 0}</p>
</div>
<div className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<ClockIcon className="w-6 h-6 text-purple-600 dark:text-purple-400" />
</div>
</div>
</div>
</div>
{/* Recent Leads List - Template Pattern */}
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden shadow-sm">
<div className="px-6 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Leads Recentes</h2>
<Link href="/cliente/leads" className="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
Ver todos
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800">
<th className="px-6 py-3 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Lead</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Contato</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Data</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{leads.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center">
<div className="flex flex-col items-center">
<ChartBarIcon className="w-12 h-12 text-zinc-300 mb-3" />
<p className="text-zinc-500 dark:text-zinc-400">Nenhum lead encontrado.</p>
</div>
</td>
</tr>
) : (
leads.slice(0, 5).map((lead) => (
<tr key={lead.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-600 dark:text-zinc-400 font-bold text-xs">
{lead.name.charAt(0).toUpperCase()}
</div>
<span className="text-sm font-medium text-zinc-900 dark:text-white">{lead.name}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-col">
<span className="text-sm text-zinc-600 dark:text-zinc-400">{lead.email}</span>
<span className="text-xs text-zinc-400">{lead.phone || 'Sem telefone'}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(lead.status)}`}>
{lead.status.charAt(0).toUpperCase() + lead.status.slice(1)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-zinc-500 dark:text-zinc-400">
{new Date(lead.created_at).toLocaleDateString('pt-BR')}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Quick Info Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-sm">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Informações da Conta</h3>
<div className="space-y-4">
<div className="flex items-center justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<span className="text-sm text-zinc-500">Empresa</span>
<span className="text-sm font-medium text-zinc-900 dark:text-white">{customer?.company}</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-zinc-100 dark:border-zinc-800">
<span className="text-sm text-zinc-500">E-mail</span>
<span className="text-sm font-medium text-zinc-900 dark:text-white">{customer?.email}</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-zinc-500">Status</span>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-green-600 dark:text-green-400">
<CheckCircleIcon className="w-4 h-4" />
Ativo
</span>
</div>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-sm">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Suporte e Ajuda</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
Precisa de ajuda com seus leads ou tem alguma dúvida sobre o portal? Nossa equipe está à disposição.
</p>
<button className="w-full py-2.5 bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-white rounded-lg text-sm font-medium hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors">
Falar com Suporte
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { DashboardLayout } from '@/components/layout/DashboardLayout';
import { AgencyBranding } from '@/components/layout/AgencyBranding';
import AuthGuard from '@/components/auth/AuthGuard';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
HomeIcon,
UsersIcon,
ListBulletIcon,
UserCircleIcon,
} from '@heroicons/react/24/outline';
const CUSTOMER_MENU_ITEMS = [
{ id: 'dashboard', label: 'Dashboard', href: '/cliente/dashboard', icon: HomeIcon },
{
id: 'crm',
label: 'CRM',
href: '#',
icon: UsersIcon,
subItems: [
{ label: 'Leads', href: '/cliente/leads' },
{ label: 'Listas', href: '/cliente/listas' },
]
},
{ id: 'perfil', label: 'Meu Perfil', href: '/cliente/perfil', icon: UserCircleIcon },
];
interface CustomerPortalLayoutProps {
children: React.ReactNode;
}
export default function CustomerPortalLayout({ children }: CustomerPortalLayoutProps) {
const router = useRouter();
const [colors, setColors] = useState<{ primary: string; secondary: string } | null>(null);
useEffect(() => {
// Buscar cores da agência
fetchBranding();
}, []);
const fetchBranding = async () => {
try {
const response = await fetch('/api/tenant/branding', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
if (data.primary_color) {
setColors({
primary: data.primary_color,
secondary: data.secondary_color || data.primary_color,
});
}
}
} catch (error) {
console.error('Error fetching branding:', error);
}
};
return (
<AuthGuard allowedTypes={['customer']}>
<AgencyBranding colors={colors} />
<DashboardLayout menuItems={CUSTOMER_MENU_ITEMS}>
{children}
</DashboardLayout>
</AuthGuard>
);
}

View File

@@ -0,0 +1,193 @@
"use client";
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
EnvelopeIcon,
PhoneIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline';
interface Lead {
id: string;
name: string;
email: string;
phone: string;
status: string;
source: string;
created_at: string;
}
export default function CustomerLeadsPage() {
const router = useRouter();
const [leads, setLeads] = useState<Lead[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
fetchLeads();
}, []);
const fetchLeads = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/portal/leads', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) throw new Error('Erro ao buscar leads');
const data = await response.json();
setLeads(data.leads || []);
} catch (error) {
console.error('Error fetching leads:', error);
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
novo: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
qualificado: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
negociacao: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
convertido: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
perdido: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
};
return colors[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300';
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
novo: 'Novo',
qualificado: 'Qualificado',
negociacao: 'Em Negociação',
convertido: 'Convertido',
perdido: 'Perdido',
};
return labels[status] || status;
};
const filteredLeads = leads.filter(lead =>
lead.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
lead.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
lead.phone?.includes(searchTerm)
);
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<svg className="animate-spin h-12 w-12 mx-auto text-blue-600" 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>
<p className="mt-4 text-gray-600 dark:text-gray-400">Carregando...</p>
</div>
</div>
);
}
return (
<div className="p-6 lg:p-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Meus Leads
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Lista completa dos seus leads
</p>
</div>
{/* Search */}
<div className="mb-6">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Buscar por nome, email ou telefone..."
className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Nome
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Contato
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Origem
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Data
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredLeads.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
{searchTerm ? 'Nenhum lead encontrado com esse filtro' : 'Nenhum lead encontrado'}
</td>
</tr>
) : (
filteredLeads.map((lead) => (
<tr key={lead.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{lead.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<EnvelopeIcon className="h-4 w-4" />
{lead.email}
</div>
{lead.phone && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<PhoneIcon className="h-4 w-4" />
{lead.phone}
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600 dark:text-gray-400 capitalize">
{lead.source || 'Manual'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(lead.status)}`}>
{getStatusLabel(lead.status)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
{new Date(lead.created_at).toLocaleDateString('pt-BR')}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import { useEffect, useState } from 'react';
import {
ListBulletIcon,
MagnifyingGlassIcon,
UserGroupIcon,
} from '@heroicons/react/24/outline';
interface List {
id: string;
name: string;
description: string;
color: string;
customer_count: number;
created_at: string;
}
export default function CustomerListsPage() {
const [lists, setLists] = useState<List[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
fetchLists();
}, []);
const fetchLists = async () => {
try {
const response = await fetch('/api/portal/lists', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
setLists(data.lists || []);
}
} catch (error) {
console.error('Error fetching lists:', error);
} finally {
setLoading(false);
}
};
const filteredLists = lists.filter(list =>
list.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
list.description.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Minhas Listas</h1>
<p className="text-gray-500 dark:text-gray-400">
Visualize as listas e segmentos onde seus leads estão organizados.
</p>
</div>
</div>
{/* Filtros e Busca */}
<div className="bg-white dark:bg-zinc-900 p-4 rounded-xl border border-gray-200 dark:border-zinc-800 shadow-sm">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Buscar listas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
/>
</div>
</div>
{/* Grid de Listas */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="h-48 bg-gray-100 dark:bg-zinc-800 animate-pulse rounded-xl" />
))}
</div>
) : filteredLists.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredLists.map((list) => (
<div
key={list.id}
className="bg-white dark:bg-zinc-900 rounded-xl border border-gray-200 dark:border-zinc-800 shadow-sm hover:shadow-md transition-all overflow-hidden group"
>
<div
className="h-2 w-full"
style={{ backgroundColor: list.color || '#3B82F6' }}
/>
<div className="p-5">
<div className="flex items-start justify-between mb-4">
<div className="p-2 rounded-lg bg-gray-50 dark:bg-zinc-800 group-hover:scale-110 transition-transform">
<ListBulletIcon
className="w-6 h-6"
style={{ color: list.color || '#3B82F6' }}
/>
</div>
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 text-xs font-medium">
<UserGroupIcon className="w-3.5 h-3.5" />
{list.customer_count || 0} Leads
</div>
</div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-1 group-hover:text-blue-600 transition-colors">
{list.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 mb-4 h-10">
{list.description || 'Sem descrição disponível.'}
</p>
<div className="pt-4 border-t border-gray-100 dark:border-zinc-800 flex items-center justify-between">
<span className="text-xs text-gray-400">
Criada em {new Date(list.created_at).toLocaleDateString('pt-BR')}
</span>
<button className="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
Ver Leads
</button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-20 bg-white dark:bg-zinc-900 rounded-xl border border-dashed border-gray-300 dark:border-zinc-700">
<ListBulletIcon className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Nenhuma lista encontrada</h3>
<p className="text-gray-500 dark:text-gray-400">
{searchTerm ? 'Tente ajustar sua busca.' : 'Você ainda não possui listas associadas aos seus leads.'}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,404 @@
'use client';
import { useEffect, useState } from 'react';
import {
UserCircleIcon,
EnvelopeIcon,
PhoneIcon,
BuildingOfficeIcon,
KeyIcon,
CalendarIcon,
ChartBarIcon,
ClockIcon,
ShieldCheckIcon,
ArrowPathIcon,
CameraIcon,
PhotoIcon
} from '@heroicons/react/24/outline';
import { Button, Input } from '@/components/ui';
import { useToast } from '@/components/layout/ToastContext';
interface CustomerProfile {
id: string;
name: string;
email: string;
phone: string;
company: string;
logo_url?: string;
portal_last_login: string | null;
created_at: string;
total_leads: number;
converted_leads: number;
}
export default function PerfilPage() {
const toast = useToast();
const [profile, setProfile] = useState<CustomerProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
const [passwordForm, setPasswordForm] = useState({
current_password: '',
new_password: '',
confirm_password: '',
});
const [passwordError, setPasswordError] = useState<string | null>(null);
const [passwordSuccess, setPasswordSuccess] = useState(false);
useEffect(() => {
fetchProfile();
}, []);
const fetchProfile = async () => {
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/portal/profile', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!res.ok) throw new Error('Erro ao carregar perfil');
const data = await res.json();
setProfile(data.customer);
} catch (error) {
console.error('Erro ao carregar perfil:', error);
} finally {
setIsLoading(false);
}
};
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validar tamanho (2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error('Arquivo muito grande', 'O logo deve ter no máximo 2MB.');
return;
}
const formData = new FormData();
formData.append('logo', file);
setIsUploadingLogo(true);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/portal/logo', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
if (!res.ok) throw new Error('Erro ao fazer upload do logo');
const data = await res.json();
setProfile(prev => prev ? { ...prev, logo_url: data.logo_url } : null);
toast.success('Logo atualizado', 'Seu logo foi atualizado com sucesso.');
} catch (error) {
console.error('Error uploading logo:', error);
toast.error('Erro no upload', 'Não foi possível atualizar seu logo.');
} finally {
setIsUploadingLogo(false);
}
};
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordError(null);
setPasswordSuccess(false);
if (passwordForm.new_password !== passwordForm.confirm_password) {
setPasswordError('As senhas não coincidem');
return;
}
if (passwordForm.new_password.length < 6) {
setPasswordError('A nova senha deve ter no mínimo 6 caracteres');
return;
}
setIsChangingPassword(true);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/portal/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
current_password: passwordForm.current_password,
new_password: passwordForm.new_password,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Erro ao alterar senha');
setPasswordSuccess(true);
setPasswordForm({
current_password: '',
new_password: '',
confirm_password: '',
});
} catch (error: any) {
setPasswordError(error.message);
} finally {
setIsChangingPassword(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-[60vh]">
<div className="text-center">
<ArrowPathIcon className="w-10 h-10 animate-spin mx-auto text-brand-500" />
<p className="mt-4 text-gray-500 dark:text-zinc-400">Carregando seu perfil...</p>
</div>
</div>
);
}
if (!profile) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center px-4">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center mb-4">
<UserCircleIcon className="w-10 h-10 text-red-600 dark:text-red-400" />
</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Ops! Algo deu errado</h2>
<p className="mt-2 text-gray-500 dark:text-zinc-400 max-w-xs">
Não conseguimos carregar suas informações. Por favor, tente novamente mais tarde.
</p>
<Button onClick={fetchProfile} className="mt-6">
Tentar Novamente
</Button>
</div>
);
}
return (
<div className="p-6 lg:p-8 max-w-5xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Meu Perfil</h1>
<p className="text-gray-500 dark:text-zinc-400 mt-1">
Gerencie suas informações pessoais e segurança da conta.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Coluna da Esquerda: Info do Usuário */}
<div className="lg:col-span-2 space-y-6">
{/* Card de Informações Básicas */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden shadow-sm">
<div className="h-32 bg-gradient-to-r from-brand-500/20 to-brand-600/20 dark:from-brand-500/10 dark:to-brand-600/10 relative">
<div className="absolute -bottom-12 left-8">
<div className="relative group">
<div className="w-24 h-24 rounded-2xl bg-white dark:bg-zinc-800 border-4 border-white dark:border-zinc-900 shadow-xl flex items-center justify-center overflow-hidden">
{profile.logo_url ? (
<img src={profile.logo_url} alt={profile.name} className="w-full h-full object-contain p-2" />
) : (
<UserCircleIcon className="w-16 h-16 text-gray-300 dark:text-zinc-600" />
)}
{isUploadingLogo && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<ArrowPathIcon className="w-8 h-8 text-white animate-spin" />
</div>
)}
</div>
<label className="absolute -bottom-2 -right-2 w-8 h-8 bg-brand-500 hover:bg-brand-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg transition-all transform group-hover:scale-110">
<CameraIcon className="w-4 h-4" />
<input
type="file"
className="hidden"
accept="image/png,image/jpeg,image/jpg,image/svg+xml"
onChange={handleLogoUpload}
disabled={isUploadingLogo}
/>
</label>
</div>
</div>
</div>
<div className="pt-16 pb-8 px-8">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{profile.name}</h2>
<p className="text-brand-600 dark:text-brand-400 font-medium">{profile.company || 'Cliente Aggios'}</p>
</div>
<div className="flex items-center gap-2 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-sm font-medium self-start">
<ShieldCheckIcon className="w-4 h-4" />
Conta Ativa
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex items-center gap-3 text-gray-600 dark:text-zinc-400">
<EnvelopeIcon className="w-5 h-5 text-gray-400" />
<div>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider">E-mail</p>
<p className="text-gray-900 dark:text-white">{profile.email}</p>
</div>
</div>
<div className="flex items-center gap-3 text-gray-600 dark:text-zinc-400">
<PhoneIcon className="w-5 h-5 text-gray-400" />
<div>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider">Telefone</p>
<p className="text-gray-900 dark:text-white">{profile.phone || 'Não informado'}</p>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-3 text-gray-600 dark:text-zinc-400">
<CalendarIcon className="w-5 h-5 text-gray-400" />
<div>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider">Membro desde</p>
<p className="text-gray-900 dark:text-white">
{new Date(profile.created_at).toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' })}
</p>
</div>
</div>
<div className="flex items-center gap-3 text-gray-600 dark:text-zinc-400">
<ClockIcon className="w-5 h-5 text-gray-400" />
<div>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider">Último Acesso</p>
<p className="text-gray-900 dark:text-white">
{profile.portal_last_login
? new Date(profile.portal_last_login).toLocaleString('pt-BR')
: 'Primeiro acesso'}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Card de Estatísticas Rápidas */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="bg-white dark:bg-zinc-900 p-6 rounded-2xl border border-gray-200 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-brand-100 dark:bg-brand-900/20 rounded-xl flex items-center justify-center">
<ChartBarIcon className="w-6 h-6 text-brand-600 dark:text-brand-400" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-zinc-400">Total de Leads</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{profile.total_leads}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 p-6 rounded-2xl border border-gray-200 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-xl flex items-center justify-center">
<ShieldCheckIcon className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-zinc-400">Leads Convertidos</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{profile.converted_leads}</p>
</div>
</div>
</div>
</div>
</div>
{/* Coluna da Direita: Segurança */}
<div className="space-y-6">
<div className="bg-white dark:bg-zinc-900 p-6 rounded-2xl border border-gray-200 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/20 rounded-lg flex items-center justify-center">
<KeyIcon className="w-5 h-5 text-amber-600 dark:text-amber-400" />
</div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white">Segurança</h3>
</div>
<form onSubmit={handlePasswordChange} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1.5">
Senha Atual
</label>
<Input
type="password"
placeholder="••••••••"
value={passwordForm.current_password}
onChange={(e) => setPasswordForm({ ...passwordForm, current_password: e.target.value })}
required
/>
</div>
<div className="h-px bg-gray-100 dark:bg-zinc-800 my-2" />
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1.5">
Nova Senha
</label>
<Input
type="password"
placeholder="Mínimo 6 caracteres"
value={passwordForm.new_password}
onChange={(e) => setPasswordForm({ ...passwordForm, new_password: e.target.value })}
required
minLength={6}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1.5">
Confirmar Nova Senha
</label>
<Input
type="password"
placeholder="Repita a nova senha"
value={passwordForm.confirm_password}
onChange={(e) => setPasswordForm({ ...passwordForm, confirm_password: e.target.value })}
required
minLength={6}
/>
</div>
{passwordError && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-900/30 rounded-xl text-red-600 dark:text-red-400 text-sm">
{passwordError}
</div>
)}
{passwordSuccess && (
<div className="p-3 bg-green-50 dark:bg-green-900/20 border border-green-100 dark:border-green-900/30 rounded-xl text-green-600 dark:text-green-400 text-sm">
Senha alterada com sucesso!
</div>
)}
<Button
type="submit"
className="w-full"
isLoading={isChangingPassword}
>
Atualizar Senha
</Button>
</form>
</div>
<div className="bg-brand-50 dark:bg-brand-900/10 p-6 rounded-2xl border border-brand-100 dark:border-brand-900/20">
<h4 className="text-brand-900 dark:text-brand-300 font-bold mb-2">Precisa de ajuda?</h4>
<p className="text-brand-700 dark:text-brand-400 text-sm mb-4">
Se você tiver problemas com sua conta ou precisar alterar dados cadastrais, entre em contato com o suporte da agência.
</p>
<a
href="mailto:suporte@aggios.app"
className="text-brand-600 dark:text-brand-400 text-sm font-bold hover:underline"
>
suporte@aggios.app
</a>
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
import { getBranding } from '@/lib/branding';
import CadastroClientePage from './cadastro-client';
export default async function CadastroPage() {
const branding = await getBranding();
return <CadastroClientePage branding={branding} />;
}

View File

@@ -0,0 +1,49 @@
import { getBranding } from '@/lib/branding';
import SucessoClient from './sucesso-client';
const lightenColor = (hexColor: string, amount = 20) => {
const fallback = '#3b82f6';
if (!hexColor) return fallback;
let color = hexColor.replace('#', '');
if (color.length === 3) {
color = color.split('').map(char => char + char).join('');
}
if (color.length !== 6) return fallback;
const num = parseInt(color, 16);
if (Number.isNaN(num)) return fallback;
const clamp = (value: number) => Math.max(0, Math.min(255, value));
const r = clamp((num >> 16) + amount);
const g = clamp(((num >> 8) & 0x00ff) + amount);
const b = clamp((num & 0x0000ff) + amount);
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
};
export default async function CadastroSucessoPage() {
const branding = await getBranding();
const primaryColor = branding.primary_color || '#3b82f6';
const accentColor = lightenColor(primaryColor, 30);
const now = new Date();
const submittedAt = now.toLocaleString('pt-BR', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return (
<SucessoClient
branding={{
name: branding.name,
logo_url: branding.logo_url,
primary_color: primaryColor
}}
accentColor={accentColor}
submittedAt={submittedAt}
/>
);
}

View File

@@ -0,0 +1,218 @@
'use client';
import { useEffect, useState } from 'react';
import { CheckCircleIcon, ClockIcon, UserCircleIcon } from '@heroicons/react/24/solid';
import { SparklesIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
interface SucessoClientProps {
branding: {
name: string;
logo_url?: string;
primary_color: string;
};
accentColor: string;
submittedAt: string;
}
const timeline = [
{
title: 'Cadastro recebido',
description: 'Confirmamos seus dados e senha automaticamente.',
status: 'done' as const,
},
{
title: 'Análise da equipe',
description: 'Nossa equipe valida seus dados e configura seu acesso.',
status: 'current' as const,
},
{
title: 'Acesso liberado',
description: 'Você receberá aviso e poderá fazer login com sua senha.',
status: 'upcoming' as const,
},
];
export default function SucessoClient({ branding, accentColor, submittedAt }: SucessoClientProps) {
const [customerName, setCustomerName] = useState<string | null>(null);
const [customerEmail, setCustomerEmail] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const name = sessionStorage.getItem('customer_name');
const email = sessionStorage.getItem('customer_email');
setCustomerName(name);
setCustomerEmail(email);
setIsLoading(false);
// Limpar sessionStorage após carregar
if (name || email) {
sessionStorage.removeItem('customer_name');
sessionStorage.removeItem('customer_email');
}
}, []);
const primaryColor = branding.primary_color || '#3b82f6';
const firstName = customerName?.split(' ')[0] || 'Cliente';
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-100 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto space-y-8">
<div className="text-center space-y-4">
{branding.logo_url ? (
<img src={branding.logo_url} alt={branding.name} className="mx-auto h-16 w-auto object-contain" />
) : (
<div className="mx-auto h-16 w-16 rounded-2xl flex items-center justify-center text-white text-2xl font-semibold" style={{ backgroundColor: primaryColor }}>
{branding.name?.substring(0, 2).toUpperCase() || 'AG'}
</div>
)}
<p className="text-sm uppercase tracking-[0.25em] text-gray-500 font-medium">Portal do Cliente</p>
</div>
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden border border-gray-200">
<div className="h-3" style={{ backgroundImage: `linear-gradient(120deg, ${primaryColor}, ${accentColor})` }} />
<div className="p-8 sm:p-12 space-y-8">
{/* Header Premium com Nome */}
<div className="flex flex-col items-center text-center space-y-6">
<div className="relative">
<div className="h-24 w-24 rounded-full flex items-center justify-center bg-gradient-to-br from-green-100 to-emerald-50 shadow-lg">
<CheckCircleIcon className="h-14 w-14 text-green-600" />
</div>
<div className="absolute -bottom-1 -right-1 h-8 w-8 rounded-full bg-white flex items-center justify-center shadow-md">
<SparklesIcon className="h-5 w-5 text-amber-500" />
</div>
</div>
{!isLoading && customerName ? (
<div className="space-y-2">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900">
Tudo certo, {firstName}! 🎉
</h1>
<p className="text-lg text-gray-600">
Seu cadastro foi enviado com sucesso
</p>
</div>
) : (
<div className="space-y-2">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900">
Cadastro enviado com sucesso! 🎉
</h1>
<p className="text-lg text-gray-600">
Recebemos todas as suas informações
</p>
</div>
)}
<div className="bg-blue-50 border border-blue-200 rounded-2xl p-4 max-w-lg">
<div className="flex items-start gap-3">
<UserCircleIcon className="h-6 w-6 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-left">
<p className="text-sm font-semibold text-blue-900">Sua senha está segura</p>
<p className="text-sm text-blue-700 mt-1">
Você definiu sua senha de acesso. Assim que a agência liberar seu cadastro,
você poderá fazer login imediatamente no portal.
</p>
</div>
</div>
</div>
{!isLoading && customerEmail && (
<p className="text-sm text-gray-500">
Login: <span className="font-mono font-semibold text-gray-700">{customerEmail}</span>
</p>
)}
<p className="text-xs text-gray-400">Enviado em {submittedAt}</p>
</div>
{/* Timeline */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{timeline.map((item, idx) => (
<div
key={item.title}
className={`rounded-2xl border-2 p-5 flex flex-col gap-3 transition-all ${item.status === 'done'
? 'border-green-200 bg-green-50/50'
: item.status === 'current'
? 'border-indigo-300 bg-indigo-50/50 shadow-lg'
: 'border-gray-200 bg-gray-50'
}`}
>
<div className="flex items-center justify-between">
<div className={`h-10 w-10 rounded-full flex items-center justify-center font-bold ${item.status === 'done'
? 'bg-green-500 text-white'
: item.status === 'current'
? 'bg-indigo-500 text-white'
: 'bg-gray-200 text-gray-400'
}`}>
{idx + 1}
</div>
{item.status === 'current' && (
<div className="flex space-x-1">
<div className="w-2 h-2 rounded-full bg-indigo-500 animate-pulse" />
<div className="w-2 h-2 rounded-full bg-indigo-500 animate-pulse" style={{ animationDelay: '0.2s' }} />
<div className="w-2 h-2 rounded-full bg-indigo-500 animate-pulse" style={{ animationDelay: '0.4s' }} />
</div>
)}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">{item.title}</p>
<p className="text-sm text-gray-600 mt-1">{item.description}</p>
</div>
</div>
))}
</div>
{/* Informações */}
<div className="bg-gradient-to-br from-gray-50 to-white rounded-2xl p-6 border border-gray-200">
<p className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
<ClockIcon className="h-5 w-5 text-amber-500" />
O que acontece agora?
</p>
<ul className="space-y-2 text-sm text-gray-700">
<li className="flex items-start gap-2">
<span className="text-green-500 font-bold mt-0.5"></span>
<span>Nossa equipe valida seus dados e configura seu ambiente no portal</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 font-bold mt-0.5"></span>
<span>Assim que aprovado, você receberá aviso pelos contatos informados</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 font-bold mt-0.5"></span>
<span>Use o login <strong>{customerEmail || 'seu e-mail'}</strong> e a senha que você criou para acessar</span>
</li>
<li className="flex items-start gap-2">
<span className="text-amber-500 font-bold mt-0.5">!</span>
<span>Em caso de urgência, fale com a equipe {branding.name} pelo telefone ou WhatsApp</span>
</li>
</ul>
</div>
{/* CTAs */}
<div className="space-y-3 pt-4">
<Link
href="/login"
className="w-full inline-flex items-center justify-center gap-2 rounded-xl px-6 py-4 text-white font-semibold shadow-lg transition-all hover:shadow-xl hover:-translate-y-0.5"
style={{ backgroundImage: `linear-gradient(120deg, ${primaryColor}, ${accentColor})` }}
>
Ir para o login do cliente
</Link>
<Link
href="/"
className="w-full inline-flex items-center justify-center gap-2 rounded-xl px-6 py-3 font-semibold border-2 border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors"
>
Voltar para o site da agência
</Link>
</div>
</div>
</div>
<div className="text-center text-sm text-gray-500 bg-white/70 backdrop-blur-sm rounded-xl p-4 border border-gray-200">
Precisa ajustar alguma informação? Entre em contato com a equipe <strong>{branding.name}</strong> pelos
canais que você informou no cadastro.
</div>
</div>
</div>
);
}