feat: added level tracker and registereed users via your referral
This commit is contained in:
parent
12e0aa4fd4
commit
6fa4f02fb2
112
src/app/referral-management/components/levelTrackerWidget.tsx
Normal file
112
src/app/referral-management/components/levelTrackerWidget.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
// total points = total registered users via your referral
|
||||
points?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
// NEW: thresholds with names (Level 1+)
|
||||
// Level 0 (<5) will be handled separately as "Starter"
|
||||
const LEVELS = [
|
||||
{ threshold: 5, name: 'Novice' }, // Level 1
|
||||
{ threshold: 25, name: 'Hustler' }, // Level 2
|
||||
{ threshold: 125, name: 'Entrepreneur' },// Level 3
|
||||
{ threshold: 625, name: 'Prestige' }, // Level 4
|
||||
{ threshold: 3125, name: 'MAX' }, // Level 5+
|
||||
]
|
||||
|
||||
// ...existing calc helpers...
|
||||
function calcLevel(points: number) {
|
||||
// Level increases when meeting thresholds 5, 25, 125, ...
|
||||
// level = count of thresholds <= points
|
||||
let lvl = 0
|
||||
let threshold = 5
|
||||
while (points >= threshold) {
|
||||
lvl++
|
||||
threshold *= 5
|
||||
}
|
||||
return lvl
|
||||
}
|
||||
|
||||
function nextThreshold(points: number) {
|
||||
let t = 5
|
||||
while (points >= t) t *= 5
|
||||
return t
|
||||
}
|
||||
|
||||
export default function LevelTrackerWidget({ points = 3, className = '' }: Props) {
|
||||
const safePoints = Math.max(0, Math.floor(points))
|
||||
|
||||
// NEW: derive level index and name
|
||||
const levelIndex = useMemo(() => {
|
||||
// index of last level whose threshold is met; -1 if below level 1
|
||||
let idx = -1
|
||||
for (let i = 0; i < LEVELS.length; i++) {
|
||||
if (safePoints >= LEVELS[i].threshold) idx = i
|
||||
else break
|
||||
}
|
||||
return idx
|
||||
}, [safePoints])
|
||||
|
||||
const level = useMemo(() => calcLevel(safePoints), [safePoints])
|
||||
const displayLevel = Math.min(level, LEVELS.length) // cap at 5 for display
|
||||
const levelName = level === 0
|
||||
? 'Starter'
|
||||
: LEVELS[Math.min(level - 1, LEVELS.length - 1)].name
|
||||
|
||||
// For progress to next target: keep growing in powers of 5, even after MAX (name stays MAX)
|
||||
const target = useMemo(() => nextThreshold(safePoints), [safePoints])
|
||||
|
||||
// Progress towards the current target
|
||||
const targetProgress = Math.min(safePoints, target)
|
||||
const percent = target === 0 ? 0 : Math.min(100, Math.round((targetProgress / target) * 100))
|
||||
|
||||
// Animate on mount
|
||||
const [animPercent, setAnimPercent] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setAnimPercent(percent), 120)
|
||||
return () => clearTimeout(id)
|
||||
}, [percent])
|
||||
|
||||
return (
|
||||
<div className={`w-full rounded-xl border border-gray-200 bg-white shadow-sm ${className}`}>
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900">Level</span>
|
||||
<span className="text-lg font-bold text-indigo-700">#{displayLevel}</span>
|
||||
</div>
|
||||
{/* NEW: level name badge */}
|
||||
<span className="inline-flex items-center rounded-full bg-indigo-50 text-indigo-700 px-2 py-[2px] text-[11px] font-semibold">
|
||||
{levelName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{targetProgress} of {target} referrals
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slim progress bar */}
|
||||
<div className="relative h-3 w-full overflow-hidden rounded-full bg-gray-100 ring-1 ring-gray-200">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 h-full rounded-full bg-gradient-to-r from-indigo-500 via-violet-500 to-fuchsia-500 shadow-[0_0_10px_rgba(99,102,241,0.35)] transition-[width] duration-800 ease-out"
|
||||
style={{ width: `${animPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium text-gray-600">
|
||||
{displayLevel >= LEVELS.length
|
||||
? 'Max level reached'
|
||||
: `Next milestone: ${target} total referrals`}
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold text-indigo-700">{animPercent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -8,6 +8,7 @@ import {
|
||||
UsersIcon,
|
||||
BuildingOffice2Icon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import LevelTrackerWidget from './levelTrackerWidget' // NEW
|
||||
|
||||
type Stats = {
|
||||
activeLinks: number
|
||||
@ -19,6 +20,7 @@ type Stats = {
|
||||
|
||||
interface Props {
|
||||
stats: Stats
|
||||
totalReferredFromBackend?: number // NEW
|
||||
}
|
||||
|
||||
const renderStatCard = (
|
||||
@ -36,7 +38,7 @@ const renderStatCard = (
|
||||
</div>
|
||||
)
|
||||
|
||||
export default function ReferralStatisticWidget({ stats }: Props) {
|
||||
export default function ReferralStatisticWidget({ stats, totalReferredFromBackend }: Props) {
|
||||
const topStats = [
|
||||
{ label: 'Active Links', value: stats.activeLinks, icon: CheckCircleIcon, color: 'bg-green-500' },
|
||||
{ label: 'Links Used', value: stats.linksUsed, icon: ChartBarIcon, color: 'bg-indigo-500' },
|
||||
@ -47,8 +49,16 @@ export default function ReferralStatisticWidget({ stats }: Props) {
|
||||
{ label: 'Total Links', value: stats.totalLinks, icon: LinkIcon, color: 'bg-yellow-500' },
|
||||
]
|
||||
|
||||
// NEW: prefer backend total_referred_users if provided
|
||||
const totalReferred =
|
||||
typeof totalReferredFromBackend === 'number'
|
||||
? totalReferredFromBackend
|
||||
: (stats.personalUsersReferred || 0) + (stats.companyUsersReferred || 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<LevelTrackerWidget points={totalReferred} className="mb-6" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 mb-5">
|
||||
{topStats.map((c, i) => renderStatCard(c, `top-${i}`))}
|
||||
</div>
|
||||
|
||||
383
src/app/referral-management/components/registeredUserList.tsx
Normal file
383
src/app/referral-management/components/registeredUserList.tsx
Normal file
@ -0,0 +1,383 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { UsersIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
type UserType = 'personal' | 'company'
|
||||
type UserStatus = 'active' | 'pending' | 'blocked'
|
||||
|
||||
export interface RegisteredUser {
|
||||
id: string | number
|
||||
name: string
|
||||
email: string
|
||||
userType: UserType
|
||||
registeredAt: string | Date
|
||||
refCode?: string // CHANGED: optional, not used in UI
|
||||
status: UserStatus
|
||||
}
|
||||
|
||||
interface Props {
|
||||
users?: RegisteredUser[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
// Base dummy set for the widget preview
|
||||
const baseUsers: RegisteredUser[] = [
|
||||
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', userType: 'personal', registeredAt: '2025-03-22T10:04:00Z', refCode: 'REF-9A2F1C', status: 'active' },
|
||||
{ id: 2, name: 'Beta GmbH', email: 'office@beta-gmbh.de', userType: 'company', registeredAt: '2025-03-20T08:31:00Z', refCode: 'REF-9A2F1C', status: 'pending' },
|
||||
{ id: 3, name: 'Carlos Diaz', email: 'carlos@sample.io', userType: 'personal', registeredAt: '2025-03-19T14:22:00Z', refCode: 'REF-77XZQ1', status: 'active' },
|
||||
{ id: 4, name: 'Delta Solutions AG', email: 'contact@delta.ag', userType: 'company', registeredAt: '2025-03-18T09:12:00Z', refCode: 'REF-77XZQ1', status: 'active' },
|
||||
{ id: 5, name: 'Emily Nguyen', email: 'emily.ng@ex.com', userType: 'personal', registeredAt: '2025-03-17T19:44:00Z', refCode: 'REF-9A2F1C', status: 'blocked' },
|
||||
]
|
||||
|
||||
// Expanded dummy list for the modal (pagination/search/filter/export demo)
|
||||
function buildDummyAll(): RegisteredUser[] {
|
||||
const list: RegisteredUser[] = []
|
||||
let id = 1
|
||||
const refCodes = ['REF-9A2F1C', 'REF-77XZQ1', 'REF-55PLK9']
|
||||
const names = [
|
||||
'Alice Johnson', 'Beta GmbH', 'Carlos Diaz', 'Delta Solutions AG', 'Emily Nguyen',
|
||||
'Foxtrot LLC', 'Green Innovations', 'Helios Corp', 'Ivy Partners', 'Jonas Weber'
|
||||
]
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const name = names[i % names.length]
|
||||
const isCompany = /GmbH|AG|LLC|Corp|Partners|Innovations/.test(name) ? 'company' : 'personal'
|
||||
const status: UserStatus = (i % 9 === 0) ? 'blocked' : (i % 3 === 0 ? 'pending' : 'active')
|
||||
const refCode = refCodes[i % refCodes.length]
|
||||
const date = new Date(2025, 2, 28 - (i % 25), 10 + (i % 12), (i * 7) % 60, 0).toISOString()
|
||||
list.push({
|
||||
id: id++,
|
||||
name,
|
||||
email: `${name.toLowerCase().replace(/[^a-z]+/g, '.')}@example.com`,
|
||||
userType: isCompany as UserType,
|
||||
registeredAt: date,
|
||||
refCode,
|
||||
status
|
||||
})
|
||||
}
|
||||
// newest first
|
||||
return list.sort((a, b) => new Date(b.registeredAt).getTime() - new Date(a.registeredAt).getTime())
|
||||
}
|
||||
|
||||
const allDummyUsers = buildDummyAll()
|
||||
|
||||
function statusBadgeClass(s: UserStatus) {
|
||||
switch (s) {
|
||||
case 'active': return 'bg-green-100 text-green-800'
|
||||
case 'pending': return 'bg-amber-100 text-amber-800'
|
||||
case 'blocked': return 'bg-rose-100 text-rose-800'
|
||||
default: return 'bg-slate-100 text-slate-800'
|
||||
}
|
||||
}
|
||||
|
||||
function typeBadgeClass(t: UserType) {
|
||||
return t === 'company' ? 'bg-indigo-100 text-indigo-800' : 'bg-blue-100 text-blue-800'
|
||||
}
|
||||
|
||||
// CSV export helper (no ref code)
|
||||
function exportCsv(rows: RegisteredUser[]) {
|
||||
const header = ['Name', 'Email', 'Type', 'Registered', 'Status'] // CHANGED
|
||||
const csv = [
|
||||
header.join(','),
|
||||
...rows.map(r => [
|
||||
`"${r.name.replace(/"/g, '""')}"`,
|
||||
`"${r.email}"`,
|
||||
r.userType,
|
||||
`"${new Date(r.registeredAt).toLocaleString()}"`,
|
||||
r.status
|
||||
].join(','))
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `registered-users-${Date.now()}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export default function RegisteredUserList({ users, loading }: Props) {
|
||||
// Main widget rows (latest 5)
|
||||
const sorted = useMemo(() => {
|
||||
const data = (users && users.length > 0 ? users : baseUsers).slice()
|
||||
return data.sort((a, b) => new Date(b.registeredAt).getTime() - new Date(a.registeredAt).getTime())
|
||||
}, [users])
|
||||
const rows = sorted.slice(0, 5)
|
||||
|
||||
// NEW: total registered count for badge (from provided users or dummy full list)
|
||||
const totalRegistered = useMemo(() => {
|
||||
return users && users.length ? users.length : allDummyUsers.length
|
||||
}, [users])
|
||||
|
||||
// Modal state
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | UserType>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | UserStatus>('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 10
|
||||
|
||||
// Full dataset for modal (dummy for now)
|
||||
const allRows = useMemo(() => {
|
||||
const data = users && users.length ? users : allDummyUsers
|
||||
return data.sort((a, b) => new Date(b.registeredAt).getTime() - new Date(a.registeredAt).getTime())
|
||||
}, [users])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return allRows.filter(r => {
|
||||
if (typeFilter !== 'all' && r.userType !== typeFilter) return false
|
||||
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
||||
if (!query.trim()) return true
|
||||
const q = query.toLowerCase()
|
||||
return (
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
r.email.toLowerCase().includes(q)
|
||||
// CHANGED: remove refCode match (not provided by backend)
|
||||
)
|
||||
})
|
||||
}, [allRows, query, typeFilter, statusFilter])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
|
||||
const pageRows = filtered.slice((page - 1) * pageSize, page * pageSize)
|
||||
|
||||
const resetAndOpen = () => {
|
||||
setQuery('')
|
||||
setTypeFilter('all')
|
||||
setStatusFilter('all')
|
||||
setPage(1)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
// NEW: lock page scroll while modal is open
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = prev }
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-8 mb-8 bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div className="p-6 border-b border-gray-100 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Registered Users via Your Referral</h2>
|
||||
{/* Moved badge directly under the title */}
|
||||
<div className="mt-2 inline-flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-violet-100 text-violet-800 px-3 py-1 text-[11px] font-semibold tracking-wide">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
TOTAL REGISTERED USER WITH YOUR REF LINK
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-violet-600 text-white px-2 py-1 text-[11px] font-bold">
|
||||
{totalRegistered}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Users who signed up using one of your referral links.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Showing the latest 5 users. Use “View all” to see the complete list.
|
||||
</p>
|
||||
</div>
|
||||
{/* ...existing code... */}
|
||||
<button
|
||||
onClick={resetAndOpen}
|
||||
className="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-500"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Registered</th>
|
||||
{/* REMOVED: Ref Code column */}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{loading ? (
|
||||
<>
|
||||
<tr><td className="px-6 py-4" colSpan={5}><div className="h-4 w-40 bg-gray-200 animate-pulse rounded" /></td></tr>
|
||||
<tr><td className="px-6 py-4" colSpan={5}><div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded" /></td></tr>
|
||||
<tr><td className="px-6 py-4" colSpan={5}><div className="h-4 w-1/2 bg-gray-200 animate-pulse rounded" /></td></tr>
|
||||
</>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-6 py-6 text-sm text-gray-500" colSpan={5}>
|
||||
No registered users found for your referral links.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map(u => {
|
||||
const date = new Date(u.registeredAt).toLocaleString()
|
||||
return (
|
||||
<tr key={u.id}>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">{u.name}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700">{u.email}</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${typeBadgeClass(u.userType)}`}>
|
||||
{u.userType === 'company' ? 'Company' : 'Personal'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700">{date}</td>
|
||||
{/* REMOVED: Ref Code cell */}
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${statusBadgeClass(u.status)}`}>
|
||||
{u.status.charAt(0).toUpperCase() + u.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal with full list */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-6xl bg-white rounded-xl shadow-2xl ring-1 ring-black/10 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">All Registered Users via Your Referral</h3>
|
||||
<p className="text-xs text-gray-600">Search, filter, paginate, or export the full list.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => exportCsv(filtered)}
|
||||
className="inline-flex items-center rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="inline-flex items-center rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-800"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setPage(1) }}
|
||||
placeholder="Search name or email…" // CHANGED
|
||||
className="md:col-span-2 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder:text-gray-700 placeholder:opacity-100"
|
||||
/>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => { setTypeFilter(e.target.value as any); setPage(1) }}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="personal">Personal</option>
|
||||
<option value="company">Company</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => { setStatusFilter(e.target.value as any); setPage(1) }}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Registered</th>
|
||||
{/* REMOVED: Ref Code column */}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{pageRows.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-6 py-6 text-sm text-gray-500" colSpan={5}>
|
||||
No users match your filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pageRows.map(u => {
|
||||
const date = new Date(u.registeredAt).toLocaleString()
|
||||
return (
|
||||
<tr key={u.id}>
|
||||
<td className="px-6 py-3 text-sm text-gray-900">{u.name}</td>
|
||||
<td className="px-6 py-3 text-sm text-gray-700">{u.email}</td>
|
||||
<td className="px-6 py-3 text-sm">
|
||||
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${typeBadgeClass(u.userType)}`}>
|
||||
{u.userType === 'company' ? 'Company' : 'Personal'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm text-gray-700">{date}</td>
|
||||
{/* REMOVED: Ref Code cell */}
|
||||
<td className="px-6 py-3 text-sm">
|
||||
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${statusBadgeClass(u.status)}`}>
|
||||
{u.status.charAt(0).toUpperCase() + u.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="px-6 py-4 flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-gray-600">
|
||||
Showing {pageRows.length} of {filtered.length} users
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 disabled:opacity-50 hover:bg-gray-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-700">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 disabled:opacity-50 hover:bg-gray-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
120
src/app/referral-management/hooks/registeredUsers.ts
Normal file
120
src/app/referral-management/hooks/registeredUsers.ts
Normal file
@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import useAuthStore from '../../store/authStore'
|
||||
|
||||
export type ReferredUserType = 'personal' | 'company'
|
||||
export type ReferredUserStatus = 'active' | 'inactive' | null
|
||||
|
||||
export interface ReferredUser {
|
||||
id: string | number
|
||||
name: string
|
||||
email: string
|
||||
type: ReferredUserType
|
||||
registeredAt: string | null
|
||||
status: ReferredUserStatus
|
||||
}
|
||||
|
||||
interface ApiResponseShape {
|
||||
success?: boolean
|
||||
total_referred_users?: number
|
||||
users?: any[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export function useRegisteredUsers() {
|
||||
const accessToken = useAuthStore(s => s.accessToken)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [total, setTotal] = useState<number>(0)
|
||||
const [users, setUsers] = useState<ReferredUser[]>([])
|
||||
|
||||
const inFlight = useRef<AbortController | null>(null)
|
||||
|
||||
const normalize = (raw: any): ReferredUser => ({
|
||||
id: raw?.id ?? raw?._id ?? raw?.userId ?? '',
|
||||
name: raw?.name ?? raw?.fullName ?? raw?.companyName ?? raw?.email ?? '',
|
||||
email: raw?.email ?? '',
|
||||
type: (raw?.type === 'company' ? 'company' : 'personal') as ReferredUserType,
|
||||
registeredAt: typeof raw?.registeredAt === 'string' ? raw.registeredAt : (raw?.createdAt || null),
|
||||
status: (raw?.status === 'active' || raw?.status === 'inactive') ? raw.status : null
|
||||
})
|
||||
|
||||
const fetchRegisteredUsers = useCallback(async () => {
|
||||
setError('')
|
||||
if (!accessToken) {
|
||||
console.warn('useRegisteredUsers: no access token available')
|
||||
setError('Not authenticated')
|
||||
return
|
||||
}
|
||||
|
||||
// Abort any in-flight request
|
||||
inFlight.current?.abort()
|
||||
const controller = new AbortController()
|
||||
inFlight.current = controller
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||
const url = `${base}/api/referral/referred-users`
|
||||
console.log('🌐 GET referred-users:', url)
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const body: ApiResponseShape = await res.json().catch(() => ({}))
|
||||
console.log('📡 referred-users status:', res.status)
|
||||
console.log('📦 referred-users body:', body)
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = (body as any)?.message || `Failed to load referred users (${res.status})`
|
||||
setError(msg)
|
||||
setUsers([])
|
||||
setTotal(0)
|
||||
return
|
||||
}
|
||||
|
||||
const list = Array.isArray(body.users) ? body.users : []
|
||||
setUsers(list.map(normalize))
|
||||
setTotal(typeof body.total_referred_users === 'number' ? body.total_referred_users : list.length)
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') {
|
||||
console.log('⏹️ referred-users request aborted')
|
||||
return
|
||||
}
|
||||
console.error('❌ referred-users fetch error:', e)
|
||||
setError('Network error')
|
||||
setUsers([])
|
||||
setTotal(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (inFlight.current === controller) inFlight.current = null
|
||||
}
|
||||
}, [accessToken])
|
||||
|
||||
// Auto-load when token becomes available
|
||||
useEffect(() => {
|
||||
if (accessToken) {
|
||||
fetchRegisteredUsers()
|
||||
}
|
||||
return () => {
|
||||
inFlight.current?.abort()
|
||||
}
|
||||
}, [accessToken, fetchRegisteredUsers])
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
total,
|
||||
users,
|
||||
reload: fetchRegisteredUsers,
|
||||
}
|
||||
}
|
||||
@ -8,8 +8,10 @@ import DeactivateReferralLinkModal from './components/deactivateReferralLinkModa
|
||||
import ReferralStatisticWidget from './components/referralStatisticWidget'
|
||||
import GenerateReferralLinkWidget from './components/generateReferralLinkWidget'
|
||||
import ReferralLinksListWidget from './components/referralLinksListWidget'
|
||||
import RegisteredUserList from './components/registeredUserList'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import { useRegisteredUsers } from './hooks/registeredUsers'
|
||||
|
||||
export default function ReferralManagementPage() {
|
||||
const router = useRouter()
|
||||
@ -204,12 +206,23 @@ export default function ReferralManagementPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// NEW: fetch referred users via hook (auto fetch on token)
|
||||
const {
|
||||
users: referredUsers,
|
||||
total: referredTotal,
|
||||
loading: usersLoading,
|
||||
error: usersError,
|
||||
reload: reloadReferredUsers,
|
||||
} = useRegisteredUsers()
|
||||
|
||||
// Load data only when permission is granted
|
||||
useEffect(() => {
|
||||
if (isPermChecked && hasReferralPerm) {
|
||||
loadData()
|
||||
// Ensure referred users are refreshed when arriving at this page
|
||||
reloadReferredUsers()
|
||||
}
|
||||
}, [isPermChecked, hasReferralPerm])
|
||||
}, [isPermChecked, hasReferralPerm]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Gate rendering until auth + permission resolved
|
||||
if (!isAuthReady || !user || !isPermChecked || !hasReferralPerm) {
|
||||
@ -238,7 +251,13 @@ export default function ReferralManagementPage() {
|
||||
</div>
|
||||
|
||||
{/* Stats overview */}
|
||||
<ReferralStatisticWidget stats={stats} />
|
||||
<ReferralStatisticWidget stats={stats} totalReferredFromBackend={referredTotal} /> {/* NEW */}
|
||||
|
||||
{/* Registered users (hook data) */}
|
||||
<RegisteredUserList
|
||||
users={referredUsers as any} // CHANGED: pass directly; no refCode mapping
|
||||
loading={usersLoading}
|
||||
/>
|
||||
|
||||
{/* Generator */}
|
||||
<GenerateReferralLinkWidget onCreated={loadData} />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user