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