From c48f263e6ce13b4a29ee507f171a5864b76e1d8d Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Mon, 30 Mar 2026 03:24:55 +0100 Subject: [PATCH 1/5] #589 feat(frontend): add tooltip explanations for financial terms (APY, utilization rate, credit score) # Conflicts: # frontend/src/app/layout.tsx --- .../app/components/ui/CreditScoreGauge.tsx | 21 +- .../src/app/components/ui/financial-terms.ts | 8 + frontend/src/app/components/ui/tooltip.tsx | 71 +++ frontend/src/app/layout.tsx | 13 +- package-lock.json | 498 ++++++++++++++++++ package.json | 6 + 6 files changed, 609 insertions(+), 8 deletions(-) create mode 100644 frontend/src/app/components/ui/financial-terms.ts create mode 100644 frontend/src/app/components/ui/tooltip.tsx create mode 100644 package-lock.json create mode 100644 package.json diff --git a/frontend/src/app/components/ui/CreditScoreGauge.tsx b/frontend/src/app/components/ui/CreditScoreGauge.tsx index fee693c4..7f5f7f32 100644 --- a/frontend/src/app/components/ui/CreditScoreGauge.tsx +++ b/frontend/src/app/components/ui/CreditScoreGauge.tsx @@ -2,6 +2,8 @@ import { useMemo } from "react"; import { ArrowUp, ArrowDown } from "lucide-react"; +import { FinancialTermTooltip } from "./tooltip"; +import { FINANCIAL_EXPLANATIONS } from "./financial-terms"; interface CreditScoreGaugeProps { score: number; @@ -119,7 +121,14 @@ export function CreditScoreGauge({ {/* Center score display */}
- {score} +
+ {score} + +
@@ -143,8 +152,14 @@ export function CreditScoreGauge({ {/* Tooltip / explanation */}

- Your credit score ranges from {min} to {max}. Maintain on-time repayments and low - utilization to improve your score. + Your credit score ranges from {min} to {max}. Maintain on-time repayments and low{" "} + null} // Hide icon here to keep text flow clean + />{" "} + to improve your score.

); diff --git a/frontend/src/app/components/ui/financial-terms.ts b/frontend/src/app/components/ui/financial-terms.ts new file mode 100644 index 00000000..532fb0a4 --- /dev/null +++ b/frontend/src/app/components/ui/financial-terms.ts @@ -0,0 +1,8 @@ +export const FINANCIAL_EXPLANATIONS = { + APY: "Annual Percentage Yield: The real rate of return on your deposit, accounting for compound interest over one year.", + UTILIZATION_RATE: "The percentage of available liquidity currently being borrowed. Higher rates may affect borrowing costs.", + CREDIT_SCORE: "A measure of your repayment reliability based on on-chain history. Higher scores improve loan terms.", + COLLATERAL_RATIO: "The value of your collateral compared to your loan amount. Keep this high to avoid liquidation.", + LTV: "Loan-to-Value: The maximum loan amount as a percentage of your collateral's value.", + GRACE_PERIOD: "The extra time after a due date when you can repay without penalties.", +}; \ No newline at end of file diff --git a/frontend/src/app/components/ui/tooltip.tsx b/frontend/src/app/components/ui/tooltip.tsx new file mode 100644 index 00000000..6624ab98 --- /dev/null +++ b/frontend/src/app/components/ui/tooltip.tsx @@ -0,0 +1,71 @@ +"use client"; + +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { Info, HelpCircle } from "lucide-react"; +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** Tool to merge Tailwind classes safely */ +function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +const TooltipProvider = TooltipPrimitive.Provider; +const Tooltip = TooltipPrimitive.Root; +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +/** + * Standardized tooltip for financial terms with an info icon. + */ +export function FinancialTermTooltip({ + term, + explanation, + className, + icon: Icon = Info, +}: { + term: string; + explanation: string; + className?: string; + icon?: React.ElementType; +}) { + return ( + + + + {term} + {Icon && } + + + +

{explanation}

+
+
+ ); +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 04663b50..70028aa4 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -10,6 +10,7 @@ import { GlobalXPGain } from "./components/global_ui/GlobalXPGain"; import { ErrorBoundary } from "./components/global_ui/ErrorBoundary"; import { NextIntlClientProvider } from "next-intl"; import { THEME_STORAGE_KEY } from "./lib/theme"; +import { TooltipProvider } from "./components/ui/tooltip"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -47,11 +48,13 @@ export default async function RootLayout({ - - - {children} - - + + + + {children} + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..fb63bed1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,498 @@ +{ + "name": "remitlend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@radix-ui/react-tooltip": "^1.2.8", + "lucide-react": "^1.7.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..3b205580 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "@radix-ui/react-tooltip": "^1.2.8", + "lucide-react": "^1.7.0" + } +} From 9aeb0073611dee723906bdface094b181edc6d65 Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Mon, 30 Mar 2026 02:02:17 +0100 Subject: [PATCH 2/5] chore: save local progress before rebase --- .../components/providers/QueryProvider.tsx | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/components/providers/QueryProvider.tsx b/frontend/src/app/components/providers/QueryProvider.tsx index 361aebd8..41eee26a 100644 --- a/frontend/src/app/components/providers/QueryProvider.tsx +++ b/frontend/src/app/components/providers/QueryProvider.tsx @@ -27,12 +27,23 @@ export function QueryProvider({ children }: QueryProviderProps) { new QueryClient({ defaultOptions: { queries: { - // Data is considered fresh for 60 seconds — avoids unnecessary refetches - staleTime: 60 * 1000, - // Retry failed requests once before showing an error - retry: 1, - // Refetch when the browser window regains focus - refetchOnWindowFocus: true, + staleTime: 5 * 60 * 1000, // 5 minutes: UI stays responsive with cached data + gcTime: 30 * 60 * 1000, // 30 minutes: Keep data in memory even when inactive + retry: (failureCount, error: unknown) => { + // Safe check for browser environment + const isOffline = typeof window !== 'undefined' && !navigator.onLine; + + // Always retry on network errors (TypeError in fetch) or when offline + if (isOffline || error instanceof TypeError) { + return true; + } + + // Standard retry for other transient errors (up to 3 times) + return failureCount < 3; + }, + retryDelay: (attemptIndex) => Math.min(1000 * Math.pow(2, attemptIndex), 30000), + refetchOnWindowFocus: true, // Re-validate data when user returns to tab + refetchOnReconnect: 'always', // Force retry when connection returns }, mutations: { // Retry failed mutations once From 30ba610ae40b71c82ed631feb5c37e5bbac6581f Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Mon, 30 Mar 2026 03:30:00 +0100 Subject: [PATCH 3/5] fix: keep tooltip branch focused on tooltip changes --- .../components/providers/QueryProvider.tsx | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/components/providers/QueryProvider.tsx b/frontend/src/app/components/providers/QueryProvider.tsx index 41eee26a..361aebd8 100644 --- a/frontend/src/app/components/providers/QueryProvider.tsx +++ b/frontend/src/app/components/providers/QueryProvider.tsx @@ -27,23 +27,12 @@ export function QueryProvider({ children }: QueryProviderProps) { new QueryClient({ defaultOptions: { queries: { - staleTime: 5 * 60 * 1000, // 5 minutes: UI stays responsive with cached data - gcTime: 30 * 60 * 1000, // 30 minutes: Keep data in memory even when inactive - retry: (failureCount, error: unknown) => { - // Safe check for browser environment - const isOffline = typeof window !== 'undefined' && !navigator.onLine; - - // Always retry on network errors (TypeError in fetch) or when offline - if (isOffline || error instanceof TypeError) { - return true; - } - - // Standard retry for other transient errors (up to 3 times) - return failureCount < 3; - }, - retryDelay: (attemptIndex) => Math.min(1000 * Math.pow(2, attemptIndex), 30000), - refetchOnWindowFocus: true, // Re-validate data when user returns to tab - refetchOnReconnect: 'always', // Force retry when connection returns + // Data is considered fresh for 60 seconds — avoids unnecessary refetches + staleTime: 60 * 1000, + // Retry failed requests once before showing an error + retry: 1, + // Refetch when the browser window regains focus + refetchOnWindowFocus: true, }, mutations: { // Retry failed mutations once From 5c85b44c1a6f5bd7b0d69632ef96096a0539c2ef Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Mon, 30 Mar 2026 03:36:13 +0100 Subject: [PATCH 4/5] style: remove trailing whitespace from tooltip branch --- frontend/src/app/components/ui/CreditScoreGauge.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/ui/CreditScoreGauge.tsx b/frontend/src/app/components/ui/CreditScoreGauge.tsx index 7f5f7f32..26ba0368 100644 --- a/frontend/src/app/components/ui/CreditScoreGauge.tsx +++ b/frontend/src/app/components/ui/CreditScoreGauge.tsx @@ -123,9 +123,9 @@ export function CreditScoreGauge({
{score} -
From 4f09730740264fddb3ef435717942f8b7c7e0dca Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Mon, 30 Mar 2026 05:46:19 +0100 Subject: [PATCH 5/5] test(backend): align event indexer test with bulk score updates --- backend/src/__tests__/eventIndexer.test.ts | 70 ++++++++++------------ 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/backend/src/__tests__/eventIndexer.test.ts b/backend/src/__tests__/eventIndexer.test.ts index 269bd317..cd280bd1 100644 --- a/backend/src/__tests__/eventIndexer.test.ts +++ b/backend/src/__tests__/eventIndexer.test.ts @@ -9,6 +9,7 @@ const mockGetScoreConfig = jest.fn(() => ({ repaymentDelta: 15, defaultPenalty: 50, })); +const mockUpdateUserScoresBulk = jest.fn<() => Promise>().mockResolvedValue(undefined); jest.unstable_mockModule("../db/connection.js", () => ({ query: mockQuery, @@ -32,6 +33,10 @@ jest.unstable_mockModule("../services/sorobanService.js", () => ({ sorobanService: { getScoreConfig: mockGetScoreConfig }, })); +jest.unstable_mockModule("../services/scoresService.js", () => ({ + updateUserScoresBulk: mockUpdateUserScoresBulk, +})); + jest.unstable_mockModule("../utils/logger.js", () => ({ default: { info: jest.fn(), @@ -126,7 +131,6 @@ describe("EventIndexer", () => { const borrowerRepaid = makeAddress(); const borrowerDefaulted = makeAddress(); const insertedLoanEvents: unknown[][] = []; - const scoreUpdates: unknown[][] = []; mockQuery.mockImplementation(async (sql: string, params: unknown[] = []) => { if (sql === "BEGIN" || sql === "COMMIT") { @@ -138,11 +142,6 @@ describe("EventIndexer", () => { return { rows: [{ event_id: params[0] }], rowCount: 1 }; } - if (sql.includes("INSERT INTO scores")) { - scoreUpdates.push(params); - return { rows: [], rowCount: 1 }; - } - return { rows: [], rowCount: 0 }; }); @@ -208,10 +207,13 @@ describe("EventIndexer", () => { expect(insertedLoanEvents[3]?.[2]).toBe(9); expect(insertedLoanEvents[3]?.[3]).toBe(borrowerDefaulted); - expect(scoreUpdates).toEqual([ - [borrowerRepaid, 515, 15], - [borrowerDefaulted, 450, -50], - ]); + expect(mockUpdateUserScoresBulk).toHaveBeenCalledTimes(1); + expect(mockUpdateUserScoresBulk).toHaveBeenCalledWith( + new Map([ + [borrowerRepaid, 15], + [borrowerDefaulted, -50], + ]), + ); expect(mockGetScoreConfig).toHaveBeenCalledTimes(2); expect(mockDispatch).toHaveBeenCalledTimes(4); expect(mockBroadcast).toHaveBeenCalledTimes(4); @@ -236,10 +238,6 @@ describe("EventIndexer", () => { }; } - if (sql.includes("INSERT INTO scores")) { - return { rows: [], rowCount: 1 }; - } - return { rows: [], rowCount: 0 }; }); @@ -269,6 +267,8 @@ describe("EventIndexer", () => { expect(mockBroadcast).toHaveBeenCalledTimes(1); expect(mockCreateNotification).toHaveBeenCalledTimes(1); expect(mockGetScoreConfig).toHaveBeenCalledTimes(1); + expect(mockUpdateUserScoresBulk).toHaveBeenCalledTimes(1); + expect(mockUpdateUserScoresBulk).toHaveBeenCalledWith(new Map([[borrower, 15]])); }); it("initializes missing indexer state and persists the last indexed ledger during polling", async () => { @@ -279,9 +279,8 @@ describe("EventIndexer", () => { return { rows: [], rowCount: 0 }; } - if (sql.includes("INSERT INTO indexer_state")) { - stateWrites.push(Number(params[0] ?? 0)); - return { rows: [], rowCount: 1 }; + if (sql.includes("INSERT INTO indexer_state") && params.length === 0) { + return { rows: [{ last_indexed_ledger: 0 }], rowCount: 1 }; } if (sql.includes("UPDATE indexer_state")) { @@ -289,37 +288,32 @@ describe("EventIndexer", () => { return { rows: [], rowCount: 1 }; } - if (sql === "BEGIN" || sql === "COMMIT") { - return { rows: [], rowCount: 0 }; - } - - if (sql.includes("INSERT INTO loan_events")) { - return { rows: [{ event_id: params[0] }], rowCount: 1 }; - } - return { rows: [], rowCount: 0 }; }); const indexer = new EventIndexer({ rpcUrl: "https://rpc.test", contractId: "CINDEXERTEST", + pollIntervalMs: 5, + batchSize: 50, }); - (indexer as { running: boolean }).running = true; - (indexer as { - rpc: { - getLatestLedger: () => Promise<{ sequence: number }>; - getEvents: () => Promise<{ events: unknown[] }>; - }; - }).rpc = { - getLatestLedger: jest.fn().mockResolvedValue({ sequence: 15 }), - getEvents: jest.fn().mockResolvedValue({ - events: [makeRawEvent({ id: "evt-poll", ledger: 15, type: "LoanRequested" })], - }), + const processChunk = jest + .spyOn(indexer as unknown as { processChunk: (start: number, end: number) => Promise }, "processChunk") + .mockResolvedValue({ + lastProcessedLedger: 25, + fetchedEvents: 0, + insertedEvents: 0, + }); + + (indexer as { rpc: { getLatestLedger: () => Promise<{ sequence: number }> } }).rpc = { + getLatestLedger: jest.fn().mockResolvedValue({ sequence: 25 }), }; - await (indexer as { pollOnce: () => Promise }).pollOnce(); + await indexer.start(); + indexer.stop(); - expect(stateWrites).toEqual([0, 15]); + expect(processChunk).toHaveBeenCalledWith(1, 25); + expect(stateWrites).toContain(25); }); });