Files
holacompi/app/dashboard/scheduled-calls/page.tsx
T
Haitham Khalifa b538d84e17 Initial commit
2026-02-16 12:18:06 +01:00

424 lines
16 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import type { CallTask } from '@/lib/types';
import { CalendarDays, PhoneCall } from 'lucide-react';
import toast from 'react-hot-toast';
const STATUS_LABELS: Record<CallTask['status'], string> = {
pending: 'Pending',
in_progress: 'In Progress',
completed: 'Completed',
failed: 'Failed',
};
const STATUS_STYLES: Record<CallTask['status'], string> = {
pending: 'bg-yellow-500/20 text-yellow-200 border-yellow-400/30',
in_progress: 'bg-blue-500/20 text-blue-200 border-blue-400/30',
completed: 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30',
failed: 'bg-red-500/20 text-red-200 border-red-400/30',
};
const FILTERS: Array<{ key: 'all' | 'pending' | 'completed' | 'failed'; label: string }> = [
{ key: 'all', label: 'All' },
{ key: 'pending', label: 'Pending' },
{ key: 'completed', label: 'Completed' },
{ key: 'failed', label: 'Failed' },
];
const formatTimestamp = (value: any) => {
if (!value) return '';
if (typeof value === 'string') return new Date(value).toLocaleString();
if (value?.seconds) return new Date(value.seconds * 1000).toLocaleString();
return value.toString();
};
export default function ScheduledCallsPage() {
const { user, isLoading } = useAuth();
const router = useRouter();
const [tasks, setTasks] = useState<CallTask[]>([]);
const [activeFilter, setActiveFilter] = useState<'all' | 'pending' | 'completed' | 'failed'>(
'all'
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
businessName: '',
phoneNumber: '',
preferredLanguage: 'English',
callGoals: '',
scheduledAt: '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
maxMinutes: 10,
allowRecalls: false,
maxRecalls: 0,
});
const [callNowNumber, setCallNowNumber] = useState('');
const [callNowError, setCallNowError] = useState<string | null>(null);
const [callNowStatus, setCallNowStatus] = useState<'idle' | 'connecting' | 'started'>('idle');
const [creditsBalance, setCreditsBalance] = useState<number | null>(null);
const [calendarMonth, setCalendarMonth] = useState(() => {
const date = new Date();
return new Date(date.getFullYear(), date.getMonth(), 1);
});
const [selectedDate, setSelectedDate] = useState<Date>(() => new Date());
const [selectedTime, setSelectedTime] = useState('10:00');
useEffect(() => {
if (!isLoading && !user) {
router.push('/auth');
}
}, [user, isLoading, router]);
const fetchTasks = async () => {
try {
const response = await fetch('/api/scheduled-calls');
const data = await response.json();
setTasks(data.tasks || []);
} catch (error) {
console.error('Error loading tasks', error);
toast.error('Unable to load scheduled calls.');
}
};
useEffect(() => {
if (user) {
fetchTasks();
fetch('/api/credits')
.then((res) => res.json())
.then((data) => setCreditsBalance(data.balance ?? 0))
.catch(() => {
setCreditsBalance(0);
toast.error('Unable to load credits.');
});
}
}, [user]);
const filteredTasks = useMemo(() => {
if (activeFilter === 'all') return tasks;
return tasks.filter((task) => task.status === activeFilter);
}, [tasks, activeFilter]);
const updateField = (key: keyof typeof formData, value: string | number | boolean) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (isSubmitting) return;
setIsSubmitting(true);
try {
const response = await fetch('/api/scheduled-calls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
scheduledAt: new Date(
`${selectedDate.toDateString()} ${selectedTime}`
).toISOString(),
maxMinutes: Number(formData.maxMinutes),
maxRecalls: Number(formData.maxRecalls),
}),
});
if (!response.ok) {
throw new Error('Failed to schedule call');
}
await fetchTasks();
toast.success('Call scheduled successfully.');
setFormData({
businessName: '',
phoneNumber: '',
preferredLanguage: 'English',
callGoals: '',
scheduledAt: '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
maxMinutes: 10,
allowRecalls: false,
maxRecalls: 0,
});
} catch (error) {
console.error('Error scheduling call', error);
toast.error('Unable to schedule the call.');
} finally {
setIsSubmitting(false);
}
};
const handleCallNow = async () => {
setCallNowError(null);
if (!callNowNumber) {
setCallNowError('Please enter a phone number.');
toast.error('Please enter a phone number.');
return;
}
if (creditsBalance !== null && creditsBalance < 1) {
setCallNowError('Insufficient credits to start a call.');
toast.error('Insufficient credits to start a call.');
return;
}
setCallNowStatus('connecting');
try {
const response = await fetch('/api/vapi/create-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phoneNumber: callNowNumber,
userId: user?.uid,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data?.error || 'Failed to start call.');
}
setCallNowStatus('started');
toast.success('Call started.');
} catch (error) {
console.error('Call now error', error);
setCallNowError(
error instanceof Error ? error.message : 'Failed to start call.'
);
toast.error('Unable to start the call.');
setCallNowStatus('idle');
}
};
const handleCancelTask = async (taskId: string) => {
try {
const response = await fetch(`/api/scheduled-calls/${taskId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to cancel task.');
}
await fetchTasks();
toast.success('Scheduled call canceled.');
} catch (error) {
console.error('Cancel task error', error);
toast.error('Unable to cancel the scheduled call.');
}
};
const daysInMonth = new Date(
calendarMonth.getFullYear(),
calendarMonth.getMonth() + 1,
0
).getDate();
const firstWeekday = new Date(
calendarMonth.getFullYear(),
calendarMonth.getMonth(),
1
).getDay();
const calendarDays = Array.from({ length: daysInMonth }, (_, i) => i + 1);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#0f0b1a] text-white">
<div className="animate-pulse text-lg">Loading...</div>
</div>
);
}
if (!user) return null;
return (
<div className="min-h-screen bg-[#0f0b1a] text-white px-6 py-10">
<div className="max-w-5xl mx-auto space-y-6">
<header className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-semibold">Scheduled Calls</h1>
<p className="text-sm text-[#9ca3af] mt-1">Agent online · 42 mins left</p>
</div>
<button
onClick={() => router.push('/dashboard')}
className="text-sm text-[#9ca3af] hover:text-white transition"
>
Back to Dashboard
</button>
</header>
<div className="flex flex-wrap gap-2 rounded-full bg-[#1a1625] p-1 border border-purple-500/20 w-fit shadow-lg">
{FILTERS.map((filter) => (
<button
key={filter.key}
onClick={() => setActiveFilter(filter.key)}
className={`px-4 py-2 text-sm rounded-full transition ${
activeFilter === filter.key
? 'bg-[#8b5cf6] text-white shadow-[0_0_16px_rgba(139,92,246,0.6)]'
: 'text-[#9ca3af] hover:text-white'
}`}
>
{filter.label}
</button>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 shadow-lg space-y-4">
<div className="flex items-center gap-2">
<PhoneCall className="h-5 w-5 text-[#8b5cf6]" />
<h2 className="text-lg font-semibold">Make Call Now</h2>
</div>
<p className="text-sm text-[#9ca3af]">
Start an immediate outbound call for quick tests.
</p>
<input
value={callNowNumber}
onChange={(event) => setCallNowNumber(event.target.value)}
className="w-full rounded-xl bg-[#0f0b1a] border border-purple-500/20 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
placeholder="+34 600 000 000"
/>
{creditsBalance !== null && (
<p className="text-xs text-[#9ca3af]">
Credits available: {creditsBalance}
</p>
)}
{callNowError && <p className="text-xs text-red-300">{callNowError}</p>}
<button
onClick={handleCallNow}
className="w-full px-4 py-2 rounded-2xl bg-[#8b5cf6] text-white font-semibold shadow-2xl hover:bg-[#7c3aed] transition"
disabled={callNowStatus === 'connecting'}
>
{callNowStatus === 'connecting' ? 'Connecting...' : 'Test Call'}
</button>
</div>
<div className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 shadow-lg space-y-4">
<div className="flex items-center gap-2">
<CalendarDays className="h-5 w-5 text-[#8b5cf6]" />
<h2 className="text-lg font-semibold">Schedule a Call</h2>
</div>
<div className="grid grid-cols-7 gap-2 text-center text-xs text-[#9ca3af]">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => (
<span key={day}>{day}</span>
))}
{Array.from({ length: firstWeekday }).map((_, index) => (
<span key={`empty-${index}`} />
))}
{calendarDays.map((day) => {
const current = new Date(
calendarMonth.getFullYear(),
calendarMonth.getMonth(),
day
);
const isSelected =
current.toDateString() === selectedDate.toDateString();
return (
<button
key={day}
type="button"
onClick={() => setSelectedDate(current)}
className={`h-9 w-9 rounded-lg border text-sm transition ${
isSelected
? 'bg-[#8b5cf6] text-white border-[#8b5cf6]'
: 'border-white/10 text-[#9ca3af] hover:text-white'
}`}
>
{day}
</button>
);
})}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<input
type="time"
value={selectedTime}
onChange={(event) => setSelectedTime(event.target.value)}
className="w-full rounded-xl bg-[#0f0b1a] border border-purple-500/20 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
/>
<input
value={formData.timezone}
onChange={(event) => updateField('timezone', event.target.value)}
className="w-full rounded-xl bg-[#0f0b1a] border border-purple-500/20 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
/>
</div>
<input
value={formData.businessName}
onChange={(event) => updateField('businessName', event.target.value)}
className="w-full rounded-xl bg-[#0f0b1a] border border-purple-500/20 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
placeholder="Business name"
/>
<input
value={formData.phoneNumber}
onChange={(event) => updateField('phoneNumber', event.target.value)}
className="w-full rounded-xl bg-[#0f0b1a] border border-purple-500/20 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
placeholder="+34 600 000 000"
/>
<select
value={formData.preferredLanguage}
onChange={(event) => updateField('preferredLanguage', event.target.value)}
className="w-full rounded-xl bg-[#0f0b1a] border border-purple-500/20 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
>
<option>English</option>
<option>Spanish</option>
</select>
<textarea
value={formData.callGoals}
onChange={(event) => updateField('callGoals', event.target.value)}
className="w-full rounded-xl bg-[#0f0b1a] border border-purple-500/20 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6] min-h-[100px]"
placeholder="Call goals"
/>
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="w-full bg-[#8b5cf6] hover:bg-[#7c3aed] transition text-white font-semibold py-3 rounded-2xl shadow-2xl"
>
{isSubmitting ? 'Scheduling...' : 'Schedule Call'}
</button>
</div>
</div>
<div className="space-y-4">
{filteredTasks.length === 0 && (
<div className="bg-[#1a1625] border border-purple-500/20 rounded-xl p-8 text-center text-[#9ca3af] shadow-lg">
No scheduled calls yet. Click the + button to add your first call.
</div>
)}
{filteredTasks.map((task) => (
<div
key={task.id}
className="bg-[#1a1625] border border-purple-500/20 rounded-xl p-5 shadow-lg"
>
<div className="flex items-center justify-between">
<span
className={`text-xs uppercase tracking-wide px-3 py-1 rounded-full border ${STATUS_STYLES[task.status]}`}
>
{STATUS_LABELS[task.status]}
</span>
<span className="text-xs text-[#9ca3af]">
{formatTimestamp(task.scheduledAt)}
</span>
</div>
<div className="mt-4 space-y-2">
<h3 className="text-lg font-semibold">{task.businessName}</h3>
<p className="text-sm text-[#9ca3af]">{task.phoneNumber}</p>
<p className="text-sm text-white/80 line-clamp-2">{task.callGoals}</p>
</div>
<div className="mt-4 flex items-center justify-between">
<span className="text-xs px-3 py-1 rounded-full bg-[#8b5cf6]/20 text-white border border-[#8b5cf6]/40">
Max {task.maxMinutes} min · UP TO {task.maxCredits}
</span>
<button
onClick={() => handleCancelTask(task.id)}
className="text-xs px-3 py-1 rounded-full bg-white/5 text-[#9ca3af] hover:text-white transition"
>
Cancel
</button>
</div>
</div>
))}
</div>
</div>
</div>
);
}