980 lines
38 KiB
TypeScript
980 lines
38 KiB
TypeScript
'use client';
|
||
|
||
import { useAuth } from '@/contexts/AuthContext';
|
||
import { usePathname, useRouter } from 'next/navigation';
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import Link from 'next/link';
|
||
import Joyride, { CallBackProps, STATUS, EVENTS } from 'react-joyride';
|
||
import {
|
||
Bell,
|
||
CalendarClock,
|
||
CreditCard,
|
||
Gauge,
|
||
MessageSquare,
|
||
Phone,
|
||
Clock,
|
||
TrendingUp,
|
||
Coins,
|
||
Settings,
|
||
Sparkles,
|
||
Timer,
|
||
X,
|
||
Search,
|
||
Download,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
} from 'lucide-react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import {
|
||
ResponsiveContainer,
|
||
LineChart,
|
||
Line,
|
||
XAxis,
|
||
YAxis,
|
||
Tooltip,
|
||
CartesianGrid,
|
||
} from 'recharts';
|
||
import toast from 'react-hot-toast';
|
||
import { getUser, updateUserOnboardingCompleted } from '@/lib/firestore';
|
||
|
||
interface Call {
|
||
id: number;
|
||
contactName: string;
|
||
date: string;
|
||
duration: string;
|
||
status: 'Completed' | 'Failed';
|
||
credits: number;
|
||
}
|
||
|
||
interface AnalyticsData {
|
||
totalCalls: number;
|
||
totalMinutes: number;
|
||
avgDuration: number;
|
||
creditsRemaining: number;
|
||
}
|
||
|
||
interface CallAnalyticsPoint {
|
||
day: string;
|
||
calls: number;
|
||
minutes: number;
|
||
}
|
||
|
||
export default function DashboardPage() {
|
||
const { user, isLoading, logout } = useAuth();
|
||
const router = useRouter();
|
||
const pathname = usePathname();
|
||
const [creditsRemaining, setCreditsRemaining] = useState<number | null>(null);
|
||
const [isStatsLoading, setIsStatsLoading] = useState(true);
|
||
const [isLowCreditsDismissed, setIsLowCreditsDismissed] = useState(true);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [dateRange, setDateRange] = useState<'7' | '30' | 'custom'>('7');
|
||
const [customStart, setCustomStart] = useState('');
|
||
const [customEnd, setCustomEnd] = useState('');
|
||
const [sortKey, setSortKey] = useState<'date' | 'duration' | 'status'>('date');
|
||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [selectedCall, setSelectedCall] = useState<Call | null>(null);
|
||
const [runTour, setRunTour] = useState(false);
|
||
const [tourStepIndex, setTourStepIndex] = useState(0);
|
||
|
||
const tourSteps = useMemo(
|
||
() => [
|
||
{
|
||
target: '[data-tour="welcome"]',
|
||
content: 'Welcome to HolaCompi! Here is your dashboard overview.',
|
||
placement: 'bottom',
|
||
},
|
||
{
|
||
target: '[data-tour="buy-credits"]',
|
||
content: 'Buy credits to power your calls whenever you need.',
|
||
placement: 'left',
|
||
},
|
||
{
|
||
target: '[data-tour="make-call"]',
|
||
content: 'Schedule a call to reach clients instantly.',
|
||
placement: 'top',
|
||
},
|
||
{
|
||
target: '[data-tour="view-settings"]',
|
||
content: 'Adjust preferences and manage your account settings here.',
|
||
placement: 'bottom',
|
||
},
|
||
],
|
||
[]
|
||
);
|
||
|
||
const mockChartData: CallAnalyticsPoint[] = [
|
||
{ day: 'Mon', calls: 8, minutes: 24 },
|
||
{ day: 'Tue', calls: 12, minutes: 38 },
|
||
{ day: 'Wed', calls: 6, minutes: 19 },
|
||
{ day: 'Thu', calls: 10, minutes: 31 },
|
||
{ day: 'Fri', calls: 7, minutes: 22 },
|
||
{ day: 'Sat', calls: 2, minutes: 12 },
|
||
{ day: 'Sun', calls: 2, minutes: 10 },
|
||
];
|
||
|
||
const mockRecentCalls: Call[] = [
|
||
{
|
||
id: 1,
|
||
contactName: 'Lucia Morales',
|
||
date: '2026-01-21 18:30',
|
||
duration: '4m 15s',
|
||
status: 'Completed',
|
||
credits: 4,
|
||
},
|
||
{
|
||
id: 2,
|
||
contactName: 'Clinic Valencia',
|
||
date: '2026-01-21 14:20',
|
||
duration: '2m 45s',
|
||
status: 'Completed',
|
||
credits: 3,
|
||
},
|
||
{
|
||
id: 3,
|
||
contactName: 'Miguel Ramos',
|
||
date: '2026-01-20 16:10',
|
||
duration: '5m 32s',
|
||
status: 'Completed',
|
||
credits: 6,
|
||
},
|
||
{
|
||
id: 4,
|
||
contactName: 'Barcelona Dental',
|
||
date: '2026-01-20 11:45',
|
||
duration: '1m 23s',
|
||
status: 'Failed',
|
||
credits: 0,
|
||
},
|
||
{
|
||
id: 5,
|
||
contactName: 'Sofia Ortega',
|
||
date: '2026-01-19 09:15',
|
||
duration: '3m 58s',
|
||
status: 'Completed',
|
||
credits: 4,
|
||
},
|
||
];
|
||
|
||
const analyticsData: AnalyticsData = useMemo(
|
||
() => ({
|
||
totalCalls: 47,
|
||
totalMinutes: 156,
|
||
avgDuration: 3.3,
|
||
creditsRemaining: creditsRemaining ?? 0,
|
||
}),
|
||
[creditsRemaining]
|
||
);
|
||
|
||
const fadeInUp = {
|
||
hidden: { opacity: 0, y: 16 },
|
||
visible: { opacity: 1, y: 0 },
|
||
};
|
||
|
||
const slideUp = {
|
||
hidden: { opacity: 0, y: 24 },
|
||
visible: { opacity: 1, y: 0 },
|
||
};
|
||
|
||
const parseCallDate = (value: string) => new Date(value.replace(' ', 'T'));
|
||
|
||
const durationToSeconds = (value: string) => {
|
||
const match = value.match(/(\d+)m\s*(\d+)s/);
|
||
if (!match) return 0;
|
||
return Number(match[1]) * 60 + Number(match[2]);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!isLoading && !user) {
|
||
router.push('/auth');
|
||
}
|
||
}, [user, isLoading, router]);
|
||
|
||
useEffect(() => {
|
||
const fetchCredits = async () => {
|
||
setIsStatsLoading(true);
|
||
try {
|
||
const response = await fetch('/api/credits');
|
||
if (!response.ok) {
|
||
toast.error('Unable to load credits. Please try again.');
|
||
setCreditsRemaining(0);
|
||
return;
|
||
}
|
||
const data = await response.json();
|
||
setCreditsRemaining(data.balance ?? 0);
|
||
} catch (error) {
|
||
console.error('Failed to load credits', error);
|
||
toast.error('Unable to load credits. Please check your connection.');
|
||
setCreditsRemaining(0);
|
||
} finally {
|
||
setIsStatsLoading(false);
|
||
}
|
||
};
|
||
|
||
if (user) {
|
||
fetchCredits();
|
||
}
|
||
}, [user]);
|
||
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return;
|
||
const dismissed = window.localStorage.getItem('lowCreditsBannerDismissed');
|
||
setIsLowCreditsDismissed(dismissed === 'true');
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let isActive = true;
|
||
const loadOnboardingStatus = async () => {
|
||
if (!user) return;
|
||
try {
|
||
const userDoc = await getUser(user.uid);
|
||
const completed = userDoc?.onboardingCompleted ?? false;
|
||
if (!completed && isActive) {
|
||
setRunTour(true);
|
||
setTourStepIndex(0);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load onboarding status', error);
|
||
}
|
||
};
|
||
loadOnboardingStatus();
|
||
return () => {
|
||
isActive = false;
|
||
};
|
||
}, [user]);
|
||
|
||
const handleDismissLowCredits = () => {
|
||
setIsLowCreditsDismissed(true);
|
||
if (typeof window !== 'undefined') {
|
||
window.localStorage.setItem('lowCreditsBannerDismissed', 'true');
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
setCurrentPage(1);
|
||
}, [searchTerm, dateRange, customStart, customEnd, sortKey, sortDirection]);
|
||
|
||
const filteredCalls = useMemo(() => {
|
||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||
const now = new Date();
|
||
let startDate: Date | null = null;
|
||
let endDate: Date | null = null;
|
||
|
||
if (dateRange === '7') {
|
||
startDate = new Date(now);
|
||
startDate.setDate(now.getDate() - 7);
|
||
} else if (dateRange === '30') {
|
||
startDate = new Date(now);
|
||
startDate.setDate(now.getDate() - 30);
|
||
} else if (dateRange === 'custom') {
|
||
startDate = customStart ? new Date(customStart) : null;
|
||
endDate = customEnd ? new Date(customEnd) : null;
|
||
if (endDate) {
|
||
endDate.setHours(23, 59, 59, 999);
|
||
}
|
||
}
|
||
|
||
return mockRecentCalls.filter((call) => {
|
||
const matchesSearch =
|
||
!normalizedSearch ||
|
||
call.contactName.toLowerCase().includes(normalizedSearch);
|
||
const callDate = parseCallDate(call.date);
|
||
const afterStart = !startDate || callDate >= startDate;
|
||
const beforeEnd = !endDate || callDate <= endDate;
|
||
return matchesSearch && afterStart && beforeEnd;
|
||
});
|
||
}, [searchTerm, dateRange, customStart, customEnd, mockRecentCalls]);
|
||
|
||
const sortedCalls = useMemo(() => {
|
||
const sorted = [...filteredCalls];
|
||
sorted.sort((a, b) => {
|
||
let comparison = 0;
|
||
if (sortKey === 'date') {
|
||
comparison = parseCallDate(a.date).getTime() - parseCallDate(b.date).getTime();
|
||
} else if (sortKey === 'duration') {
|
||
comparison = durationToSeconds(a.duration) - durationToSeconds(b.duration);
|
||
} else {
|
||
comparison = a.status.localeCompare(b.status);
|
||
}
|
||
return sortDirection === 'asc' ? comparison : -comparison;
|
||
});
|
||
return sorted;
|
||
}, [filteredCalls, sortKey, sortDirection]);
|
||
|
||
const callsPerPage = 10;
|
||
const totalPages = Math.max(1, Math.ceil(sortedCalls.length / callsPerPage));
|
||
const paginatedCalls = sortedCalls.slice(
|
||
(currentPage - 1) * callsPerPage,
|
||
currentPage * callsPerPage
|
||
);
|
||
|
||
const handleSort = (key: 'date' | 'duration' | 'status') => {
|
||
if (sortKey === key) {
|
||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||
return;
|
||
}
|
||
setSortKey(key);
|
||
setSortDirection('desc');
|
||
};
|
||
|
||
const handleExportCsv = () => {
|
||
if (sortedCalls.length === 0) {
|
||
toast.error('No calls available to export.');
|
||
return;
|
||
}
|
||
const rows = [
|
||
['Contact', 'Date', 'Duration', 'Status', 'Credits'],
|
||
...sortedCalls.map((call) => [
|
||
call.contactName,
|
||
call.date,
|
||
call.duration,
|
||
call.status,
|
||
call.credits.toString(),
|
||
]),
|
||
];
|
||
const csvContent = rows
|
||
.map((row) => row.map((value) => `"${value.replace(/"/g, '""')}"`).join(','))
|
||
.join('\n');
|
||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.setAttribute('download', 'recent-calls.csv');
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
const handleJoyride = async (data: CallBackProps) => {
|
||
const { status, index, type } = data;
|
||
if (type === EVENTS.STEP_AFTER) {
|
||
setTourStepIndex(index + 1);
|
||
}
|
||
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
|
||
setRunTour(false);
|
||
setTourStepIndex(0);
|
||
if (user) {
|
||
try {
|
||
await updateUserOnboardingCompleted(user.uid, true);
|
||
} catch (error) {
|
||
console.error('Failed to save onboarding status', error);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] text-white">
|
||
<div className="text-center">
|
||
<div className="animate-pulse text-2xl">Loading...</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!user) {
|
||
return null;
|
||
}
|
||
|
||
const handleLogout = async () => {
|
||
try {
|
||
await logout();
|
||
router.push('/auth');
|
||
toast.success('Signed out successfully.');
|
||
} catch (error) {
|
||
console.error('Logout failed', error);
|
||
toast.error('Unable to log out. Please try again.');
|
||
}
|
||
};
|
||
|
||
const navLinks = [
|
||
{ label: 'Scheduled Calls', href: '/dashboard/scheduled-calls', icon: CalendarClock },
|
||
{ label: 'Voice Agent', href: '/dashboard/agent-settings', icon: Sparkles },
|
||
{ label: 'Credits & Limits', href: '/dashboard/credits', icon: CreditCard },
|
||
{ label: 'Notifications', href: '/dashboard/notifications', icon: Bell },
|
||
{ label: 'Chat', href: '/dashboard/chat', icon: MessageSquare },
|
||
{ label: 'Settings', href: '/dashboard/settings', icon: Settings },
|
||
];
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] text-white">
|
||
<Joyride
|
||
steps={tourSteps}
|
||
run={runTour}
|
||
stepIndex={tourStepIndex}
|
||
continuous
|
||
showSkipButton
|
||
scrollToFirstStep
|
||
disableOverlayClose
|
||
callback={handleJoyride}
|
||
locale={{ skip: 'Skip Tour', next: 'Next', last: 'Done' }}
|
||
styles={{
|
||
options: {
|
||
primaryColor: '#8b5cf6',
|
||
backgroundColor: '#1a1625',
|
||
textColor: '#ffffff',
|
||
overlayColor: 'rgba(15, 11, 26, 0.65)',
|
||
arrowColor: '#1a1625',
|
||
zIndex: 60,
|
||
},
|
||
tooltip: {
|
||
borderRadius: 16,
|
||
border: '1px solid rgba(139,92,246,0.35)',
|
||
},
|
||
buttonNext: {
|
||
backgroundColor: '#8b5cf6',
|
||
color: '#fff',
|
||
},
|
||
buttonSkip: {
|
||
color: '#c7c5d1',
|
||
},
|
||
}}
|
||
/>
|
||
<nav className="border-b border-white/10 bg-white/5 backdrop-blur-sm">
|
||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||
<div className="flex flex-wrap items-center justify-between gap-4 py-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="h-10 w-10 rounded-2xl bg-[#8b5cf6]/20 border border-[#8b5cf6]/40 flex items-center justify-center">
|
||
<Sparkles className="h-5 w-5 text-[#8b5cf6]" />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-2xl font-bold">HolaCompi</h1>
|
||
<p className="text-xs text-[#9ca3af]">
|
||
Your AI voice assistant dashboard
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={handleLogout}
|
||
className="px-4 py-2 rounded-xl bg-red-500/10 hover:bg-red-500/20 text-red-300 border border-red-500/20 transition-colors"
|
||
>
|
||
Logout
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-wrap gap-3 pb-4">
|
||
{navLinks.map((item) => {
|
||
const isActive = pathname === item.href;
|
||
const Icon = item.icon;
|
||
return (
|
||
<Link
|
||
key={item.href}
|
||
href={item.href}
|
||
className={`flex items-center gap-2 px-4 py-2 rounded-full border text-sm transition ${
|
||
isActive
|
||
? 'bg-[#8b5cf6]/20 border-[#8b5cf6]/50 text-white shadow-lg'
|
||
: 'bg-white/5 border-white/10 text-[#9ca3af] hover:text-white hover:bg-white/10'
|
||
}`}
|
||
data-tour={item.label === 'Settings' ? 'view-settings' : undefined}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
{item.label}
|
||
</Link>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
{!isLowCreditsDismissed && creditsRemaining !== null && creditsRemaining < 10 && (
|
||
<motion.div
|
||
initial={{ y: -20, opacity: 0 }}
|
||
animate={{ y: 0, opacity: 1 }}
|
||
transition={{ duration: 0.3 }}
|
||
className="sticky top-[72px] z-40"
|
||
>
|
||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||
<div className="mt-4 rounded-2xl border border-yellow-400/40 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-100 shadow-lg backdrop-blur-xl flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
⚠️ Running low on credits! You have {creditsRemaining} credits remaining.
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={() => router.push('/dashboard/credits')}
|
||
className="px-3 py-1.5 rounded-full bg-[#8b5cf6] text-white text-xs font-semibold hover:bg-[#7c3aed] transition"
|
||
>
|
||
Buy More Credits
|
||
</button>
|
||
<button
|
||
onClick={handleDismissLowCredits}
|
||
className="text-yellow-100 hover:text-white transition"
|
||
aria-label="Dismiss low credits warning"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||
<div className="space-y-10">
|
||
<motion.div
|
||
className="bg-[#1a1625] rounded-3xl border border-[#8b5cf6]/20 p-8 shadow-lg transition-transform hover:scale-[1.01]"
|
||
variants={fadeInUp}
|
||
initial="hidden"
|
||
animate="visible"
|
||
data-tour="welcome"
|
||
>
|
||
<h2 className="text-3xl font-semibold mb-3">
|
||
Welcome back, {user.name || user.email}! 👋
|
||
</h2>
|
||
<p className="text-[#9ca3af]">
|
||
Schedule calls, monitor credits, and tune your voice agent.
|
||
</p>
|
||
</motion.div>
|
||
|
||
<section className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||
<Gauge className="h-5 w-5 text-[#8b5cf6]" />
|
||
Usage Overview
|
||
</h3>
|
||
<button
|
||
onClick={() => router.push('/dashboard/credits')}
|
||
className="text-xs text-[#9ca3af] hover:text-white transition animate-pulse"
|
||
data-tour="buy-credits"
|
||
>
|
||
View credits →
|
||
</button>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
{(isStatsLoading ? new Array(4).fill(null) : [
|
||
{
|
||
label: 'Total Calls Made',
|
||
value: `${analyticsData.totalCalls} calls`,
|
||
icon: Phone,
|
||
gradient: 'from-[#8b5cf6]/30 to-[#4f46e5]/30',
|
||
},
|
||
{
|
||
label: 'Total Minutes Used',
|
||
value: `${analyticsData.totalMinutes} minutes`,
|
||
icon: Clock,
|
||
gradient: 'from-[#4f46e5]/30 to-[#3b82f6]/30',
|
||
},
|
||
{
|
||
label: 'Average Call Duration',
|
||
value: `${analyticsData.avgDuration} minutes`,
|
||
icon: TrendingUp,
|
||
gradient: 'from-[#8b5cf6]/30 to-[#ec4899]/30',
|
||
},
|
||
{
|
||
label: 'Credits Remaining',
|
||
value:
|
||
creditsRemaining === null
|
||
? 'Loading...'
|
||
: `${analyticsData.creditsRemaining} credits`,
|
||
icon: Coins,
|
||
gradient: 'from-[#10b981]/20 to-[#8b5cf6]/30',
|
||
},
|
||
]).map((card, index) => {
|
||
if (isStatsLoading) {
|
||
return (
|
||
<div
|
||
key={`skeleton-${index}`}
|
||
className="bg-white/5 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-xl animate-pulse"
|
||
>
|
||
<div className="h-12 w-12 rounded-2xl bg-white/10 border border-white/10" />
|
||
<div className="mt-4 h-3 w-24 rounded-full bg-white/10" />
|
||
<div className="mt-3 h-6 w-32 rounded-full bg-white/10" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const Icon = card.icon;
|
||
const action =
|
||
card.label === 'Total Calls Made'
|
||
? () => {
|
||
const section = document.getElementById('call-history');
|
||
section?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
: card.label === 'Credits Remaining'
|
||
? () => router.push('/dashboard/credits')
|
||
: null;
|
||
const isClickable = Boolean(action);
|
||
return (
|
||
<motion.div
|
||
key={card.label}
|
||
className={`group relative bg-white/5 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-xl transition hover:shadow-2xl ${
|
||
isClickable ? 'cursor-pointer' : ''
|
||
}`}
|
||
variants={fadeInUp}
|
||
initial="hidden"
|
||
animate="visible"
|
||
transition={{ delay: index * 0.08, duration: 0.4, ease: 'easeOut' }}
|
||
whileHover={{ scale: 1.05 }}
|
||
onClick={action ?? undefined}
|
||
>
|
||
<div className="pointer-events-none absolute inset-0 rounded-2xl opacity-0 group-hover:opacity-100 transition">
|
||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-[#8b5cf6]/40 to-[#4f46e5]/30 blur-lg" />
|
||
<div className="absolute inset-0 rounded-2xl border border-[#8b5cf6]/50" />
|
||
</div>
|
||
<div
|
||
className={`relative rounded-2xl p-3 bg-gradient-to-r ${card.gradient} border border-white/10`}
|
||
>
|
||
<Icon className="h-5 w-5 text-white" />
|
||
</div>
|
||
<p className="text-xs uppercase text-[#9ca3af] mt-4">{card.label}</p>
|
||
<p className="text-2xl font-semibold mt-2">{card.value}</p>
|
||
<button
|
||
type="button"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
action?.();
|
||
}}
|
||
disabled={!isClickable}
|
||
className={`mt-4 text-xs font-semibold text-[#c4b5fd] transition-all ${
|
||
isClickable ? 'group-hover:text-white' : 'text-[#6b7280]'
|
||
} opacity-0 group-hover:opacity-100 translate-y-1 group-hover:translate-y-0`}
|
||
>
|
||
View Details →
|
||
</button>
|
||
</motion.div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||
<motion.div
|
||
className="lg:col-span-3 bg-[#1a1625] border border-white/10 rounded-3xl p-6 shadow-lg backdrop-blur-xl transition-transform hover:scale-[1.01]"
|
||
variants={slideUp}
|
||
initial="hidden"
|
||
animate="visible"
|
||
transition={{ duration: 0.45, ease: 'easeOut' }}
|
||
>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h3 className="text-lg font-semibold">Weekly Usage</h3>
|
||
<p className="text-xs text-[#9ca3af]">Last 7 days</p>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-xs text-[#9ca3af]">
|
||
<Phone className="h-4 w-4 text-[#8b5cf6]" />
|
||
Calls & Minutes
|
||
</div>
|
||
</div>
|
||
<div className="h-64">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<LineChart data={mockChartData}>
|
||
<CartesianGrid stroke="rgba(255,255,255,0.08)" strokeDasharray="4 4" />
|
||
<XAxis dataKey="day" stroke="#9ca3af" />
|
||
<YAxis stroke="#9ca3af" />
|
||
<Tooltip
|
||
contentStyle={{
|
||
background: '#1a1625',
|
||
border: '1px solid rgba(139,92,246,0.3)',
|
||
borderRadius: '12px',
|
||
color: '#fff',
|
||
}}
|
||
formatter={(value: number, name: string) => [
|
||
value,
|
||
name === 'calls' ? 'Calls' : 'Minutes',
|
||
]}
|
||
labelFormatter={(label) => `Day: ${label}`}
|
||
/>
|
||
<Line type="monotone" dataKey="calls" stroke="#8b5cf6" strokeWidth={3} />
|
||
<Line type="monotone" dataKey="minutes" stroke="#4f46e5" strokeWidth={3} />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</motion.div>
|
||
|
||
<motion.div
|
||
id="call-history"
|
||
className="lg:col-span-2 bg-[#1a1625] border border-white/10 rounded-3xl p-6 shadow-lg backdrop-blur-xl transition-transform hover:scale-[1.01]"
|
||
variants={fadeInUp}
|
||
initial="hidden"
|
||
animate="visible"
|
||
transition={{ duration: 0.4, delay: 0.1, ease: 'easeOut' }}
|
||
>
|
||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-lg font-semibold">Recent Activity</h3>
|
||
<Timer className="h-5 w-5 text-[#8b5cf6]" />
|
||
</div>
|
||
<button
|
||
onClick={handleExportCsv}
|
||
className="text-xs px-3 py-1.5 rounded-full bg-white/5 border border-white/10 text-[#9ca3af] hover:text-white hover:bg-white/10 transition flex items-center gap-2"
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
Export CSV
|
||
</button>
|
||
</div>
|
||
<div className="grid grid-cols-1 gap-3 mb-4">
|
||
<div className="flex items-center gap-2 rounded-2xl bg-[#0f0b1a] border border-white/10 px-4 py-2">
|
||
<Search className="h-4 w-4 text-[#9ca3af]" />
|
||
<input
|
||
value={searchTerm}
|
||
onChange={(event) => setSearchTerm(event.target.value)}
|
||
placeholder="Search by contact name"
|
||
className="w-full bg-transparent text-sm text-white placeholder:text-[#9ca3af] focus:outline-none"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<select
|
||
value={dateRange}
|
||
onChange={(event) => setDateRange(event.target.value as '7' | '30' | 'custom')}
|
||
className="rounded-full bg-[#0f0b1a] border border-white/10 px-4 py-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
|
||
>
|
||
<option value="7">Last 7 days</option>
|
||
<option value="30">Last 30 days</option>
|
||
<option value="custom">Custom range</option>
|
||
</select>
|
||
{dateRange === 'custom' && (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<input
|
||
type="date"
|
||
value={customStart}
|
||
onChange={(event) => setCustomStart(event.target.value)}
|
||
className="rounded-full bg-[#0f0b1a] border border-white/10 px-4 py-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
|
||
/>
|
||
<span className="text-xs text-[#9ca3af]">to</span>
|
||
<input
|
||
type="date"
|
||
value={customEnd}
|
||
onChange={(event) => setCustomEnd(event.target.value)}
|
||
className="rounded-full bg-[#0f0b1a] border border-white/10 px-4 py-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{sortedCalls.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center text-center py-12">
|
||
<svg
|
||
width="160"
|
||
height="120"
|
||
viewBox="0 0 160 120"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<rect x="12" y="18" width="136" height="84" rx="16" fill="#1f1a2e" />
|
||
<rect x="28" y="34" width="104" height="12" rx="6" fill="#2f2544" />
|
||
<rect x="28" y="54" width="72" height="10" rx="5" fill="#2f2544" />
|
||
<rect x="28" y="72" width="88" height="10" rx="5" fill="#2f2544" />
|
||
<circle cx="126" cy="72" r="18" fill="#2f2544" />
|
||
<path
|
||
d="M120 72l6 6 10-12"
|
||
stroke="#8b5cf6"
|
||
strokeWidth="4"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
<p className="text-sm text-[#9ca3af] mt-4">
|
||
No calls match your filters yet.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="text-[#9ca3af]">
|
||
<tr>
|
||
<th className="text-left pb-2 font-medium">Contact</th>
|
||
<th className="text-left pb-2 font-medium">
|
||
<button
|
||
onClick={() => handleSort('date')}
|
||
className="text-[#9ca3af] hover:text-white transition"
|
||
>
|
||
Date/Time {sortKey === 'date' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
|
||
</button>
|
||
</th>
|
||
<th className="text-left pb-2 font-medium">
|
||
<button
|
||
onClick={() => handleSort('duration')}
|
||
className="text-[#9ca3af] hover:text-white transition"
|
||
>
|
||
Duration {sortKey === 'duration' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
|
||
</button>
|
||
</th>
|
||
<th className="text-left pb-2 font-medium">
|
||
<button
|
||
onClick={() => handleSort('status')}
|
||
className="text-[#9ca3af] hover:text-white transition"
|
||
>
|
||
Status {sortKey === 'status' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
|
||
</button>
|
||
</th>
|
||
<th className="text-right pb-2 font-medium">Credits</th>
|
||
</tr>
|
||
</thead>
|
||
<motion.tbody
|
||
className="text-white/90"
|
||
initial="hidden"
|
||
animate="visible"
|
||
variants={{ hidden: {}, visible: {} }}
|
||
>
|
||
{paginatedCalls.map((call, index) => (
|
||
<motion.tr
|
||
key={call.id}
|
||
className="border-t border-white/5 cursor-pointer hover:bg-white/5 transition"
|
||
variants={fadeInUp}
|
||
transition={{ delay: 0.1 + index * 0.08, duration: 0.35 }}
|
||
onClick={() => setSelectedCall(call)}
|
||
>
|
||
<td className="py-3">{call.contactName}</td>
|
||
<td className="py-3">{call.date}</td>
|
||
<td className="py-3">{call.duration}</td>
|
||
<td className="py-3">
|
||
<span
|
||
className={`px-2.5 py-1 rounded-full text-xs border ${
|
||
call.status === 'Completed'
|
||
? 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30'
|
||
: 'bg-red-500/20 text-red-200 border-red-400/30'
|
||
}`}
|
||
>
|
||
{call.status}
|
||
</span>
|
||
</td>
|
||
<td className="py-3 text-right">{call.credits}</td>
|
||
</motion.tr>
|
||
))}
|
||
</motion.tbody>
|
||
</table>
|
||
<div className="flex items-center justify-between pt-4 text-xs text-[#9ca3af]">
|
||
<span>
|
||
Page {currentPage} of {totalPages}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||
disabled={currentPage === 1}
|
||
className="px-2 py-1 rounded-full border border-white/10 text-[#9ca3af] hover:text-white disabled:opacity-40"
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||
disabled={currentPage === totalPages}
|
||
className="px-2 py-1 rounded-full border border-white/10 text-[#9ca3af] hover:text-white disabled:opacity-40"
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
</section>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
<button
|
||
onClick={() => router.push('/dashboard/chat')}
|
||
className="bg-[#1a1625] rounded-2xl border border-white/10 p-6 shadow-lg hover:-translate-y-1 hover:border-[#8b5cf6]/40 hover:shadow-2xl transition text-left w-full hover:scale-[1.01]"
|
||
>
|
||
<MessageSquare className="h-10 w-10 text-[#8b5cf6] mb-4" />
|
||
<h3 className="text-xl font-semibold mb-2">Chat</h3>
|
||
<p className="text-[#9ca3af] text-sm">
|
||
Talk to your AI companion in real-time.
|
||
</p>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => router.push('/dashboard/scheduled-calls')}
|
||
className="bg-[#1a1625] rounded-2xl border border-white/10 p-6 shadow-lg hover:-translate-y-1 hover:border-[#8b5cf6]/40 hover:shadow-2xl transition text-left w-full hover:scale-[1.01]"
|
||
data-tour="make-call"
|
||
>
|
||
<CalendarClock className="h-10 w-10 text-[#8b5cf6] mb-4" />
|
||
<h3 className="text-xl font-semibold mb-2">Scheduled Calls</h3>
|
||
<p className="text-[#9ca3af] text-sm">
|
||
Plan new calls and manage your queue.
|
||
</p>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => router.push('/dashboard/credits')}
|
||
className="bg-[#1a1625] rounded-2xl border border-white/10 p-6 shadow-lg hover:-translate-y-1 hover:border-[#8b5cf6]/40 hover:shadow-2xl transition text-left w-full hover:scale-[1.01]"
|
||
>
|
||
<CreditCard className="h-10 w-10 text-[#8b5cf6] mb-4" />
|
||
<h3 className="text-xl font-semibold mb-2">Credits & Limits</h3>
|
||
<p className="text-[#9ca3af] text-sm">
|
||
Track balance and adjust default call limits.
|
||
</p>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="bg-gradient-to-r from-[#8b5cf6]/10 to-[#4f46e5]/10 rounded-2xl border border-[#8b5cf6]/20 p-6 shadow-lg transition-transform hover:scale-[1.01]">
|
||
<h3 className="text-lg font-semibold mb-2 flex items-center gap-2">
|
||
<Sparkles className="h-5 w-5 text-[#8b5cf6]" />
|
||
Account Status
|
||
</h3>
|
||
<p className="text-sm text-[#9ca3af] mb-3">
|
||
Email: <span className="text-white">{user.email}</span>
|
||
</p>
|
||
<p className="text-sm text-[#9ca3af]">
|
||
UID: <span className="font-mono text-xs text-white/80">{user.uid}</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<AnimatePresence>
|
||
{selectedCall && (
|
||
<motion.div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
onClick={() => setSelectedCall(null)}
|
||
>
|
||
<motion.div
|
||
initial={{ scale: 0.96, opacity: 0, y: 20 }}
|
||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||
exit={{ scale: 0.96, opacity: 0, y: 10 }}
|
||
transition={{ duration: 0.2 }}
|
||
className="w-full max-w-md rounded-3xl border border-[#8b5cf6]/30 bg-[#1a1625] p-6 shadow-2xl"
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<h3 className="text-xl font-semibold">{selectedCall.contactName}</h3>
|
||
<p className="text-xs text-[#9ca3af] mt-1">{selectedCall.date}</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setSelectedCall(null)}
|
||
className="text-[#9ca3af] hover:text-white transition"
|
||
aria-label="Close call details"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="mt-5 space-y-3 text-sm">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[#9ca3af]">Duration</span>
|
||
<span className="text-white">{selectedCall.duration}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[#9ca3af]">Status</span>
|
||
<span
|
||
className={`px-2.5 py-1 rounded-full text-xs border ${
|
||
selectedCall.status === 'Completed'
|
||
? 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30'
|
||
: 'bg-red-500/20 text-red-200 border-red-400/30'
|
||
}`}
|
||
>
|
||
{selectedCall.status}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[#9ca3af]">Credits Used</span>
|
||
<span className="text-white">{selectedCall.credits}</span>
|
||
</div>
|
||
</div>
|
||
<div className="mt-6 flex justify-end">
|
||
<button
|
||
onClick={() => setSelectedCall(null)}
|
||
className="px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-white hover:bg-white/10 transition"
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|