Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
825
letsbe-hub/src/app/admin/customers/[id]/page.tsx
Normal file
825
letsbe-hub/src/app/admin/customers/[id]/page.tsx
Normal file
@@ -0,0 +1,825 @@
|
||||
'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, useDeleteCustomer } from '@/hooks/use-customers'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { customerKeys } from '@/hooks/use-customers'
|
||||
import { SliderConfirmDialog } from '@/components/ui/slider-confirm-dialog'
|
||||
import {
|
||||
ArrowLeft,
|
||||
User,
|
||||
Mail,
|
||||
Building2,
|
||||
Calendar,
|
||||
Server,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Edit,
|
||||
Ban,
|
||||
CheckCircle,
|
||||
ExternalLink,
|
||||
CreditCard,
|
||||
Activity,
|
||||
Package,
|
||||
X,
|
||||
Save,
|
||||
ShoppingCart,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
Trash2,
|
||||
Plus,
|
||||
} from 'lucide-react'
|
||||
import { CreateOrderDialog } from '@/components/admin/create-order-dialog'
|
||||
|
||||
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 with enhanced styling
|
||||
function UserStatusBadge({ status }: { status: UserStatus }) {
|
||||
const statusConfig: Record<UserStatus, { label: string; bgColor: string; textColor: string; borderColor: string; dotColor: string }> = {
|
||||
ACTIVE: {
|
||||
label: 'Active',
|
||||
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
textColor: 'text-emerald-700 dark:text-emerald-400',
|
||||
borderColor: 'border-emerald-200 dark:border-emerald-800',
|
||||
dotColor: 'bg-emerald-500'
|
||||
},
|
||||
SUSPENDED: {
|
||||
label: 'Suspended',
|
||||
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||
textColor: 'text-red-700 dark:text-red-400',
|
||||
borderColor: 'border-red-200 dark:border-red-800',
|
||||
dotColor: 'bg-red-500'
|
||||
},
|
||||
PENDING_VERIFICATION: {
|
||||
label: 'Pending',
|
||||
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
textColor: 'text-amber-700 dark:text-amber-400',
|
||||
borderColor: 'border-amber-200 dark:border-amber-800',
|
||||
dotColor: 'bg-amber-500'
|
||||
},
|
||||
}
|
||||
const config = statusConfig[status]
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm font-medium ${config.bgColor} ${config.textColor} border ${config.borderColor}`}>
|
||||
<span className={`h-2 w-2 rounded-full ${config.dotColor} ${status === 'ACTIVE' ? 'animate-pulse' : ''}`} />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SubscriptionBadge({ status }: { status: SubscriptionStatus }) {
|
||||
const statusConfig: Record<SubscriptionStatus, { label: string; bgColor: string; textColor: string; borderColor: string }> = {
|
||||
TRIAL: {
|
||||
label: 'Trial',
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
textColor: 'text-blue-700 dark:text-blue-400',
|
||||
borderColor: 'border-blue-200 dark:border-blue-800'
|
||||
},
|
||||
ACTIVE: {
|
||||
label: 'Active',
|
||||
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
textColor: 'text-emerald-700 dark:text-emerald-400',
|
||||
borderColor: 'border-emerald-200 dark:border-emerald-800'
|
||||
},
|
||||
CANCELED: {
|
||||
label: 'Canceled',
|
||||
bgColor: 'bg-slate-50 dark:bg-slate-900/20',
|
||||
textColor: 'text-slate-600 dark:text-slate-400',
|
||||
borderColor: 'border-slate-200 dark:border-slate-700'
|
||||
},
|
||||
PAST_DUE: {
|
||||
label: 'Past Due',
|
||||
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||
textColor: 'text-red-700 dark:text-red-400',
|
||||
borderColor: 'border-red-200 dark:border-red-800'
|
||||
},
|
||||
}
|
||||
const config = statusConfig[status]
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${config.bgColor} ${config.textColor} border ${config.borderColor}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function OrderStatusBadge({ status }: { status: OrderStatus }) {
|
||||
const statusConfig: Record<OrderStatus, { label: string; bgColor: string; textColor: string; borderColor: string }> = {
|
||||
PAYMENT_CONFIRMED: {
|
||||
label: 'Payment Confirmed',
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
textColor: 'text-blue-700 dark:text-blue-400',
|
||||
borderColor: 'border-blue-200 dark:border-blue-800'
|
||||
},
|
||||
AWAITING_SERVER: {
|
||||
label: 'Awaiting Server',
|
||||
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
textColor: 'text-amber-700 dark:text-amber-400',
|
||||
borderColor: 'border-amber-200 dark:border-amber-800'
|
||||
},
|
||||
SERVER_READY: {
|
||||
label: 'Server Ready',
|
||||
bgColor: 'bg-cyan-50 dark:bg-cyan-900/20',
|
||||
textColor: 'text-cyan-700 dark:text-cyan-400',
|
||||
borderColor: 'border-cyan-200 dark:border-cyan-800'
|
||||
},
|
||||
DNS_PENDING: {
|
||||
label: 'DNS Pending',
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
|
||||
textColor: 'text-orange-700 dark:text-orange-400',
|
||||
borderColor: 'border-orange-200 dark:border-orange-800'
|
||||
},
|
||||
DNS_READY: {
|
||||
label: 'DNS Ready',
|
||||
bgColor: 'bg-teal-50 dark:bg-teal-900/20',
|
||||
textColor: 'text-teal-700 dark:text-teal-400',
|
||||
borderColor: 'border-teal-200 dark:border-teal-800'
|
||||
},
|
||||
PROVISIONING: {
|
||||
label: 'Provisioning',
|
||||
bgColor: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
textColor: 'text-purple-700 dark:text-purple-400',
|
||||
borderColor: 'border-purple-200 dark:border-purple-800'
|
||||
},
|
||||
FULFILLED: {
|
||||
label: 'Fulfilled',
|
||||
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
textColor: 'text-emerald-700 dark:text-emerald-400',
|
||||
borderColor: 'border-emerald-200 dark:border-emerald-800'
|
||||
},
|
||||
EMAIL_CONFIGURED: {
|
||||
label: 'Email Configured',
|
||||
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
textColor: 'text-emerald-700 dark:text-emerald-400',
|
||||
borderColor: 'border-emerald-200 dark:border-emerald-800'
|
||||
},
|
||||
FAILED: {
|
||||
label: 'Failed',
|
||||
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||
textColor: 'text-red-700 dark:text-red-400',
|
||||
borderColor: 'border-red-200 dark:border-red-800'
|
||||
},
|
||||
}
|
||||
const config = statusConfig[status] || {
|
||||
label: status,
|
||||
bgColor: 'bg-slate-50 dark:bg-slate-900/20',
|
||||
textColor: 'text-slate-600 dark:text-slate-400',
|
||||
borderColor: 'border-slate-200 dark:border-slate-700'
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${config.bgColor} ${config.textColor} border ${config.borderColor}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Token usage progress bar with threshold colors
|
||||
function TokenUsageBar({ used, limit }: { used: number; limit: number }) {
|
||||
const percentage = Math.min((used / limit) * 100, 100)
|
||||
|
||||
const getBarColor = () => {
|
||||
if (percentage > 90) return 'bg-gradient-to-r from-red-500 to-red-600'
|
||||
if (percentage > 75) return 'bg-gradient-to-r from-amber-500 to-orange-500'
|
||||
if (percentage > 50) return 'bg-gradient-to-r from-yellow-400 to-amber-500'
|
||||
return 'bg-gradient-to-r from-emerald-500 to-emerald-600'
|
||||
}
|
||||
|
||||
const getTextColor = () => {
|
||||
if (percentage > 90) return 'text-red-600 dark:text-red-400'
|
||||
if (percentage > 75) return 'text-amber-600 dark:text-amber-400'
|
||||
return 'text-emerald-600 dark:text-emerald-400'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Token Usage</span>
|
||||
<span className={`text-sm font-semibold tabular-nums ${getTextColor()}`}>
|
||||
{used.toLocaleString()} / {limit.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 w-full overflow-hidden rounded-full bg-muted/60 ring-1 ring-inset ring-black/5">
|
||||
<div
|
||||
className={`h-full ${getBarColor()} transition-all duration-500 ease-out rounded-full`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span className={getTextColor()}>{percentage.toFixed(1)}% used</span>
|
||||
<span>{(100 - percentage).toFixed(1)}% remaining</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showCreateOrderDialog, setShowCreateOrderDialog] = useState(false)
|
||||
|
||||
const { data: customer, isLoading, isError, error, refetch, isFetching } = useCustomer(customerId)
|
||||
const deleteCustomer = useDeleteCustomer()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteCustomer = async () => {
|
||||
await deleteCustomer.mutateAsync(customerId)
|
||||
router.push('/admin/customers')
|
||||
}
|
||||
|
||||
// Loading state with enhanced styling
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl animate-pulse" />
|
||||
<div className="relative p-4 rounded-full bg-muted">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Loading customer details...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state with enhanced styling
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()} className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Customers
|
||||
</Button>
|
||||
<div className="rounded-xl border-2 border-dashed border-destructive/20 bg-destructive/5 py-16 text-center">
|
||||
<div className="mx-auto w-fit p-4 rounded-full bg-destructive/10 mb-4">
|
||||
<AlertCircle className="h-10 w-10 text-destructive/60" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg text-destructive">Failed to load customer</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">
|
||||
{error instanceof Error ? error.message : 'An error occurred'}
|
||||
</p>
|
||||
<div className="flex justify-center gap-3 mt-6">
|
||||
<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="space-y-6">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()} className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Customers
|
||||
</Button>
|
||||
<div className="rounded-xl border-2 border-dashed border-muted-foreground/20 bg-muted/20 py-16 text-center">
|
||||
<div className="mx-auto w-fit p-4 rounded-full bg-muted/60 mb-4">
|
||||
<User className="h-10 w-10 text-muted-foreground/60" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg">Customer not found</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">
|
||||
The customer you are looking for does not exist or you do not have access.
|
||||
</p>
|
||||
<Link href="/admin/customers">
|
||||
<Button variant="outline" className="mt-6">
|
||||
View All Customers
|
||||
</Button>
|
||||
</Link>
|
||||
</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-8">
|
||||
{/* Hero Header Section */}
|
||||
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30 p-6 md:p-8">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 right-0 -mt-16 -mr-16 h-64 w-64 rounded-full bg-gradient-to-br from-primary/5 to-primary/10 blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 -mb-16 -ml-16 h-48 w-48 rounded-full bg-gradient-to-tr from-primary/5 to-transparent blur-2xl" />
|
||||
|
||||
<div className="relative">
|
||||
{/* Back link */}
|
||||
<Link href="/admin/customers" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors mb-4">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Customers
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
{/* Customer identity */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 border-2 border-primary/20">
|
||||
<User className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">{customer.name || customer.email}</h1>
|
||||
<UserStatusBadge status={customer.status as UserStatus} />
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1">{customer.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowCreateOrderDialog(true)}
|
||||
className="gap-2 bg-primary shadow-lg shadow-primary/20"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Order
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="shrink-0 gap-2"
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
{customer.status === 'ACTIVE' ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange('SUSPENDED')}
|
||||
disabled={isUpdating}
|
||||
className="gap-2 shadow-lg shadow-red-500/20"
|
||||
>
|
||||
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Ban className="h-4 w-4" />}
|
||||
Suspend
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange('ACTIVE')}
|
||||
disabled={isUpdating}
|
||||
className="gap-2 bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<SliderConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
title="Delete Customer"
|
||||
description={`This will permanently delete customer "${customer.name || customer.email}" and ALL related records including orders, subscriptions, and token usage. This action cannot be undone. Actual servers will NOT be affected.`}
|
||||
confirmText="Delete Customer"
|
||||
variant="destructive"
|
||||
onConfirm={handleDeleteCustomer}
|
||||
isLoading={deleteCustomer.isPending}
|
||||
/>
|
||||
|
||||
{/* Create Order dialog */}
|
||||
<CreateOrderDialog
|
||||
open={showCreateOrderDialog}
|
||||
onOpenChange={setShowCreateOrderDialog}
|
||||
preselectedCustomer={{
|
||||
id: customer.id,
|
||||
name: customer.name,
|
||||
email: customer.email,
|
||||
company: customer.company,
|
||||
}}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: customerKeys.detail(customerId) })
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Stats Row with colored icon backgrounds */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="rounded-xl border bg-gradient-to-br from-card to-blue-50/30 dark:to-blue-950/10 p-5 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-blue-100 dark:bg-blue-900/30">
|
||||
<Package className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold tabular-nums">{customer._count?.orders || 0}</div>
|
||||
<p className="text-sm text-muted-foreground">Total Orders</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-gradient-to-br from-card to-emerald-50/30 dark:to-emerald-950/10 p-5 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<Server className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold tabular-nums">
|
||||
{customer.orders?.filter((o: { status: string }) => o.status === 'FULFILLED').length || 0}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Active Servers</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-gradient-to-br from-card to-violet-50/30 dark:to-violet-950/10 p-5 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-violet-100 dark:bg-violet-900/30">
|
||||
<Activity className="h-5 w-5 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold tabular-nums">{totalTokensUsed.toLocaleString()}</div>
|
||||
<p className="text-sm text-muted-foreground">Tokens Used</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-gradient-to-br from-card to-amber-50/30 dark:to-amber-950/10 p-5 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-amber-100 dark:bg-amber-900/30">
|
||||
<CreditCard className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Customer Profile Card */}
|
||||
<div className="lg:col-span-1">
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">Profile</h2>
|
||||
<p className="text-sm text-muted-foreground">Customer information</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<Button variant="ghost" size="sm" onClick={handleEdit} className="gap-2">
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-5">
|
||||
{isEditing ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
className="bg-background"
|
||||
/>
|
||||
</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 })}
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button size="sm" onClick={handleSave} disabled={isUpdating} className="gap-2">
|
||||
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)} className="gap-2">
|
||||
<X className="h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">{customer.name || 'No name'}</p>
|
||||
<p className="text-xs text-muted-foreground">Name</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-100 dark:bg-blue-900/30">
|
||||
<Mail className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">{customer.email}</p>
|
||||
<p className="text-xs text-muted-foreground">Email</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-purple-100 dark:bg-purple-900/30">
|
||||
<Building2 className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">{customer.company || 'Not set'}</p>
|
||||
<p className="text-xs text-muted-foreground">Company</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<Calendar className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
{new Date(customer.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Member Since</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Subscription Card */}
|
||||
<div className="lg:col-span-2">
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">Subscription</h2>
|
||||
<p className="text-sm text-muted-foreground">Current plan and token usage</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-gradient-to-br from-card via-card to-primary/5 p-6">
|
||||
{currentSubscription ? (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-primary/20 to-primary/10">
|
||||
<TrendingUp className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-bold capitalize">
|
||||
{currentSubscription.plan.toLowerCase()} Plan
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground capitalize">
|
||||
{currentSubscription.tier.replace('_', ' ').toLowerCase()} tier
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SubscriptionBadge status={currentSubscription.status as SubscriptionStatus} />
|
||||
</div>
|
||||
|
||||
{currentSubscription.trialEndsAt && (
|
||||
<div className="rounded-xl bg-gradient-to-r from-blue-50 to-blue-100/50 dark:from-blue-900/20 dark:to-blue-800/10 p-4 border border-blue-200/50 dark:border-blue-800/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/50">
|
||||
<Calendar className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-800 dark:text-blue-300">Trial Period Active</p>
|
||||
<p className="text-xs text-blue-600/80 dark:text-blue-400/80">
|
||||
Ends {new Date(currentSubscription.trialEndsAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl bg-muted/30 p-4">
|
||||
<TokenUsageBar used={totalTokensUsed} limit={currentSubscription.tokenLimit} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto w-fit p-4 rounded-full bg-muted/60 mb-4">
|
||||
<CreditCard className="h-10 w-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg">No Active Subscription</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">
|
||||
This customer does not have an active subscription plan
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orders History */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">Orders History</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{customer.orders?.length || 0} order{(customer.orders?.length || 0) !== 1 ? 's' : ''} total
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/admin/orders?customer=${customerId}`}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
View All
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card overflow-hidden">
|
||||
{customer.orders && customer.orders.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30">
|
||||
<th className="px-5 py-4 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Domain</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Tier</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Status</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Server IP</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Created</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{customer.orders.map((order: {
|
||||
id: string
|
||||
domain: string
|
||||
tier: string
|
||||
status: OrderStatus
|
||||
serverIp: string | null
|
||||
createdAt: Date | string
|
||||
}) => (
|
||||
<tr
|
||||
key={order.id}
|
||||
className="group hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<td className="px-5 py-4">
|
||||
<span className="font-medium">{order.domain}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="capitalize text-sm text-muted-foreground">
|
||||
{order.tier.replace('_', ' ').toLowerCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<code className="font-mono text-sm text-muted-foreground">
|
||||
{order.serverIp || '-'}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(order.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<Link href={`/admin/orders/${order.id}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 opacity-70 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<div className="mx-auto w-fit p-4 rounded-full bg-muted/60 mb-4">
|
||||
<ShoppingCart className="h-10 w-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg">No Orders Yet</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">
|
||||
This customer has not placed any orders
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user