424 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|