diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 805ce3c..a11c0f6 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -11,7 +11,7 @@ const navLinks = [ { href: '/dashboard', label: 'Dashboard' }, { href: '/sponsors', label: 'Sponsors' }, { href: '/vendors', label: 'Vendors' }, - { href: '/vouch', label: 'Vouch' }, + { href: '/mentor', label: 'Mentor' }, ] export function Navbar() { diff --git a/src/hooks/useMentor.ts b/src/hooks/useMentor.ts new file mode 100644 index 0000000..a8a7e8d --- /dev/null +++ b/src/hooks/useMentor.ts @@ -0,0 +1,71 @@ +import { useQuery } from '@tanstack/react-query' +import { useWalletStore } from '../stores/wallet.store' +import { vouchingService } from '../services/vouching.service' +import { reputationService } from '../services/reputation.service' +import { queryKeys } from '../services/queryKeys' +import type { ReputationScore } from '../types' + +function deriveTier(score: number): ReputationScore['tier'] { + if (score >= 90) return 'Gold' + if (score >= 75) return 'Silver' + if (score >= 60) return 'Bronze' + return 'Starter' +} + +export function useMentor() { + const address = useWalletStore((s) => s.address) + + const reputationQuery = useQuery({ + queryKey: queryKeys.reputation.score(address ?? ''), + queryFn: async () => { + if (!address) return null + const res = await reputationService.getScore(address) + return res as ReputationScore + }, + enabled: !!address, + }) + + const requestsQuery = useQuery({ + queryKey: queryKeys.vouches.requests(), + queryFn: vouchingService.getVouchRequests, + }) + + const activeVouchesQuery = useQuery({ + queryKey: queryKeys.vouches.myVouches(), + queryFn: vouchingService.getMyVouches, + }) + + const score = reputationQuery.data?.score ?? 0 + const tier = reputationQuery.data?.tier ?? deriveTier(score) + const totalVouchesGiven = activeVouchesQuery.data?.length ?? 0 + const activeVouchCount = activeVouchesQuery.data?.filter( + (v) => v.repaymentStatus === 'current' + ).length ?? 0 + const atRiskCount = activeVouchesQuery.data?.filter( + (v) => v.repaymentStatus === 'late' || v.repaymentStatus === 'defaulted' + ).length ?? 0 + const totalLoanImpact = (activeVouchesQuery.data ?? []).reduce( + (sum, v) => sum + v.loanAmount, + 0 + ) + + return { + address, + score, + tier, + totalVouchesGiven, + activeVouchCount, + atRiskCount, + totalLoanImpact, + requests: requestsQuery.data ?? [], + activeVouches: activeVouchesQuery.data ?? [], + isLoadingReputation: reputationQuery.isLoading, + isLoadingRequests: requestsQuery.isLoading, + isLoadingActiveVouches: activeVouchesQuery.isLoading, + isErrorReputation: reputationQuery.isError, + isErrorRequests: requestsQuery.isError, + isErrorActiveVouches: activeVouchesQuery.isError, + refetchRequests: requestsQuery.refetch, + refetchActiveVouches: activeVouchesQuery.refetch, + } +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 2d13eff..fc446c6 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -23,7 +23,7 @@ export function Dashboard() { } if (role === 'mentor') { - return + return } return diff --git a/src/pages/MentorDashboard.tsx b/src/pages/MentorDashboard.tsx new file mode 100644 index 0000000..472c575 --- /dev/null +++ b/src/pages/MentorDashboard.tsx @@ -0,0 +1,568 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + ClipboardList, ShieldCheck, Award, AlertTriangle, RotateCw, + Clock, DollarSign, Percent, Ban, ExternalLink, XCircle, + UserCheck, TrendingUp, Wallet, +} from 'lucide-react' +import { signTransaction, isConnected, requestAccess } from '@stellar/freighter-api' +import { useSubmitVouch, useRevokeVouch } from '../hooks/useOptimisticVouch' +import { useMentor } from '../hooks/useMentor' +import { Card } from '../components/ui/Card' +import { Button } from '../components/ui/Button' +import { Badge } from '../components/ui/Badge' +import { Spinner } from '../components/ui/Spinner' +import { VouchRequestCard } from '../components/vouch/VouchRequestCard' +import { VouchImpactPreview } from '../components/vouch/VouchImpactPreview' +import { useWallet } from '../hooks/useWallet' +import { useToast } from '../hooks/useToast' +import { STELLAR_NETWORK } from '../constants/config' +import type { VouchRequest } from '../types' + +const REPAYMENT_VARIANTS: Record = { + current: 'green', + late: 'amber', + defaulted: 'red', +} + +const TIER_VARIANTS: Record = { + Gold: 'amber', + Silver: 'muted', + Bronze: 'amber', + Starter: 'green', +} + +function formatWallet(addr: string) { + if (addr.length <= 10) return addr + return `${addr.slice(0, 6)}...${addr.slice(-4)}` +} + +function ConfirmRevokeDialog({ + open, + onConfirm, + onCancel, + revoking, +}: { + open: boolean + onConfirm: () => void + onCancel: () => void + revoking: boolean +}) { + if (!open) return null + + return ( +
+
+ +
+
+
+
+

+ Revoke Vouch +

+

+ This will remove your vouch and the learner will lose the reputation bonus. + This action cannot be undone. +

+
+
+
+ +

+ Revoking a vouch may affect your own reputation score and future vouch capacity. + Only revoke if the learner has defaulted or the agreement has been breached. +

+
+
+ + +
+
+
+
+ ) +} + +function ProfileSection({ + address, + score, + tier, + totalVouchesGiven, + activeVouchCount, + atRiskCount, + totalLoanImpact, + isLoading, +}: { + address: string | null + score: number + tier: string + totalVouchesGiven: number + activeVouchCount: number + atRiskCount: number + totalLoanImpact: number + isLoading: boolean +}) { + if (!address) return null + + if (isLoading) { + return ( +
+ +
+ ) + } + + const statCards = [ + { label: 'Vouches Given', value: totalVouchesGiven, icon: UserCheck, color: 'text-brand' }, + { label: 'Active Vouches', value: activeVouchCount, icon: ShieldCheck, color: 'text-blue-400' }, + { label: 'At Risk', value: atRiskCount, icon: AlertTriangle, color: 'text-amber-400' }, + { label: 'Total Impact', value: `$${totalLoanImpact.toLocaleString()}`, icon: DollarSign, color: 'text-brand' }, + ] + + return ( + +
+
+
+ +
+
+
+

Mentor Profile

+ +
+

{formatWallet(address)}

+
+ + {score} + reputation score +
+
+
+
+ +
+ {statCards.map((stat) => ( +
+
+
+ {stat.value} +
+ ))} +
+
+ ) +} + +export function MentorDashboard() { + const navigate = useNavigate() + const { toast } = useToast() + const { isConnected: walletConnected, connectFreighter } = useWallet() + + const [activeTab, setActiveTab] = useState<'requests' | 'active'>('requests') + const [previewRequest, setPreviewRequest] = useState(null) + const [revokeTarget, setRevokeTarget] = useState(null) + const [decliningId, setDecliningId] = useState(null) + + const { + address, + score, + tier, + totalVouchesGiven, + activeVouchCount, + atRiskCount, + totalLoanImpact, + requests, + activeVouches, + isLoadingReputation, + isLoadingRequests, + isLoadingActiveVouches, + isErrorRequests, + isErrorActiveVouches, + refetchRequests, + refetchActiveVouches, + } = useMentor() + + const submitMutation = useSubmitVouch() + const revokeMutation = useRevokeVouch() + + const handleVouchConfirm = async () => { + if (!previewRequest) return + + try { + if (!walletConnected) { + await connectFreighter() + } + + const connection = await isConnected() + if (!connection.isConnected) { + throw new Error('Freighter not installed. Download at freighter.app') + } + + const access = await requestAccess() + if (access.error) { + throw new Error(access.error.message) + } + + const txXdr = `AAAAAgAAAABz...${Math.random().toString(36).slice(2)}` + const result = await signTransaction(txXdr, { + networkPassphrase: + STELLAR_NETWORK === 'TESTNET' + ? 'Test SDF Network ; September 2015' + : 'Public Global Stellar Network ; September 2015', + }) + + const txHash = 'signedTxXdr' in result ? (result as { signedTxXdr: string }).signedTxXdr : '' + + submitMutation.mutate( + { + learnerAddress: previewRequest.learnerAddress, + txHash, + }, + { + onSuccess: () => { + setPreviewRequest(null) + toast.success('Vouch submitted successfully.') + }, + onError: (error) => { + const message = error instanceof Error ? error.message : 'Failed to submit vouch.' + toast.error(message) + }, + } + ) + } catch (err) { + const message = err instanceof Error ? err.message : 'Transaction failed' + toast.error(message) + } + } + + const handleDecline = async (id: string) => { + setDecliningId(id) + await new Promise((r) => setTimeout(r, 600)) + setDecliningId(null) + } + + const tabs = [ + { + key: 'requests' as const, + label: 'Pending Requests', + icon: ClipboardList, + count: requests.length, + }, + { + key: 'active' as const, + label: 'My Active Vouches', + icon: ShieldCheck, + count: activeVouches.length, + }, + ] + + return ( +
+
+
+

+ Mentor Portal +

+

+ Manage your vouch requests, active vouches, and track your mentoring impact. +

+
+ {!walletConnected && ( + + )} +
+ + + +
+ {tabs.map((tab) => ( + + ))} +
+ + + + + ) +} diff --git a/src/router/index.tsx b/src/router/index.tsx index 21c24fb..e3f34e9 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -11,6 +11,7 @@ import { VendorDashboard } from '../pages/VendorDashboard' import { Sponsors } from '../pages/Sponsors' import { SponsorOnboarding } from '../pages/SponsorOnboarding' import { Vouch } from '../pages/Vouch' +import { MentorDashboard } from '../pages/MentorDashboard' import { LearnerProfile } from '../pages/LearnerProfile' import { NotFound } from '../pages/NotFound' import { History } from '../pages/History' @@ -87,6 +88,10 @@ const router = createBrowserRouter([ path: '/sponsors/onboarding', element: , }, + { + path: '/mentor', + element: , + }, { path: '/vouch', element: , diff --git a/src/stores/role.store.ts b/src/stores/role.store.ts index bff69e6..3a70b13 100644 --- a/src/stores/role.store.ts +++ b/src/stores/role.store.ts @@ -6,7 +6,7 @@ export type UserRole = 'sponsor' | 'vendor' | 'mentor' | null export const ROLE_ROUTES: Record, string> = { sponsor: '/sponsors', vendor: '/vendors/dashboard', - mentor: '/vouch', + mentor: '/mentor', } interface RoleStore {