Initial commit
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user