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,
|
UsersIcon,
|
||||||
BuildingOffice2Icon,
|
BuildingOffice2Icon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
|
import LevelTrackerWidget from './levelTrackerWidget' // NEW
|
||||||
|
|
||||||
type Stats = {
|
type Stats = {
|
||||||
activeLinks: number
|
activeLinks: number
|
||||||
@ -19,6 +20,7 @@ type Stats = {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
stats: Stats
|
stats: Stats
|
||||||
|
totalReferredFromBackend?: number // NEW
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderStatCard = (
|
const renderStatCard = (
|
||||||
@ -36,7 +38,7 @@ const renderStatCard = (
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
export default function ReferralStatisticWidget({ stats }: Props) {
|
export default function ReferralStatisticWidget({ stats, totalReferredFromBackend }: Props) {
|
||||||
const topStats = [
|
const topStats = [
|
||||||
{ label: 'Active Links', value: stats.activeLinks, icon: CheckCircleIcon, color: 'bg-green-500' },
|
{ label: 'Active Links', value: stats.activeLinks, icon: CheckCircleIcon, color: 'bg-green-500' },
|
||||||
{ label: 'Links Used', value: stats.linksUsed, icon: ChartBarIcon, color: 'bg-indigo-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' },
|
{ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<LevelTrackerWidget points={totalReferred} className="mb-6" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 mb-5">
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 mb-5">
|
||||||
{topStats.map((c, i) => renderStatCard(c, `top-${i}`))}
|
{topStats.map((c, i) => renderStatCard(c, `top-${i}`))}
|
||||||
</div>
|
</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 ReferralStatisticWidget from './components/referralStatisticWidget'
|
||||||
import GenerateReferralLinkWidget from './components/generateReferralLinkWidget'
|
import GenerateReferralLinkWidget from './components/generateReferralLinkWidget'
|
||||||
import ReferralLinksListWidget from './components/referralLinksListWidget'
|
import ReferralLinksListWidget from './components/referralLinksListWidget'
|
||||||
|
import RegisteredUserList from './components/registeredUserList'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
|
import { useRegisteredUsers } from './hooks/registeredUsers'
|
||||||
|
|
||||||
export default function ReferralManagementPage() {
|
export default function ReferralManagementPage() {
|
||||||
const router = useRouter()
|
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
|
// Load data only when permission is granted
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPermChecked && hasReferralPerm) {
|
if (isPermChecked && hasReferralPerm) {
|
||||||
loadData()
|
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
|
// Gate rendering until auth + permission resolved
|
||||||
if (!isAuthReady || !user || !isPermChecked || !hasReferralPerm) {
|
if (!isAuthReady || !user || !isPermChecked || !hasReferralPerm) {
|
||||||
@ -238,7 +251,13 @@ export default function ReferralManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats overview */}
|
{/* 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 */}
|
{/* Generator */}
|
||||||
<GenerateReferralLinkWidget onCreated={loadData} />
|
<GenerateReferralLinkWidget onCreated={loadData} />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user