add: referral Management with Backend Link | ROUTE NOT PROTECTED
This commit is contained in:
parent
0fdd727821
commit
d0bf865552
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
src/app/referral-management/hooks/generateReferralLink.ts
Normal file
80
src/app/referral-management/hooks/generateReferralLink.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/app/referral-management/hooks/getReferralStats.ts
Normal file
73
src/app/referral-management/hooks/getReferralStats.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
148
src/app/referral-management/page.tsx
Normal file
148
src/app/referral-management/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user