From d0bf865552b136592b6cfe61b0465f2b577186b9 Mon Sep 17 00:00:00 2001 From: DeathKaioken Date: Thu, 16 Oct 2025 07:44:53 +0200 Subject: [PATCH] add: referral Management with Backend Link | ROUTE NOT PROTECTED --- .../deactivateReferralLinkModal.tsx | 98 ++++++++ .../components/generateReferralLinkWidget.tsx | 176 ++++++++++++++ .../components/referralLinksListWidget.tsx | 227 ++++++++++++++++++ .../components/referralStatisticWidget.tsx | 60 +++++ .../hooks/generateReferralLink.ts | 80 ++++++ .../hooks/getReferralStats.ts | 73 ++++++ src/app/referral-management/page.tsx | 148 ++++++++++++ 7 files changed, 862 insertions(+) create mode 100644 src/app/referral-management/components/deactivateReferralLinkModal.tsx create mode 100644 src/app/referral-management/components/generateReferralLinkWidget.tsx create mode 100644 src/app/referral-management/components/referralLinksListWidget.tsx create mode 100644 src/app/referral-management/components/referralStatisticWidget.tsx create mode 100644 src/app/referral-management/hooks/generateReferralLink.ts create mode 100644 src/app/referral-management/hooks/getReferralStats.ts create mode 100644 src/app/referral-management/page.tsx diff --git a/src/app/referral-management/components/deactivateReferralLinkModal.tsx b/src/app/referral-management/components/deactivateReferralLinkModal.tsx new file mode 100644 index 0000000..65cc150 --- /dev/null +++ b/src/app/referral-management/components/deactivateReferralLinkModal.tsx @@ -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 +} + +export default function DeactivateReferralLinkModal({ + open, + pending = false, + linkPreview, + fullUrl, + onClose, + onConfirm, +}: DeactivateReferralLinkModalProps) { + return ( + + + +
+ + +
+
+ + +
+
+ +
+
+ + Deactivate referral link? + +
+

This will immediately deactivate the selected referral link so it can no longer be used.

+ {linkPreview && ( +
+ Link +
+ {linkPreview} +
+
+ )} +
+
+
+ +
+ + +
+
+
+
+
+
+
+ ) +} diff --git a/src/app/referral-management/components/generateReferralLinkWidget.tsx b/src/app/referral-management/components/generateReferralLinkWidget.tsx new file mode 100644 index 0000000..fe1c33d --- /dev/null +++ b/src/app/referral-management/components/generateReferralLinkWidget.tsx @@ -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 +} + +export default function GenerateReferralLinkWidget({ onCreated }: Props) { + // Defaults: Unlimited + Never expires + const [maxUses, setMaxUses] = useState('-1') + const [expiresInDays, setExpiresInDays] = useState('-1') + // Track which select is locking the other: 'max' | 'exp' | null + const [lockedBy, setLockedBy] = useState<'max' | 'exp' | null>('max') + + const [generatedLink, setGeneratedLink] = useState('') + 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 ( +
+

Generate Referral Link

+
+
+ + + {disableMaxUses &&

Locked by “Never expires”.

} +
+ +
+ + + {disableExpires &&

Locked by “Unlimited uses”.

} +
+
+ +
+ + {generatedLink && ( +
+ {generatedLink} + +
+ )} +
+
+ ) +} diff --git a/src/app/referral-management/components/referralLinksListWidget.tsx b/src/app/referral-management/components/referralLinksListWidget.tsx new file mode 100644 index 0000000..230a201 --- /dev/null +++ b/src/app/referral-management/components/referralLinksListWidget.tsx @@ -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 ( + <> +
+
+

All Referral Links

+

Manage your links and see their status.

+
+ +
+ + + + + + + + + + + + {links.length === 0 ? ( + + + + ) : ( + 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 ( + + + + {/* Created - badge */} + + + {/* Expires - badge */} + + + {/* Usage - badge */} + + + {/* Status - existing badge */} + + + + + ) + }) + )} + +
LinkCreatedExpiresUsageStatus +
+ No referral links found. +
+
+ {/* Desktop/Tablet: show preview + tooltip */} + 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)} + + {/* Desktop/Tablet copy button */} + + {/* Mobile: only copy button */} + +
+
+ + {created} + + + + {expires} + + + + {usage} + + + + {String(l.status || 'active')} + + + +
+
+
+ + {/* Local global floating tooltip (fixed, non-scrolling) */} +
+ {tooltip.visible && ( +
+ {tooltip.text} +
+ )} +
+ + ) +} diff --git a/src/app/referral-management/components/referralStatisticWidget.tsx b/src/app/referral-management/components/referralStatisticWidget.tsx new file mode 100644 index 0000000..9a73487 --- /dev/null +++ b/src/app/referral-management/components/referralStatisticWidget.tsx @@ -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 +) => ( +
+
+
+
+

{c.label}

+
+
{c.value}
+
+) + +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 ( + <> +
+ {topStats.map((c, i) => renderStatCard(c, `top-${i}`))} +
+
+ {bottomStats.map((c, i) => renderStatCard(c, `bottom-${i}`))} +
+ + ) +} diff --git a/src/app/referral-management/hooks/generateReferralLink.ts b/src/app/referral-management/hooks/generateReferralLink.ts new file mode 100644 index 0000000..047fc54 --- /dev/null +++ b/src/app/referral-management/hooks/generateReferralLink.ts @@ -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 { + 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 } + } +} diff --git a/src/app/referral-management/hooks/getReferralStats.ts b/src/app/referral-management/hooks/getReferralStats.ts new file mode 100644 index 0000000..a9dab2c --- /dev/null +++ b/src/app/referral-management/hooks/getReferralStats.ts @@ -0,0 +1,73 @@ +'use client' + +import useAuthStore from '../../store/authStore' + +async function getAccessToken(): Promise { + 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 } + } +} diff --git a/src/app/referral-management/page.tsx b/src/app/referral-management/page.tsx new file mode 100644 index 0000000..8dfd3f1 --- /dev/null +++ b/src/app/referral-management/page.tsx @@ -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([]) + + // Modal state for deactivation + const [deactivateOpen, setDeactivateOpen] = useState(false) + const [deactivatePending, setDeactivatePending] = useState(false) + const [selectedLink, setSelectedLink] = useState(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 ( + +
+
+ {/* Title */} +
+

Referral Management

+

+ Create and manage your referral links. Track performance at a glance. +

+
+ + {/* Stats overview */} + + + {/* Generator */} + + + {/* Referral links list (refactored) */} + +
+
+ + {/* Deactivate modal */} + { if (!deactivatePending) setDeactivateOpen(false) }} + onConfirm={confirmDeactivate} + /> +
+ ) +} \ No newline at end of file