542 lines
20 KiB
TypeScript
542 lines
20 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { useParams, useRouter } from 'next/navigation'
|
|
import Link from 'next/link'
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { useCustomer } from '@/hooks/use-customers'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { customerKeys } from '@/hooks/use-customers'
|
|
import {
|
|
ArrowLeft,
|
|
User,
|
|
Mail,
|
|
Building2,
|
|
Calendar,
|
|
Server,
|
|
Loader2,
|
|
AlertCircle,
|
|
RefreshCw,
|
|
Edit,
|
|
Ban,
|
|
CheckCircle,
|
|
ExternalLink,
|
|
CreditCard,
|
|
Activity,
|
|
Package,
|
|
X,
|
|
Save,
|
|
} from 'lucide-react'
|
|
|
|
type UserStatus = 'ACTIVE' | 'SUSPENDED' | 'PENDING_VERIFICATION'
|
|
type SubscriptionStatus = 'TRIAL' | 'ACTIVE' | 'CANCELED' | 'PAST_DUE'
|
|
type OrderStatus = 'PAYMENT_CONFIRMED' | 'AWAITING_SERVER' | 'SERVER_READY' | 'DNS_PENDING' | 'DNS_READY' | 'PROVISIONING' | 'FULFILLED' | 'EMAIL_CONFIGURED' | 'FAILED'
|
|
|
|
// Status badge components
|
|
function UserStatusBadge({ status }: { status: UserStatus }) {
|
|
const statusConfig: Record<UserStatus, { label: string; className: string }> = {
|
|
ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' },
|
|
SUSPENDED: { label: 'Suspended', className: 'bg-red-100 text-red-800' },
|
|
PENDING_VERIFICATION: { label: 'Pending', className: 'bg-yellow-100 text-yellow-800' },
|
|
}
|
|
const config = statusConfig[status]
|
|
return (
|
|
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${config.className}`}>
|
|
{config.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function SubscriptionBadge({ status }: { status: SubscriptionStatus }) {
|
|
const statusConfig: Record<SubscriptionStatus, { label: string; className: string }> = {
|
|
TRIAL: { label: 'Trial', className: 'bg-blue-100 text-blue-800' },
|
|
ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' },
|
|
CANCELED: { label: 'Canceled', className: 'bg-gray-100 text-gray-800' },
|
|
PAST_DUE: { label: 'Past Due', className: 'bg-red-100 text-red-800' },
|
|
}
|
|
const config = statusConfig[status]
|
|
return (
|
|
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.className}`}>
|
|
{config.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function OrderStatusBadge({ status }: { status: OrderStatus }) {
|
|
const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
|
|
PAYMENT_CONFIRMED: { label: 'Payment Confirmed', className: 'bg-blue-100 text-blue-800' },
|
|
AWAITING_SERVER: { label: 'Awaiting Server', className: 'bg-yellow-100 text-yellow-800' },
|
|
SERVER_READY: { label: 'Server Ready', className: 'bg-cyan-100 text-cyan-800' },
|
|
DNS_PENDING: { label: 'DNS Pending', className: 'bg-orange-100 text-orange-800' },
|
|
DNS_READY: { label: 'DNS Ready', className: 'bg-teal-100 text-teal-800' },
|
|
PROVISIONING: { label: 'Provisioning', className: 'bg-purple-100 text-purple-800' },
|
|
FULFILLED: { label: 'Fulfilled', className: 'bg-green-100 text-green-800' },
|
|
EMAIL_CONFIGURED: { label: 'Email Configured', className: 'bg-emerald-100 text-emerald-800' },
|
|
FAILED: { label: 'Failed', className: 'bg-red-100 text-red-800' },
|
|
}
|
|
const config = statusConfig[status] || { label: status, className: 'bg-gray-100 text-gray-800' }
|
|
return (
|
|
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.className}`}>
|
|
{config.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
export default function CustomerDetailPage() {
|
|
const params = useParams()
|
|
const router = useRouter()
|
|
const queryClient = useQueryClient()
|
|
const customerId = params.id as string
|
|
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
const [editForm, setEditForm] = useState({ name: '', company: '' })
|
|
const [isUpdating, setIsUpdating] = useState(false)
|
|
|
|
const { data: customer, isLoading, isError, error, refetch, isFetching } = useCustomer(customerId)
|
|
|
|
const handleEdit = () => {
|
|
if (customer) {
|
|
setEditForm({
|
|
name: customer.name || '',
|
|
company: customer.company || '',
|
|
})
|
|
setIsEditing(true)
|
|
}
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
setIsUpdating(true)
|
|
try {
|
|
const response = await fetch(`/api/v1/admin/customers/${customerId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(editForm),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to update customer')
|
|
}
|
|
|
|
await queryClient.invalidateQueries({ queryKey: customerKeys.detail(customerId) })
|
|
setIsEditing(false)
|
|
} catch (err) {
|
|
console.error('Error updating customer:', err)
|
|
} finally {
|
|
setIsUpdating(false)
|
|
}
|
|
}
|
|
|
|
const handleStatusChange = async (newStatus: UserStatus) => {
|
|
setIsUpdating(true)
|
|
try {
|
|
const response = await fetch(`/api/v1/admin/customers/${customerId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status: newStatus }),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to update status')
|
|
}
|
|
|
|
await queryClient.invalidateQueries({ queryKey: customerKeys.detail(customerId) })
|
|
} catch (err) {
|
|
console.error('Error updating status:', err)
|
|
} finally {
|
|
setIsUpdating(false)
|
|
}
|
|
}
|
|
|
|
// Loading state
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-[50vh]">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
<p className="text-muted-foreground">Loading customer details...</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Error state
|
|
if (isError) {
|
|
return (
|
|
<div className="flex items-center justify-center h-[50vh]">
|
|
<div className="flex flex-col items-center gap-4 text-center">
|
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
|
<div>
|
|
<p className="font-medium text-destructive">Failed to load customer</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{error instanceof Error ? error.message : 'An error occurred'}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => router.back()}>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Go Back
|
|
</Button>
|
|
<Button variant="outline" onClick={() => refetch()}>
|
|
<RefreshCw className="h-4 w-4 mr-2" />
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!customer) {
|
|
return (
|
|
<div className="flex items-center justify-center h-[50vh]">
|
|
<div className="flex flex-col items-center gap-4 text-center">
|
|
<AlertCircle className="h-8 w-8 text-muted-foreground" />
|
|
<p className="text-muted-foreground">Customer not found</p>
|
|
<Button variant="outline" onClick={() => router.back()}>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Go Back
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const currentSubscription = customer.subscriptions?.[0]
|
|
const totalTokensUsed = customer.totalTokensUsed || 0
|
|
const tokenUsagePercent = currentSubscription
|
|
? Math.min((totalTokensUsed / currentSubscription.tokenLimit) * 100, 100)
|
|
: 0
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Button>
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">{customer.name || customer.email}</h1>
|
|
<p className="text-muted-foreground">{customer.email}</p>
|
|
</div>
|
|
<UserStatusBadge status={customer.status as UserStatus} />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => refetch()}
|
|
disabled={isFetching}
|
|
>
|
|
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</Button>
|
|
{customer.status === 'ACTIVE' ? (
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => handleStatusChange('SUSPENDED')}
|
|
disabled={isUpdating}
|
|
>
|
|
<Ban className="h-4 w-4 mr-2" />
|
|
Suspend
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => handleStatusChange('ACTIVE')}
|
|
disabled={isUpdating}
|
|
>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
Activate
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Row */}
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-3">
|
|
<Package className="h-8 w-8 text-primary" />
|
|
<div>
|
|
<div className="text-2xl font-bold">{customer._count?.orders || 0}</div>
|
|
<p className="text-sm text-muted-foreground">Total Orders</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-3">
|
|
<Server className="h-8 w-8 text-green-600" />
|
|
<div>
|
|
<div className="text-2xl font-bold">
|
|
{customer.orders?.filter((o: { status: string }) => o.status === 'FULFILLED').length || 0}
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">Active Servers</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-3">
|
|
<Activity className="h-8 w-8 text-blue-600" />
|
|
<div>
|
|
<div className="text-2xl font-bold">{totalTokensUsed.toLocaleString()}</div>
|
|
<p className="text-sm text-muted-foreground">Tokens Used</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-3">
|
|
<CreditCard className="h-8 w-8 text-purple-600" />
|
|
<div>
|
|
<div className="text-2xl font-bold capitalize">
|
|
{currentSubscription?.plan.toLowerCase() || 'None'}
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">Current Plan</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-3">
|
|
{/* Customer Profile Card */}
|
|
<Card className="lg:col-span-1">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>Profile</CardTitle>
|
|
{!isEditing && (
|
|
<Button variant="ghost" size="sm" onClick={handleEdit}>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{isEditing ? (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Name</Label>
|
|
<Input
|
|
id="name"
|
|
value={editForm.name}
|
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="company">Company</Label>
|
|
<Input
|
|
id="company"
|
|
value={editForm.company}
|
|
onChange={(e) => setEditForm({ ...editForm, company: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button size="sm" onClick={handleSave} disabled={isUpdating}>
|
|
<Save className="h-4 w-4 mr-2" />
|
|
Save
|
|
</Button>
|
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)}>
|
|
<X className="h-4 w-4 mr-2" />
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
|
<User className="h-6 w-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium">{customer.name || 'No name'}</p>
|
|
<p className="text-sm text-muted-foreground">Name</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-50">
|
|
<Mail className="h-6 w-6 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium">{customer.email}</p>
|
|
<p className="text-sm text-muted-foreground">Email</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-purple-50">
|
|
<Building2 className="h-6 w-6 text-purple-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium">{customer.company || 'Not set'}</p>
|
|
<p className="text-sm text-muted-foreground">Company</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-50">
|
|
<Calendar className="h-6 w-6 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium">
|
|
{new Date(customer.createdAt).toLocaleDateString()}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">Member Since</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Subscription Card */}
|
|
<Card className="lg:col-span-2">
|
|
<CardHeader>
|
|
<CardTitle>Subscription</CardTitle>
|
|
<CardDescription>Current plan and token usage</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{currentSubscription ? (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-lg font-semibold capitalize">
|
|
{currentSubscription.plan.toLowerCase()} Plan
|
|
</p>
|
|
<p className="text-sm text-muted-foreground capitalize">
|
|
{currentSubscription.tier.replace('_', ' ').toLowerCase()} tier
|
|
</p>
|
|
</div>
|
|
<SubscriptionBadge status={currentSubscription.status as SubscriptionStatus} />
|
|
</div>
|
|
|
|
{currentSubscription.trialEndsAt && (
|
|
<div className="rounded-lg bg-blue-50 p-4">
|
|
<p className="text-sm font-medium text-blue-800">
|
|
Trial ends {new Date(currentSubscription.trialEndsAt).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">Token Usage</span>
|
|
<span className="font-medium">
|
|
{totalTokensUsed.toLocaleString()} / {currentSubscription.tokenLimit.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div className="h-3 w-full overflow-hidden rounded-full bg-gray-200">
|
|
<div
|
|
className={`h-full transition-all ${
|
|
tokenUsagePercent > 90 ? 'bg-red-500' : tokenUsagePercent > 70 ? 'bg-yellow-500' : 'bg-primary'
|
|
}`}
|
|
style={{ width: `${tokenUsagePercent}%` }}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{(100 - tokenUsagePercent).toFixed(1)}% remaining
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8">
|
|
<p className="text-muted-foreground">No active subscription</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Orders History */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle>Orders History</CardTitle>
|
|
<CardDescription>
|
|
{customer.orders?.length || 0} order{(customer.orders?.length || 0) !== 1 ? 's' : ''}
|
|
</CardDescription>
|
|
</div>
|
|
<Link href={`/admin/orders?customer=${customerId}`}>
|
|
<Button variant="outline" size="sm">
|
|
View All
|
|
<ExternalLink className="h-4 w-4 ml-2" />
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{customer.orders && customer.orders.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b text-left text-sm text-muted-foreground">
|
|
<th className="px-4 py-3 font-medium">Domain</th>
|
|
<th className="px-4 py-3 font-medium">Tier</th>
|
|
<th className="px-4 py-3 font-medium">Status</th>
|
|
<th className="px-4 py-3 font-medium">Server IP</th>
|
|
<th className="px-4 py-3 font-medium">Created</th>
|
|
<th className="px-4 py-3 font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{customer.orders.map((order: {
|
|
id: string
|
|
domain: string
|
|
tier: string
|
|
status: OrderStatus
|
|
serverIp: string | null
|
|
createdAt: Date | string
|
|
}) => (
|
|
<tr key={order.id} className="border-b hover:bg-gray-50">
|
|
<td className="px-4 py-3 font-medium">{order.domain}</td>
|
|
<td className="px-4 py-3 capitalize">
|
|
{order.tier.replace('_', ' ').toLowerCase()}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<OrderStatusBadge status={order.status} />
|
|
</td>
|
|
<td className="px-4 py-3 font-mono text-sm">
|
|
{order.serverIp || '-'}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
|
{new Date(order.createdAt).toLocaleDateString()}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Link href={`/admin/orders/${order.id}`}>
|
|
<Button variant="ghost" size="sm">
|
|
<ExternalLink className="h-4 w-4" />
|
|
</Button>
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8">
|
|
<p className="text-muted-foreground">No orders yet</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|