add: referral Management with Backend Link | ROUTE NOT PROTECTED

This commit is contained in:
DeathKaioken 2025-10-16 07:44:53 +02:00
parent 0fdd727821
commit d0bf865552
7 changed files with 862 additions and 0 deletions

View File

@ -0,0 +1,98 @@
'use client'
import { Fragment } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
interface DeactivateReferralLinkModalProps {
open: boolean
pending?: boolean
linkPreview?: string
fullUrl?: string
onClose: () => void
onConfirm: () => Promise<void> | void
}
export default function DeactivateReferralLinkModal({
open,
pending = false,
linkPreview,
fullUrl,
onClose,
onConfirm,
}: DeactivateReferralLinkModalProps) {
return (
<Transition show={open} as={Fragment}>
<Dialog onClose={onClose} className="relative z-[1100]">
<Transition.Child
as={Fragment}
enter="transition-opacity ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<Transition.Child
as={Fragment}
enter="transition-all ease-out duration-200"
enterFrom="opacity-0 translate-y-2 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="transition-all ease-in duration-150"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-2 sm:scale-95"
>
<Dialog.Panel className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl ring-1 ring-black/10">
<div className="flex items-start gap-3">
<div className="shrink-0">
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
</div>
<div className="flex-1">
<Dialog.Title className="text-lg font-semibold text-gray-900">
Deactivate referral link?
</Dialog.Title>
<div className="mt-2 text-sm text-gray-600">
<p>This will immediately deactivate the selected referral link so it can no longer be used.</p>
{linkPreview && (
<div className="mt-3">
<span className="text-xs uppercase text-gray-500">Link</span>
<div title={fullUrl} className="mt-1 inline-flex items-center rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800">
{linkPreview}
</div>
</div>
)}
</div>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-2">
<button
type="button"
disabled={pending}
onClick={onClose}
className="inline-flex items-center rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 hover:bg-gray-50 disabled:opacity-60"
>
Cancel
</button>
<button
type="button"
disabled={pending}
onClick={onConfirm}
className="inline-flex items-center rounded-md border border-red-300 bg-red-600 px-3 py-2 text-sm text-white hover:bg-red-700 disabled:opacity-60"
>
{pending ? 'Deactivating…' : 'Deactivate'}
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
)
}

View File

@ -0,0 +1,176 @@
'use client'
import React, { useMemo, useState } from 'react'
import { ClipboardDocumentIcon } from '@heroicons/react/24/outline'
import { createReferralLink } from '../hooks/generateReferralLink'
interface Props {
onCreated?: () => void | Promise<void>
}
export default function GenerateReferralLinkWidget({ onCreated }: Props) {
// Defaults: Unlimited + Never expires
const [maxUses, setMaxUses] = useState<string>('-1')
const [expiresInDays, setExpiresInDays] = useState<string>('-1')
// Track which select is locking the other: 'max' | 'exp' | null
const [lockedBy, setLockedBy] = useState<'max' | 'exp' | null>('max')
const [generatedLink, setGeneratedLink] = useState<string>('')
const [isCopying, setIsCopying] = useState(false)
const [isGenerating, setIsGenerating] = useState(false)
const expiryOptions = useMemo(
() => [
{ value: '1', label: '1 day' },
{ value: '2', label: '2 days' },
{ value: '3', label: '3 days' },
{ value: '4', label: '4 days' },
{ value: '5', label: '5 days' },
{ value: '6', label: '6 days' },
{ value: '7', label: '7 days' },
{ value: '-1', label: 'Never expires' },
],
[]
)
const maxUsesOptions = useMemo(
() => [
{ value: '1', label: '1 use' },
{ value: '5', label: '5 uses' },
{ value: '10', label: '10 uses' },
{ value: '50', label: '50 uses' },
{ value: '-1', label: 'Unlimited' },
],
[]
)
// Handlers that enforce coupling
const onChangeMaxUses = (val: string) => {
setMaxUses(val)
if (val === '-1') {
// Unlimited -> force never expires, lock expires
setExpiresInDays('-1')
setLockedBy('max')
} else {
// Unlock if this was the locker
if (lockedBy === 'max') setLockedBy(null)
}
}
const onChangeExpires = (val: string) => {
setExpiresInDays(val)
if (val === '-1') {
// Never expires -> force unlimited, lock max uses
setMaxUses('-1')
setLockedBy('exp')
} else {
// Unlock if this was the locker
if (lockedBy === 'exp') setLockedBy(null)
}
}
const onGenerate = async () => {
setIsGenerating(true)
setGeneratedLink('')
try {
const payload = {
expiresInDays: parseInt(expiresInDays, 10),
maxUses: parseInt(maxUses, 10),
}
const res = await createReferralLink(payload)
console.log('✅ Referral create result:', res)
const body: any = res.body
const url =
body?.data?.url ||
body?.data?.link ||
body?.url ||
body?.link ||
(body?.data?.code ? `${window.location.origin}/signup?ref=${body.data.code}` : '') ||
(body?.code ? `${window.location.origin}/signup?ref=${body.code}` : '')
if (url) setGeneratedLink(url)
if (res.ok && onCreated) await onCreated()
} catch {
// optional error handling
} finally {
setIsGenerating(false)
}
}
const onCopy = async () => {
if (!generatedLink) return
try {
setIsCopying(true)
await navigator.clipboard.writeText(generatedLink)
setTimeout(() => setIsCopying(false), 800)
} catch {
setIsCopying(false)
}
}
const disableExpires = lockedBy === 'max'
const disableMaxUses = lockedBy === 'exp'
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Generate Referral Link</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max Uses</label>
<select
value={maxUses}
onChange={(e) => onChangeMaxUses(e.target.value)}
disabled={disableMaxUses}
className="w-full rounded-md border border-gray-300 text-gray-900 bg-white focus:border-[#8D6B1D] focus:ring-[#8D6B1D] disabled:bg-gray-100 disabled:text-gray-500"
>
{maxUsesOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{disableMaxUses && <p className="mt-1 text-xs text-gray-500">Locked by Never expires.</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Expires In</label>
<select
value={expiresInDays}
onChange={(e) => onChangeExpires(e.target.value)}
disabled={disableExpires}
className="w-full rounded-md border border-gray-300 text-gray-900 bg-white focus:border-[#8D6B1D] focus:ring-[#8D6B1D] disabled:bg-gray-100 disabled:text-gray-500"
>
{expiryOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{disableExpires && <p className="mt-1 text-xs text-gray-500">Locked by Unlimited uses.</p>}
</div>
</div>
<div className="mt-6 flex items-center gap-3">
<button
onClick={onGenerate}
disabled={isGenerating}
className="inline-flex items-center gap-2 rounded-md bg-[#8D6B1D] px-4 py-2 text-white hover:bg-[#7A5E1A] disabled:opacity-60"
>
{isGenerating ? 'Generating...' : 'Generate Link'}
</button>
{generatedLink && (
<div className="flex items-center gap-2">
<code className="rounded bg-gray-100 px-3 py-2 text-sm text-gray-700 break-all">{generatedLink}</code>
<button
onClick={onCopy}
className="inline-flex items-center gap-1 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 hover:bg-gray-50"
>
<ClipboardDocumentIcon className="h-4 w-4" />
{isCopying ? 'Copied' : 'Copy'}
</button>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,227 @@
'use client'
import React, { useState } from 'react'
import { ClipboardDocumentIcon } from '@heroicons/react/24/outline'
interface ReferralLink {
id?: string | number
tokenId?: number
code?: string
url?: string
createdAt?: string | Date | null
expiresAt?: string | Date | null
uses?: number
maxUses?: number
isUnlimited?: boolean
status?: 'active' | 'inactive' | 'expired' | string
}
interface Props {
links: ReferralLink[]
onDeactivate: (link: ReferralLink) => void
}
function shortLink(href?: string) {
if (!href) return '—'
try {
const u = new URL(href)
const host = u.hostname.replace(/^www\./, '')
const ref = u.searchParams.get('ref')
if (ref) {
const tail = `${ref.slice(0, 6)}${ref.slice(-6)}`
return `${host}/register?ref=${tail}`
}
const path = (u.pathname || '/').slice(0, 12)
return `${host}${path}${u.pathname.length > 12 ? '…' : ''}`
} catch {
return href.length > 32 ? `${href.slice(0, 29)}` : href
}
}
export default function ReferralLinksListWidget({ links, onDeactivate }: Props) {
// Local floating tooltip (fixed) so table doesn't scroll to show it
const [tooltip, setTooltip] = useState<{ visible: boolean; text: string; x: number; y: number }>({
visible: false,
text: '',
x: 0,
y: 0,
})
const showTooltip = (e: React.MouseEvent, text: string) => {
setTooltip({ visible: true, text, x: e.clientX, y: e.clientY })
}
const moveTooltip = (e: React.MouseEvent) => {
setTooltip(t => ({ ...t, x: e.clientX, y: e.clientY }))
}
const hideTooltip = () => setTooltip(t => ({ ...t, visible: false }))
return (
<>
<div className="mt-8 bg-white rounded-lg shadow-sm border border-gray-200">
<div className="p-6 border-b border-gray-100">
<h2 className="text-xl font-semibold text-gray-900">All Referral Links</h2>
<p className="text-sm text-gray-600 mt-1">Manage your links and see their status.</p>
</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">Link</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expires</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Usage</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-6 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{links.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-6 text-sm text-gray-500">
No referral links found.
</td>
</tr>
) : (
links.map((l) => {
const createdDate = l.createdAt ? new Date(l.createdAt) : null
const created = createdDate ? createdDate.toLocaleString() : '—'
const expiresDate = l.expiresAt ? new Date(l.expiresAt) : null
const expires = expiresDate ? expiresDate.toLocaleString() : 'Never'
const unlimited = !!(l.isUnlimited || l.maxUses === -1)
// Usage text and badge color
const usagePct = !unlimited && typeof l.uses === 'number' && typeof l.maxUses === 'number' && l.maxUses > 0
? l.uses / l.maxUses
: 0
const usage =
unlimited
? 'Unlimited'
: (typeof l.uses === 'number' && typeof l.maxUses === 'number' && l.maxUses > 0
? `${l.uses} / ${l.maxUses}`
: (typeof l.uses === 'number' ? String(l.uses) : '—'))
const usageBadge =
unlimited
? 'bg-violet-100 text-violet-800'
: (typeof l.maxUses === 'number' && l.maxUses > 0 && (l.uses ?? 0) >= l.maxUses)
? 'bg-red-100 text-red-800'
: (usagePct >= 0.8)
? 'bg-amber-100 text-amber-800'
: 'bg-slate-100 text-slate-800'
// Expires badge color
const now = Date.now()
const msInDay = 24 * 60 * 60 * 1000
const daysLeft = expiresDate ? Math.ceil((expiresDate.getTime() - now) / msInDay) : null
const expired = expiresDate ? expiresDate.getTime() < now : false
const expiresBadge =
!expiresDate
? 'bg-slate-100 text-slate-800'
: expired
? 'bg-red-100 text-red-800'
: (daysLeft !== null && daysLeft <= 3)
? 'bg-amber-100 text-amber-800'
: 'bg-green-100 text-green-800'
const statusBadge =
l.status === 'active'
? 'bg-green-100 text-green-800'
: l.status === 'expired'
? 'bg-gray-100 text-gray-800'
: 'bg-yellow-100 text-yellow-800'
return (
<tr key={l.id || l.code}>
<td className="px-6 py-4 text-sm">
<div className="relative flex items-center gap-2">
{/* Desktop/Tablet: show preview + tooltip */}
<a
href={l.url}
target="_blank"
rel="noreferrer"
onMouseEnter={(e) => showTooltip(e, l.url || '')}
onMouseMove={moveTooltip}
onMouseLeave={hideTooltip}
className="hidden md:inline text-[#8D6B1D] hover:text-[#7A5E1A] font-mono truncate max-w-[240px] lg:max-w-[360px]"
>
{shortLink(l.url)}
</a>
{/* Desktop/Tablet copy button */}
<button
onClick={async () => { try { await navigator.clipboard.writeText(l.url || String(l.code || '')); } catch {} }}
className="hidden md:inline-flex items-center gap-1 rounded border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50"
>
<ClipboardDocumentIcon className="h-4 w-4" />
Copy
</button>
{/* Mobile: only copy button */}
<button
onClick={async () => { try { await navigator.clipboard.writeText(l.url || String(l.code || '')); } catch {} }}
className="inline-flex md:hidden items-center gap-2 rounded border border-gray-300 px-3 py-2 text-xs text-gray-700 hover:bg-gray-50"
aria-label="Copy referral link"
>
<ClipboardDocumentIcon className="h-4 w-4" />
Copy link
</button>
</div>
</td>
{/* Created - badge */}
<td className="px-6 py-4 text-sm">
<span className="inline-flex items-center rounded px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800">
{created}
</span>
</td>
{/* Expires - badge */}
<td className="px-6 py-4 text-sm">
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${expiresBadge}`}>
{expires}
</span>
</td>
{/* Usage - badge */}
<td className="px-6 py-4 text-sm">
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${usageBadge}`}>
{usage}
</span>
</td>
{/* Status - existing badge */}
<td className="px-6 py-4 text-sm">
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${statusBadge}`}>
{String(l.status || 'active')}
</span>
</td>
<td className="px-6 py-4 text-right text-sm">
<button
disabled={l.status !== 'active'}
onClick={() => onDeactivate(l)}
className="inline-flex items-center rounded-md border border-red-300 px-3 py-1.5 text-sm text-red-700 hover:bg-red-50 disabled:opacity-50"
>
Deactivate
</button>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
{/* Local global floating tooltip (fixed, non-scrolling) */}
<div
className={`fixed z-[1000] pointer-events-none transition-opacity duration-75 ${tooltip.visible ? 'opacity-100' : 'opacity-0'}`}
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
>
{tooltip.visible && (
<div className="max-w-[80vw] break-words rounded-md bg-gray-900 text-white text-xs px-3 py-2 shadow-lg ring-1 ring-black/10">
{tooltip.text}
</div>
)}
</div>
</>
)
}

View File

@ -0,0 +1,60 @@
'use client'
import React from 'react'
import {
LinkIcon,
ChartBarIcon,
CheckCircleIcon,
UsersIcon,
BuildingOffice2Icon,
} from '@heroicons/react/24/outline'
type Stats = {
activeLinks: number
linksUsed: number
personalUsersReferred: number
companyUsersReferred: number
totalLinks: number
}
interface Props {
stats: Stats
}
const renderStatCard = (
c: { label: string; value: number; icon: any; color: string },
key: React.Key
) => (
<div key={key} className="relative overflow-hidden rounded-lg bg-white px-4 pb-6 pt-5 shadow-sm border border-gray-200 sm:px-6 sm:pt-6">
<dt>
<div className={`absolute rounded-md ${c.color} p-3`}>
<c.icon className="h-6 w-6 text-white" aria-hidden="true" />
</div>
<p className="ml-16 truncate text-sm font-medium text-gray-500">{c.label}</p>
</dt>
<dd className="ml-16 mt-2 text-2xl font-semibold text-gray-900">{c.value}</dd>
</div>
)
export default function ReferralStatisticWidget({ stats }: 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' },
]
const bottomStats = [
{ label: 'Personal Users', value: stats.personalUsersReferred, icon: UsersIcon, color: 'bg-blue-500' },
{ label: 'Company Users', value: stats.companyUsersReferred, icon: BuildingOffice2Icon, color: 'bg-amber-600' },
{ label: 'Total Links', value: stats.totalLinks, icon: LinkIcon, color: 'bg-yellow-500' },
]
return (
<>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 mb-5">
{topStats.map((c, i) => renderStatCard(c, `top-${i}`))}
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 mb-10">
{bottomStats.map((c, i) => renderStatCard(c, `bottom-${i}`))}
</div>
</>
)
}

View File

@ -0,0 +1,80 @@
'use client'
import useAuthStore from '../../store/authStore'
export interface CreateReferralPayload {
expiresInDays: number; // 1-7, or -1 for never
maxUses: number; // positive integer, or -1 for unlimited
}
function getBaseUrl(): string {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
if (!base) console.warn('⚠️ NEXT_PUBLIC_API_BASE_URL is not set')
return base
}
async function getAccessToken(): Promise<string | null> {
const store = useAuthStore.getState()
if (store.accessToken) return store.accessToken
if (store.refreshAuthToken) {
try {
const ok = await store.refreshAuthToken()
if (ok) return useAuthStore.getState().accessToken
} catch {}
}
return null
}
export async function createReferralLink(payload: CreateReferralPayload) {
const base = getBaseUrl()
const url = `${base}/api/referral/create`
const token = await getAccessToken()
console.log('🌐 Creating referral link:', url, payload)
try {
const res = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify(payload)
})
console.log('📡 /api/referral/create status:', res.status)
const body = await res.json().catch(() => null)
console.log('📦 /api/referral/create body:', body)
return { ok: res.ok, status: res.status, body }
} catch (e) {
console.error('❌ /api/referral/create error:', e)
return { ok: false, status: 0, body: null }
}
}
export async function deactivateReferralLink(tokenId: number | string) {
const base = getBaseUrl()
const url = `${base}/api/referral/deactivate`
const token = await getAccessToken()
console.log('🌐 Deactivating referral link:', url, { tokenId })
try {
const res = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ tokenId: Number(tokenId) })
})
console.log('📡 /api/referral/deactivate status:', res.status)
const body = await res.json().catch(() => null)
console.log('📦 /api/referral/deactivate body:', body)
return { ok: res.ok, status: res.status, body }
} catch (e) {
console.error('❌ /api/referral/deactivate error:', e)
return { ok: false, status: 0, body: null }
}
}

View File

@ -0,0 +1,73 @@
'use client'
import useAuthStore from '../../store/authStore'
async function getAccessToken(): Promise<string | null> {
const store = useAuthStore.getState()
if (store.accessToken) return store.accessToken
if (store.refreshAuthToken) {
try {
const refreshed = await store.refreshAuthToken()
if (refreshed) return useAuthStore.getState().accessToken
} catch {}
}
return null
}
function getBaseUrl(): string {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
if (!base) {
console.warn('⚠️ NEXT_PUBLIC_API_BASE_URL is not set')
}
return base
}
export async function fetchReferralStats() {
const token = await getAccessToken()
const base = getBaseUrl()
const url = `${base}/api/referral/stats`
console.log('🌐 Fetching referral stats:', url)
try {
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
})
console.log('📡 /api/referral/stats status:', res.status)
const body = await res.json().catch(() => null)
console.log('📦 /api/referral/stats body:', body)
return { ok: res.ok, status: res.status, body }
} catch (e) {
console.error('❌ /api/referral/stats error:', e)
return { ok: false, status: 0, body: null }
}
}
export async function fetchReferralList() {
const token = await getAccessToken()
const base = getBaseUrl()
const url = `${base}/api/referral/list`
console.log('🌐 Fetching referral list:', url)
try {
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
})
console.log('📡 /api/referral/list status:', res.status)
const body = await res.json().catch(() => null)
console.log('📦 /api/referral/list body:', body)
return { ok: res.ok, status: res.status, body }
} catch (e) {
console.error('❌ /api/referral/list error:', e)
return { ok: false, status: 0, body: null }
}
}

View File

@ -0,0 +1,148 @@
'use client'
import React, { useMemo, useState, useEffect } from 'react'
import PageLayout from '../components/PageLayout'
import { fetchReferralList, fetchReferralStats } from './hooks/getReferralStats'
import { deactivateReferralLink } from './hooks/generateReferralLink'
import DeactivateReferralLinkModal from './components/deactivateReferralLinkModal'
import ReferralStatisticWidget from './components/referralStatisticWidget'
import GenerateReferralLinkWidget from './components/generateReferralLinkWidget'
import ReferralLinksListWidget from './components/referralLinksListWidget'
export default function ReferralManagementPage() {
// Replace mock stats with backend-aligned shape
const [stats, setStats] = useState({
activeLinks: 0,
linksUsed: 0,
personalUsersReferred: 0,
companyUsersReferred: 0,
totalLinks: 0
})
// NEW: referral links list state
const [links, setLinks] = useState<any[]>([])
// Modal state for deactivation
const [deactivateOpen, setDeactivateOpen] = useState(false)
const [deactivatePending, setDeactivatePending] = useState(false)
const [selectedLink, setSelectedLink] = useState<any | null>(null)
// Add back missing handlers
const openDeactivateModal = (link: any) => {
setSelectedLink(link)
setDeactivateOpen(true)
}
const confirmDeactivate = async () => {
if (!selectedLink) return
setDeactivatePending(true)
try {
const tokenId = selectedLink?.tokenId ?? selectedLink?.id
if (tokenId == null) return
const res = await deactivateReferralLink(tokenId)
console.log('✅ Deactivate result:', res)
await loadData()
} catch (e) {
// optional: toast error
} finally {
setDeactivatePending(false)
setDeactivateOpen(false)
setSelectedLink(null)
}
}
// Helper: normalize list payload shapes
const normalizeList = (raw: any): any[] => {
const arr = Array.isArray(raw)
? raw
: (raw?.tokens || raw?.data || raw?.links || raw?.list || [])
if (!Array.isArray(arr)) return []
return arr.map((item: any) => {
const id = item?.id || item?._id || item?.token || item?.code
const tokenId = item?.tokenId ?? item?.id ?? null
const code = item?.token || item?.code || id
const url =
item?.link || item?.url || (code && typeof window !== 'undefined'
? `${window.location.origin}/signup?ref=${code}`
: '')
const createdAt = item?.created_at || item?.createdAt || item?.created || item?.timestamp || null
const expiresAt = item?.expires_at ?? item?.expiresAt ?? null
const uses =
typeof item?.usage_count === 'number' ? item.usage_count
: typeof item?.used === 'number' ? item.used
: typeof item?.uses === 'number' ? item.uses
: 0
const maxUses =
typeof item?.max_uses === 'number' ? item.max_uses
: typeof item?.maxUses === 'number' ? item.maxUses
: (item?.isUnlimited ? -1 : -1)
const isUnlimited = !!(item?.isUnlimited || item?.max_uses === -1 || item?.maxUses === -1 || item?.usage === 'unlimited' || item?.usageDisplay === 'unlimited')
const statusRaw = item?.status
const activeFlag = typeof item?.active === 'boolean' ? item.active : undefined
const expired = expiresAt ? new Date(expiresAt).getTime() < Date.now() : false
const status = statusRaw || (expired ? 'expired' : (activeFlag === false ? 'inactive' : 'active'))
return { id, tokenId, code, url, createdAt, expiresAt, uses, maxUses, isUnlimited, status }
})
}
// Helper: fetch stats + list
const loadData = async () => {
const [statsRes, listRes] = await Promise.all([fetchReferralStats(), fetchReferralList()])
console.log('✅ Referral stats fetched:', statsRes)
console.log('✅ Referral list fetched:', listRes)
if (statsRes.ok && statsRes.body) {
const b: any = statsRes.body?.data || statsRes.body?.stats || statsRes.body
setStats({
activeLinks: Number(b?.activeLinks ?? 0),
linksUsed: Number(b?.linksUsed ?? 0),
personalUsersReferred: Number(b?.personalUsersReferred ?? 0),
companyUsersReferred: Number(b?.companyUsersReferred ?? 0),
totalLinks: Number(b?.totalLinks ?? 0)
})
}
if (listRes.ok) {
setLinks(normalizeList(listRes.body))
} else {
setLinks([])
}
}
// Remove previous effect and use loadData
useEffect(() => {
loadData()
}, [])
return (
<PageLayout>
<div className="min-h-screen bg-gray-50">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Title */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Referral Management</h1>
<p className="text-gray-600 mt-2">
Create and manage your referral links. Track performance at a glance.
</p>
</div>
{/* Stats overview */}
<ReferralStatisticWidget stats={stats} />
{/* Generator */}
<GenerateReferralLinkWidget onCreated={loadData} />
{/* Referral links list (refactored) */}
<ReferralLinksListWidget links={links} onDeactivate={openDeactivateModal} />
</main>
</div>
{/* Deactivate modal */}
<DeactivateReferralLinkModal
open={deactivateOpen}
pending={deactivatePending}
linkPreview={selectedLink?.url}
fullUrl={selectedLink?.url}
onClose={() => { if (!deactivatePending) setDeactivateOpen(false) }}
onConfirm={confirmDeactivate}
/>
</PageLayout>
)
}