441 lines
17 KiB
TypeScript
441 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import type { DefaultLimits, Purchase } from '@/lib/types';
|
|
import Link from 'next/link';
|
|
import { CreditCard, Flame, SlidersHorizontal, Wallet } from 'lucide-react';
|
|
import { getPurchasesByUser } from '@/lib/firestore';
|
|
import toast from 'react-hot-toast';
|
|
|
|
export default function CreditsPage() {
|
|
const { user, isLoading } = useAuth();
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const searchParams = useSearchParams();
|
|
const [balance, setBalance] = useState(0);
|
|
const [limits, setLimits] = useState<DefaultLimits | null>(null);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [purchaseHistory, setPurchaseHistory] = useState<Purchase[]>([]);
|
|
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
|
const [isCreatingCheckout, setIsCreatingCheckout] = useState<string | null>(null);
|
|
const getFallbackLimits = (userId: string): DefaultLimits => ({
|
|
userId,
|
|
defaultMaxMinutes: 15,
|
|
defaultMaxCredits: 15,
|
|
allowAutoExtension: false,
|
|
maxExtraMinutes: 0,
|
|
defaultMaxRecalls: 3,
|
|
minDelayBetweenRecalls: 30,
|
|
updatedAt: null as unknown as DefaultLimits['updatedAt'],
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!isLoading && !user) {
|
|
router.push('/auth');
|
|
}
|
|
}, [user, isLoading, router]);
|
|
|
|
const fetchCredits = async () => {
|
|
try {
|
|
const response = await fetch('/api/credits');
|
|
if (!response.ok) {
|
|
toast.error('Unable to load credits right now.');
|
|
setBalance(0);
|
|
return;
|
|
}
|
|
const data = await response.json();
|
|
setBalance(data.balance ?? 0);
|
|
} catch (error) {
|
|
toast.error('Unable to load credits. Please try again.');
|
|
setBalance(0);
|
|
}
|
|
};
|
|
|
|
const fetchLimits = async () => {
|
|
try {
|
|
const response = await fetch('/api/credits/limits');
|
|
if (!response.ok) {
|
|
toast.error('Unable to load limits right now.');
|
|
setLimits(getFallbackLimits(user?.uid ?? 'test-user-id'));
|
|
return;
|
|
}
|
|
const data = await response.json();
|
|
setLimits(data.limits ?? getFallbackLimits(user?.uid ?? 'test-user-id'));
|
|
} catch (error) {
|
|
toast.error('Unable to load limits. Please try again.');
|
|
setLimits(getFallbackLimits(user?.uid ?? 'test-user-id'));
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (user) {
|
|
fetchCredits();
|
|
fetchLimits();
|
|
getPurchasesByUser(user.uid)
|
|
.then(setPurchaseHistory)
|
|
.catch((error) => {
|
|
console.error('Error loading purchases', error);
|
|
toast.error('Unable to load purchases right now.');
|
|
});
|
|
}
|
|
}, [user]);
|
|
|
|
const estimatedValue = useMemo(() => {
|
|
return balance.toFixed(2);
|
|
}, [balance]);
|
|
|
|
const updateLimits = (patch: Partial<DefaultLimits>) => {
|
|
setLimits((prev) => (prev ? { ...prev, ...patch } : prev));
|
|
};
|
|
|
|
const handleSaveLimits = async () => {
|
|
if (!limits || isSaving) return;
|
|
setIsSaving(true);
|
|
try {
|
|
const payload = {
|
|
defaultMaxMinutes: Number(limits.defaultMaxMinutes),
|
|
defaultMaxCredits: Number(limits.defaultMaxCredits),
|
|
allowAutoExtension: limits.allowAutoExtension,
|
|
maxExtraMinutes: Number(limits.maxExtraMinutes),
|
|
defaultMaxRecalls: Number(limits.defaultMaxRecalls),
|
|
minDelayBetweenRecalls: Number(limits.minDelayBetweenRecalls),
|
|
};
|
|
const response = await fetch('/api/credits/limits', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!response.ok) throw new Error('Failed');
|
|
await fetchLimits();
|
|
toast.success('Limits updated.');
|
|
} catch (error) {
|
|
console.error('Error saving limits', error);
|
|
toast.error('Failed to save limits.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleAddCredits = async () => {
|
|
const raw = window.prompt('Enter credits to add', '10');
|
|
if (!raw) return;
|
|
const amount = Number(raw);
|
|
if (!Number.isFinite(amount) || amount <= 0) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/credits', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ amount }),
|
|
});
|
|
if (!response.ok) throw new Error('Failed');
|
|
const data = await response.json();
|
|
setBalance(data.balance ?? balance);
|
|
toast.success('Credits added successfully.');
|
|
} catch (error) {
|
|
console.error('Error adding credits', error);
|
|
toast.error('Unable to add credits right now.');
|
|
}
|
|
};
|
|
|
|
const creditPackages = [
|
|
{ id: 'credits-10', label: '10 Credits', price: '€10', value: 10, tag: '€1/credit' },
|
|
{ id: 'credits-50', label: '50 Credits', price: '€45', value: 50, tag: 'Best Value' },
|
|
{ id: 'credits-100', label: '100 Credits', price: '€85', value: 100, tag: 'Enterprise' },
|
|
];
|
|
|
|
const handleCheckout = async (packageId: string) => {
|
|
setCheckoutError(null);
|
|
setIsCreatingCheckout(packageId);
|
|
const selected = creditPackages.find((pack) => pack.id === packageId);
|
|
if (!selected) {
|
|
setCheckoutError('Invalid credit package.');
|
|
setIsCreatingCheckout(null);
|
|
return;
|
|
}
|
|
try {
|
|
const response = await fetch('/api/stripe/checkout', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
credits: selected.value,
|
|
amount: Number(selected.price.replace('€', '')),
|
|
userId: user?.uid,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Unable to create a checkout session.');
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (data.url) {
|
|
toast.success('Redirecting to checkout...');
|
|
window.location.href = data.url;
|
|
} else {
|
|
throw new Error('Missing Stripe checkout URL.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Checkout error', error);
|
|
setCheckoutError('Unable to start checkout. Please try again.');
|
|
toast.error('Checkout failed. Please try again.');
|
|
} finally {
|
|
setIsCreatingCheckout(null);
|
|
}
|
|
};
|
|
|
|
if (isLoading || !limits) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-[#0f0b1a] text-white">
|
|
<div className="animate-pulse text-lg">Loading...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const navLinks = [
|
|
{ label: 'Dashboard', href: '/dashboard' },
|
|
{ label: 'Scheduled Calls', href: '/dashboard/scheduled-calls' },
|
|
{ label: 'Voice Agent', href: '/dashboard/agent-settings' },
|
|
{ label: 'Notifications', href: '/dashboard/notifications' },
|
|
{ label: 'Settings', href: '/dashboard/settings' },
|
|
];
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] text-white px-6 py-10">
|
|
<div className="max-w-4xl mx-auto space-y-6">
|
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => router.push('/dashboard')}
|
|
className="text-[#9ca3af] hover:text-white transition"
|
|
>
|
|
←
|
|
</button>
|
|
<h1 className="text-2xl font-semibold">Credits & Limits</h1>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{navLinks.map((link) => {
|
|
const isActive = pathname === link.href;
|
|
return (
|
|
<Link
|
|
key={link.href}
|
|
href={link.href}
|
|
className={`px-3 py-1.5 rounded-full border text-xs transition ${
|
|
isActive
|
|
? 'bg-[#8b5cf6]/20 border-[#8b5cf6]/40 text-white'
|
|
: 'bg-white/5 border-white/10 text-[#9ca3af] hover:text-white'
|
|
}`}
|
|
>
|
|
{link.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</header>
|
|
|
|
<div className="bg-[#1a1625] border border-[#8b5cf6]/40 rounded-3xl p-6 shadow-2xl space-y-6">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-xs uppercase text-[#9ca3af]">Current Balance</p>
|
|
<h2 className="text-4xl font-semibold mt-2">{balance} credits</h2>
|
|
<div className="text-sm text-[#9ca3af] mt-2 space-y-1">
|
|
<p>≈ {balance} minutes of calls</p>
|
|
<p>Estimated value: €{estimatedValue}</p>
|
|
</div>
|
|
</div>
|
|
<div className="h-14 w-14 rounded-2xl bg-[#8b5cf6]/20 border border-[#8b5cf6]/40 flex items-center justify-center">
|
|
<Wallet className="h-6 w-6 text-[#8b5cf6]" />
|
|
</div>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-white/5 border border-white/10 overflow-hidden">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-[#8b5cf6] to-[#4f46e5]"
|
|
style={{ width: `${Math.min(balance, 100)}%` }}
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleAddCredits}
|
|
className="w-full md:w-auto px-6 py-3 rounded-2xl bg-[#8b5cf6] text-white font-semibold shadow-2xl hover:bg-[#7c3aed] transition flex items-center gap-2"
|
|
>
|
|
<CreditCard className="h-4 w-4" />
|
|
Add Credits
|
|
</button>
|
|
</div>
|
|
|
|
{(searchParams.get('success') || searchParams.get('canceled')) && (
|
|
<div
|
|
className={`rounded-2xl border p-4 text-sm ${
|
|
searchParams.get('success')
|
|
? 'border-emerald-400/40 bg-emerald-500/10 text-emerald-200'
|
|
: 'border-red-400/40 bg-red-500/10 text-red-200'
|
|
}`}
|
|
>
|
|
{searchParams.get('success')
|
|
? 'Payment successful! Credits will appear shortly.'
|
|
: 'Payment canceled. You can try again anytime.'}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{creditPackages.map((pack) => (
|
|
<div
|
|
key={pack.id}
|
|
className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 shadow-lg space-y-4"
|
|
>
|
|
<div>
|
|
<p className="text-xs uppercase text-[#9ca3af]">Package</p>
|
|
<h3 className="text-xl font-semibold">{pack.label}</h3>
|
|
<p className="text-3xl font-semibold mt-2">{pack.price}</p>
|
|
<p className="text-xs text-[#9ca3af] mt-2">{pack.tag}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => handleCheckout(pack.id)}
|
|
disabled={isCreatingCheckout === pack.id}
|
|
className="w-full px-4 py-2 rounded-2xl bg-[#8b5cf6] text-white font-semibold shadow-2xl hover:bg-[#7c3aed] transition disabled:opacity-60"
|
|
>
|
|
{isCreatingCheckout === pack.id ? 'Redirecting...' : 'Buy Now'}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{checkoutError && (
|
|
<div className="text-sm text-red-300">{checkoutError}</div>
|
|
)}
|
|
|
|
<div className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 shadow-lg">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-semibold">Monthly Usage</h3>
|
|
<p className="text-xs text-[#9ca3af]">Last 30 days</p>
|
|
</div>
|
|
<span className="text-sm text-emerald-400 flex items-center gap-1">
|
|
<Flame className="h-4 w-4" />
|
|
+12%
|
|
</span>
|
|
</div>
|
|
<div className="mt-6 h-32 rounded-2xl bg-gradient-to-b from-[#8b5cf6]/40 to-transparent border border-[#8b5cf6]/30 flex items-end gap-3 px-4 pb-4">
|
|
{[30, 40, 20, 55].map((value, index) => (
|
|
<div
|
|
key={index}
|
|
className="w-6 rounded-full bg-[#8b5cf6]/70"
|
|
style={{ height: `${value}%` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 shadow-lg space-y-4">
|
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
|
<SlidersHorizontal className="h-5 w-5 text-[#8b5cf6]" />
|
|
Default limits per task
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-xs uppercase text-[#9ca3af]">Max Minutes</label>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
value={limits.defaultMaxMinutes}
|
|
onChange={(event) =>
|
|
updateLimits({ defaultMaxMinutes: Number(event.target.value) })
|
|
}
|
|
className="mt-2 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>
|
|
<div>
|
|
<label className="text-xs uppercase text-[#9ca3af]">Max Credits</label>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
value={limits.defaultMaxCredits}
|
|
onChange={(event) =>
|
|
updateLimits({ defaultMaxCredits: Number(event.target.value) })
|
|
}
|
|
className="mt-2 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>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-xs uppercase text-[#9ca3af]">Max Recalls</label>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
value={limits.defaultMaxRecalls}
|
|
onChange={(event) =>
|
|
updateLimits({ defaultMaxRecalls: Number(event.target.value) })
|
|
}
|
|
className="mt-2 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>
|
|
<div>
|
|
<label className="text-xs uppercase text-[#9ca3af]">
|
|
Min Delay (sec)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
value={limits.minDelayBetweenRecalls}
|
|
onChange={(event) =>
|
|
updateLimits({ minDelayBetweenRecalls: Number(event.target.value) })
|
|
}
|
|
className="mt-2 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>
|
|
</div>
|
|
<button
|
|
onClick={handleSaveLimits}
|
|
className="w-full md:w-auto px-6 py-2 rounded-full bg-white/5 border border-[#8b5cf6]/40 text-white hover:bg-[#8b5cf6]/20 transition"
|
|
disabled={isSaving}
|
|
>
|
|
{isSaving ? 'Saving...' : 'Save Limits'}
|
|
</button>
|
|
<p className="text-xs text-[#9ca3af]">
|
|
Credits are consumed per task. One credit is approximately equal to 1 minute of
|
|
processing time or 1 voice call minute. Rates vary by destination.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-[#1a1625] border border-purple-500/20 rounded-3xl p-6 shadow-lg space-y-4">
|
|
<h3 className="text-lg font-semibold">Purchase History</h3>
|
|
{purchaseHistory.length === 0 ? (
|
|
<p className="text-sm text-[#9ca3af]">No purchases yet.</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="text-[#9ca3af]">
|
|
<tr>
|
|
<th className="text-left pb-2 font-medium">Date</th>
|
|
<th className="text-left pb-2 font-medium">Credits</th>
|
|
<th className="text-left pb-2 font-medium">Amount</th>
|
|
<th className="text-left pb-2 font-medium">Session</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="text-white/90">
|
|
{purchaseHistory.map((purchase) => (
|
|
<tr key={purchase.id} className="border-t border-white/5">
|
|
<td className="py-3">
|
|
{purchase.createdAt?.toDate
|
|
? purchase.createdAt.toDate().toLocaleDateString()
|
|
: '—'}
|
|
</td>
|
|
<td className="py-3">{purchase.credits}</td>
|
|
<td className="py-3">€{purchase.amount}</td>
|
|
<td className="py-3 text-xs text-[#9ca3af]">
|
|
{purchase.stripeSessionId.slice(0, 8)}...
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|