From 32ebca7843a694b1e1febf5e05dcb4a250bc3d17 Mon Sep 17 00:00:00 2001 From: pvsaint Date: Sun, 29 Mar 2026 23:23:49 +0100 Subject: [PATCH 1/5] feat: Real-time Risk Score & Loan Visualization --- frontend/package.json | 1 + frontend/src/app/risk/[wallet]/page.tsx | 175 +++++++--- frontend/src/components/risk/LoanTimeline.tsx | 302 ++++++++++++++++++ .../src/components/risk/ScoreBreakdown.tsx | 52 ++- frontend/src/components/risk/ScoreGauge.tsx | 25 +- .../src/components/risk/ScoreHistoryChart.tsx | 68 +++- frontend/src/components/risk/Tooltip.tsx | 95 ++++++ frontend/src/components/risk/tooltipConfig.ts | 6 + frontend/src/hooks/useRiskScore.ts | 258 ++++++++++++--- frontend/src/hooks/useWebSocket.ts | 135 ++++++++ frontend/src/utils/riskScoreParsers.ts | 109 +++++++ 11 files changed, 1101 insertions(+), 125 deletions(-) create mode 100644 frontend/src/components/risk/LoanTimeline.tsx create mode 100644 frontend/src/components/risk/Tooltip.tsx create mode 100644 frontend/src/components/risk/tooltipConfig.ts create mode 100644 frontend/src/hooks/useWebSocket.ts create mode 100644 frontend/src/utils/riskScoreParsers.ts diff --git a/frontend/package.json b/frontend/package.json index f7821f6..0bebed4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "fast-check": "^3.23.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/frontend/src/app/risk/[wallet]/page.tsx b/frontend/src/app/risk/[wallet]/page.tsx index 587a2dd..663e14d 100644 --- a/frontend/src/app/risk/[wallet]/page.tsx +++ b/frontend/src/app/risk/[wallet]/page.tsx @@ -1,10 +1,12 @@ 'use client'; -import React, { useState, use } from 'react'; +import { use, useCallback, useMemo, useState } from 'react'; import { useRiskScore } from '../../../hooks/useRiskScore'; +import { useWebSocket } from '../../../hooks/useWebSocket'; import ScoreGauge from '../../../components/risk/ScoreGauge'; import ScoreBreakdown from '../../../components/risk/ScoreBreakdown'; import ScoreHistoryChart from '../../../components/risk/ScoreHistoryChart'; +import LoanTimeline from '../../../components/risk/LoanTimeline'; import { ArrowLeft, TrendingUp, @@ -15,24 +17,59 @@ import { Info } from 'lucide-react'; import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; + +type Range = '6m' | '1y' | 'all'; + +const VALID_RANGES: Range[] = ['6m', '1y', 'all']; + +function parseRange(value: string | null): Range { + if (value && VALID_RANGES.includes(value as Range)) return value as Range; + return '6m'; +} export default function WalletRiskPage({ params }: { params: Promise<{ wallet: string }> }) { const { wallet: walletAddress } = use(params); const searchParams = useSearchParams(); - const startDate = searchParams.get('start_date'); - const endDate = searchParams.get('end_date'); + const router = useRouter(); + + const [range, setRange] = useState(() => parseRange(searchParams.get('range'))); - const { data, loading, error, simulateScore } = useRiskScore(walletAddress); - const [loanSim, setLoanSim] = useState('0'); - const [projectedScore, setProjectedScore] = useState(null); + const { + data, + history, + historyLoading, + historyError, + loading, + error, + simulationResult, + simulationLoading, + simulationError, + activateSimulation, + deactivateSimulation, + appendHistoryPoint, + } = useRiskScore(walletAddress, range); - const handleSimulate = () => { - const amount = parseFloat(loanSim); - if (!isNaN(amount)) { - setProjectedScore(simulateScore(amount)); - } - }; + const { connectionFailed } = useWebSocket({ + walletAddress, + onScoreUpdate: useCallback((event) => { + appendHistoryPoint({ date: event.calculated_at, score: event.overall_score }); + }, [appendHistoryPoint]), + }); + + function handleRangeChange(newRange: Range) { + setRange(newRange); + router.replace(`?range=${newRange}`); + } + const projectedScore = simulationResult?.projectedScore ?? null; + + // Compute a stable projected date (30 days from when simulation was activated) + const simulationDate = useMemo(() => { + if (!simulationResult) return null; + const d = new Date(); + d.setDate(d.getDate() + 30); + return d.toISOString().split('T')[0]; + }, [simulationResult]); if (loading) { return ( @@ -65,16 +102,16 @@ export default function WalletRiskPage({ params }: { params: Promise<{ wallet: s ); } - // Optional: filter history based on dates if provided - const filteredHistory = data.history.filter(h => { - if (startDate && h.date < startDate) return false; - if (endDate && h.date > endDate) return false; - return true; - }); - return (
+ {/* Stale-data banner */} + {connectionFailed && ( +
+ Live updates unavailable — data may be stale +
+ )} + {/* Navigation & Header */}
@@ -84,7 +121,7 @@ export default function WalletRiskPage({ params }: { params: Promise<{ wallet: s > Back to Assessment -

+

Risk Report MODERN ASSESSMENT + {simulationResult && ( + + SIMULATION + + )}

{walletAddress} @@ -112,7 +154,7 @@ export default function WalletRiskPage({ params }: { params: Promise<{ wallet: s {/* Main Score & Breakdown */}

- +
@@ -145,12 +187,37 @@ export default function WalletRiskPage({ params }: { params: Promise<{ wallet: s

Historical Trend

- 6M - 1Y - ALL + {(['6m', '1y', 'all'] as Range[]).map((r) => ( + + ))}
- + {historyError && ( +

{historyError}

+ )} + +
+ +
+

Loan Timeline

+
@@ -166,41 +233,47 @@ export default function WalletRiskPage({ params }: { params: Promise<{ wallet: s
- -
- setLoanSim(e.target.value)} - className="w-full pl-4 pr-12 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-purple-600 focus:outline-none text-gray-900" - placeholder="0" - /> - USDC -
- +

+ Simulate the impact of a 5,000 USDC loan on your risk score. +

+ {simulationResult ? ( + + ) : ( + + )} + {simulationError && ( +

{simulationError}

+ )}
- {projectedScore !== null && ( + {simulationResult && (
Projected Score - {projectedScore.toFixed(0)} + {simulationResult.projectedScore.toFixed(0)}
-
- -{(data.score - projectedScore).toFixed(0)} points - estimated impact +
+ + {simulationResult.scoreDelta > 0 ? '+' : '−'}{Math.abs(simulationResult.scoreDelta).toFixed(0)} pts + + estimated impact
diff --git a/frontend/src/components/risk/LoanTimeline.tsx b/frontend/src/components/risk/LoanTimeline.tsx new file mode 100644 index 0000000..8feb000 --- /dev/null +++ b/frontend/src/components/risk/LoanTimeline.tsx @@ -0,0 +1,302 @@ +'use client'; + +import React, { useEffect, useState, useCallback, useRef } from 'react'; + +export interface TimelineLoan { + id: string; + principalAmount: number; + interestRate: number; // basis points + status: 'active' | 'repaid' | 'defaulted' | 'pending'; + createdAt: Date; + dueAt: Date; + endAt: Date; // dueAt for active/pending, updatedAt for completed +} + +export interface LoanTimelineProps { + walletAddress: string; +} + +interface ApiLoan { + id: string; + principal_amount: number; + interest_rate: number; + status: string; + created_at: string; + due_at: string; + updated_at: string; +} + +interface TooltipState { + loan: TimelineLoan; + x: number; + y: number; +} + +const STATUS_COLORS: Record = { + active: '#3b82f6', + repaid: '#22c55e', + defaulted: '#ef4444', + pending: '#f59e0b', +}; + +const BAR_HEIGHT = 18; +const BAR_SPACING = 28; +const AXIS_PADDING = 40; +const MIN_BAR_WIDTH = 4; +const COMPRESS_THRESHOLD = 20; + +function parseStatus(raw: string): TimelineLoan['status'] { + const lower = raw.toLowerCase(); + if (lower === 'active' || lower === 'repaid' || lower === 'defaulted' || lower === 'pending') { + return lower as TimelineLoan['status']; + } + return 'pending'; +} + +function mapApiLoan(raw: ApiLoan): TimelineLoan { + const status = parseStatus(raw.status); + const dueAt = new Date(raw.due_at); + const updatedAt = new Date(raw.updated_at); + const endAt = status === 'active' || status === 'pending' ? dueAt : updatedAt; + return { + id: raw.id, + principalAmount: raw.principal_amount, + interestRate: raw.interest_rate, + status, + createdAt: new Date(raw.created_at), + dueAt, + endAt, + }; +} + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); +} + +function formatDate(date: Date): string { + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); +} + +function formatRate(basisPoints: number): string { + return (basisPoints / 100).toFixed(2) + '%'; +} + +const LoanTimeline: React.FC = ({ walletAddress }) => { + const [loans, setLoans] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tooltip, setTooltip] = useState(null); + const svgRef = useRef(null); + + const fetchLoans = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/loans?wallet=${encodeURIComponent(walletAddress)}`); + if (!res.ok) throw new Error(`Failed to fetch loans: ${res.status}`); + const data: ApiLoan[] = await res.json(); + setLoans(data.map(mapApiLoan)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load loan history'); + } finally { + setLoading(false); + } + }, [walletAddress]); + + useEffect(() => { + fetchLoans(); + }, [fetchLoans]); + + if (loading) { + return ( +
+ Loading loan history… +
+ ); + } + + if (error) { + return ( +
+ {error} + +
+ ); + } + + if (loans.length === 0) { + return ( +
+ No loan history found. +
+ ); + } + + const numLoans = loans.length; + const svgHeight = Math.max(120, numLoans * BAR_SPACING + AXIS_PADDING); + const viewBoxWidth = 800; + + const timeMin = Math.min(...loans.map((l) => l.createdAt.getTime())); + const timeMax = Math.max(...loans.map((l) => l.endAt.getTime())); + const totalSpan = timeMax - timeMin || 1; + + // When >= COMPRESS_THRESHOLD loans, the axis is already proportionally compressed + // because we use a fixed viewBox width — bars just get narrower naturally. + // The compress flag is kept for potential future use but the SVG viewBox handles it. + const _compress = numLoans >= COMPRESS_THRESHOLD; // eslint-disable-line @typescript-eslint/no-unused-vars + + const leftPad = 8; + const rightPad = 8; + const axisWidth = viewBoxWidth - leftPad - rightPad; + + function timeToX(t: number): number { + return leftPad + ((t - timeMin) / totalSpan) * axisWidth; + } + + function loanBarWidth(loan: TimelineLoan): number { + const raw = ((loan.endAt.getTime() - loan.createdAt.getTime()) / totalSpan) * axisWidth; + return Math.max(raw, MIN_BAR_WIDTH); + } + + const axisY = svgHeight - 20; + const startDate = new Date(timeMin); + const endDate = new Date(timeMax); + + return ( +
+ + {/* Loan bars */} + {loans.map((loan, i) => { + const x = timeToX(loan.createdAt.getTime()); + const w = loanBarWidth(loan); + const y = i * BAR_SPACING + 10; + const color = STATUS_COLORS[loan.status]; + + return ( + { + const rect = (e.target as SVGRectElement).getBoundingClientRect(); + setTooltip({ + loan, + x: rect.left + rect.width / 2, + y: rect.top, + }); + }} + onMouseLeave={() => setTooltip(null)} + onFocus={(e) => { + const rect = (e.target as SVGRectElement).getBoundingClientRect(); + setTooltip({ + loan, + x: rect.left + rect.width / 2, + y: rect.top, + }); + }} + onBlur={() => setTooltip(null)} + /> + ); + })} + + {/* Time axis line */} + + + {/* Start tick */} + + + {formatDate(startDate)} + + + {/* End tick */} + + + {formatDate(endDate)} + + + + {/* Tooltip overlay */} + {tooltip && ( +
+
+ {tooltip.loan.id.slice(0, 8)} +
+
+
+ Principal: + {formatCurrency(tooltip.loan.principalAmount)} +
+
+ Rate: + {formatRate(tooltip.loan.interestRate)} +
+
+ Status: + + {tooltip.loan.status} + +
+
+ Start: + {formatDate(tooltip.loan.createdAt)} +
+
+ Due: + {formatDate(tooltip.loan.dueAt)} +
+
+
+ )} +
+ ); +}; + +export default LoanTimeline; diff --git a/frontend/src/components/risk/ScoreBreakdown.tsx b/frontend/src/components/risk/ScoreBreakdown.tsx index 66b1cd8..ad6a6db 100644 --- a/frontend/src/components/risk/ScoreBreakdown.tsx +++ b/frontend/src/components/risk/ScoreBreakdown.tsx @@ -1,5 +1,8 @@ import React from 'react'; +import { Info } from 'lucide-react'; import { RiskScoreBreakdown } from '../../hooks/useRiskScore'; +import Tooltip from './Tooltip'; +import { TOOLTIP_CONFIG } from './tooltipConfig'; interface ScoreBreakdownProps { breakdown: RiskScoreBreakdown[]; @@ -10,21 +13,42 @@ const ScoreBreakdown: React.FC = ({ breakdown }) => {

Component Breakdown

- {breakdown.map((item, index) => ( -
-
- {item.label} - {item.value}% + {breakdown.map((item, index) => { + const description = TOOLTIP_CONFIG[item.componentKey]; + return ( +
+
+
+ {item.label} + {description && ( + + + + )} +
+ {item.value} +
+
+
+
+

Weight: {item.weight * 100}%

-
-
-
-

Weight: {item.weight * 100}%

-
- ))} + ); + })}
); diff --git a/frontend/src/components/risk/ScoreGauge.tsx b/frontend/src/components/risk/ScoreGauge.tsx index b572865..c9a7412 100644 --- a/frontend/src/components/risk/ScoreGauge.tsx +++ b/frontend/src/components/risk/ScoreGauge.tsx @@ -4,15 +4,20 @@ interface ScoreGaugeProps { score: number; grade: 'A' | 'B' | 'C' | 'D' | 'F'; size?: number; + projectedScore?: number | null; } -const ScoreGauge: React.FC = ({ score, grade, size = 200 }) => { +const ScoreGauge: React.FC = ({ score, grade, size = 200, projectedScore }) => { const radius = size * 0.4; const stroke = size * 0.08; const normalizedRadius = radius - stroke * 2; const circumference = normalizedRadius * 2 * Math.PI; const strokeDashoffset = circumference - (score / 1000) * circumference; + const projectedDashoffset = projectedScore != null + ? circumference - (Math.max(0, Math.min(1000, projectedScore)) / 1000) * circumference + : null; + const colorMap = { A: '#10b981', // green-500 B: '#14b8a6', // teal-500 @@ -51,6 +56,24 @@ const ScoreGauge: React.FC = ({ score, grade, size = 200 }) => cx={size / 2} cy={size / 2} /> + {/* Projected score arc overlay */} + {projectedDashoffset != null && ( + + )}
{score} diff --git a/frontend/src/components/risk/ScoreHistoryChart.tsx b/frontend/src/components/risk/ScoreHistoryChart.tsx index c1c080c..c9aa3da 100644 --- a/frontend/src/components/risk/ScoreHistoryChart.tsx +++ b/frontend/src/components/risk/ScoreHistoryChart.tsx @@ -14,24 +14,59 @@ import { RiskHistoryEntry } from '../../hooks/useRiskScore'; interface ScoreHistoryChartProps { history: RiskHistoryEntry[]; + loading?: boolean; + simulationPoint?: { date: string; score: number } | null; } -const ScoreHistoryChart: React.FC = ({ history }) => { +const ScoreHistoryChart: React.FC = ({ history, loading, simulationPoint }) => { + // Build simulation line data: last real point → simulation point + const simulationData: { date: string; score: number }[] = []; + if (simulationPoint && history.length > 0) { + const lastReal = history[history.length - 1]; + simulationData.push({ date: lastReal.date, score: lastReal.score }); + simulationData.push({ date: simulationPoint.date, score: simulationPoint.score }); + } + + const tickFormatter = (str: string) => { + const date = new Date(str); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }; + + if (loading && history.length === 0) { + return ( +
+
+
+ ); + } + + if (!loading && history.length === 0) { + return ( +
+ No history available +
+ ); + } + return ( -
+
+ {loading && ( +
+
+
+ )} - + { - const date = new Date(str); - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - }} + tickFormatter={tickFormatter} /> = ({ history }) => { }} itemStyle={{ color: '#1e3a8a', fontWeight: 'bold' }} labelStyle={{ color: '#6b7280', marginBottom: '4px' }} + labelFormatter={(label) => tickFormatter(String(label))} /> + {/* Real history line */} = ({ history }) => { dot={{ fill: '#1e3a8a', strokeWidth: 2, r: 4, stroke: '#fff' }} activeDot={{ r: 6, strokeWidth: 0 }} animationDuration={1500} + name="Score" /> + {/* Simulation projection line */} + {simulationData.length === 2 && ( + + )}
diff --git a/frontend/src/components/risk/Tooltip.tsx b/frontend/src/components/risk/Tooltip.tsx new file mode 100644 index 0000000..e757c5f --- /dev/null +++ b/frontend/src/components/risk/Tooltip.tsx @@ -0,0 +1,95 @@ +import React, { useState, useRef, useCallback } from 'react'; + +export interface TooltipContent { + componentName: string; + weight: number; + description: string; + score: number; +} + +interface TooltipProps { + content: TooltipContent; + children: React.ReactNode; +} + +const HIDE_DELAY_MS = 150; + +const Tooltip: React.FC = ({ content, children }) => { + const [visible, setVisible] = useState(false); + const hideTimerRef = useRef | null>(null); + + const clearHideTimer = useCallback(() => { + if (hideTimerRef.current !== null) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }, []); + + const scheduleHide = useCallback(() => { + clearHideTimer(); + hideTimerRef.current = setTimeout(() => setVisible(false), HIDE_DELAY_MS); + }, [clearHideTimer]); + + const show = useCallback(() => { + clearHideTimer(); + setVisible(true); + }, [clearHideTimer]); + + const handleTriggerKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setVisible((v) => !v); + } else if (e.key === 'Escape') { + setVisible(false); + } + }, []); + + const handleDocumentKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') setVisible(false); + }, []); + + React.useEffect(() => { + if (visible) { + document.addEventListener('keydown', handleDocumentKeyDown); + } + return () => document.removeEventListener('keydown', handleDocumentKeyDown); + }, [visible, handleDocumentKeyDown]); + + return ( +
+ {/* Trigger wrapper */} +
+ {children} +
+ + {/* Tooltip body */} + {visible && ( +
+ {/* Arrow */} +
+ +

{content.componentName}

+

Weight: {Math.round(content.weight * 100)}%

+

{content.description}

+

Score: {content.score} / 1000

+
+ )} +
+ ); +}; + +export default Tooltip; diff --git a/frontend/src/components/risk/tooltipConfig.ts b/frontend/src/components/risk/tooltipConfig.ts new file mode 100644 index 0000000..7488d6a --- /dev/null +++ b/frontend/src/components/risk/tooltipConfig.ts @@ -0,0 +1,6 @@ +export const TOOLTIP_CONFIG: Record = { + on_chain_activity: "Measures the volume and diversity of your on-chain transactions over the past 90 days.", + repayment_history: "Tracks your record of repaying loans on time. Defaults and late payments reduce this score.", + collateral_quality: "Evaluates the value and diversity of collateral assets you have posted.", + document_verification: "Reflects the completeness and recency of your KYC document verification.", +}; diff --git a/frontend/src/hooks/useRiskScore.ts b/frontend/src/hooks/useRiskScore.ts index 19cdfb7..2aad4f7 100644 --- a/frontend/src/hooks/useRiskScore.ts +++ b/frontend/src/hooks/useRiskScore.ts @@ -1,9 +1,19 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback, useReducer } from 'react'; +import { + parseRiskScoreResponse, + parseHistoricalScores, + type RiskScoreResponseV2, + type HistoricalScore, + type SimulateResponse, +} from '../utils/riskScoreParsers'; + +// ─── Public types ──────────────────────────────────────────────────────────── export interface RiskScoreBreakdown { label: string; - value: number; // 0-100 - weight: number; + componentKey: string; // machine key e.g. "on_chain_activity" + value: number; // 0–1000 + weight: number; // 0–1 } export interface RiskHistoryEntry { @@ -12,80 +22,224 @@ export interface RiskHistoryEntry { } export interface RiskScoreData { - score: number; // 0-1000 + score: number; grade: 'A' | 'B' | 'C' | 'D' | 'F'; breakdown: RiskScoreBreakdown[]; history: RiskHistoryEntry[]; + confidence: number; + calculatedAt: string; +} + +export interface SimulationResult { + currentScore: number; + projectedScore: number; + scoreDelta: number; + scenarioDescription: string; +} + +// ─── Hook options / return ──────────────────────────────────────────────────── + +export interface UseRiskScoreOptions { + walletAddress: string | null; + range?: '6m' | '1y' | 'all'; +} + +export interface UseRiskScoreReturn { + data: RiskScoreData | null; + history: RiskHistoryEntry[]; + historyLoading: boolean; + historyError: string | null; + loading: boolean; + error: string | null; + simulationResult: SimulationResult | null; + simulationLoading: boolean; + simulationError: string | null; + activateSimulation: () => Promise; + deactivateSimulation: () => void; + appendHistoryPoint: (entry: RiskHistoryEntry) => void; +} + +// ─── History reducer ────────────────────────────────────────────────────────── + +type HistoryAction = + | { type: 'SET'; entries: RiskHistoryEntry[] } + | { type: 'APPEND'; entry: RiskHistoryEntry }; + +function historyReducer(state: RiskHistoryEntry[], action: HistoryAction): RiskHistoryEntry[] { + switch (action.type) { + case 'SET': + return action.entries; + case 'APPEND': + return [...state, action.entry]; + default: + return state; + } +} + +// ─── Date helpers ───────────────────────────────────────────────────────────── + +function getStartDate(range: '6m' | '1y' | 'all'): string | null { + if (range === 'all') return null; + const now = new Date(); + if (range === '6m') { + now.setMonth(now.getMonth() - 6); + } else { + now.setFullYear(now.getFullYear() - 1); + } + return now.toISOString(); } -export const useRiskScore = (walletAddress: string | null) => { +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export function useRiskScore( + walletAddress: string | null, + range: '6m' | '1y' | 'all' = '6m', +): UseRiskScoreReturn { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [history, dispatchHistory] = useReducer(historyReducer, []); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(null); + + const [simulationResult, setSimulationResult] = useState(null); + const [simulationLoading, setSimulationLoading] = useState(false); + const [simulationError, setSimulationError] = useState(null); + + // ── 1.3 Fetch current risk score ────────────────────────────────────────── useEffect(() => { if (!walletAddress) { setData(null); + setError(null); return; } - const fetchRiskScore = async () => { + let cancelled = false; + + const fetchScore = async () => { setLoading(true); setError(null); try { - // Mocking API call - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Basic validation for Stellar address (standard G...) - if (!/^G[A-Z2-7]{55}$/.test(walletAddress)) { - throw new Error('Invalid Stellar wallet address'); + const res = await fetch(`/api/risk/${encodeURIComponent(walletAddress)}`); + if (!res.ok) { + throw new Error(`Failed to fetch risk score: ${res.status} ${res.statusText}`); + } + const raw: RiskScoreResponseV2 = await res.json(); + if (!cancelled) { + setData(parseRiskScoreResponse(raw)); } - - // Generate mock data based on wallet address for consistency in demo - const hash = walletAddress.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); - const baseScore = 600 + (hash % 350); - - const mockData: RiskScoreData = { - score: baseScore, - grade: getGrade(baseScore), - breakdown: [ - { label: 'On-chain Activity', value: 70 + (hash % 25), weight: 0.3 }, - { label: 'Asset Diversity', value: 60 + (hash % 30), weight: 0.2 }, - { label: 'Wallet Longevity', value: 80 + (hash % 15), weight: 0.25 }, - { label: 'Transaction History', value: 65 + (hash % 30), weight: 0.25 }, - ], - history: Array.from({ length: 6 }).map((_, i) => ({ - date: new Date(Date.now() - (5 - i) * 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - score: baseScore - (5 - i) * 10 + (Math.random() * 20 - 10), - })), - }; - - setData(mockData); } catch (err: unknown) { - setError((err as Error).message || 'Failed to fetch risk score'); - setData(null); + if (!cancelled) { + setError((err as Error).message ?? 'Failed to fetch risk score'); + setData(null); + } } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; - fetchRiskScore(); + fetchScore(); + return () => { cancelled = true; }; }, [walletAddress]); - const simulateScore = (loanAmount: number): number => { - if (!data) return 0; - // Simple simulation: more loan = higher risk = lower score - const impact = Math.min(loanAmount / 1000, 100); - return Math.max(0, data.score - impact); - }; + // ── 1.4 Fetch history ───────────────────────────────────────────────────── + useEffect(() => { + if (!walletAddress) { + dispatchHistory({ type: 'SET', entries: [] }); + setHistoryError(null); + return; + } + + let cancelled = false; + + const fetchHistory = async () => { + setHistoryLoading(true); + setHistoryError(null); + try { + const params = new URLSearchParams(); + const startDate = getStartDate(range); + if (startDate) params.set('start_date', startDate); + // end_date defaults to now; omit for simplicity + const url = `/api/risk/${encodeURIComponent(walletAddress)}/history?${params.toString()}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to fetch history: ${res.status} ${res.statusText}`); + } + const raw: HistoricalScore[] = await res.json(); + if (!cancelled) { + dispatchHistory({ type: 'SET', entries: parseHistoricalScores(raw) }); + } + } catch (err: unknown) { + if (!cancelled) { + setHistoryError((err as Error).message ?? 'Failed to fetch history'); + dispatchHistory({ type: 'SET', entries: [] }); + } + } finally { + if (!cancelled) setHistoryLoading(false); + } + }; + + fetchHistory(); + return () => { cancelled = true; }; + }, [walletAddress, range]); - return { data, loading, error, simulateScore }; -}; + // ── 1.5 Simulation ──────────────────────────────────────────────────────── + const activateSimulation = useCallback(async () => { + if (!walletAddress || !data) return; + setSimulationLoading(true); + setSimulationError(null); + try { + const res = await fetch(`/api/risk/${encodeURIComponent(walletAddress)}/simulate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ loan_amount: 5000, currency: 'USDC' }), + }); + if (!res.ok) { + throw new Error(`Simulation request failed: ${res.status} ${res.statusText}`); + } + const raw: SimulateResponse = await res.json(); + const projectedScore = + typeof raw.projected_score === 'number' ? raw.projected_score : 0; + const scenarioDescription = + typeof raw.scenario_description === 'string' ? raw.scenario_description : ''; + const currentScore = data.score; + setSimulationResult({ + currentScore, + projectedScore, + scoreDelta: projectedScore - currentScore, + scenarioDescription, + }); + } catch (err: unknown) { + setSimulationError((err as Error).message ?? 'Simulation failed'); + setSimulationResult(null); + } finally { + setSimulationLoading(false); + } + }, [walletAddress, data]); + + const deactivateSimulation = useCallback(() => { + setSimulationResult(null); + setSimulationError(null); + }, []); -const getGrade = (score: number): 'A' | 'B' | 'C' | 'D' | 'F' => { - if (score >= 850) return 'A'; - if (score >= 700) return 'B'; - if (score >= 550) return 'C'; - if (score >= 400) return 'D'; - return 'F'; -}; + // ── 1.6 WebSocket-driven append ─────────────────────────────────────────── + const appendHistoryPoint = useCallback((entry: RiskHistoryEntry) => { + dispatchHistory({ type: 'APPEND', entry }); + }, []); + + return { + data, + history, + historyLoading, + historyError, + loading, + error, + simulationResult, + simulationLoading, + simulationError, + activateSimulation, + deactivateSimulation, + appendHistoryPoint, + }; +} diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..e37464b --- /dev/null +++ b/frontend/src/hooks/useWebSocket.ts @@ -0,0 +1,135 @@ +import { useEffect, useRef, useState } from 'react'; +import type { RiskScoreBreakdown } from './useRiskScore'; + +// ─── Public types ───────────────────────────────────────────────────────────── + +export interface ScoreUpdateEvent { + type: 'RiskScoreUpdated'; + wallet_address: string; + overall_score: number; + grade: 'A' | 'B' | 'C' | 'D' | 'F'; + components: RiskScoreBreakdown[]; + calculated_at: string; +} + +export interface UseWebSocketOptions { + walletAddress: string | null; + onScoreUpdate: (event: ScoreUpdateEvent) => void; +} + +export interface UseWebSocketReturn { + connected: boolean; + connectionFailed: boolean; +} + +// ─── Reconnect delay helper (exported for property-based testing) ───────────── + +export function computeReconnectDelay(attempt: number): number { + return Math.min(Math.pow(2, attempt - 1) * 1000, 30000); +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +const MAX_FAILURES = 3; + +export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn { + const { walletAddress, onScoreUpdate } = options; + + const [connected, setConnected] = useState(false); + const [connectionFailed, setConnectionFailed] = useState(false); + + // Stable ref to the latest callback so we don't need to re-open the socket + // when the parent re-renders with a new function reference. + const onScoreUpdateRef = useRef(onScoreUpdate); + useEffect(() => { + onScoreUpdateRef.current = onScoreUpdate; + }, [onScoreUpdate]); + + useEffect(() => { + if (!walletAddress) { + setConnected(false); + setConnectionFailed(false); + return; + } + + let ws: WebSocket | null = null; + let attempt = 0; + let consecutiveFailures = 0; + let reconnectTimer: ReturnType | null = null; + let stopped = false; + + function clearTimer() { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + } + + function connect() { + if (stopped) return; + + attempt += 1; + ws = new WebSocket('/ws'); + + ws.onopen = () => { + if (stopped) { + ws?.close(); + return; + } + consecutiveFailures = 0; + setConnected(true); + setConnectionFailed(false); + }; + + ws.onmessage = (event: MessageEvent) => { + if (stopped) return; + try { + const data = JSON.parse(event.data as string); + if (data && data.type === 'RiskScoreUpdated') { + onScoreUpdateRef.current(data as ScoreUpdateEvent); + } + } catch { + // Ignore malformed messages + } + }; + + ws.onclose = () => { + if (stopped) return; + setConnected(false); + consecutiveFailures += 1; + + if (consecutiveFailures >= MAX_FAILURES) { + setConnectionFailed(true); + return; // Stop reconnecting + } + + const delay = computeReconnectDelay(consecutiveFailures); + reconnectTimer = setTimeout(connect, delay); + }; + + ws.onerror = () => { + if (stopped) return; + setConnected(false); + // onclose will fire after onerror, so we handle reconnect there + }; + } + + connect(); + + return () => { + stopped = true; + clearTimer(); + if (ws) { + ws.onopen = null; + ws.onmessage = null; + ws.onclose = null; + ws.onerror = null; + ws.close(); + ws = null; + } + setConnected(false); + }; + }, [walletAddress]); + + return { connected, connectionFailed }; +} diff --git a/frontend/src/utils/riskScoreParsers.ts b/frontend/src/utils/riskScoreParsers.ts new file mode 100644 index 0000000..4f89b56 --- /dev/null +++ b/frontend/src/utils/riskScoreParsers.ts @@ -0,0 +1,109 @@ +/** + * Utilities for mapping backend API responses to frontend types. + * Implements safe defaults for unexpected field types (Req 7.1, 7.5). + */ + +import type { + RiskScoreData, + RiskScoreBreakdown, + RiskHistoryEntry, +} from '../hooks/useRiskScore'; + +// Backend shape for the v2 risk score response +export interface RiskScoreResponseV2 { + overall_score: unknown; + risk_tier: unknown; + components: unknown; + confidence: unknown; + calculated_at: unknown; +} + +// Backend shape for a single historical score entry +export interface HistoricalScore { + date: unknown; + score: unknown; + tier: unknown; +} + +// Backend shape for the simulate response +export interface SimulateResponse { + projected_score: unknown; + scenario_description: unknown; +} + +const TIER_TO_GRADE: Record = { + excellent: 'A', + good: 'B', + fair: 'C', + poor: 'D', + very_poor: 'F', + high_risk: 'F', + unscored: 'F', +}; + +function safeNumber(value: unknown, fieldName: string, defaultValue = 0): number { + if (typeof value === 'number' && isFinite(value)) return value; + console.warn(`[parseRiskScoreResponse] Unexpected type for field "${fieldName}": ${typeof value}. Using default ${defaultValue}.`); + return defaultValue; +} + +function safeString(value: unknown, fieldName: string, defaultValue = ''): string { + if (typeof value === 'string') return value; + console.warn(`[parseRiskScoreResponse] Unexpected type for field "${fieldName}": ${typeof value}. Using default "${defaultValue}".`); + return defaultValue; +} + +/** + * Maps a RiskScoreResponseV2 from the backend to the frontend RiskScoreData type. + * Uses safe defaults for any field with an unexpected type (Req 7.1, 7.5). + */ +export function parseRiskScoreResponse(raw: RiskScoreResponseV2): RiskScoreData { + const score = safeNumber(raw.overall_score, 'overall_score'); + const tierRaw = safeString(raw.risk_tier, 'risk_tier'); + const grade: RiskScoreData['grade'] = TIER_TO_GRADE[tierRaw] ?? 'F'; + const confidence = safeNumber(raw.confidence, 'confidence'); + const calculatedAt = safeString(raw.calculated_at, 'calculated_at'); + + let breakdown: RiskScoreBreakdown[] = []; + if (raw.components !== null && typeof raw.components === 'object' && !Array.isArray(raw.components)) { + const components = raw.components as Record; + breakdown = Object.entries(components).map(([key, componentRaw]) => { + const component = + componentRaw !== null && typeof componentRaw === 'object' && !Array.isArray(componentRaw) + ? (componentRaw as Record) + : {}; + const value = safeNumber(component.score, `components.${key}.score`); + const weight = safeNumber(component.weight, `components.${key}.weight`); + // Convert snake_case key to a human-readable label + const label = key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + return { label, componentKey: key, value, weight }; + }); + } else if (raw.components !== undefined && raw.components !== null) { + console.warn('[parseRiskScoreResponse] Unexpected type for field "components". Using default [].'); + } + + return { + score, + grade, + breakdown, + history: [], + confidence, + calculatedAt, + }; +} + +/** + * Maps an array of HistoricalScore objects from the backend to RiskHistoryEntry[]. + * Preserves date and score; drops tier (Req 1.5, 7.2). + */ +export function parseHistoricalScores(raw: HistoricalScore[]): RiskHistoryEntry[] { + if (!Array.isArray(raw)) { + console.warn('[parseHistoricalScores] Expected array, got:', typeof raw); + return []; + } + return raw.map((item, index) => { + const date = safeString(item.date, `history[${index}].date`); + const score = safeNumber(item.score, `history[${index}].score`); + return { date, score }; + }); +} From 16cd67b1ee27606345148dcb1db9bc0555e2404c Mon Sep 17 00:00:00 2001 From: pvsaint Date: Sun, 29 Mar 2026 23:31:28 +0100 Subject: [PATCH 2/5] fix: sync frontend package-lock for npm ci --- frontend/package-lock.json | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 136968d..6699ea7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.2", + "fast-check": "^3.23.2", "tailwindcss": "^4", "typescript": "^5" } @@ -4003,6 +4004,29 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6055,6 +6079,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", From 2ba0a6a7fb5d413d287179b423010b2f114c91b2 Mon Sep 17 00:00:00 2001 From: pvsaint Date: Sun, 29 Mar 2026 23:36:47 +0100 Subject: [PATCH 3/5] fix: resolve eslint warnings+errors in frontend --- frontend/src/app/loans/[id]/page.tsx | 4 +- frontend/src/app/page.tsx | 34 +--- .../src/components/auth/WalletPickerModal.tsx | 179 +++++++++--------- .../components/kyc/KycVerificationBanner.tsx | 102 +++++----- frontend/src/components/loans/LoanCard.tsx | 1 - frontend/src/hooks/useWebSocket.ts | 23 ++- frontend/src/types/index.ts | 29 +-- frontend/src/utils/stellar.ts | 65 ++++--- 8 files changed, 222 insertions(+), 215 deletions(-) diff --git a/frontend/src/app/loans/[id]/page.tsx b/frontend/src/app/loans/[id]/page.tsx index 7877ddd..d11a7e8 100644 --- a/frontend/src/app/loans/[id]/page.tsx +++ b/frontend/src/app/loans/[id]/page.tsx @@ -6,8 +6,8 @@ import Link from "next/link"; import { useLoans } from "@/hooks/useLoans"; import { RepaymentSchedule } from "@/components/loans/RepaymentSchedule"; import { Button } from "@/components/ui/Button"; -import { shortenAddress, getExplorerUrl } from "@/utils/stellar"; -import { ArrowLeft, Loader2, ExternalLink } from "lucide-react"; +import { shortenAddress } from "@/utils/stellar"; +import { ArrowLeft, Loader2 } from "lucide-react"; const STATUS_STYLES: Record = { PENDING: diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 3ec5055..1895abf 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React from "react"; import { ChevronRight, Lock, @@ -8,33 +8,11 @@ import { TrendingUp, CheckCircle2, ArrowUpRight, - Wallet, - LogOut, } from "lucide-react"; import Link from "next/link"; import { Footer, Navbar } from "@/components"; export default function Home() { - const [isConnected, setIsConnected] = useState(false); - const [walletAddress, setWalletAddress] = useState(null); - const [isWalletMenuOpen, setIsWalletMenuOpen] = useState(false); - - const handleWalletConnect = async () => { - const mockAddress = - "G" + Math.random().toString(16).slice(2, 54).toUpperCase(); - setWalletAddress(mockAddress); - setIsConnected(true); - setIsWalletMenuOpen(false); - }; - - const handleWalletDisconnect = () => { - setWalletAddress(null); - setIsConnected(false); - }; - - const shortenAddress = (addr: string) => { - return `${addr.slice(0, 6)}...${addr.slice(-4)}`; - }; return (
{/* Navigation */} @@ -58,9 +36,7 @@ export default function Home() {

- @@ -455,9 +431,7 @@ export default function Home() {

-
); } diff --git a/frontend/src/components/auth/WalletPickerModal.tsx b/frontend/src/components/auth/WalletPickerModal.tsx index aa86737..490e607 100644 --- a/frontend/src/components/auth/WalletPickerModal.tsx +++ b/frontend/src/components/auth/WalletPickerModal.tsx @@ -1,97 +1,106 @@ -import { useEffect, useState } from 'react'; -import { isAllowed } from '@stellar/freighter-api'; -import { X } from 'lucide-react'; -import { clsx } from 'clsx'; -import { twMerge } from 'tailwind-merge'; +import { useEffect, useState } from "react"; +import { X } from "lucide-react"; +import { twMerge } from "tailwind-merge"; interface WalletPickerModalProps { - isOpen: boolean; - onClose: () => void; - onConnect: () => void; + isOpen: boolean; + onClose: () => void; + onConnect: () => void; } -export function WalletPickerModal({ isOpen, onClose, onConnect }: WalletPickerModalProps) { - const [hasFreighter, setHasFreighter] = useState(false); +export function WalletPickerModal({ + isOpen, + onClose, + onConnect, +}: WalletPickerModalProps) { + const [hasFreighter, setHasFreighter] = useState(false); - useEffect(() => { - // Check if Freighter is installed - // The freighter-api doesn't expose a direct "isInstalled" check easily without try/catch or checking window - // But we can try to see if the extension is present in window. - // However, usually we just try to connect. - const checkFreighter = async () => { - // Simple check if the extension object exists - // @ts-expect-error freighter is injected by browser extension - if (window.freighter) { - setHasFreighter(true); - } - }; - checkFreighter(); - }, []); + useEffect(() => { + // Check if Freighter is installed + // The freighter-api doesn't expose a direct "isInstalled" check easily without try/catch or checking window + // But we can try to see if the extension is present in window. + // However, usually we just try to connect. + const checkFreighter = async () => { + // Simple check if the extension object exists + // @ts-expect-error freighter is injected by browser extension + if (window.freighter) { + setHasFreighter(true); + } + }; + checkFreighter(); + }, []); - if (!isOpen) return null; + if (!isOpen) return null; - return ( -
-
-
-

Connect Wallet

- -
- -
- + return ( +
+
+
+

+ Connect Wallet +

+ +
- -
+
+ -
-

- By connecting a wallet, you agree to our Terms of Service and Privacy Policy. -

-
+ +
+ +
+

+ By connecting a wallet, you agree to our Terms of Service and + Privacy Policy. +

- ); +
+
+ ); } diff --git a/frontend/src/components/kyc/KycVerificationBanner.tsx b/frontend/src/components/kyc/KycVerificationBanner.tsx index bab9924..d14e876 100644 --- a/frontend/src/components/kyc/KycVerificationBanner.tsx +++ b/frontend/src/components/kyc/KycVerificationBanner.tsx @@ -1,9 +1,14 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import { AlertCircle, X, CheckCircle, Clock, XCircle } from 'lucide-react'; +import { useState } from "react"; +import { AlertCircle, X, Clock, XCircle } from "lucide-react"; -export type KycStatus = 'unverified' | 'pending' | 'verified' | 'rejected' | 'expired'; +export type KycStatus = + | "unverified" + | "pending" + | "verified" + | "rejected" + | "expired"; interface KycVerificationBannerProps { kycStatus: KycStatus; @@ -21,7 +26,10 @@ export function KycVerificationBanner({ const [isDismissed, setIsDismissed] = useState(false); // Don't show banner if verified and not expired - if (kycStatus === 'verified' && (!kycExpiry || new Date(kycExpiry) > new Date())) { + if ( + kycStatus === "verified" && + (!kycExpiry || new Date(kycExpiry) > new Date()) + ) { return null; } @@ -37,52 +45,56 @@ export function KycVerificationBanner({ const getBannerConfig = () => { switch (kycStatus) { - case 'unverified': + case "unverified": return { icon: AlertCircle, - bgColor: 'bg-yellow-50 dark:bg-yellow-900/20', - borderColor: 'border-yellow-200 dark:border-yellow-800', - iconColor: 'text-yellow-600 dark:text-yellow-400', - textColor: 'text-yellow-900 dark:text-yellow-100', - title: 'Verification Required', - message: 'Complete identity verification to create escrows over $10,000.', - actionText: 'Start Verification', + bgColor: "bg-yellow-50 dark:bg-yellow-900/20", + borderColor: "border-yellow-200 dark:border-yellow-800", + iconColor: "text-yellow-600 dark:text-yellow-400", + textColor: "text-yellow-900 dark:text-yellow-100", + title: "Verification Required", + message: + "Complete identity verification to create escrows over $10,000.", + actionText: "Start Verification", showAction: true, }; - case 'pending': + case "pending": return { icon: Clock, - bgColor: 'bg-blue-50 dark:bg-blue-900/20', - borderColor: 'border-blue-200 dark:border-blue-800', - iconColor: 'text-blue-600 dark:text-blue-400', - textColor: 'text-blue-900 dark:text-blue-100', - title: 'Verification Pending', - message: 'Your identity verification is being processed. This usually takes 1-2 business days.', + bgColor: "bg-blue-50 dark:bg-blue-900/20", + borderColor: "border-blue-200 dark:border-blue-800", + iconColor: "text-blue-600 dark:text-blue-400", + textColor: "text-blue-900 dark:text-blue-100", + title: "Verification Pending", + message: + "Your identity verification is being processed. This usually takes 1-2 business days.", actionText: null, showAction: false, }; - case 'rejected': + case "rejected": return { icon: XCircle, - bgColor: 'bg-red-50 dark:bg-red-900/20', - borderColor: 'border-red-200 dark:border-red-800', - iconColor: 'text-red-600 dark:text-red-400', - textColor: 'text-red-900 dark:text-red-100', - title: 'Verification Rejected', - message: 'Your identity verification was not approved. Please contact support for assistance.', - actionText: 'Contact Support', + bgColor: "bg-red-50 dark:bg-red-900/20", + borderColor: "border-red-200 dark:border-red-800", + iconColor: "text-red-600 dark:text-red-400", + textColor: "text-red-900 dark:text-red-100", + title: "Verification Rejected", + message: + "Your identity verification was not approved. Please contact support for assistance.", + actionText: "Contact Support", showAction: true, }; - case 'expired': + case "expired": return { icon: AlertCircle, - bgColor: 'bg-orange-50 dark:bg-orange-900/20', - borderColor: 'border-orange-200 dark:border-orange-800', - iconColor: 'text-orange-600 dark:text-orange-400', - textColor: 'text-orange-900 dark:text-orange-100', - title: 'Verification Expired', - message: 'Your identity verification has expired. Please verify again to continue.', - actionText: 'Renew Verification', + bgColor: "bg-orange-50 dark:bg-orange-900/20", + borderColor: "border-orange-200 dark:border-orange-800", + iconColor: "text-orange-600 dark:text-orange-400", + textColor: "text-orange-900 dark:text-orange-100", + title: "Verification Expired", + message: + "Your identity verification has expired. Please verify again to continue.", + actionText: "Renew Verification", showAction: true, }; default: @@ -101,8 +113,11 @@ export function KycVerificationBanner({ role="alert" >
- - + +

{config.title} @@ -110,14 +125,15 @@ export function KycVerificationBanner({

{config.message}

- + {config.showAction && config.actionText && (