All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 4s
Covers architecture, configuration steps, user flows, API routes, webhooks, pricing, testing with Stripe CLI, production checklist, and troubleshooting.
192 lines
8.1 KiB
TypeScript
192 lines
8.1 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { Button } from '@/components/ui/button'
|
|
import { deleteUser, updateUserRole, updateUserSubscription } from '@/app/actions/admin'
|
|
import { toast } from 'sonner'
|
|
import { Trash2, Shield, ShieldOff, Crown, ChevronDown } from 'lucide-react'
|
|
import { format } from 'date-fns'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
|
|
const TIERS = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE'] as const
|
|
|
|
const tierColors: Record<string, string> = {
|
|
BASIC: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
|
PRO: 'bg-brand-accent/10 text-brand-accent',
|
|
BUSINESS: 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
|
ENTERPRISE: 'bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
|
}
|
|
|
|
function TierDropdown({ userId, tier, isOpen, onToggle, onClose, onChange }: {
|
|
userId: string
|
|
tier: string
|
|
isOpen: boolean
|
|
onToggle: () => void
|
|
onClose: () => void
|
|
onChange: (tier: string) => void
|
|
}) {
|
|
const btnRef = useRef<HTMLButtonElement>(null)
|
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
|
|
|
const updatePos = useCallback(() => {
|
|
if (btnRef.current) {
|
|
const r = btnRef.current.getBoundingClientRect()
|
|
setPos({ top: r.bottom + 4, left: r.left })
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
updatePos()
|
|
window.addEventListener('scroll', updatePos, true)
|
|
window.addEventListener('resize', updatePos)
|
|
}
|
|
return () => {
|
|
window.removeEventListener('scroll', updatePos, true)
|
|
window.removeEventListener('resize', updatePos)
|
|
}
|
|
}, [isOpen, updatePos])
|
|
|
|
return (
|
|
<td className="p-4 align-middle">
|
|
<button
|
|
ref={btnRef}
|
|
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
|
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors cursor-pointer hover:opacity-80 ${tierColors[tier] || tierColors.BASIC}`}
|
|
>
|
|
{tier === 'ENTERPRISE' && <Crown size={10} />}
|
|
{tier}
|
|
<ChevronDown size={10} />
|
|
</button>
|
|
{isOpen && typeof document !== 'undefined' && createPortal(
|
|
<>
|
|
<div className="fixed inset-0 z-[9998]" onClick={onClose} />
|
|
<div
|
|
className="fixed z-[9999] bg-white dark:bg-zinc-900 border border-border rounded-xl shadow-xl py-1 min-w-[140px]"
|
|
style={{ top: pos.top, left: pos.left }}
|
|
>
|
|
{TIERS.map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={(e) => { e.stopPropagation(); onChange(t) }}
|
|
className={`w-full text-left px-4 py-2 text-xs font-medium transition-colors hover:bg-muted flex items-center gap-2 ${tier === t ? 'text-brand-accent font-bold' : 'text-foreground'}`}
|
|
>
|
|
{tier === t && '✓'}
|
|
<span className={`inline-flex items-center gap-1 ${tier === t ? '' : 'ml-4'}`}>
|
|
{t === 'ENTERPRISE' && <Crown size={10} />}
|
|
{t}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>,
|
|
document.body
|
|
)}
|
|
</td>
|
|
)
|
|
}
|
|
|
|
export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
|
const { t } = useLanguage()
|
|
const [users, setUsers] = useState(initialUsers)
|
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm(t('admin.users.confirmDelete'))) return
|
|
try {
|
|
await deleteUser(id)
|
|
toast.success(t('admin.users.deleteSuccess'))
|
|
setUsers(prev => prev.filter(u => u.id !== id))
|
|
} catch (e) {
|
|
toast.error(t('admin.users.deleteFailed'))
|
|
}
|
|
}
|
|
|
|
const handleRoleToggle = async (user: any) => {
|
|
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
|
|
try {
|
|
await updateUserRole(user.id, newRole)
|
|
toast.success(t('admin.users.roleUpdateSuccess', { role: newRole }))
|
|
setUsers(prev => prev.map(u => u.id === user.id ? { ...u, role: newRole } : u))
|
|
} catch (e) {
|
|
toast.error(t('admin.users.roleUpdateFailed'))
|
|
}
|
|
}
|
|
|
|
const handleTierChange = async (userId: string, tier: string) => {
|
|
setOpenDropdown(null)
|
|
try {
|
|
await updateUserSubscription(userId, tier)
|
|
toast.success(t('admin.users.tierUpdateSuccess', { tier }))
|
|
setUsers(prev => prev.map(u => u.id === userId ? { ...u, subscription: { tier, status: 'ACTIVE' } } : u))
|
|
} catch (e) {
|
|
toast.error(t('admin.users.tierUpdateFailed'))
|
|
}
|
|
}
|
|
|
|
const getUserTier = (user: any) => user.subscription?.tier || 'BASIC'
|
|
|
|
return (
|
|
<div className="w-full overflow-auto">
|
|
<table className="w-full caption-bottom text-sm text-left">
|
|
<thead className="[&_tr]:border-b">
|
|
<tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.name')}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.email')}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.role')}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.subscription')}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.createdAt')}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground text-right">{t('admin.users.table.actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="[&_tr:last-child]:border-0">
|
|
{users.map((user) => {
|
|
const tier = getUserTier(user)
|
|
return (
|
|
<tr key={user.id} className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
|
<td className="p-4 align-middle font-medium">{user.name || t('common.notAvailable')}</td>
|
|
<td className="p-4 align-middle">{user.email}</td>
|
|
<td className="p-4 align-middle">
|
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${user.role === 'ADMIN' ? 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80' : 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80'}`}>
|
|
{user.role === 'ADMIN' ? t('admin.users.roles.admin') : t('admin.users.roles.user')}
|
|
</span>
|
|
</td>
|
|
<TierDropdown
|
|
userId={user.id}
|
|
tier={tier}
|
|
isOpen={openDropdown === user.id}
|
|
onToggle={() => setOpenDropdown(openDropdown === user.id ? null : user.id)}
|
|
onClose={() => setOpenDropdown(null)}
|
|
onChange={(t) => handleTierChange(user.id, t)}
|
|
/>
|
|
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
|
|
<td className="p-4 align-middle text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRoleToggle(user)}
|
|
title={user.role === 'ADMIN' ? t('admin.users.demote') : t('admin.users.promote')}
|
|
>
|
|
{user.role === 'ADMIN' ? <ShieldOff className="h-4 w-4" /> : <Shield className="h-4 w-4" />}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDelete(user.id)}
|
|
className="text-red-600 hover:text-red-900"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|