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

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>
);
}