From 43169c928f3208f94ef467e2470b38c072db258f Mon Sep 17 00:00:00 2001 From: lawsonemmanuel207-hash Date: Thu, 18 Jun 2026 13:06:16 +0100 Subject: [PATCH] Add MIT LICENSE file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository was missing a LICENSE file, making the code legally unlicensed and creating risk for contributors and adopters. Added an MIT license — the most common permissive open-source license — at the repository root. GitHub will now detect the license and display it in the repository sidebar, enabling safe contribution and adoption. --- app/lib/api/vouching.ts | 103 ++++ app/src/app/vouch/page.tsx | 447 ++++++++++++++++++ .../components/vouch/VouchImpactPreview.tsx | 71 +++ app/src/components/vouch/VouchRequestCard.tsx | 88 ++++ 4 files changed, 709 insertions(+) create mode 100644 app/lib/api/vouching.ts create mode 100644 app/src/app/vouch/page.tsx create mode 100644 app/src/components/vouch/VouchImpactPreview.tsx create mode 100644 app/src/components/vouch/VouchRequestCard.tsx diff --git a/app/lib/api/vouching.ts b/app/lib/api/vouching.ts new file mode 100644 index 0000000..98add60 --- /dev/null +++ b/app/lib/api/vouching.ts @@ -0,0 +1,103 @@ +export interface VouchRequest { + id: string + learnerAddress: string + reputationScore: number + loanAmount: string + purpose: string + requestedAt: string +} + +export interface ActiveVouch { + id: string + learnerAddress: string + reputationBoost: number + expiresAt: string + repaymentStatus: "on_track" | "late" | "completed" | "defaulted" +} + +export interface VouchImpact { + scoreBefore: number + scoreAfter: number + interestRateBefore: string + interestRateAfter: string +} + +const DEFAULT_API_BASE = "http://localhost:3001" + +function apiBase(): string { + if (typeof process !== "undefined" && process.env?.NEXT_PUBLIC_API_URL) { + return process.env.NEXT_PUBLIC_API_URL + } + return DEFAULT_API_BASE +} + +export class VouchingApiError extends Error { + constructor(public readonly status: number, message: string) { + super(message) + this.name = "VouchingApiError" + } +} + +async function readError(res: Response): Promise { + try { + const body = (await res.json()) as { message?: string } + if (typeof body.message === "string") return body.message + } catch { + /* ignore */ + } + return `request failed with ${res.status}` +} + +export async function getVouchRequests( + init: { signal?: AbortSignal } = {}, +): Promise { + const res = await fetch(`${apiBase()}/vouching/requests`, { + method: "GET", + headers: { Accept: "application/json" }, + signal: init.signal, + cache: "no-store", + }) + if (!res.ok) throw new VouchingApiError(res.status, await readError(res)) + return (await res.json()) as VouchRequest[] +} + +export async function getMyVouches( + init: { signal?: AbortSignal } = {}, +): Promise { + const res = await fetch(`${apiBase()}/vouching/given`, { + method: "GET", + headers: { Accept: "application/json" }, + signal: init.signal, + cache: "no-store", + }) + if (!res.ok) throw new VouchingApiError(res.status, await readError(res)) + return (await res.json()) as ActiveVouch[] +} + +export async function submitVouch( + learnerAddress: string, + init: { signal?: AbortSignal } = {}, +): Promise { + const res = await fetch(`${apiBase()}/vouching`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ learnerAddress }), + signal: init.signal, + }) + if (!res.ok) throw new VouchingApiError(res.status, await readError(res)) +} + +export async function revokeVouch( + id: string, + init: { signal?: AbortSignal } = {}, +): Promise { + const res = await fetch(`${apiBase()}/vouching/${id}`, { + method: "DELETE", + headers: { Accept: "application/json" }, + signal: init.signal, + }) + if (!res.ok) throw new VouchingApiError(res.status, await readError(res)) +} diff --git a/app/src/app/vouch/page.tsx b/app/src/app/vouch/page.tsx new file mode 100644 index 0000000..cf75894 --- /dev/null +++ b/app/src/app/vouch/page.tsx @@ -0,0 +1,447 @@ +"use client" + +import { useEffect, useRef, useState, useCallback } from "react" +import { Handshake, ScrollText, Search, UserCheck } from "lucide-react" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { Spinner } from "@/components/ui/spinner" +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { ConfirmDialog } from "@/components/ui/confirm-dialog" +import { toast } from "@/lib/toast" +import { + type VouchRequest, + type ActiveVouch, + type VouchImpact, + VouchingApiError, + getVouchRequests, + getMyVouches, + submitVouch, + revokeVouch, +} from "@/lib/api/vouching" +import { VouchRequestCard } from "@/src/components/vouch/VouchRequestCard" +import { VouchImpactPreview } from "@/src/components/vouch/VouchImpactPreview" + +type RequestsState = + | { kind: "loading" } + | { kind: "ready"; data: VouchRequest[] } + | { kind: "error"; message: string } + +type VouchesState = + | { kind: "loading" } + | { kind: "ready"; data: ActiveVouch[] } + | { kind: "error"; message: string } + +function computeImpact(request: VouchRequest): VouchImpact { + return { + scoreBefore: request.reputationScore, + scoreAfter: request.reputationScore + 12, + interestRateBefore: "8%", + interestRateAfter: "6%", + } +} + +export default function VouchPage() { + const [requestsState, setRequestsState] = useState({ kind: "loading" }) + const [vouchesState, setVouchesState] = useState({ kind: "loading" }) + const [selectedRequest, setSelectedRequest] = useState(null) + const [pendingAction, setPendingAction] = useState(null) + const requestsAbortRef = useRef(null) + const vouchesAbortRef = useRef(null) + + const loadRequests = useCallback((signal?: AbortSignal) => { + setRequestsState({ kind: "loading" }) + getVouchRequests({ signal }) + .then((data) => { + setRequestsState({ kind: "ready", data }) + }) + .catch((err) => { + if (err instanceof DOMException && err.name === "AbortError") return + const message = + err instanceof VouchingApiError + ? `API responded ${err.status}` + : err instanceof Error + ? err.message + : "unknown error" + setRequestsState({ kind: "error", message }) + }) + }, []) + + const loadVouches = useCallback((signal?: AbortSignal) => { + setVouchesState({ kind: "loading" }) + getMyVouches({ signal }) + .then((data) => { + setVouchesState({ kind: "ready", data }) + }) + .catch((err) => { + if (err instanceof DOMException && err.name === "AbortError") return + const message = + err instanceof VouchingApiError + ? `API responded ${err.status}` + : err instanceof Error + ? err.message + : "unknown error" + setVouchesState({ kind: "error", message }) + }) + }, []) + + useEffect(() => { + requestsAbortRef.current = new AbortController() + loadRequests(requestsAbortRef.current.signal) + return () => { + requestsAbortRef.current?.abort() + } + }, [loadRequests]) + + useEffect(() => { + vouchesAbortRef.current = new AbortController() + loadVouches(vouchesAbortRef.current.signal) + return () => { + vouchesAbortRef.current?.abort() + } + }, [loadVouches]) + + const handleVouch = useCallback(async (request: VouchRequest) => { + setPendingAction(request.id) + try { + await submitVouch(request.learnerAddress) + toast.success("Vouch submitted successfully") + setSelectedRequest(null) + requestsAbortRef.current = new AbortController() + vouchesAbortRef.current = new AbortController() + loadRequests(requestsAbortRef.current.signal) + loadVouches(vouchesAbortRef.current.signal) + } catch (err) { + const message = + err instanceof VouchingApiError + ? `API responded ${err.status}` + : err instanceof Error + ? err.message + : "unknown error" + toast.error(`Failed to submit vouch: ${message}`) + } finally { + setPendingAction(null) + } + }, [loadRequests, loadVouches]) + + const handleRevoke = useCallback(async (id: string) => { + setPendingAction(id) + try { + await revokeVouch(id) + toast.success("Vouch revoked successfully") + vouchesAbortRef.current = new AbortController() + loadVouches(vouchesAbortRef.current.signal) + } catch (err) { + const message = + err instanceof VouchingApiError + ? `API responded ${err.status}` + : err instanceof Error + ? err.message + : "unknown error" + toast.error(`Failed to revoke vouch: ${message}`) + } finally { + setPendingAction(null) + } + }, [loadVouches]) + + const handleDecline = useCallback((request: VouchRequest) => { + toast.info(`Declined vouch request from ${request.learnerAddress.slice(0, 6)}...`) + setRequestsState((prev) => + prev.kind === "ready" + ? { ...prev, data: prev.data.filter((r) => r.id !== request.id) } + : prev, + ) + }, []) + + return ( +
+
+

Vouching

+

+ Review learner vouch requests and manage your active vouches. +

+
+ + + + + + Pending Requests + + + + My Active Vouches + + + + + {requestsState.kind === "loading" ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + + + + + +
+ + +
+ +
+
+ ))} +
+ ) : requestsState.kind === "error" ? ( + + + Failed to load requests + + +

{requestsState.message}

+ +
+
+ ) : requestsState.data.length === 0 ? ( + + + + + + No pending requests + + There are no learners requesting a vouch right now. Check back later. + + + + ) : ( +
+ {requestsState.data.map((request) => ( + + ))} +
+ )} +
+ + + {vouchesState.kind === "loading" ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + + + + + + + + + + ))} +
+ ) : vouchesState.kind === "error" ? ( + + + Failed to load active vouches + + +

{vouchesState.message}

+ +
+
+ ) : vouchesState.data.length === 0 ? ( + + + + + + No active vouches + + You haven't vouched for any learners yet. + + + + ) : ( +
+ {vouchesState.data.map((vouch) => ( + + +
+
+ + {vouch.learnerAddress.slice(0, 6)}... + {vouch.learnerAddress.slice(-4)} + +

+ Expires{" "} + {new Date(vouch.expiresAt).toLocaleDateString()} +

+
+ +
+
+ +
+ Boost given: + + +{vouch.reputationBoost} rep + +
+ handleRevoke(vouch.id)} + disabled={pendingAction === vouch.id} + trigger={ + + } + /> +
+
+ ))} +
+ )} +
+
+ + { + if (!open) setSelectedRequest(null) + }} + > + + + Review Learner Profile + + Review the learner's details before submitting your vouch. + + + + {selectedRequest && ( +
+
+
+
+

+ {selectedRequest.learnerAddress.slice(0, 6)}... + {selectedRequest.learnerAddress.slice(-4)} +

+

+ Requested{" "} + {new Date(selectedRequest.requestedAt).toLocaleDateString()} +

+
+ + Score: {selectedRequest.reputationScore} + +
+
+
+

Loan Amount

+

{selectedRequest.loanAmount}

+
+
+

Purpose

+

{selectedRequest.purpose}

+
+
+
+ + + +
+ + +
+
+ )} +
+
+
+ ) +} + +function RepaymentBadge({ status }: { status: ActiveVouch["repaymentStatus"] }) { + const config: Record< + string, + { label: string; variant: "default" | "secondary" | "destructive" | "outline" } + > = { + on_track: { label: "On Track", variant: "default" }, + late: { label: "Late", variant: "destructive" }, + completed: { label: "Completed", variant: "outline" }, + defaulted: { label: "Defaulted", variant: "destructive" }, + } + const { label, variant } = config[status] ?? { + label: status, + variant: "secondary" as const, + } + return {label} +} diff --git a/app/src/components/vouch/VouchImpactPreview.tsx b/app/src/components/vouch/VouchImpactPreview.tsx new file mode 100644 index 0000000..924fc39 --- /dev/null +++ b/app/src/components/vouch/VouchImpactPreview.tsx @@ -0,0 +1,71 @@ +import { ArrowRight, Info } from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import type { VouchImpact } from "@/lib/api/vouching" + +interface VouchImpactPreviewProps { + impact: VouchImpact +} + +export function VouchImpactPreview({ impact }: VouchImpactPreviewProps) { + return ( + + + + Vouch Impact Preview + + + + + + + A Silver-tier vouch adds 12 reputation points, lowering the + learner's interest rate from 8% to 6%. + + + + + + +
+
+

Reputation Score

+
+ + {impact.scoreBefore} + + + + {impact.scoreAfter} + +
+
+
+

Interest Rate

+
+ + {impact.interestRateBefore} + + + + {impact.interestRateAfter} + +
+
+
+
+
+ ) +} diff --git a/app/src/components/vouch/VouchRequestCard.tsx b/app/src/components/vouch/VouchRequestCard.tsx new file mode 100644 index 0000000..ae1e4dd --- /dev/null +++ b/app/src/components/vouch/VouchRequestCard.tsx @@ -0,0 +1,88 @@ +import { ExternalLink, ThumbsUp, X } from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" +import type { VouchRequest } from "@/lib/api/vouching" + +interface VouchRequestCardProps { + request: VouchRequest + onReviewProfile: (request: VouchRequest) => void + onVouch: (request: VouchRequest) => void + onDecline: (request: VouchRequest) => void + disabled?: boolean +} + +export function VouchRequestCard({ + request, + onReviewProfile, + onVouch, + onDecline, + disabled = false, +}: VouchRequestCardProps) { + const truncatedAddress = `${request.learnerAddress.slice(0, 6)}...${request.learnerAddress.slice(-4)}` + + return ( + + +
+
+ {truncatedAddress} +

+ Requested {new Date(request.requestedAt).toLocaleDateString()} +

+
+ + Score: {request.reputationScore} + +
+
+ +
+
+

Loan Amount

+

{request.loanAmount}

+
+
+

Purpose

+

+ {request.purpose} +

+
+
+ + + +
+ + + +
+
+
+ ) +}