Add prepaid resource credits with prorated deploy billing.
When users delete an app before plan expiry, remaining resources become credits for a new deploy. The deploy calculator shows covered vs additional charges, prices optional services correctly, and prorates extras to days left on the credit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -566,9 +566,14 @@ export default function AppDetailPage() {
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/applications/${appId}`),
|
||||
onSuccess: () => {
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
toast.success('Application deleted');
|
||||
queryClient.invalidateQueries({ queryKey: ['resource-credits'] });
|
||||
if (res.data?.resourceCredit) {
|
||||
toast.success('Application deleted. Prepaid resources are on your dashboard.');
|
||||
} else {
|
||||
toast.success('Application deleted');
|
||||
}
|
||||
router.push('/dashboard/apps');
|
||||
},
|
||||
onError: () => toast.error('Failed to delete application'),
|
||||
@@ -862,7 +867,11 @@ export default function AppDetailPage() {
|
||||
const handleDelete = async () => {
|
||||
const ok = await confirm({
|
||||
title: `Delete "${app.name}"?`,
|
||||
message: 'This will permanently remove:\n• All Kubernetes resources (pods, services, ingress)\n• Database and volumes\n• All deployment records\n• Uploaded source code',
|
||||
message:
|
||||
'This will permanently remove all Kubernetes resources, data, and deployment records.\n\n' +
|
||||
(app.planExpiresAt && new Date(app.planExpiresAt) > new Date()
|
||||
? 'Your remaining paid resources will appear on the dashboard for use on a new app at no extra charge.'
|
||||
: ''),
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
@@ -937,11 +946,12 @@ export default function AppDetailPage() {
|
||||
</>
|
||||
)}
|
||||
<button onClick={handleDelete} disabled={deleteMutation.isPending} className="btn-danger text-sm disabled:opacity-50">
|
||||
{deleteMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : 'Delete'}
|
||||
{deleteMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Renewal Banner for Expired/Suspended Apps */}
|
||||
{needsRenewal && (
|
||||
<div className={`rounded-xl p-4 border-2 ${
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Application, AppLifecycleStatus } from '@/types';
|
||||
import type { Application } from '@/types';
|
||||
import { Rocket, Package, Hexagon, Database, Box, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
|
||||
@@ -57,9 +57,14 @@ export default function AppsPage() {
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/applications/${id}`),
|
||||
onSuccess: () => {
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
toast.success('Application deleted');
|
||||
queryClient.invalidateQueries({ queryKey: ['resource-credits'] });
|
||||
if (res.data?.resourceCredit) {
|
||||
toast.success('Application deleted. Your prepaid resources are shown on the dashboard.');
|
||||
} else {
|
||||
toast.success('Application deleted');
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('Failed to delete application'),
|
||||
});
|
||||
@@ -110,7 +115,6 @@ export default function AppsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block table-wrapper">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50/80">
|
||||
@@ -129,22 +133,21 @@ export default function AppsPage() {
|
||||
const lifecycle = app.lifecycleStatus || 'active';
|
||||
const expiry = formatExpiry(app.planExpiresAt);
|
||||
return (
|
||||
<tr key={app.id} className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''}`}>
|
||||
<tr
|
||||
key={app.id}
|
||||
className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''}`}
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 group">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
|
||||
<Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">
|
||||
{app.name}
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">{app.name}</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 capitalize">{app.runtime}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{latestStatus}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
|
||||
@@ -168,8 +171,14 @@ export default function AppsPage() {
|
||||
View
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({ title: 'Delete Application', message: `Are you sure you want to delete "${app.name}"?`, confirmText: 'Delete', variant: 'danger' });
|
||||
const ok = await confirm({
|
||||
title: 'Delete Application',
|
||||
message: `Permanently delete "${app.name}" and all its data?\n\nIf your plan still has time left, the prepaid resources will appear on your dashboard for use on a new app at no extra charge.`,
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteMutation.mutate(app.id);
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors"
|
||||
@@ -185,7 +194,6 @@ export default function AppsPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="md:hidden grid gap-3">
|
||||
{apps.map((app) => {
|
||||
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||
@@ -207,11 +215,8 @@ export default function AppsPage() {
|
||||
<p className="text-xs text-gray-500 capitalize">{app.runtime}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{latestStatus}</span>
|
||||
</div>
|
||||
{/* Lifecycle & Expiry row */}
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
|
||||
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
|
||||
@@ -225,8 +230,12 @@ export default function AppsPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1"><Database className="w-3 h-3" /> {app.databaseType}</span>
|
||||
<span className="flex items-center gap-1"><Box className="w-3 h-3" /> {app.replicas} replica{app.replicas > 1 ? 's' : ''}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Database className="w-3 h-3" /> {app.databaseType}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Box className="w-3 h-3" /> {app.replicas} replica{app.replicas > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import api from '@/lib/api';
|
||||
import { parseDotenv } from '@/lib/parseDotenv';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types';
|
||||
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, DeployCostPreview, BillingCycle } from '@/types';
|
||||
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2, Globe, Copy, AlertCircle, ShieldCheck } from 'lucide-react';
|
||||
|
||||
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
|
||||
@@ -123,22 +123,25 @@ export default function DeployPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Cost calculation for the review step
|
||||
const { data: costData, isLoading: costLoading } = useQuery<CostBreakdown>({
|
||||
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch, enableCustomDomain],
|
||||
queryFn: () => api.post('/billing/calculate', {
|
||||
runtime: form.runtime,
|
||||
databaseType: form.databaseType,
|
||||
cpuLimit: form.cpuLimit,
|
||||
memoryLimit: form.memoryLimit,
|
||||
replicas: form.replicas,
|
||||
dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}` : undefined,
|
||||
appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}`,
|
||||
enableRedis: form.enableRedis,
|
||||
enableRabbitmq: form.enableRabbitmq,
|
||||
enableElasticsearch: form.enableElasticsearch,
|
||||
enableCustomDomain,
|
||||
}).then((r) => r.data),
|
||||
const deployCostPayload = {
|
||||
runtime: form.runtime,
|
||||
databaseType: form.databaseType,
|
||||
cpuLimit: form.cpuLimit,
|
||||
memoryLimit: form.memoryLimit,
|
||||
replicas: form.replicas,
|
||||
dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}Gi` : undefined,
|
||||
appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}Gi`,
|
||||
enableRedis: form.enableRedis,
|
||||
enableRabbitmq: form.enableRabbitmq,
|
||||
enableElasticsearch: form.enableElasticsearch,
|
||||
enableCustomDomain,
|
||||
cycle: selectedCycle,
|
||||
};
|
||||
|
||||
// Cost calculation for the review step (includes prepaid resource credits)
|
||||
const { data: costData, isLoading: costLoading } = useQuery<DeployCostPreview>({
|
||||
queryKey: ['deploy-cost', deployCostPayload],
|
||||
queryFn: () => api.post('/billing/calculate-deploy', deployCostPayload).then((r) => r.data),
|
||||
enabled: step === 3,
|
||||
});
|
||||
|
||||
@@ -149,9 +152,14 @@ export default function DeployPage() {
|
||||
enabled: step === 3,
|
||||
});
|
||||
|
||||
const payAmount = costData ? costData[selectedCycle] : 0;
|
||||
const payAmount = costData?.amountDue ?? 0;
|
||||
const fullPrice = costData?.fullAmount ?? 0;
|
||||
const coveredAmount = costData?.coveredAmount ?? 0;
|
||||
const extrasBreakdown = costData?.extrasBreakdown ?? [];
|
||||
const prepaidCreditUsed = costData?.prepaidCreditUsed ?? false;
|
||||
const walletBalance = walletData?.balance ?? 0;
|
||||
const hasEnoughBalance = walletBalance >= payAmount;
|
||||
const hasEnoughBalance = payAmount === 0 || walletBalance >= payAmount;
|
||||
const requiresPayment = (costData?.monthly ?? 0) > 0 && payAmount > 0;
|
||||
|
||||
const walletPayMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -218,18 +226,20 @@ export default function DeployPage() {
|
||||
mutationFn: async () => {
|
||||
// Initiate gateway
|
||||
setDeployStage('paying');
|
||||
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
||||
amount: payAmount,
|
||||
description: `Deploy: ${form.name} (${selectedCycle})`,
|
||||
callbackUrl: `${window.location.origin}/dashboard/deploy`,
|
||||
});
|
||||
if (payAmount > 0) {
|
||||
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
||||
amount: payAmount,
|
||||
description: `Deploy: ${form.name} (${selectedCycle})`,
|
||||
callbackUrl: `${window.location.origin}/dashboard/deploy`,
|
||||
});
|
||||
|
||||
// In production, redirect to gw.gatewayUrl
|
||||
// For now, auto-verify (simulated)
|
||||
await api.post('/billing/gateway/verify', {
|
||||
trackingCode: gw.trackingCode,
|
||||
amount: payAmount,
|
||||
});
|
||||
// In production, redirect to gw.gatewayUrl
|
||||
// For now, auto-verify (simulated)
|
||||
await api.post('/billing/gateway/verify', {
|
||||
trackingCode: gw.trackingCode,
|
||||
amount: payAmount,
|
||||
});
|
||||
}
|
||||
|
||||
// Now create the app
|
||||
setDeployStage('creating');
|
||||
@@ -2227,6 +2237,18 @@ export default function DeployPage() {
|
||||
<div className="text-sm text-gray-400 text-center py-3">Calculating...</div>
|
||||
) : costData && costData.monthly > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{prepaidCreditUsed && costData.creditApplied && (
|
||||
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-900">
|
||||
<p className="font-medium">Prepaid credit applied</p>
|
||||
<p className="text-indigo-700 mt-0.5">
|
||||
Resources from "{costData.creditApplied.sourceAppName || 'deleted app'}" are covered
|
||||
until {costData.creditApplied.remainingLabel} remaining.
|
||||
{payAmount > 0
|
||||
? ` You only pay for new add-ons below (prorated to ${costData.prorateRemainingDays ?? '?'}/${costData.proratePeriodDays ?? '?'} days left on your credit).`
|
||||
: ' No charge for this deploy.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Billing cycle selector */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['hourly', 'monthly', 'yearly'] as BillingCycle[]).map((cycle) => (
|
||||
@@ -2244,14 +2266,67 @@ export default function DeployPage() {
|
||||
{cycle === 'hourly' ? 'Hourly' : cycle === 'monthly' ? 'Monthly' : 'Yearly'}
|
||||
</p>
|
||||
<p className="text-lg font-bold text-emerald-700">
|
||||
{Number(costData[cycle]).toLocaleString('en-US')}
|
||||
{prepaidCreditUsed && cycle === selectedCycle && fullPrice > payAmount ? (
|
||||
<>
|
||||
<span className="block text-xs font-normal text-gray-400 line-through">
|
||||
{Number(costData[cycle]).toLocaleString('en-US')}
|
||||
</span>
|
||||
{Number(payAmount).toLocaleString('en-US')}
|
||||
</>
|
||||
) : (
|
||||
Number(costData[cycle]).toLocaleString('en-US')
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">Toman</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{costData.breakdown && costData.breakdown.length > 0 && (
|
||||
{prepaidCreditUsed && (
|
||||
<div className="mt-2 pt-3 border-t border-emerald-200/50 space-y-2">
|
||||
<p className="text-xs font-medium text-gray-500">Prepaid credit pricing</p>
|
||||
<div className="flex justify-between text-xs py-1">
|
||||
<span className="text-gray-600">Full plan price ({selectedCycle})</span>
|
||||
<span className="text-gray-900 font-medium">
|
||||
{Number(fullPrice).toLocaleString('en-US')} T
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs py-1 text-indigo-700">
|
||||
<span>Covered by prepaid credit</span>
|
||||
<span className="font-medium">
|
||||
−{Number(coveredAmount).toLocaleString('en-US')} T
|
||||
</span>
|
||||
</div>
|
||||
{extrasBreakdown.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs font-medium text-amber-700 pt-1">Additional charges (you pay)</p>
|
||||
{extrasBreakdown.map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-xs py-1 gap-2">
|
||||
<span className="text-gray-600">{item.label}</span>
|
||||
<span className="text-amber-800 font-medium text-right shrink-0">
|
||||
{item.fullPeriodAmount != null && item.fullPeriodAmount > item.amount && (
|
||||
<span className="text-gray-400 line-through block text-[10px]">
|
||||
{Number(item.fullPeriodAmount).toLocaleString('en-US')} T full period
|
||||
</span>
|
||||
)}
|
||||
+{Number(item.amount).toLocaleString('en-US')} T
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-emerald-700">No additional charges — fully covered.</p>
|
||||
)}
|
||||
<div className="flex justify-between text-sm py-2 border-t border-emerald-200/50 font-semibold">
|
||||
<span className="text-gray-800">Amount due ({selectedCycle})</span>
|
||||
<span className="text-emerald-800">
|
||||
{Number(payAmount).toLocaleString('en-US')} Toman
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!prepaidCreditUsed && costData.breakdown && costData.breakdown.length > 0 && (
|
||||
<div className="mt-2 pt-3 border-t border-emerald-200/50">
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">Breakdown</p>
|
||||
{costData.breakdown.map((item, i) => (
|
||||
@@ -2262,6 +2337,12 @@ export default function DeployPage() {
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between text-sm py-2 border-t border-emerald-200/50 mt-1 font-semibold">
|
||||
<span className="text-gray-800">Amount due ({selectedCycle})</span>
|
||||
<span className="text-emerald-800">
|
||||
{Number(payAmount).toLocaleString('en-US')} Toman
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2271,7 +2352,12 @@ export default function DeployPage() {
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
{costData && costData.monthly > 0 && (
|
||||
{costData && costData.monthly > 0 && !requiresPayment && (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
|
||||
No payment required — your prepaid resource credit covers this deployment.
|
||||
</div>
|
||||
)}
|
||||
{requiresPayment && (
|
||||
<div className="bg-white rounded-xl p-5 border border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Payment Method</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -2286,7 +2372,9 @@ export default function DeployPage() {
|
||||
<p className="mt-2 font-semibold text-sm text-gray-900">Pay from Wallet</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Balance: {Number(walletBalance).toLocaleString('en-US')} T
|
||||
{!hasEnoughBalance && <span className="text-red-500 block mt-0.5">Insufficient balance</span>}
|
||||
{requiresPayment && !hasEnoughBalance && (
|
||||
<span className="text-red-500 block mt-0.5">Insufficient balance</span>
|
||||
)}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
@@ -2304,7 +2392,14 @@ export default function DeployPage() {
|
||||
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Amount to pay ({selectedCycle})</span>
|
||||
<span className="text-lg font-bold text-gray-900">{Number(payAmount).toLocaleString('en-US')} Toman</span>
|
||||
<div className="text-right">
|
||||
{prepaidCreditUsed && fullPrice > payAmount && (
|
||||
<span className="block text-sm text-gray-400 line-through">
|
||||
{Number(fullPrice).toLocaleString('en-US')} Toman
|
||||
</span>
|
||||
)}
|
||||
<span className="text-lg font-bold text-gray-900">{Number(payAmount).toLocaleString('en-US')} Toman</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -2337,6 +2432,8 @@ export default function DeployPage() {
|
||||
if (!costData || costData.monthly === 0) {
|
||||
// No pricing — deploy directly
|
||||
handleSubmit();
|
||||
} else if (payAmount === 0) {
|
||||
walletPayMutation.mutate();
|
||||
} else if (paymentMethod === 'wallet') {
|
||||
if (!hasEnoughBalance) {
|
||||
toast.error('Insufficient wallet balance. Please top up or use payment gateway.');
|
||||
@@ -2355,7 +2452,9 @@ export default function DeployPage() {
|
||||
? `Uploading... ${uploadProgress}%`
|
||||
: 'Processing...'
|
||||
: costData && costData.monthly > 0
|
||||
? <><CreditCard className="w-4 h-4 inline" /> Pay & Deploy</>
|
||||
? payAmount === 0
|
||||
? <><Rocket className="w-4 h-4 inline" /> Deploy with prepaid credit</>
|
||||
: <><CreditCard className="w-4 h-4 inline" /> Pay & Deploy</>
|
||||
: <><Rocket className="w-4 h-4 inline" /> Deploy Application</>}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,8 @@ import type { ReactNode } from 'react';
|
||||
import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import type { Application } from '@/types';
|
||||
import { Rocket, Package, Circle, Hexagon } from 'lucide-react';
|
||||
import { Rocket, Package, Circle, Hexagon, Wallet, Clock } from 'lucide-react';
|
||||
import type { ResourceCredit } from '@/types';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'bg-emerald-100 text-emerald-700',
|
||||
@@ -36,6 +37,11 @@ export default function DashboardPage() {
|
||||
queryFn: () => api.get('/applications').then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: resourceCredits = [] } = useQuery<ResourceCredit[]>({
|
||||
queryKey: ['resource-credits'],
|
||||
queryFn: () => api.get('/billing/resource-credits').then((r) => r.data),
|
||||
});
|
||||
|
||||
const runningApps = apps.filter(
|
||||
(a) => a.deployments?.some((d) => d.status === 'running'),
|
||||
);
|
||||
@@ -55,6 +61,47 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{resourceCredits.length > 0 && (
|
||||
<div className="card border-2 border-indigo-100 bg-indigo-50/40 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Wallet className="w-5 h-5 text-indigo-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-indigo-900">Prepaid resource credits</h2>
|
||||
<p className="text-sm text-indigo-700 mt-1">
|
||||
If you delete an app before your plan ends, you can deploy a new app with the same resources at no extra charge until the credit expires.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{resourceCredits.map((credit) => (
|
||||
<div key={credit.id} className="rounded-xl border border-indigo-200 bg-white p-4 text-sm">
|
||||
<p className="font-semibold text-gray-900">
|
||||
{credit.sourceAppName ? `From app “${credit.sourceAppName}”` : 'Resource credit'}
|
||||
</p>
|
||||
<p className="mt-2 inline-flex items-center gap-1 text-indigo-700 font-medium">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{credit.remainingLabel} remaining
|
||||
</p>
|
||||
<ul className="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-gray-600">
|
||||
<li>CPU: {credit.cpuLimit}</li>
|
||||
<li>RAM: {credit.memoryLimit}</li>
|
||||
<li>Replicas: {credit.replicas}</li>
|
||||
<li>DB: {credit.databaseType}</li>
|
||||
<li>DB disk: {credit.dbStorageSize}</li>
|
||||
<li>App disk: {credit.appStorageSize}</li>
|
||||
{credit.enableRedis && <li>Redis</li>}
|
||||
{credit.enableRabbitmq && <li>RabbitMQ</li>}
|
||||
{credit.enableElasticsearch && <li>Elasticsearch</li>}
|
||||
</ul>
|
||||
<Link href="/dashboard/deploy" className="mt-3 inline-block text-xs font-medium text-primary-600 hover:underline">
|
||||
Use on new app →
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="stat-card">
|
||||
|
||||
@@ -144,6 +144,25 @@ export type DeploymentStatus =
|
||||
|
||||
export type AppLifecycleStatus = 'active' | 'suspended' | 'pending_deletion' | 'deleted';
|
||||
|
||||
export interface ResourceCredit {
|
||||
id: string;
|
||||
sourceAppName?: string;
|
||||
runtime: string;
|
||||
databaseType: string;
|
||||
cpuLimit: string;
|
||||
memoryLimit: string;
|
||||
replicas: number;
|
||||
dbStorageSize?: string;
|
||||
appStorageSize?: string;
|
||||
enableRedis: boolean;
|
||||
enableRabbitmq: boolean;
|
||||
enableElasticsearch: boolean;
|
||||
billingCycle?: string;
|
||||
expiresAt: string;
|
||||
remainingMs: number;
|
||||
remainingLabel: string;
|
||||
}
|
||||
|
||||
export interface Cluster {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -399,6 +418,25 @@ export interface CostBreakdown {
|
||||
breakdown: { label: string; hourly: number; monthly: number; yearly: number }[];
|
||||
}
|
||||
|
||||
export interface DeployExtraChargeLine {
|
||||
label: string;
|
||||
amount: number;
|
||||
fullPeriodAmount?: number;
|
||||
}
|
||||
|
||||
export interface DeployCostPreview extends CostBreakdown {
|
||||
cycle: BillingCycle;
|
||||
fullAmount: number;
|
||||
amountDue: number;
|
||||
coveredAmount: number;
|
||||
waivedAmount: number;
|
||||
extrasBreakdown: DeployExtraChargeLine[];
|
||||
creditApplied: ResourceCredit | null;
|
||||
prepaidCreditUsed: boolean;
|
||||
prorateRemainingDays?: number;
|
||||
proratePeriodDays?: number;
|
||||
}
|
||||
|
||||
// ─── Snapshot / Rollback types ──────────────────────
|
||||
|
||||
export type SnapshotType = 'pre_deploy' | 'manual';
|
||||
|
||||
Reference in New Issue
Block a user