mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-15 01:47:59 -05:00
improvement(waitlist): added batch send requests, fixed UI (#305)
* improvement(waitlist): added batch send requests, fixed UI * updated package lock * added protection against rate limts for resend with a delay + exponential backoff
This commit is contained in:
@@ -3,8 +3,25 @@ import PasswordAuth from './password-auth'
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<PasswordAuth>
|
||||
<div>
|
||||
<h1>Admin Page</h1>
|
||||
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 md:px-8 py-6">
|
||||
<div className="mb-6 px-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Admin Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Manage Sim Studio platform settings and users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<a
|
||||
href="/admin/waitlist"
|
||||
className="border border-gray-200 dark:border-gray-800 rounded-md p-4 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors"
|
||||
>
|
||||
<h2 className="text-lg font-medium">Waitlist Management</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Review and manage users on the waitlist
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</PasswordAuth>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckSquareIcon, SquareIcon, UserCheckIcon, XIcon } from 'lucide-react'
|
||||
|
||||
interface BatchActionsProps {
|
||||
hasSelectedEmails: boolean
|
||||
selectedCount: number
|
||||
loading: boolean
|
||||
onToggleSelectAll: () => void
|
||||
onClearSelections: () => void
|
||||
onBatchApprove: () => void
|
||||
entriesExist: boolean
|
||||
someSelected: boolean
|
||||
}
|
||||
|
||||
export function BatchActions({
|
||||
hasSelectedEmails,
|
||||
selectedCount,
|
||||
loading,
|
||||
onToggleSelectAll,
|
||||
onClearSelections,
|
||||
onBatchApprove,
|
||||
entriesExist,
|
||||
someSelected,
|
||||
}: BatchActionsProps) {
|
||||
if (!entriesExist) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={hasSelectedEmails ? "default" : "outline"}
|
||||
onClick={onToggleSelectAll}
|
||||
disabled={loading || !entriesExist}
|
||||
className="whitespace-nowrap h-8 px-2.5 text-xs"
|
||||
>
|
||||
{someSelected ? (
|
||||
<CheckSquareIcon className="h-3.5 w-3.5 mr-1.5" />
|
||||
) : (
|
||||
<SquareIcon className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{someSelected ? "Deselect All" : "Select All"}
|
||||
</Button>
|
||||
|
||||
{hasSelectedEmails && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onClearSelections}
|
||||
className="whitespace-nowrap h-8 px-2.5 text-xs"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5 mr-1.5" />
|
||||
Clear Selection
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={onBatchApprove}
|
||||
disabled={!hasSelectedEmails || loading}
|
||||
className="whitespace-nowrap h-8 px-2.5 text-xs"
|
||||
>
|
||||
<UserCheckIcon className="h-3.5 w-3.5 mr-1.5" />
|
||||
{loading ? "Processing..." : `Approve Selected (${selectedCount})`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { CheckIcon, XIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
type BatchResult = {
|
||||
email: string
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
interface BatchResultsModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
results: Array<BatchResult> | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function BatchResultsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
results,
|
||||
onClose,
|
||||
}: BatchResultsModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Batch Approval Results</DialogTitle>
|
||||
<DialogDescription>
|
||||
Results of the batch approval operation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{results && results.length > 0 ? (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span>Total: {results.length}</span>
|
||||
<span>
|
||||
Success: {results.filter(r => r.success).length} /
|
||||
Failed: {results.filter(r => !r.success).length}
|
||||
</span>
|
||||
</div>
|
||||
{results.map((result, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`p-2 rounded text-sm ${
|
||||
result.success
|
||||
? 'bg-green-50 border border-green-200 text-green-800'
|
||||
: 'bg-red-50 border border-red-200 text-red-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{result.success ? <CheckIcon className="h-4 w-4" /> : <XIcon className="h-4 w-4" />}
|
||||
<span className="font-medium">{result.email}</span>
|
||||
</div>
|
||||
<div className="ml-6 text-xs mt-1">{result.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-gray-500">No results to display</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface FilterButtonProps {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
icon: ReactNode
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FilterButton({ active, onClick, icon, label, className }: FilterButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant={active ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 h-9 px-3 ${className || ''}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
74
sim/app/admin/waitlist/components/filter-bar/filter-bar.tsx
Normal file
74
sim/app/admin/waitlist/components/filter-bar/filter-bar.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
UserIcon,
|
||||
UserCheckIcon,
|
||||
UserXIcon,
|
||||
CheckIcon
|
||||
} from 'lucide-react'
|
||||
import { FilterButton } from './components/filter-button'
|
||||
|
||||
interface FilterBarProps {
|
||||
currentStatus: string
|
||||
onStatusChange: (status: string) => void
|
||||
}
|
||||
|
||||
export function FilterBar({ currentStatus, onStatusChange }: FilterBarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<FilterButton
|
||||
active={currentStatus === 'all'}
|
||||
onClick={() => onStatusChange('all')}
|
||||
icon={<UserIcon className="h-3.5 w-3.5" />}
|
||||
label="All"
|
||||
className={
|
||||
currentStatus === 'all'
|
||||
? 'bg-blue-100 text-blue-900 hover:bg-blue-200 hover:text-blue-900'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
active={currentStatus === 'pending'}
|
||||
onClick={() => onStatusChange('pending')}
|
||||
icon={<UserIcon className="h-3.5 w-3.5" />}
|
||||
label="Pending"
|
||||
className={
|
||||
currentStatus === 'pending'
|
||||
? 'bg-amber-100 text-amber-900 hover:bg-amber-200 hover:text-amber-900'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
active={currentStatus === 'approved'}
|
||||
onClick={() => onStatusChange('approved')}
|
||||
icon={<UserCheckIcon className="h-3.5 w-3.5" />}
|
||||
label="Approved"
|
||||
className={
|
||||
currentStatus === 'approved'
|
||||
? 'bg-green-100 text-green-900 hover:bg-green-200 hover:text-green-900'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
active={currentStatus === 'rejected'}
|
||||
onClick={() => onStatusChange('rejected')}
|
||||
icon={<UserXIcon className="h-3.5 w-3.5" />}
|
||||
label="Rejected"
|
||||
className={
|
||||
currentStatus === 'rejected'
|
||||
? 'bg-red-100 text-red-900 hover:bg-red-200 hover:text-red-900'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
active={currentStatus === 'signed_up'}
|
||||
onClick={() => onStatusChange('signed_up')}
|
||||
icon={<CheckIcon className="h-3.5 w-3.5" />}
|
||||
label="Signed Up"
|
||||
className={
|
||||
currentStatus === 'signed_up'
|
||||
? 'bg-purple-100 text-purple-900 hover:bg-purple-200 hover:text-purple-900'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
sim/app/admin/waitlist/components/pagination/pagination.tsx
Normal file
87
sim/app/admin/waitlist/components/pagination/pagination.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
totalItems: number
|
||||
itemsPerPage: number
|
||||
loading: boolean
|
||||
onFirstPage: () => void
|
||||
onPrevPage: () => void
|
||||
onNextPage: () => void
|
||||
onLastPage: () => void
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
loading,
|
||||
onFirstPage,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
onLastPage,
|
||||
}: PaginationProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1.5 my-3 pb-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onFirstPage}
|
||||
disabled={page === 1 || loading}
|
||||
title="First Page"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronsLeftIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onPrevPage}
|
||||
disabled={page === 1 || loading}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5 mr-1" />
|
||||
Prev
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground mx-2">
|
||||
Page {page} of {totalPages}
|
||||
•
|
||||
{totalItems} total entries
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onNextPage}
|
||||
disabled={page >= totalPages || loading}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
Next
|
||||
<ChevronRightIcon className="h-3.5 w-3.5 ml-1" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onLastPage}
|
||||
disabled={page >= totalPages || loading}
|
||||
title="Last Page"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronsRightIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
sim/app/admin/waitlist/components/search-bar/search-bar.tsx
Normal file
49
sim/app/admin/waitlist/components/search-bar/search-bar.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
|
||||
interface SearchBarProps {
|
||||
initialValue: string
|
||||
onSearch: (value: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
initialValue = '',
|
||||
onSearch,
|
||||
disabled = false,
|
||||
placeholder = 'Search by email...'
|
||||
}: SearchBarProps) {
|
||||
const [searchInputValue, setSearchInputValue] = useState(initialValue)
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setSearchInputValue(value)
|
||||
|
||||
// Clear any existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Set a new timeout for debounce
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
onSearch(value)
|
||||
}, 500) // 500ms debounce
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-grow sm:flex-grow-0 w-full sm:w-64">
|
||||
<SearchIcon className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={searchInputValue}
|
||||
onChange={handleSearchChange}
|
||||
className="pl-8 h-9 text-sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { AlertCircleIcon } from 'lucide-react'
|
||||
|
||||
type AlertType = 'error' | 'email-error' | 'rate-limit' | null
|
||||
|
||||
interface WaitlistAlertProps {
|
||||
type: AlertType
|
||||
message: string
|
||||
onDismiss: () => void
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
export function WaitlistAlert({ type, message, onDismiss, onRefresh }: WaitlistAlertProps) {
|
||||
if (!type) return null
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant={
|
||||
type === 'error' || type === 'email-error'
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
className="mb-4"
|
||||
>
|
||||
<AlertCircleIcon className="h-4 w-4" />
|
||||
<AlertTitle className="ml-2">
|
||||
{type === 'email-error'
|
||||
? 'Email Delivery Failed'
|
||||
: type === 'rate-limit'
|
||||
? 'Rate Limit Exceeded'
|
||||
: 'Error'}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="ml-2 flex items-center justify-between">
|
||||
<span>{message}</span>
|
||||
<div className="flex gap-2">
|
||||
{onRefresh && (
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-4"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={onDismiss}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-4"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
CheckIcon,
|
||||
InfoIcon,
|
||||
MailIcon,
|
||||
RotateCcwIcon,
|
||||
UserCheckIcon,
|
||||
UserXIcon,
|
||||
XIcon,
|
||||
CheckSquareIcon,
|
||||
SquareIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface WaitlistEntry {
|
||||
id: string
|
||||
email: string
|
||||
status: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
interface WaitlistTableProps {
|
||||
entries: WaitlistEntry[]
|
||||
status: string
|
||||
actionLoading: string | null
|
||||
selectedEmails: Record<string, boolean>
|
||||
onToggleSelection: (email: string) => void
|
||||
onApprove: (email: string, id: string) => void
|
||||
onReject: (email: string, id: string) => void
|
||||
onResendApproval: (email: string, id: string) => void
|
||||
formatDate: (date: Date) => string
|
||||
getDetailedTimeTooltip: (date: Date) => string
|
||||
}
|
||||
|
||||
export function WaitlistTable({
|
||||
entries,
|
||||
status,
|
||||
actionLoading,
|
||||
selectedEmails,
|
||||
onToggleSelection,
|
||||
onApprove,
|
||||
onReject,
|
||||
onResendApproval,
|
||||
formatDate,
|
||||
getDetailedTimeTooltip,
|
||||
}: WaitlistTableProps) {
|
||||
return (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* Add selection checkbox column */}
|
||||
{status !== 'approved' && (
|
||||
<TableHead className="w-[60px] text-center">Select</TableHead>
|
||||
)}
|
||||
<TableHead className="w-[250px]">Email</TableHead>
|
||||
<TableHead className="w-[120px]">Status</TableHead>
|
||||
<TableHead className="w-[120px]">Date Added</TableHead>
|
||||
<TableHead className="w-[150px] text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => (
|
||||
<TableRow
|
||||
key={entry.id}
|
||||
className="hover:bg-muted/30"
|
||||
>
|
||||
{/* Add selection checkbox */}
|
||||
{status !== 'approved' && (
|
||||
<TableCell className="text-center py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onToggleSelection(entry.email)}
|
||||
className="p-0 h-8 w-8"
|
||||
>
|
||||
{selectedEmails[entry.email] ? (
|
||||
<CheckSquareIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<SquareIcon className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
<TableCell className="font-medium">{entry.email}</TableCell>
|
||||
<TableCell>
|
||||
{/* Status badge */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`
|
||||
${entry.status === 'pending' ? 'bg-amber-100 text-amber-800 border border-amber-200 hover:bg-amber-200' : ''}
|
||||
${entry.status === 'approved' ? 'bg-green-100 text-green-800 border border-green-200 hover:bg-green-200' : ''}
|
||||
${entry.status === 'rejected' ? 'bg-red-100 text-red-800 border border-red-200 hover:bg-red-200' : ''}
|
||||
${entry.status === 'signed_up' ? 'bg-purple-100 text-purple-800 border border-purple-200 hover:bg-purple-200' : ''}
|
||||
`}
|
||||
>
|
||||
{entry.status === 'pending' && <InfoIcon className="mr-1 h-3 w-3" />}
|
||||
{entry.status === 'approved' && <UserCheckIcon className="mr-1 h-3 w-3" />}
|
||||
{entry.status === 'rejected' && <UserXIcon className="mr-1 h-3 w-3" />}
|
||||
{entry.status === 'signed_up' && <CheckIcon className="mr-1 h-3 w-3" />}
|
||||
{entry.status.charAt(0).toUpperCase() + entry.status.slice(1).replace('_', ' ')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help">{formatDate(entry.createdAt)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getDetailedTimeTooltip(entry.createdAt)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end space-x-1.5">
|
||||
{entry.status !== 'approved' && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => onApprove(entry.email, entry.id)}
|
||||
disabled={actionLoading === entry.id}
|
||||
className="hover:border-green-500 hover:text-green-600 h-8 w-8"
|
||||
>
|
||||
{actionLoading === entry.id ? (
|
||||
<RotateCcwIcon className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<CheckIcon className="h-3.5 w-3.5 text-green-500" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Approve user and send access email</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{entry.status === 'approved' && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onResendApproval(entry.email, entry.id)}
|
||||
disabled={actionLoading === entry.id}
|
||||
className="hover:border-blue-500 hover:text-blue-600 h-8 text-xs px-2"
|
||||
>
|
||||
{actionLoading === entry.id ? (
|
||||
<RotateCcwIcon className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<MailIcon className="h-3.5 w-3.5 mr-1" />
|
||||
Resend
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Resend approval email with sign-up link
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{entry.status !== 'rejected' && entry.status !== 'approved' && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => onReject(entry.email, entry.id)}
|
||||
disabled={actionLoading === entry.id}
|
||||
className="hover:border-red-500 hover:text-red-600 h-8 w-8"
|
||||
>
|
||||
{actionLoading === entry.id ? (
|
||||
<RotateCcwIcon className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<XIcon className="h-3.5 w-3.5 text-red-500" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reject user</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`https://mail.google.com/mail/?view=cm&fs=1&to=${entry.email}`
|
||||
)
|
||||
}
|
||||
className="hover:border-blue-500 hover:text-blue-600 h-8 w-8"
|
||||
>
|
||||
<MailIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Email user in Gmail</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Metadata } from 'next'
|
||||
import PasswordAuth from '../password-auth'
|
||||
import { WaitlistTable } from './waitlist-table'
|
||||
import { WaitlistTable } from './waitlist'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Waitlist Management | Sim Studio',
|
||||
@@ -10,15 +10,15 @@ export const metadata: Metadata = {
|
||||
export default function WaitlistPage() {
|
||||
return (
|
||||
<PasswordAuth>
|
||||
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 md:px-8 py-10">
|
||||
<div className="mb-8 px-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Waitlist Management</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 md:px-8 py-6">
|
||||
<div className="mb-6 px-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Waitlist Management</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Review and manage users who have signed up for the waitlist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full border-none shadow-md bg-white dark:bg-gray-950 rounded-md">
|
||||
<div className="w-full border border-gray-200 dark:border-gray-800 shadow-sm bg-white dark:bg-gray-950 rounded-md overflow-hidden">
|
||||
<WaitlistTable />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,812 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
InfoIcon,
|
||||
MailIcon,
|
||||
RotateCcwIcon,
|
||||
SearchIcon,
|
||||
UserCheckIcon,
|
||||
UserIcon,
|
||||
UserXIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Logger } from '@/lib/logs/console-logger'
|
||||
import { useWaitlistStore } from './stores/store'
|
||||
|
||||
const logger = new Logger('WaitlistTable')
|
||||
|
||||
interface FilterButtonProps {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Alert types for more specific error display
|
||||
type AlertType = 'error' | 'email-error' | 'rate-limit' | null
|
||||
|
||||
// Filter button component
|
||||
const FilterButton = ({ active, onClick, icon, label, className }: FilterButtonProps) => (
|
||||
<Button
|
||||
variant={active ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-2 ${className || ''}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
export function WaitlistTable() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
// Get all values from the store
|
||||
const {
|
||||
entries,
|
||||
filteredEntries,
|
||||
status,
|
||||
searchTerm,
|
||||
page,
|
||||
totalEntries,
|
||||
loading,
|
||||
error,
|
||||
actionLoading,
|
||||
setStatus,
|
||||
setSearchTerm,
|
||||
setPage,
|
||||
setActionLoading,
|
||||
setError,
|
||||
fetchEntries,
|
||||
} = useWaitlistStore()
|
||||
|
||||
// Local state for search input with debounce
|
||||
const [searchInputValue, setSearchInputValue] = useState(searchTerm)
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Enhanced error state
|
||||
const [alertInfo, setAlertInfo] = useState<{
|
||||
type: AlertType
|
||||
message: string
|
||||
entryId?: string
|
||||
}>({ type: null, message: '' })
|
||||
|
||||
// Auto-dismiss alert after 7 seconds
|
||||
useEffect(() => {
|
||||
if (alertInfo.type) {
|
||||
const timer = setTimeout(() => {
|
||||
setAlertInfo({ type: null, message: '' })
|
||||
}, 7000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [alertInfo])
|
||||
|
||||
// Auth token for API calls
|
||||
const [apiToken, setApiToken] = useState('')
|
||||
const [authChecked, setAuthChecked] = useState(false)
|
||||
|
||||
// Check authentication and redirect if needed
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated
|
||||
const token = sessionStorage.getItem('admin-auth-token') || ''
|
||||
const isAuth = sessionStorage.getItem('admin-auth') === 'true'
|
||||
|
||||
setApiToken(token)
|
||||
|
||||
// If not authenticated, redirect to admin home page to show the login form
|
||||
if (!isAuth || !token) {
|
||||
logger.warn('Not authenticated, redirecting to admin page')
|
||||
router.push('/admin')
|
||||
return
|
||||
}
|
||||
|
||||
setAuthChecked(true)
|
||||
}, [router])
|
||||
|
||||
// Get status from URL on initial load - only if authenticated
|
||||
useEffect(() => {
|
||||
if (!authChecked) return
|
||||
|
||||
const urlStatus = searchParams.get('status') || 'all'
|
||||
// Make sure it's a valid status
|
||||
const validStatus = ['all', 'pending', 'approved', 'rejected'].includes(urlStatus)
|
||||
? urlStatus
|
||||
: 'all'
|
||||
|
||||
setStatus(validStatus)
|
||||
}, [searchParams, setStatus, authChecked])
|
||||
|
||||
// Handle status filter change
|
||||
const handleStatusChange = useCallback(
|
||||
(newStatus: string) => {
|
||||
if (newStatus !== status) {
|
||||
setStatus(newStatus)
|
||||
router.push(`?status=${newStatus}`)
|
||||
}
|
||||
},
|
||||
[status, setStatus, router]
|
||||
)
|
||||
|
||||
// Handle search input change with debounce
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setSearchInputValue(value)
|
||||
|
||||
// Clear any existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Set a new timeout for debounce
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
setSearchTerm(value)
|
||||
}, 500) // 500ms debounce
|
||||
}
|
||||
|
||||
// Handle individual approval
|
||||
const handleApprove = async (email: string, id: string) => {
|
||||
try {
|
||||
setActionLoading(id)
|
||||
setError(null)
|
||||
setAlertInfo({ type: null, message: '' })
|
||||
|
||||
const response = await fetch('/api/admin/waitlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({ email, action: 'approve' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle specific error types
|
||||
if (response.status === 429) {
|
||||
setAlertInfo({
|
||||
type: 'rate-limit',
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
} else if (data.message?.includes('email') || data.message?.includes('resend')) {
|
||||
setAlertInfo({
|
||||
type: 'email-error',
|
||||
message: `Email delivery failed: ${data.message}`,
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: data.message || 'Failed to approve user',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.success) {
|
||||
if (data.message?.includes('email') || data.message?.includes('resend')) {
|
||||
setAlertInfo({
|
||||
type: 'email-error',
|
||||
message: `Email delivery failed: ${data.message}`,
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: data.message || 'Failed to approve user',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Success - don't refresh the table, just clear any errors
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to approve user'
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
entryId: id,
|
||||
})
|
||||
logger.error('Error approving user:', error)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle individual rejection
|
||||
const handleReject = async (email: string, id: string) => {
|
||||
try {
|
||||
setActionLoading(id)
|
||||
setError(null)
|
||||
setAlertInfo({ type: null, message: '' })
|
||||
|
||||
const response = await fetch('/api/admin/waitlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({ email, action: 'reject' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: data.message || 'Failed to reject user',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Success - don't refresh the table
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to reject user'
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
entryId: id,
|
||||
})
|
||||
logger.error('Error rejecting user:', error)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle resending approval email
|
||||
const handleResendApproval = async (email: string, id: string) => {
|
||||
try {
|
||||
setActionLoading(id)
|
||||
setError(null)
|
||||
setAlertInfo({ type: null, message: '' })
|
||||
|
||||
const response = await fetch('/api/admin/waitlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({ email, action: 'resend' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle specific error types
|
||||
if (response.status === 429) {
|
||||
setAlertInfo({
|
||||
type: 'rate-limit',
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
} else if (data.message?.includes('email') || data.message?.includes('resend')) {
|
||||
setAlertInfo({
|
||||
type: 'email-error',
|
||||
message: `Email delivery failed: ${data.message}`,
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: data.message || 'Failed to resend approval email',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.success) {
|
||||
if (data.message?.includes('email') || data.message?.includes('resend')) {
|
||||
setAlertInfo({
|
||||
type: 'email-error',
|
||||
message: `Email delivery failed: ${data.message}`,
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: data.message || 'Failed to resend approval email',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// No UI update needed on success, just clear error state
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to resend approval email'
|
||||
setAlertInfo({
|
||||
type: 'email-error',
|
||||
message: errorMessage,
|
||||
entryId: id,
|
||||
})
|
||||
logger.error('Error resending approval email:', error)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation
|
||||
const handleNextPage = () => setPage(page + 1)
|
||||
const handlePrevPage = () => setPage(Math.max(page - 1, 1))
|
||||
const handleFirstPage = () => setPage(1)
|
||||
const handleLastPage = () => {
|
||||
const lastPage = Math.max(1, Math.ceil(totalEntries / 50))
|
||||
setPage(lastPage)
|
||||
}
|
||||
const handleRefresh = () => {
|
||||
fetchEntries()
|
||||
setAlertInfo({ type: null, message: '' })
|
||||
}
|
||||
|
||||
// Format date helper
|
||||
const formatDate = (date: Date) => {
|
||||
const now = new Date()
|
||||
const diffInMs = now.getTime() - date.getTime()
|
||||
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffInDays < 1) return 'today'
|
||||
if (diffInDays === 1) return 'yesterday'
|
||||
if (diffInDays < 30) return `${diffInDays} days ago`
|
||||
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
// Get formatted timestamp for tooltips
|
||||
const getDetailedTimeTooltip = (date: Date) => {
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// If not authenticated yet, show loading state
|
||||
if (!authChecked) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<Skeleton className="h-16 w-16 rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 w-full">
|
||||
{/* Filter bar - similar to logs.tsx */}
|
||||
<div className="border-b px-6">
|
||||
<div className="flex flex-wrap items-center gap-2 py-3">
|
||||
<div className="flex justify-between items-center">
|
||||
{/* Filter buttons */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
<FilterButton
|
||||
active={status === 'all'}
|
||||
onClick={() => handleStatusChange('all')}
|
||||
icon={<UserIcon className="h-4 w-4" />}
|
||||
label="All"
|
||||
className={
|
||||
status === 'all'
|
||||
? 'bg-blue-100 text-blue-900 hover:bg-blue-200 hover:text-blue-900'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
active={status === 'pending'}
|
||||
onClick={() => handleStatusChange('pending')}
|
||||
icon={<UserIcon className="h-4 w-4" />}
|
||||
label="Pending"
|
||||
className={
|
||||
status === 'pending'
|
||||
? 'bg-amber-100 text-amber-900 hover:bg-amber-200 hover:text-amber-900'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
active={status === 'approved'}
|
||||
onClick={() => handleStatusChange('approved')}
|
||||
icon={<UserCheckIcon className="h-4 w-4" />}
|
||||
label="Approved"
|
||||
className={
|
||||
status === 'approved'
|
||||
? 'bg-green-100 text-green-900 hover:bg-green-200 hover:text-green-900'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
active={status === 'rejected'}
|
||||
onClick={() => handleStatusChange('rejected')}
|
||||
icon={<UserXIcon className="h-4 w-4" />}
|
||||
label="Rejected"
|
||||
className={
|
||||
status === 'rejected'
|
||||
? 'bg-red-100 text-red-900 hover:bg-red-200 hover:text-red-900'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
active={status === 'signed_up'}
|
||||
onClick={() => handleStatusChange('signed_up')}
|
||||
icon={<CheckIcon className="h-4 w-4" />}
|
||||
label="Signed Up"
|
||||
className={
|
||||
status === 'signed_up'
|
||||
? 'bg-purple-100 text-purple-900 hover:bg-purple-200 hover:text-purple-900'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and refresh bar */}
|
||||
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-4 md:items-center px-6">
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by email..."
|
||||
value={searchInputValue}
|
||||
onChange={handleSearchChange}
|
||||
className="w-full pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<RotateCcwIcon className={`h-4 w-4 ${loading ? 'animate-spin text-blue-500' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Alert system */}
|
||||
{alertInfo.type && (
|
||||
<Alert
|
||||
variant={
|
||||
alertInfo.type === 'error'
|
||||
? 'destructive'
|
||||
: alertInfo.type === 'email-error'
|
||||
? 'destructive'
|
||||
: alertInfo.type === 'rate-limit'
|
||||
? 'default'
|
||||
: 'default'
|
||||
}
|
||||
className="mx-6 w-auto"
|
||||
>
|
||||
<AlertCircleIcon className="h-4 w-4" />
|
||||
<AlertTitle className="ml-2">
|
||||
{alertInfo.type === 'email-error'
|
||||
? 'Email Delivery Failed'
|
||||
: alertInfo.type === 'rate-limit'
|
||||
? 'Rate Limit Exceeded'
|
||||
: 'Error'}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="ml-2 flex items-center justify-between">
|
||||
<span>{alertInfo.message}</span>
|
||||
<Button
|
||||
onClick={() => setAlertInfo({ type: null, message: '' })}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-4"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Original error alert - kept for backward compatibility */}
|
||||
{error && !alertInfo.type && (
|
||||
<Alert variant="destructive" className="mx-6 w-auto">
|
||||
<AlertCircleIcon className="h-4 w-4" />
|
||||
<AlertDescription className="ml-2">
|
||||
{error}
|
||||
<Button onClick={handleRefresh} variant="outline" size="sm" className="ml-4">
|
||||
Try Again
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{loading ? (
|
||||
<div className="space-y-4 px-6">
|
||||
<div className="space-y-2 w-full">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<div className="rounded-md border mx-6 p-8 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<InfoIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold">No entries found</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{searchTerm
|
||||
? 'No matching entries found with the current search term'
|
||||
: `No ${status === 'all' ? '' : status} entries found in the waitlist.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Table */}
|
||||
<div className="rounded-md border mx-6 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[180px]">Email</TableHead>
|
||||
<TableHead className="min-w-[150px]">Joined</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredEntries.map((entry) => (
|
||||
<TableRow
|
||||
key={entry.id}
|
||||
className={`hover:bg-muted/30 ${
|
||||
alertInfo.entryId === entry.id && alertInfo.type
|
||||
? alertInfo.type === 'error' || alertInfo.type === 'email-error'
|
||||
? 'bg-red-50'
|
||||
: 'bg-amber-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<TableCell className="font-medium">{entry.email}</TableCell>
|
||||
<TableCell>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help">{formatDate(entry.createdAt)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getDetailedTimeTooltip(entry.createdAt)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{/* Status badge */}
|
||||
<div className="py-2.5 px-4">
|
||||
{entry.status === 'pending' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-amber-100 text-amber-800 border border-amber-200 hover:bg-amber-200"
|
||||
>
|
||||
<InfoIcon className="mr-1 h-3 w-3" />
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
{entry.status === 'approved' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-green-100 text-green-800 border border-green-200 hover:bg-green-200"
|
||||
>
|
||||
<UserCheckIcon className="mr-1 h-3 w-3" />
|
||||
Approved
|
||||
</Badge>
|
||||
)}
|
||||
{entry.status === 'rejected' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-red-100 text-red-800 border border-red-200 hover:bg-red-200"
|
||||
>
|
||||
<UserXIcon className="mr-1 h-3 w-3" />
|
||||
Rejected
|
||||
</Badge>
|
||||
)}
|
||||
{entry.status === 'signed_up' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-purple-100 text-purple-800 border border-purple-200 hover:bg-purple-200"
|
||||
>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
Signed Up
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{entry.status !== 'approved' && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => handleApprove(entry.email, entry.id)}
|
||||
disabled={actionLoading === entry.id}
|
||||
className="hover:border-green-500 hover:text-green-600"
|
||||
>
|
||||
{actionLoading === entry.id ? (
|
||||
<RotateCcwIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Approve user and send access email</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{entry.status === 'approved' && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleResendApproval(entry.email, entry.id)}
|
||||
disabled={actionLoading === entry.id}
|
||||
className="hover:border-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{actionLoading === entry.id ? (
|
||||
<RotateCcwIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<MailIcon className="h-4 w-4 mr-1" />
|
||||
Resend Approval
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Resend approval email with sign-up link
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{entry.status !== 'rejected' && entry.status !== 'approved' && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => handleReject(entry.email, entry.id)}
|
||||
disabled={actionLoading === entry.id}
|
||||
className="hover:border-red-500 hover:text-red-600"
|
||||
>
|
||||
{actionLoading === entry.id ? (
|
||||
<RotateCcwIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<XIcon className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reject user</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`https://mail.google.com/mail/?view=cm&fs=1&to=${entry.email}`
|
||||
)
|
||||
}
|
||||
className="hover:border-blue-500 hover:text-blue-600"
|
||||
>
|
||||
<MailIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Email user in Gmail</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!searchTerm && (
|
||||
<div className="flex items-center justify-center gap-2 mx-6 my-4 pb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFirstPage}
|
||||
disabled={page === 1 || loading}
|
||||
title="First Page"
|
||||
>
|
||||
<ChevronsLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={page === 1 || loading}
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
<span className="ml-1">Prev</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-muted-foreground mx-2">
|
||||
Page {page} of {Math.ceil(totalEntries / 50) || 1}
|
||||
•
|
||||
{totalEntries} total entries
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={page >= Math.ceil(totalEntries / 50) || loading}
|
||||
>
|
||||
<span className="mr-1">Next</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLastPage}
|
||||
disabled={page >= Math.ceil(totalEntries / 50) || loading}
|
||||
title="Last Page"
|
||||
>
|
||||
<ChevronsRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
625
sim/app/admin/waitlist/waitlist.tsx
Normal file
625
sim/app/admin/waitlist/waitlist.tsx
Normal file
@@ -0,0 +1,625 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
InfoIcon,
|
||||
RotateCcwIcon,
|
||||
} from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Logger } from '@/lib/logs/console-logger'
|
||||
import { useWaitlistStore } from './stores/store'
|
||||
import { FilterBar } from './components/filter-bar/filter-bar'
|
||||
import { SearchBar } from './components/search-bar/search-bar'
|
||||
import { WaitlistAlert } from './components/waitlist-alert/waitlist-alert'
|
||||
import { Pagination } from './components/pagination/pagination'
|
||||
import { BatchActions } from './components/batch-actions/batch-actions'
|
||||
import { BatchResultsModal } from './components/batch-results-modal/batch-results-modal'
|
||||
import { WaitlistTable as WaitlistDataTable } from './components/waitlist-table/waitlist-table'
|
||||
|
||||
const logger = new Logger('WaitlistTable')
|
||||
|
||||
type AlertType = 'error' | 'email-error' | 'rate-limit' | null
|
||||
|
||||
export function WaitlistTable() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const {
|
||||
entries,
|
||||
filteredEntries,
|
||||
status,
|
||||
searchTerm,
|
||||
page,
|
||||
totalEntries,
|
||||
loading,
|
||||
error,
|
||||
actionLoading,
|
||||
setStatus,
|
||||
setSearchTerm,
|
||||
setPage,
|
||||
setActionLoading,
|
||||
setError,
|
||||
fetchEntries,
|
||||
} = useWaitlistStore()
|
||||
|
||||
// Enhanced error state
|
||||
const [alertInfo, setAlertInfo] = useState<{
|
||||
type: AlertType
|
||||
message: string
|
||||
entryId?: string
|
||||
}>({ type: null, message: '' })
|
||||
|
||||
// Auto-dismiss alert after 7 seconds
|
||||
useEffect(() => {
|
||||
if (alertInfo.type) {
|
||||
const timer = setTimeout(() => {
|
||||
setAlertInfo({ type: null, message: '' })
|
||||
}, 7000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [alertInfo])
|
||||
|
||||
// Auth token for API calls
|
||||
const [apiToken, setApiToken] = useState('')
|
||||
const [authChecked, setAuthChecked] = useState(false)
|
||||
|
||||
// Check authentication and redirect if needed
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated
|
||||
const token = sessionStorage.getItem('admin-auth-token') || ''
|
||||
const isAuth = sessionStorage.getItem('admin-auth') === 'true'
|
||||
|
||||
setApiToken(token)
|
||||
|
||||
// If not authenticated, redirect to admin home page to show the login form
|
||||
if (!isAuth || !token) {
|
||||
logger.warn('Not authenticated, redirecting to admin page')
|
||||
router.push('/admin')
|
||||
return
|
||||
}
|
||||
|
||||
setAuthChecked(true)
|
||||
}, [router])
|
||||
|
||||
// Get status from URL on initial load - only if authenticated
|
||||
useEffect(() => {
|
||||
if (!authChecked) return
|
||||
|
||||
const urlStatus = searchParams.get('status') || 'all'
|
||||
// Make sure it's a valid status
|
||||
const validStatus = ['all', 'pending', 'approved', 'rejected'].includes(urlStatus)
|
||||
? urlStatus
|
||||
: 'all'
|
||||
|
||||
setStatus(validStatus)
|
||||
}, [searchParams, setStatus, authChecked])
|
||||
|
||||
// Handle status filter change
|
||||
const handleStatusChange = useCallback(
|
||||
(newStatus: string) => {
|
||||
if (newStatus !== status) {
|
||||
setStatus(newStatus)
|
||||
router.push(`?status=${newStatus}`)
|
||||
}
|
||||
},
|
||||
[status, setStatus, router]
|
||||
)
|
||||
|
||||
// Handle individual approval
|
||||
const handleApprove = async (email: string, id: string) => {
|
||||
try {
|
||||
setActionLoading(id)
|
||||
setError(null)
|
||||
setAlertInfo({ type: null, message: '' })
|
||||
|
||||
const response = await fetch('/api/admin/waitlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({ email, action: 'approve' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle specific error types
|
||||
if (response.status === 429) {
|
||||
setAlertInfo({
|
||||
type: 'rate-limit',
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
} else if (data.message?.includes('email') || data.message?.includes('resend')) {
|
||||
setAlertInfo({
|
||||
type: 'email-error',
|
||||
message: `Email delivery failed: ${data.message}`,
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: data.message || 'Failed to approve user',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.success) {
|
||||
if (data.message?.includes('email') || data.message?.includes('resend')) {
|
||||
setAlertInfo({
|
||||
type: 'email-error',
|
||||
message: `Email delivery failed: ${data.message}`,
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: data.message || 'Failed to approve user',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Success - don't refresh the table, just clear any errors
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to approve user'
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
entryId: id,
|
||||
})
|
||||
logger.error('Error approving user:', error)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle individual rejection
|
||||
const handleReject = async (email: string, id: string) => {
|
||||
try {
|
||||
setActionLoading(id)
|
||||
setError(null)
|
||||
setAlertInfo({ type: null, message: '' })
|
||||
|
||||
const response = await fetch('/api/admin/waitlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({ email, action: 'reject' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: data.message || 'Failed to reject user',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Success - don't refresh the table
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to reject user'
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
entryId: id,
|
||||
})
|
||||
logger.error('Error rejecting user:', error)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle resending approval email
|
||||
const handleResendApproval = async (email: string, id: string) => {
|
||||
try {
|
||||
setActionLoading(id)
|
||||
setError(null)
|
||||
setAlertInfo({ type: null, message: '' })
|
||||
|
||||
const response = await fetch('/api/admin/waitlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({ email, action: 'resend' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle specific error types
|
||||
if (response.status === 429) {
|
||||
setAlertInfo({
|
||||
type: 'rate-limit',
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
} else if (data.message?.includes('email') || data.message?.includes('resend')) {
|
||||
setAlertInfo({
|
||||
type: 'email-error',
|
||||
message: `Email delivery failed: ${data.message}`,
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: data.message || 'Failed to resend approval email',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.success) {
|
||||
if (data.message?.includes('email') || data.message?.includes('resend')) {
|
||||
setAlertInfo({
|
||||
type: 'email-error',
|
||||
message: `Email delivery failed: ${data.message}`,
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: data.message || 'Failed to resend approval email',
|
||||
entryId: id,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// No UI update needed on success, just clear error state
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to resend approval email'
|
||||
setAlertInfo({
|
||||
type: 'email-error',
|
||||
message: errorMessage,
|
||||
entryId: id,
|
||||
})
|
||||
logger.error('Error resending approval email:', error)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation
|
||||
const handleNextPage = () => setPage(page + 1)
|
||||
const handlePrevPage = () => setPage(Math.max(page - 1, 1))
|
||||
const handleFirstPage = () => setPage(1)
|
||||
const handleLastPage = () => {
|
||||
const lastPage = Math.max(1, Math.ceil(totalEntries / 50))
|
||||
setPage(lastPage)
|
||||
}
|
||||
const handleRefresh = () => {
|
||||
fetchEntries()
|
||||
setAlertInfo({ type: null, message: '' })
|
||||
}
|
||||
|
||||
// Format date helper
|
||||
const formatDate = (date: Date) => {
|
||||
const now = new Date()
|
||||
const diffInMs = now.getTime() - date.getTime()
|
||||
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffInDays < 1) return 'today'
|
||||
if (diffInDays === 1) return 'yesterday'
|
||||
if (diffInDays < 30) return `${diffInDays} days ago`
|
||||
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
// Get formatted timestamp for tooltips
|
||||
const getDetailedTimeTooltip = (date: Date) => {
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// State for selected emails (for batch operations)
|
||||
const [selectedEmails, setSelectedEmails] = useState<Record<string, boolean>>({})
|
||||
const [showBatchDialog, setShowBatchDialog] = useState(false)
|
||||
const [batchActionLoading, setBatchActionLoading] = useState(false)
|
||||
const [batchResults, setBatchResults] = useState<Array<{ email: string; success: boolean; message: string }> | null>(null)
|
||||
|
||||
// Helper to check if any emails are selected
|
||||
const hasSelectedEmails = Object.values(selectedEmails).some(Boolean)
|
||||
|
||||
// Count of selected emails
|
||||
const selectedEmailsCount = Object.values(selectedEmails).filter(Boolean).length
|
||||
|
||||
// Toggle selection of a single email
|
||||
const toggleEmailSelection = (email: string) => {
|
||||
setSelectedEmails(prev => ({
|
||||
...prev,
|
||||
[email]: !prev[email]
|
||||
}))
|
||||
}
|
||||
|
||||
// Clear all selections
|
||||
const clearSelections = () => {
|
||||
setSelectedEmails({})
|
||||
}
|
||||
|
||||
// Select/deselect all visible emails
|
||||
const toggleSelectAll = () => {
|
||||
if (filteredEntries.some(entry => selectedEmails[entry.email])) {
|
||||
// If any are selected, deselect all
|
||||
const newSelection = { ...selectedEmails }
|
||||
filteredEntries.forEach(entry => {
|
||||
newSelection[entry.email] = false
|
||||
})
|
||||
setSelectedEmails(newSelection)
|
||||
} else {
|
||||
// Select all visible entries
|
||||
const newSelection = { ...selectedEmails }
|
||||
filteredEntries.forEach(entry => {
|
||||
newSelection[entry.email] = true
|
||||
})
|
||||
setSelectedEmails(newSelection)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle batch approval
|
||||
const handleBatchApprove = async () => {
|
||||
try {
|
||||
setBatchActionLoading(true)
|
||||
setBatchResults(null)
|
||||
setAlertInfo({ type: null, message: '' })
|
||||
|
||||
// Get list of selected emails
|
||||
const emails = Object.entries(selectedEmails)
|
||||
.filter(([_, isSelected]) => isSelected)
|
||||
.map(([email]) => email)
|
||||
|
||||
if (emails.length === 0) {
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: 'No emails selected for batch approval',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch('/api/admin/waitlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({ emails, action: 'batchApprove' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle specific error types
|
||||
if (response.status === 429) {
|
||||
setAlertInfo({
|
||||
type: 'rate-limit',
|
||||
message: 'Rate limit exceeded. Please try again later with fewer emails.',
|
||||
})
|
||||
setBatchResults(data.results || null)
|
||||
return
|
||||
} else if (data.message?.includes('email') || data.message?.includes('resend')) {
|
||||
setAlertInfo({
|
||||
type: 'email-error',
|
||||
message: `Email delivery failed: ${data.message}`,
|
||||
})
|
||||
setBatchResults(data.results || null)
|
||||
return
|
||||
} else {
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: data.message || 'Failed to approve users',
|
||||
})
|
||||
setBatchResults(data.results || null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.success) {
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: data.message || 'Failed to approve some or all users',
|
||||
})
|
||||
setBatchResults(data.results || null)
|
||||
return
|
||||
}
|
||||
|
||||
// Success
|
||||
setShowBatchDialog(true)
|
||||
setBatchResults(data.results || [])
|
||||
|
||||
// Clear selections for successfully approved emails
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
const successfulEmails = data.results
|
||||
.filter((result: { success: boolean }) => result.success)
|
||||
.map((result: { email: string }) => result.email)
|
||||
|
||||
if (successfulEmails.length > 0) {
|
||||
const newSelection = { ...selectedEmails }
|
||||
successfulEmails.forEach((email: string) => {
|
||||
newSelection[email] = false
|
||||
})
|
||||
setSelectedEmails(newSelection)
|
||||
|
||||
// Refresh the entries to show updated statuses
|
||||
fetchEntries()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to approve users'
|
||||
setAlertInfo({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
logger.error('Error batch approving users:', error)
|
||||
} finally {
|
||||
setBatchActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// If not authenticated yet, show loading state
|
||||
if (!authChecked) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<Skeleton className="h-16 w-16 rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 w-full p-4">
|
||||
{/* Top bar with filters, search and refresh */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start gap-3 mb-2">
|
||||
{/* Filter buttons in a single row */}
|
||||
<FilterBar
|
||||
currentStatus={status}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
|
||||
{/* Search and refresh aligned to the right */}
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<SearchBar
|
||||
initialValue={searchTerm}
|
||||
onSearch={setSearchTerm}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="flex-shrink-0 h-9 w-9 p-0"
|
||||
>
|
||||
<RotateCcwIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Alert system */}
|
||||
<WaitlistAlert
|
||||
type={alertInfo.type}
|
||||
message={alertInfo.message}
|
||||
onDismiss={() => setAlertInfo({ type: null, message: '' })}
|
||||
onRefresh={alertInfo.type === 'error' ? handleRefresh : undefined}
|
||||
/>
|
||||
|
||||
{/* Original error alert - kept for backward compatibility */}
|
||||
{error && !alertInfo.type && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertCircleIcon className="h-4 w-4" />
|
||||
<AlertDescription className="ml-2">
|
||||
{error}
|
||||
<Button onClick={handleRefresh} variant="outline" size="sm" className="ml-4">
|
||||
Try Again
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Select All row - only shown when not in approved view and entries exist */}
|
||||
{status !== 'approved' && filteredEntries.length > 0 && !loading && (
|
||||
<BatchActions
|
||||
hasSelectedEmails={hasSelectedEmails}
|
||||
selectedCount={selectedEmailsCount}
|
||||
loading={batchActionLoading}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onClearSelections={clearSelections}
|
||||
onBatchApprove={handleBatchApprove}
|
||||
entriesExist={filteredEntries.length > 0}
|
||||
someSelected={filteredEntries.some(entry => selectedEmails[entry.email])}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 w-full">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<div className="rounded-md border p-8 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<InfoIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold">No entries found</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{searchTerm
|
||||
? 'No matching entries found with the current search term'
|
||||
: `No ${status === 'all' ? '' : status} entries found in the waitlist.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Table */}
|
||||
<WaitlistDataTable
|
||||
entries={filteredEntries}
|
||||
status={status}
|
||||
actionLoading={actionLoading}
|
||||
selectedEmails={selectedEmails}
|
||||
onToggleSelection={toggleEmailSelection}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
onResendApproval={handleResendApproval}
|
||||
formatDate={formatDate}
|
||||
getDetailedTimeTooltip={getDetailedTimeTooltip}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{!searchTerm && (
|
||||
<Pagination
|
||||
page={page}
|
||||
totalItems={totalEntries}
|
||||
itemsPerPage={50}
|
||||
loading={loading}
|
||||
onFirstPage={handleFirstPage}
|
||||
onPrevPage={handlePrevPage}
|
||||
onNextPage={handleNextPage}
|
||||
onLastPage={handleLastPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Batch results dialog */}
|
||||
<BatchResultsModal
|
||||
open={showBatchDialog}
|
||||
onOpenChange={setShowBatchDialog}
|
||||
results={batchResults}
|
||||
onClose={() => {
|
||||
setShowBatchDialog(false)
|
||||
setBatchResults(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import { Logger } from '@/lib/logs/console-logger'
|
||||
import {
|
||||
approveWaitlistUser,
|
||||
approveBatchWaitlistUsers,
|
||||
getWaitlistEntries,
|
||||
rejectWaitlistUser,
|
||||
resendApprovalEmail
|
||||
@@ -24,6 +25,12 @@ const actionSchema = z.object({
|
||||
action: z.enum(['approve', 'reject', 'resend']),
|
||||
})
|
||||
|
||||
// Schema for batch approval request
|
||||
const batchActionSchema = z.object({
|
||||
emails: z.array(z.string().email()).min(1).max(100),
|
||||
action: z.literal('batchApprove'),
|
||||
})
|
||||
|
||||
// Admin password from environment variables
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''
|
||||
|
||||
@@ -149,265 +156,321 @@ export async function POST(request: NextRequest) {
|
||||
// Parse request body
|
||||
const body = await request.json()
|
||||
|
||||
// Validate request
|
||||
const validatedData = actionSchema.safeParse(body)
|
||||
// Check if it's a batch action
|
||||
if (body.action === 'batchApprove' && Array.isArray(body.emails)) {
|
||||
// Validate batch request
|
||||
const validatedData = batchActionSchema.safeParse(body)
|
||||
|
||||
if (!validatedData.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid request',
|
||||
errors: validatedData.error.format(),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (!validatedData.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid batch request',
|
||||
errors: validatedData.error.format(),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { email, action } = validatedData.data
|
||||
|
||||
let result: any
|
||||
|
||||
// Perform the requested action
|
||||
if (action === 'approve') {
|
||||
const { emails } = validatedData.data
|
||||
|
||||
logger.info(`Processing batch approval for ${emails.length} emails`)
|
||||
|
||||
try {
|
||||
// Need to handle email errors specially to prevent approving users when email fails
|
||||
result = await approveWaitlistUser(email)
|
||||
|
||||
// First check for email delivery errors from Resend
|
||||
if (!result.success && result?.emailError) {
|
||||
logger.error('Email delivery error:', result.emailError)
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (result.rateLimited || detectResendRateLimitError(result.emailError)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending. User was NOT approved.',
|
||||
rateLimited: true,
|
||||
emailError: true
|
||||
},
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Email delivery failed: ${result.message || 'Unknown email error'}. User was NOT approved.`,
|
||||
emailError: true
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
const result = await approveBatchWaitlistUsers(emails)
|
||||
|
||||
// Check for rate limiting
|
||||
if (!result.success && result?.rateLimited) {
|
||||
if (!result.success && result.rateLimited) {
|
||||
logger.warn('Rate limit reached for email sending')
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending. User was NOT approved.',
|
||||
rateLimited: true
|
||||
message: 'Rate limit exceeded for email sending. Users were NOT approved.',
|
||||
rateLimited: true,
|
||||
results: result.results
|
||||
},
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
// General failure
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: result.message || 'Failed to approve user'
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
// Return the result, even if partially successful
|
||||
return NextResponse.json({
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
results: result.results
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error approving waitlist user:', error)
|
||||
|
||||
// Check if it's the JWT_SECRET missing error
|
||||
if (error instanceof Error && error.message.includes('JWT_SECRET')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message:
|
||||
'Configuration error: JWT_SECRET environment variable is missing. Please contact the administrator.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Resend API errors specifically
|
||||
if (error instanceof Error &&
|
||||
(error.message.includes('email') ||
|
||||
error.message.includes('resend'))) {
|
||||
logger.error('Error in batch approval:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to process batch approval',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Handle individual actions
|
||||
// Validate request
|
||||
const validatedData = actionSchema.safeParse(body)
|
||||
|
||||
if (!validatedData.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid request',
|
||||
errors: validatedData.error.format(),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { email, action } = validatedData.data
|
||||
|
||||
let result: any
|
||||
|
||||
// Perform the requested action
|
||||
if (action === 'approve') {
|
||||
try {
|
||||
// Need to handle email errors specially to prevent approving users when email fails
|
||||
result = await approveWaitlistUser(email)
|
||||
|
||||
// Handle rate limiting specifically
|
||||
if (detectResendRateLimitError(error)) {
|
||||
// First check for email delivery errors from Resend
|
||||
if (!result.success && result?.emailError) {
|
||||
logger.error('Email delivery error:', result.emailError)
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (result.rateLimited || detectResendRateLimitError(result.emailError)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending. User was NOT approved.',
|
||||
rateLimited: true,
|
||||
emailError: true
|
||||
},
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Email delivery failed: ${result.message || 'Unknown email error'}. User was NOT approved.`,
|
||||
emailError: true
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check for rate limiting
|
||||
if (!result.success && result?.rateLimited) {
|
||||
logger.warn('Rate limit reached for email sending')
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending. User was NOT approved.',
|
||||
rateLimited: true,
|
||||
emailError: true
|
||||
rateLimited: true
|
||||
},
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Email delivery failed: ${error.message}. User was NOT approved.`,
|
||||
emailError: true
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to approve user',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} else if (action === 'reject') {
|
||||
try {
|
||||
result = await rejectWaitlistUser(email)
|
||||
} catch (error) {
|
||||
logger.error('Error rejecting waitlist user:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to reject user',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} else if (action === 'resend') {
|
||||
try {
|
||||
result = await resendApprovalEmail(email)
|
||||
|
||||
// First check for email delivery errors from Resend
|
||||
if (!result.success && result?.emailError) {
|
||||
logger.error('Email delivery error:', result.emailError)
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (result.rateLimited || detectResendRateLimitError(result.emailError)) {
|
||||
// General failure
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending.',
|
||||
rateLimited: true,
|
||||
message: result.message || 'Failed to approve user'
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error approving waitlist user:', error)
|
||||
|
||||
// Check if it's the JWT_SECRET missing error
|
||||
if (error instanceof Error && error.message.includes('JWT_SECRET')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message:
|
||||
'Configuration error: JWT_SECRET environment variable is missing. Please contact the administrator.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Resend API errors specifically
|
||||
if (error instanceof Error &&
|
||||
(error.message.includes('email') ||
|
||||
error.message.includes('resend'))) {
|
||||
|
||||
// Handle rate limiting specifically
|
||||
if (detectResendRateLimitError(error)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending. User was NOT approved.',
|
||||
rateLimited: true,
|
||||
emailError: true
|
||||
},
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Email delivery failed: ${error.message}. User was NOT approved.`,
|
||||
emailError: true
|
||||
},
|
||||
{ status: 429 }
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Email delivery failed: ${result.message || 'Unknown email error'}`,
|
||||
emailError: true
|
||||
message: error instanceof Error ? error.message : 'Failed to approve user',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check for rate limiting
|
||||
if (!result.success && result?.rateLimited) {
|
||||
logger.warn('Rate limit reached for email sending')
|
||||
} else if (action === 'reject') {
|
||||
try {
|
||||
result = await rejectWaitlistUser(email)
|
||||
} catch (error) {
|
||||
logger.error('Error rejecting waitlist user:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending',
|
||||
rateLimited: true
|
||||
},
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
// General failure
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: result.message || 'Failed to resend approval email'
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error resending approval email:', error)
|
||||
|
||||
// Check if it's the JWT_SECRET missing error
|
||||
if (error instanceof Error && error.message.includes('JWT_SECRET')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message:
|
||||
'Configuration error: JWT_SECRET environment variable is missing. Please contact the administrator.',
|
||||
message: error instanceof Error ? error.message : 'Failed to reject user',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Resend API errors specifically
|
||||
if (error instanceof Error &&
|
||||
(error.message.includes('email') ||
|
||||
error.message.includes('resend'))) {
|
||||
} else if (action === 'resend') {
|
||||
try {
|
||||
result = await resendApprovalEmail(email)
|
||||
|
||||
// Handle rate limiting specifically
|
||||
if (detectResendRateLimitError(error)) {
|
||||
// First check for email delivery errors from Resend
|
||||
if (!result.success && result?.emailError) {
|
||||
logger.error('Email delivery error:', result.emailError)
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (result.rateLimited || detectResendRateLimitError(result.emailError)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending.',
|
||||
rateLimited: true,
|
||||
emailError: true
|
||||
},
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Email delivery failed: ${result.message || 'Unknown email error'}`,
|
||||
emailError: true
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check for rate limiting
|
||||
if (!result.success && result?.rateLimited) {
|
||||
logger.warn('Rate limit reached for email sending')
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending',
|
||||
rateLimited: true,
|
||||
emailError: true
|
||||
rateLimited: true
|
||||
},
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
// General failure
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: result.message || 'Failed to resend approval email'
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error resending approval email:', error)
|
||||
|
||||
// Check if it's the JWT_SECRET missing error
|
||||
if (error instanceof Error && error.message.includes('JWT_SECRET')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message:
|
||||
'Configuration error: JWT_SECRET environment variable is missing. Please contact the administrator.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Resend API errors specifically
|
||||
if (error instanceof Error &&
|
||||
(error.message.includes('email') ||
|
||||
error.message.includes('resend'))) {
|
||||
|
||||
// Handle rate limiting specifically
|
||||
if (detectResendRateLimitError(error)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending',
|
||||
rateLimited: true,
|
||||
emailError: true
|
||||
},
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Email delivery failed: ${error.message}`,
|
||||
emailError: true
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Email delivery failed: ${error.message}`,
|
||||
emailError: true
|
||||
message: error instanceof Error ? error.message : 'Failed to resend approval email',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (!result || !result.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to resend approval email',
|
||||
message: result?.message || 'Failed to perform action',
|
||||
},
|
||||
{ status: 500 }
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!result || !result.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: result?.message || 'Failed to perform action',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin waitlist API error:', error)
|
||||
|
||||
|
||||
@@ -8,12 +8,23 @@ interface EmailOptions {
|
||||
from?: string
|
||||
}
|
||||
|
||||
interface BatchEmailOptions {
|
||||
emails: EmailOptions[]
|
||||
}
|
||||
|
||||
interface SendEmailResult {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: any
|
||||
}
|
||||
|
||||
interface BatchSendEmailResult {
|
||||
success: boolean
|
||||
message: string
|
||||
results: SendEmailResult[]
|
||||
data?: any
|
||||
}
|
||||
|
||||
const logger = createLogger('Mailer')
|
||||
|
||||
const resendApiKey = process.env.RESEND_API_KEY
|
||||
@@ -72,3 +83,188 @@ export async function sendEmail({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendBatchEmails({
|
||||
emails,
|
||||
}: BatchEmailOptions): Promise<BatchSendEmailResult> {
|
||||
try {
|
||||
const senderEmail = 'noreply@simstudio.ai'
|
||||
const results: SendEmailResult[] = []
|
||||
|
||||
if (!resend) {
|
||||
logger.info('Batch emails not sent (Resend not configured):', {
|
||||
emailCount: emails.length,
|
||||
})
|
||||
|
||||
// Create mock results for each email
|
||||
emails.forEach(() => {
|
||||
results.push({
|
||||
success: true,
|
||||
message: 'Email logging successful (Resend not configured)',
|
||||
data: { id: 'mock-email-id' },
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Batch email logging successful (Resend not configured)',
|
||||
results,
|
||||
data: { ids: Array(emails.length).fill('mock-email-id') },
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare emails for batch sending
|
||||
const batchEmails = emails.map(email => ({
|
||||
from: `Sim Studio <${email.from || senderEmail}>`,
|
||||
to: email.to,
|
||||
subject: email.subject,
|
||||
html: email.html,
|
||||
}))
|
||||
|
||||
// Send batch emails (maximum 100 per batch as per Resend API limits)
|
||||
// Process in chunks of 50 to be safe
|
||||
const BATCH_SIZE = 50
|
||||
let allSuccessful = true
|
||||
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
let rateDelay = 500
|
||||
|
||||
for (let i = 0; i < batchEmails.length; i += BATCH_SIZE) {
|
||||
if (i > 0) {
|
||||
logger.info(`Rate limit protection: Waiting ${rateDelay}ms before sending next batch`)
|
||||
await delay(rateDelay)
|
||||
}
|
||||
|
||||
const batch = batchEmails.slice(i, i + BATCH_SIZE)
|
||||
|
||||
try {
|
||||
logger.info(`Sending batch ${Math.floor(i/BATCH_SIZE) + 1} of ${Math.ceil(batchEmails.length/BATCH_SIZE)} (${batch.length} emails)`)
|
||||
const response = await resend.batch.send(batch)
|
||||
|
||||
if (response.error) {
|
||||
logger.error('Resend batch API error:', response.error)
|
||||
|
||||
// Add failure results for this batch
|
||||
batch.forEach(() => {
|
||||
results.push({
|
||||
success: false,
|
||||
message: response.error?.message || 'Failed to send batch email',
|
||||
})
|
||||
})
|
||||
|
||||
allSuccessful = false
|
||||
} else if (response.data) {
|
||||
if (Array.isArray(response.data)) {
|
||||
response.data.forEach((item: { id: string }) => {
|
||||
results.push({
|
||||
success: true,
|
||||
message: 'Email sent successfully',
|
||||
data: item,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
logger.info('Resend batch API returned unexpected format, assuming success')
|
||||
batch.forEach((_, index) => {
|
||||
results.push({
|
||||
success: true,
|
||||
message: 'Email sent successfully',
|
||||
data: { id: `batch-${i}-item-${index}` },
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error sending batch emails:', error)
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (error instanceof Error &&
|
||||
(error.message.toLowerCase().includes('rate') ||
|
||||
error.message.toLowerCase().includes('too many') ||
|
||||
error.message.toLowerCase().includes('429'))) {
|
||||
logger.warn('Rate limit exceeded, increasing delay and retrying...')
|
||||
|
||||
// Wait a bit longer and try again with this batch
|
||||
await delay(rateDelay * 5)
|
||||
|
||||
try {
|
||||
logger.info(`Retrying batch ${Math.floor(i/BATCH_SIZE) + 1} with longer delay`)
|
||||
const retryResponse = await resend.batch.send(batch)
|
||||
|
||||
if (retryResponse.error) {
|
||||
logger.error('Retry failed with error:', retryResponse.error)
|
||||
|
||||
batch.forEach(() => {
|
||||
results.push({
|
||||
success: false,
|
||||
message: retryResponse.error?.message || 'Failed to send batch email after retry',
|
||||
})
|
||||
})
|
||||
|
||||
allSuccessful = false
|
||||
} else if (retryResponse.data) {
|
||||
if (Array.isArray(retryResponse.data)) {
|
||||
retryResponse.data.forEach((item: { id: string }) => {
|
||||
results.push({
|
||||
success: true,
|
||||
message: 'Email sent successfully on retry',
|
||||
data: item,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
batch.forEach((_, index) => {
|
||||
results.push({
|
||||
success: true,
|
||||
message: 'Email sent successfully on retry',
|
||||
data: { id: `retry-batch-${i}-item-${index}` },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Increase the standard delay since we hit a rate limit
|
||||
logger.info('Increasing delay between batches after rate limit hit')
|
||||
rateDelay = rateDelay * 2
|
||||
}
|
||||
} catch (retryError) {
|
||||
logger.error('Retry also failed:', retryError)
|
||||
|
||||
batch.forEach(() => {
|
||||
results.push({
|
||||
success: false,
|
||||
message: retryError instanceof Error ? retryError.message : 'Failed to send email even after retry',
|
||||
})
|
||||
})
|
||||
|
||||
allSuccessful = false
|
||||
}
|
||||
} else {
|
||||
// Non-rate limit error
|
||||
batch.forEach(() => {
|
||||
results.push({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to send batch email',
|
||||
})
|
||||
})
|
||||
|
||||
allSuccessful = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: allSuccessful,
|
||||
message: allSuccessful
|
||||
? 'All batch emails sent successfully'
|
||||
: 'Some batch emails failed to send',
|
||||
results,
|
||||
data: { count: results.filter(r => r.success).length },
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in batch email sending:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to send batch emails',
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { and, count, desc, eq, like, or, SQL } from 'drizzle-orm'
|
||||
import { and, count, desc, eq, like, or, SQL, inArray } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import {
|
||||
getEmailSubject,
|
||||
renderWaitlistApprovalEmail,
|
||||
renderWaitlistConfirmationEmail,
|
||||
} from '@/components/emails/render-email'
|
||||
import { sendEmail } from '@/lib/mailer'
|
||||
import { sendEmail, sendBatchEmails } from '@/lib/mailer'
|
||||
import { createToken, verifyToken } from '@/lib/waitlist/token'
|
||||
import { db } from '@/db'
|
||||
import { waitlist } from '@/db/schema'
|
||||
@@ -491,3 +491,159 @@ export async function resendApprovalEmail(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Approve multiple users from the waitlist and send approval emails in batches
|
||||
export async function approveBatchWaitlistUsers(
|
||||
emails: string[]
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
results: Array<{ email: string, success: boolean, message: string }>
|
||||
emailErrors?: any
|
||||
rateLimited?: boolean
|
||||
}> {
|
||||
try {
|
||||
if (!emails || emails.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No emails provided for batch approval',
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all users from the waitlist that match the emails
|
||||
const normalizedEmails = emails.map(email => email.trim().toLowerCase())
|
||||
|
||||
const users = await db
|
||||
.select()
|
||||
.from(waitlist)
|
||||
.where(
|
||||
and(
|
||||
inArray(waitlist.email, normalizedEmails),
|
||||
// Only select users who aren't already approved
|
||||
or(
|
||||
eq(waitlist.status, 'pending'),
|
||||
eq(waitlist.status, 'rejected')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (users.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No valid users found for approval',
|
||||
results: emails.map(email => ({
|
||||
email,
|
||||
success: false,
|
||||
message: 'User not found or already approved',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// Create email options for each user
|
||||
const emailOptions = await Promise.all(
|
||||
users.map(async user => {
|
||||
// Create a special signup token
|
||||
const token = await createToken({
|
||||
email: user.email,
|
||||
type: 'waitlist-approval',
|
||||
expiresIn: '7d',
|
||||
})
|
||||
|
||||
// Generate signup link with token
|
||||
const signupLink = `${process.env.NEXT_PUBLIC_APP_URL}/signup?token=${token}`
|
||||
|
||||
// Generate email HTML
|
||||
const emailHtml = await renderWaitlistApprovalEmail(user.email, signupLink)
|
||||
const subject = getEmailSubject('waitlist-approval')
|
||||
|
||||
return {
|
||||
to: user.email,
|
||||
subject,
|
||||
html: emailHtml,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Send batch emails
|
||||
const emailResults = await sendBatchEmails({ emails: emailOptions })
|
||||
|
||||
// Process results and update database
|
||||
const results = users.map((user, index) => {
|
||||
const emailResult = emailResults.results[index]
|
||||
|
||||
if (emailResult?.success) {
|
||||
// Update user status to approved in database
|
||||
return {
|
||||
email: user.email,
|
||||
success: true,
|
||||
message: 'User approved and email sent successfully',
|
||||
data: emailResult.data,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
email: user.email,
|
||||
success: false,
|
||||
message: emailResult?.message || 'Failed to send approval email',
|
||||
error: emailResult,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Update approved users in the database
|
||||
const successfulEmails = results
|
||||
.filter(result => result.success)
|
||||
.map(result => result.email)
|
||||
|
||||
if (successfulEmails.length > 0) {
|
||||
await db
|
||||
.update(waitlist)
|
||||
.set({
|
||||
status: 'approved',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
inArray(waitlist.email, successfulEmails),
|
||||
// Only update users who aren't already approved
|
||||
or(
|
||||
eq(waitlist.status, 'pending'),
|
||||
eq(waitlist.status, 'rejected')
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if any rate limit errors occurred
|
||||
const rateLimitError = emailResults.results.some(
|
||||
(result: { message?: string }) =>
|
||||
result.message?.toLowerCase().includes('rate') ||
|
||||
result.message?.toLowerCase().includes('too many') ||
|
||||
result.message?.toLowerCase().includes('limit')
|
||||
)
|
||||
|
||||
return {
|
||||
success: successfulEmails.length > 0,
|
||||
message: successfulEmails.length === users.length
|
||||
? 'All users approved successfully'
|
||||
: successfulEmails.length > 0
|
||||
? 'Some users approved successfully'
|
||||
: 'Failed to approve any users',
|
||||
results: results.map(({ email, success, message }: { email: string; success: boolean; message: string }) =>
|
||||
({ email, success, message })),
|
||||
emailErrors: emailResults.results.some((r: { success: boolean }) => !r.success),
|
||||
rateLimited: rateLimitError,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error approving batch waitlist users:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while approving users',
|
||||
results: emails.map(email => ({
|
||||
email,
|
||||
success: false,
|
||||
message: 'Operation failed due to server error',
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user