diff --git a/src/components/vouch/VouchImpactPreview.tsx b/src/components/vouch/VouchImpactPreview.tsx new file mode 100644 index 0000000..7fc82b0 --- /dev/null +++ b/src/components/vouch/VouchImpactPreview.tsx @@ -0,0 +1,115 @@ +import { ArrowRight, Award, DollarSign, TrendingDown } from 'lucide-react' +import { Card } from '../ui/Card' +import { Button } from '../ui/Button' +import type { VouchRequest } from '../../types' + +const VOUCH_REPUTATION_BOOST = 12 + +interface VouchImpactPreviewProps { + request: VouchRequest + onConfirm: () => void + onClose: () => void + confirming: boolean +} + +export function VouchImpactPreview({ + request, + onConfirm, + onClose, + confirming, +}: VouchImpactPreviewProps) { + const currentRate = 8 + const boostedScore = request.score + VOUCH_REPUTATION_BOOST + const newRate = 6 + + const impacts = [ + { + label: 'Reputation Score', + before: request.score.toString(), + after: boostedScore.toString(), + icon: Award, + color: '#F59E0B', + }, + { + label: 'Interest Rate', + before: `${currentRate}%`, + after: `${newRate}%`, + icon: TrendingDown, + color: '#22C55E', + }, + { + label: 'Monthly Payment (est.)', + before: `$${Math.round(request.loanAmount * (currentRate / 100) / 12).toLocaleString()}`, + after: `$${Math.round(request.loanAmount * (newRate / 100) / 12).toLocaleString()}`, + icon: DollarSign, + color: '#2563EB', + }, + ] + + return ( +
+
+ +
+
+ +

+ Vouch Impact Preview +

+
+

+ You are about to vouch for{' '} + + {request.learnerWallet} + +

+
+ +
+ {impacts.map((impact) => ( +
+
+ +
+
+

+ {impact.label} +

+
+ {impact.before} + + + {impact.after} + +
+
+
+ ))} +
+ +
+ +

+ A Silver tier vouch adds {VOUCH_REPUTATION_BOOST} reputation points, + reducing the learner's interest rate from {currentRate}% to {newRate}%. + This transaction requires wallet signing. +

+
+ +
+ + +
+
+
+
+ ) +} diff --git a/src/components/vouch/VouchRequestCard.tsx b/src/components/vouch/VouchRequestCard.tsx new file mode 100644 index 0000000..85b532d --- /dev/null +++ b/src/components/vouch/VouchRequestCard.tsx @@ -0,0 +1,96 @@ +import { Award, DollarSign, FileText, ExternalLink, XCircle, UserCheck } from 'lucide-react' +import { Card } from '../ui/Card' +import { Button } from '../ui/Button' +import { Badge } from '../ui/Badge' +import type { VouchRequest } from '../../types' + +const TIER_VARIANTS: Record = { + Gold: 'amber', + Silver: 'muted', + Bronze: 'amber', + Starter: 'green', +} + +interface VouchRequestCardProps { + request: VouchRequest + onReviewProfile: (wallet: string) => void + onVouch: (request: VouchRequest) => void + onDecline: (id: string) => void + declining: boolean +} + +export function VouchRequestCard({ + request, + onReviewProfile, + onVouch, + onDecline, + declining, +}: VouchRequestCardProps) { + return ( + +
+
+
+
+ +
+ + {request.learnerWallet} + + +
+ +
+ + + Score: {request.score} + + + + Loan: ${request.loanAmount.toLocaleString()} + + + + {request.purpose} + +
+ + {request.skills.length > 0 && ( +
+ {request.skills.map((skill) => ( + + ))} +
+ )} +
+ +
+ + + +
+
+
+ ) +} diff --git a/src/pages/Vouch.tsx b/src/pages/Vouch.tsx index f31fca4..7610c73 100644 --- a/src/pages/Vouch.tsx +++ b/src/pages/Vouch.tsx @@ -1,14 +1,434 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { ClipboardList, ShieldCheck, Award, AlertTriangle, RotateCw, Clock, DollarSign, Percent, Ban, ExternalLink, XCircle } from 'lucide-react' +import { signTransaction, isConnected, requestAccess } from '@stellar/freighter-api' +import { vouchingService } from '../services/vouching.service' +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 { 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. +

+
+
+
+ + +
+
+
+
+ ) +} + export function Vouch() { + const navigate = useNavigate() + const queryClient = useQueryClient() + 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 requestsQuery = useQuery({ + queryKey: ['vouch-requests'], + queryFn: vouchingService.getVouchRequests, + }) + + const activeVouchesQuery = useQuery({ + queryKey: ['my-vouches'], + queryFn: vouchingService.getMyVouches, + }) + + const submitMutation = useMutation({ + mutationFn: ({ learnerAddress, txHash }: { learnerAddress: string; txHash: string }) => + vouchingService.submitVouch(learnerAddress, txHash), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['vouch-requests'] }) + queryClient.invalidateQueries({ queryKey: ['my-vouches'] }) + setPreviewRequest(null) + }, + }) + + const revokeMutation = useMutation({ + mutationFn: (id: string) => vouchingService.revokeVouch(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['my-vouches'] }) + setRevokeTarget(null) + }, + }) + + 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, + }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Transaction failed' + alert(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: requestsQuery.data?.length, + }, + { + key: 'active' as const, + label: 'My Active Vouches', + icon: ShieldCheck, + count: activeVouchesQuery.data?.length, + }, + ] + return (
-

- Mentor Vouching -

-

- Vouch for learners and help them access - better loan terms. Coming soon. -

+
+
+

+ Mentor Vouching +

+

+ Vouch for learners and help them access better loan terms. +

+
+ {!walletConnected && ( + + )} +
+ +
+ {tabs.map((tab) => ( + + ))} +
+ + {activeTab === 'requests' && ( + <> + {requestsQuery.isLoading ? ( +
+ +

Loading vouch requests...

+
+ ) : requestsQuery.isError ? ( +
+
+ +
+

+ Failed to load requests +

+

+ Could not fetch vouch requests. Please try again later. +

+ +
+ ) : !requestsQuery.data?.length ? ( +
+
+ +
+

+ No pending requests +

+

+ There are no learners requesting vouches right now. Check back later. +

+
+ ) : ( +
+ {requestsQuery.data.map((request) => ( + + navigate(`/learner/${request.learnerAddress}`) + } + onVouch={setPreviewRequest} + onDecline={handleDecline} + declining={decliningId === request.id} + /> + ))} +
+ )} + + )} + + {activeTab === 'active' && ( + <> + {activeVouchesQuery.isLoading ? ( +
+ +

Loading active vouches...

+
+ ) : activeVouchesQuery.isError ? ( +
+
+ +
+

+ Failed to load vouches +

+

+ Could not fetch your active vouches. Please try again later. +

+ +
+ ) : !activeVouchesQuery.data?.length ? ( +
+
+ +
+

+ No active vouches +

+

+ You haven't vouched for anyone yet. Go to the Pending Requests tab to find learners. +

+
+ ) : ( +
+ {activeVouchesQuery.data.map((vouch) => ( + +
+
+
+
+ +
+ + {formatWallet(vouch.learnerWallet)} + + + +
+ +
+
+

Rep Boost

+
+ + +{vouch.reputationBoost} +
+
+
+

Interest

+
+ + {vouch.interestRateBefore}% → {vouch.interestRateAfter}% +
+
+
+

Expiry

+
+ + + {new Date(vouch.expiryDate).toLocaleDateString()} + +
+
+
+

Repayment

+
+ + + ${vouch.paidAmount.toLocaleString()} / ${vouch.loanAmount.toLocaleString()} + +
+
+
+ + {vouch.installments > 0 && ( +
+
+
+
+ + {vouch.paidInstallments}/{vouch.installments} + +
+ )} +
+ +
+ + +
+
+ + ))} +
+ )} + + )} + + {previewRequest && ( + setPreviewRequest(null)} + confirming={submitMutation.isPending} + /> + )} + + { + if (revokeTarget) revokeMutation.mutate(revokeTarget) + }} + onCancel={() => setRevokeTarget(null)} + revoking={revokeMutation.isPending} + />
) } diff --git a/src/services/vouching.service.ts b/src/services/vouching.service.ts new file mode 100644 index 0000000..543383e --- /dev/null +++ b/src/services/vouching.service.ts @@ -0,0 +1,27 @@ +import { api } from './api' +import type { VouchRequest, ActiveVouch, VouchResponse } from '../types' + +export const vouchingService = { + getVouchRequests: async () => { + const res = await api.get('/vouching/requests') + return res.data + }, + + getMyVouches: async () => { + const res = await api.get('/vouching/given') + return res.data + }, + + submitVouch: async (learnerAddress: string, txHash: string) => { + const res = await api.post('/vouching', { + learnerAddress, + txHash, + }) + return res.data + }, + + revokeVouch: async (id: string) => { + const res = await api.delete(`/vouching/${id}`) + return res.data + }, +} diff --git a/src/types/index.ts b/src/types/index.ts index c755d3c..57be2ba 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -132,3 +132,46 @@ export interface Vouch { status: 'Active' | 'Revoked' createdAt: string } + +export interface VouchRequest { + id: string + learnerAddress: string + learnerWallet: string + score: number + tier: 'Starter' | 'Bronze' | 'Silver' | 'Gold' + totalLoans: number + activeLoans: number + totalBorrowed: number + totalRepaid: number + loanAmount: number + purpose: string + requestedAt: string + skills: string[] +} + +export interface ActiveVouch { + id: string + learnerAddress: string + learnerWallet: string + score: number + tier: 'Starter' | 'Bronze' | 'Silver' | 'Gold' + reputationBoost: number + interestRateBefore: number + interestRateAfter: number + expiryDate: string + repaymentStatus: 'current' | 'late' | 'defaulted' + createdAt: string + loanAmount: number + paidAmount: number + installments: number + paidInstallments: number +} + +export interface VouchResponse { + id: string + learnerAddress: string + mentorAddress: string + status: 'Active' | 'Revoked' + createdAt: string + txHash: string +}