diff --git a/src/app/referral-management/components/levelTrackerWidget.tsx b/src/app/referral-management/components/levelTrackerWidget.tsx new file mode 100644 index 0000000..c23a3b8 --- /dev/null +++ b/src/app/referral-management/components/levelTrackerWidget.tsx @@ -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 ( +
+
+
+
+
+ Level + #{displayLevel} +
+ {/* NEW: level name badge */} + + {levelName} + +
+
+ {targetProgress} of {target} referrals +
+
+ + {/* Slim progress bar */} +
+
+
+ +
+ + {displayLevel >= LEVELS.length + ? 'Max level reached' + : `Next milestone: ${target} total referrals`} + + {animPercent}% +
+
+
+ ) +} diff --git a/src/app/referral-management/components/referralStatisticWidget.tsx b/src/app/referral-management/components/referralStatisticWidget.tsx index 9a73487..e1fe85b 100644 --- a/src/app/referral-management/components/referralStatisticWidget.tsx +++ b/src/app/referral-management/components/referralStatisticWidget.tsx @@ -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 = (
) -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 ( <> + +
{topStats.map((c, i) => renderStatCard(c, `top-${i}`))}
diff --git a/src/app/referral-management/components/registeredUserList.tsx b/src/app/referral-management/components/registeredUserList.tsx new file mode 100644 index 0000000..a13be28 --- /dev/null +++ b/src/app/referral-management/components/registeredUserList.tsx @@ -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 ( + <> +
+
+
+

Registered Users via Your Referral

+ {/* Moved badge directly under the title */} +
+ + + TOTAL REGISTERED USER WITH YOUR REF LINK + + + {totalRegistered} + +
+

+ Users who signed up using one of your referral links. +

+

+ Showing the latest 5 users. Use “View all” to see the complete list. +

+
+ {/* ...existing code... */} + +
+ +
+ + + + + + + + {/* REMOVED: Ref Code column */} + + + + + {loading ? ( + <> + + + + + ) : rows.length === 0 ? ( + + + + ) : ( + rows.map(u => { + const date = new Date(u.registeredAt).toLocaleString() + return ( + + + + + + {/* REMOVED: Ref Code cell */} + + + ) + }) + )} + +
UserEmailTypeRegisteredStatus
+ No registered users found for your referral links. +
{u.name}{u.email} + + {u.userType === 'company' ? 'Company' : 'Personal'} + + {date} + + {u.status.charAt(0).toUpperCase() + u.status.slice(1)} + +
+
+
+ + {/* Modal with full list */} + {open && ( +
+
setOpen(false)} + aria-hidden + /> +
+
+
+
+

All Registered Users via Your Referral

+

Search, filter, paginate, or export the full list.

+
+
+ + +
+
+ + {/* Controls */} +
+ { 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" + /> + + +
+ + {/* Table */} +
+ + + + + + + + {/* REMOVED: Ref Code column */} + + + + + {pageRows.length === 0 ? ( + + + + ) : ( + pageRows.map(u => { + const date = new Date(u.registeredAt).toLocaleString() + return ( + + + + + + {/* REMOVED: Ref Code cell */} + + + ) + }) + )} + +
UserEmailTypeRegisteredStatus
+ No users match your filters. +
{u.name}{u.email} + + {u.userType === 'company' ? 'Company' : 'Personal'} + + {date} + + {u.status.charAt(0).toUpperCase() + u.status.slice(1)} + +
+
+ + {/* Pagination */} +
+ + Showing {pageRows.length} of {filtered.length} users + +
+ + + Page {page} of {totalPages} + + +
+
+
+
+
+ )} + + ) +} diff --git a/src/app/referral-management/hooks/registeredUsers.ts b/src/app/referral-management/hooks/registeredUsers.ts new file mode 100644 index 0000000..92b8a45 --- /dev/null +++ b/src/app/referral-management/hooks/registeredUsers.ts @@ -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('') + const [total, setTotal] = useState(0) + const [users, setUsers] = useState([]) + + const inFlight = useRef(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, + } +} diff --git a/src/app/referral-management/page.tsx b/src/app/referral-management/page.tsx index f11087b..6b55929 100644 --- a/src/app/referral-management/page.tsx +++ b/src/app/referral-management/page.tsx @@ -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() {
{/* Stats overview */} - + {/* NEW */} + + {/* Registered users (hook data) */} + {/* Generator */}