Skip to content

Commit 0ac266c

Browse files
feat: extract frontend lending components into reusable SDK package (#652)
Co-authored-by: Gutopro <nicholasigber@gmail.com>
1 parent a76ac74 commit 0ac266c

18 files changed

Lines changed: 842 additions & 0 deletions

File tree

packages/ui-lending/package.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "@stellarlend/ui-lending",
3+
"version": "0.1.0",
4+
"description": "Reusable lending UI components, hooks, and utilities for StellarLend",
5+
"main": "dist/index.js",
6+
"module": "dist/index.mjs",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.mjs",
12+
"require": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "tsup",
17+
"test": "jest",
18+
"storybook": "storybook dev -p 6006",
19+
"build-storybook": "storybook build"
20+
},
21+
"peerDependencies": {
22+
"react": ">=18.0.0",
23+
"react-dom": ">=18.0.0"
24+
},
25+
"devDependencies": {
26+
"@types/react": "^18.0.0",
27+
"@types/react-dom": "^18.0.0",
28+
"typescript": "^5.0.0"
29+
},
30+
"keywords": ["stellar", "lending", "defi", "react", "components"],
31+
"license": "MIT"
32+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import React from 'react';
2+
import type { HealthFactor, LendingTheme } from '../../types';
3+
import { LIGHT_COLORS, getHealthColor } from '../../utils/theme';
4+
5+
interface HealthMeterProps {
6+
health: HealthFactor;
7+
theme?: LendingTheme;
8+
showDetails?: boolean;
9+
isLoading?: boolean;
10+
className?: string;
11+
}
12+
13+
/**
14+
* HealthMeter — visual gauge for account health factor.
15+
* Green ≥ 2.0, Yellow ≥ 1.5, Red < 1.5, Critical < 1.0.
16+
*/
17+
export function HealthMeter({
18+
health,
19+
theme,
20+
showDetails = true,
21+
isLoading = false,
22+
className = '',
23+
}: HealthMeterProps) {
24+
const colors = theme?.colors ?? LIGHT_COLORS;
25+
const healthColor = getHealthColor(health.value, colors);
26+
const percentage = Math.min(100, (health.value / 3) * 100);
27+
28+
if (isLoading) {
29+
return (
30+
<div className={`health-meter ${className}`} style={{ padding: 16, background: colors.surface, borderRadius: 12, border: `1px solid ${colors.border}` }}>
31+
<div style={{ height: 12, background: colors.border, borderRadius: 6 }} />
32+
</div>
33+
);
34+
}
35+
36+
return (
37+
<div className={`health-meter ${className}`} style={{ padding: 16, background: colors.surface, borderRadius: 12, border: `1px solid ${colors.border}` }} data-testid="health-meter">
38+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
39+
<span style={{ color: colors.textMuted, fontSize: 13 }}>Health Factor</span>
40+
<span style={{ color: healthColor, fontWeight: 700, fontSize: 18 }}>
41+
{health.value === Infinity ? '∞' : health.value.toFixed(2)}
42+
</span>
43+
</div>
44+
45+
<div style={{ height: 8, background: colors.border, borderRadius: 4, overflow: 'hidden', marginBottom: 12 }}>
46+
<div style={{ height: '100%', width: `${percentage}%`, background: healthColor, borderRadius: 4, transition: 'width 0.3s ease' }} />
47+
</div>
48+
49+
<div style={{ display: 'flex', justifyContent: 'center' }}>
50+
<span style={{ fontSize: 12, color: healthColor, fontWeight: 600, textTransform: 'uppercase' }}>
51+
{health.status}
52+
</span>
53+
</div>
54+
55+
{showDetails && (
56+
<div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
57+
<div>
58+
<div style={{ color: colors.textMuted, fontSize: 11 }}>Collateral Value</div>
59+
<div style={{ color: colors.text, fontSize: 13, fontWeight: 500 }}>${health.collateralValue.toFixed(2)}</div>
60+
</div>
61+
<div>
62+
<div style={{ color: colors.textMuted, fontSize: 11 }}>Borrowed Value</div>
63+
<div style={{ color: colors.text, fontSize: 13, fontWeight: 500 }}>${health.borrowedValue.toFixed(2)}</div>
64+
</div>
65+
</div>
66+
)}
67+
</div>
68+
);
69+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react';
2+
import type { LiquidationRisk, LendingTheme } from '../../types';
3+
import { LIGHT_COLORS, getRiskColor } from '../../utils/theme';
4+
5+
interface LiquidationRiskGaugeProps {
6+
risk: LiquidationRisk;
7+
theme?: LendingTheme;
8+
isLoading?: boolean;
9+
className?: string;
10+
}
11+
12+
/**
13+
* LiquidationRiskGauge — displays liquidation risk with price buffer visualization.
14+
*/
15+
export function LiquidationRiskGauge({ risk, theme, isLoading = false, className = '' }: LiquidationRiskGaugeProps) {
16+
const colors = theme?.colors ?? LIGHT_COLORS;
17+
const riskColor = getRiskColor(risk.riskLevel, colors);
18+
const bufferPct = Math.max(0, Math.min(100, risk.safetyBuffer * 100));
19+
20+
if (isLoading) {
21+
return (
22+
<div className={`liquidation-gauge ${className}`} style={{ padding: 16, background: colors.surface, borderRadius: 12, border: `1px solid ${colors.border}` }}>
23+
<div style={{ height: 12, background: colors.border, borderRadius: 6 }} />
24+
</div>
25+
);
26+
}
27+
28+
return (
29+
<div className={`liquidation-gauge ${className}`} style={{ padding: 16, background: colors.surface, borderRadius: 12, border: `1px solid ${colors.border}` }} data-testid="liquidation-gauge">
30+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
31+
<span style={{ color: colors.text, fontWeight: 600 }}>Liquidation Risk</span>
32+
<span style={{ color: riskColor, fontWeight: 700, textTransform: 'uppercase', fontSize: 13 }}>{risk.riskLevel}</span>
33+
</div>
34+
35+
<div style={{ height: 10, background: colors.border, borderRadius: 5, overflow: 'hidden', marginBottom: 16 }}>
36+
<div style={{ height: '100%', width: `${bufferPct}%`, background: riskColor, borderRadius: 5, transition: 'width 0.3s ease' }} />
37+
</div>
38+
39+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
40+
<div>
41+
<div style={{ color: colors.textMuted, fontSize: 11 }}>Current Price</div>
42+
<div style={{ color: colors.text, fontWeight: 500 }}>${risk.currentPrice.toFixed(2)}</div>
43+
</div>
44+
<div>
45+
<div style={{ color: colors.textMuted, fontSize: 11 }}>Liquidation Price</div>
46+
<div style={{ color: colors.danger, fontWeight: 500 }}>${risk.liquidationPrice.toFixed(2)}</div>
47+
</div>
48+
<div>
49+
<div style={{ color: colors.textMuted, fontSize: 11 }}>Safety Buffer</div>
50+
<div style={{ color: riskColor, fontWeight: 500 }}>{(risk.safetyBuffer * 100).toFixed(1)}%</div>
51+
</div>
52+
<div>
53+
<div style={{ color: colors.textMuted, fontSize: 11 }}>Health Factor</div>
54+
<div style={{ color: riskColor, fontWeight: 500 }}>{risk.healthFactor.toFixed(2)}</div>
55+
</div>
56+
</div>
57+
</div>
58+
);
59+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import React from 'react';
2+
import type { PoolData, LendingTheme } from '../../types';
3+
import { LIGHT_COLORS } from '../../utils/theme';
4+
5+
interface PoolCardProps {
6+
pool: PoolData;
7+
theme?: LendingTheme;
8+
onSupply?: (pool: PoolData) => void;
9+
onBorrow?: (pool: PoolData) => void;
10+
isLoading?: boolean;
11+
className?: string;
12+
}
13+
14+
/**
15+
* PoolCard — displays a lending pool with utilization, APY, and actions.
16+
*/
17+
export function PoolCard({ pool, theme, onSupply, onBorrow, isLoading = false, className = '' }: PoolCardProps) {
18+
const colors = theme?.colors ?? LIGHT_COLORS;
19+
20+
if (isLoading) {
21+
return (
22+
<div className={`pool-card ${className}`} style={{ background: colors.surface, border: `1px solid ${colors.border}`, borderRadius: 12, padding: 20 }} role="status">
23+
<div style={{ height: 16, background: colors.border, borderRadius: 4, marginBottom: 8, width: '50%' }} />
24+
<div style={{ height: 12, background: colors.border, borderRadius: 4, width: '70%' }} />
25+
</div>
26+
);
27+
}
28+
29+
if (!pool.isActive) {
30+
return (
31+
<div className={`pool-card pool-card--inactive ${className}`} style={{ background: colors.surface, border: `1px solid ${colors.border}`, borderRadius: 12, padding: 20, opacity: 0.5 }} data-testid="pool-card-inactive">
32+
<h3 style={{ color: colors.text, margin: 0 }}>{pool.asset}</h3>
33+
<span style={{ color: colors.textMuted, fontSize: 13 }}>Pool inactive</span>
34+
</div>
35+
);
36+
}
37+
38+
return (
39+
<div className={`pool-card ${className}`} style={{ background: colors.surface, border: `1px solid ${colors.border}`, borderRadius: 12, padding: 20 }} data-testid="pool-card">
40+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
41+
<div>
42+
<h3 style={{ color: colors.text, margin: 0 }}>{pool.asset}</h3>
43+
<span style={{ color: colors.textMuted, fontSize: 13 }}>{pool.symbol}</span>
44+
</div>
45+
<div style={{ textAlign: 'right' }}>
46+
<div style={{ color: colors.textMuted, fontSize: 11 }}>Utilization</div>
47+
<div style={{ color: colors.primary, fontWeight: 600 }}>{(pool.utilization * 100).toFixed(1)}%</div>
48+
</div>
49+
</div>
50+
51+
<div style={{ height: 6, background: colors.border, borderRadius: 3, marginBottom: 16 }}>
52+
<div style={{ height: '100%', width: `${pool.utilization * 100}%`, background: colors.primary, borderRadius: 3 }} />
53+
</div>
54+
55+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
56+
<div>
57+
<div style={{ color: colors.textMuted, fontSize: 11 }}>Supply APY</div>
58+
<div style={{ color: colors.success, fontWeight: 600 }}>{pool.supplyApy.toFixed(2)}%</div>
59+
</div>
60+
<div>
61+
<div style={{ color: colors.textMuted, fontSize: 11 }}>Borrow APY</div>
62+
<div style={{ color: colors.danger, fontWeight: 600 }}>{pool.borrowApy.toFixed(2)}%</div>
63+
</div>
64+
<div>
65+
<div style={{ color: colors.textMuted, fontSize: 11 }}>Total Supply</div>
66+
<div style={{ color: colors.text, fontSize: 13 }}>${pool.totalSupply.toLocaleString()}</div>
67+
</div>
68+
<div>
69+
<div style={{ color: colors.textMuted, fontSize: 11 }}>Total Borrow</div>
70+
<div style={{ color: colors.text, fontSize: 13 }}>${pool.totalBorrow.toLocaleString()}</div>
71+
</div>
72+
</div>
73+
74+
<div style={{ display: 'flex', gap: 8 }}>
75+
{onSupply && (
76+
<button onClick={() => onSupply(pool)} style={{ flex: 1, padding: '8px 12px', background: colors.primary, color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13 }}>Supply</button>
77+
)}
78+
{onBorrow && (
79+
<button onClick={() => onBorrow(pool)} style={{ flex: 1, padding: '8px 12px', background: colors.surface, color: colors.text, border: `1px solid ${colors.border}`, borderRadius: 8, cursor: 'pointer', fontSize: 13 }}>Borrow</button>
80+
)}
81+
</div>
82+
</div>
83+
);
84+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { PositionCard } from './index';
4+
5+
const mockPosition = {
6+
id: '1',
7+
asset: 'USDC',
8+
symbol: 'USDC',
9+
supplied: 1000,
10+
borrowed: 250,
11+
supplyApy: 4.5,
12+
borrowApy: 6.2,
13+
collateralFactor: 0.8,
14+
price: 1,
15+
};
16+
17+
describe('PositionCard', () => {
18+
it('renders asset name and symbol', () => {
19+
render(<PositionCard position={mockPosition} />);
20+
expect(screen.getByText('USDC')).toBeTruthy();
21+
});
22+
23+
it('shows loading skeleton when isLoading is true', () => {
24+
render(<PositionCard position={mockPosition} isLoading />);
25+
expect(screen.getByRole('status')).toBeTruthy();
26+
});
27+
28+
it('calls onSupply when supply button clicked', () => {
29+
const onSupply = jest.fn();
30+
render(<PositionCard position={mockPosition} onSupply={onSupply} />);
31+
fireEvent.click(screen.getByText('Supply'));
32+
expect(onSupply).toHaveBeenCalledWith(mockPosition);
33+
});
34+
35+
it('calls onBorrow when borrow button clicked', () => {
36+
const onBorrow = jest.fn();
37+
render(<PositionCard position={mockPosition} onBorrow={onBorrow} />);
38+
fireEvent.click(screen.getByText('Borrow'));
39+
expect(onBorrow).toHaveBeenCalledWith(mockPosition);
40+
});
41+
42+
it('renders supplied and borrowed amounts', () => {
43+
render(<PositionCard position={mockPosition} />);
44+
expect(screen.getByText('1000.0000')).toBeTruthy();
45+
expect(screen.getByText('250.0000')).toBeTruthy();
46+
});
47+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import React from 'react';
2+
import type { Position, LendingTheme } from '../../types';
3+
import { LIGHT_COLORS } from '../../utils/theme';
4+
5+
interface PositionCardProps {
6+
position: Position;
7+
theme?: LendingTheme;
8+
onSupply?: (position: Position) => void;
9+
onBorrow?: (position: Position) => void;
10+
onRepay?: (position: Position) => void;
11+
onWithdraw?: (position: Position) => void;
12+
isLoading?: boolean;
13+
className?: string;
14+
}
15+
16+
/**
17+
* PositionCard — displays a single lending position with supply/borrow details.
18+
* Supports dark/light theming and loading/empty states.
19+
*/
20+
export function PositionCard({
21+
position,
22+
theme,
23+
onSupply,
24+
onBorrow,
25+
onRepay,
26+
onWithdraw,
27+
isLoading = false,
28+
className = '',
29+
}: PositionCardProps) {
30+
const colors = theme?.colors ?? LIGHT_COLORS;
31+
32+
if (isLoading) {
33+
return (
34+
<div
35+
className={`position-card position-card--loading ${className}`}
36+
style={{ background: colors.surface, border: `1px solid ${colors.border}`, borderRadius: 12, padding: 20 }}
37+
role="status"
38+
aria-label="Loading position"
39+
>
40+
<div style={{ height: 16, background: colors.border, borderRadius: 4, marginBottom: 8, width: '60%' }} />
41+
<div style={{ height: 12, background: colors.border, borderRadius: 4, width: '40%' }} />
42+
</div>
43+
);
44+
}
45+
46+
const netValue = (position.supplied - position.borrowed) * position.price;
47+
48+
return (
49+
<div
50+
className={`position-card ${className}`}
51+
style={{ background: colors.surface, border: `1px solid ${colors.border}`, borderRadius: 12, padding: 20 }}
52+
data-testid="position-card"
53+
>
54+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
55+
<div>
56+
<h3 style={{ color: colors.text, margin: 0, fontSize: 18, fontWeight: 600 }}>{position.asset}</h3>
57+
<span style={{ color: colors.textMuted, fontSize: 13 }}>{position.symbol}</span>
58+
</div>
59+
<span style={{ color: netValue >= 0 ? colors.success : colors.danger, fontWeight: 600 }}>
60+
${netValue.toFixed(2)}
61+
</span>
62+
</div>
63+
64+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
65+
<div>
66+
<div style={{ color: colors.textMuted, fontSize: 12, marginBottom: 4 }}>Supplied</div>
67+
<div style={{ color: colors.text, fontWeight: 500 }}>{position.supplied.toFixed(4)}</div>
68+
<div style={{ color: colors.success, fontSize: 12 }}>{position.supplyApy.toFixed(2)}% APY</div>
69+
</div>
70+
<div>
71+
<div style={{ color: colors.textMuted, fontSize: 12, marginBottom: 4 }}>Borrowed</div>
72+
<div style={{ color: colors.text, fontWeight: 500 }}>{position.borrowed.toFixed(4)}</div>
73+
<div style={{ color: colors.danger, fontSize: 12 }}>{position.borrowApy.toFixed(2)}% APY</div>
74+
</div>
75+
</div>
76+
77+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
78+
{onSupply && (
79+
<button onClick={() => onSupply(position)} style={{ flex: 1, padding: '8px 12px', background: colors.primary, color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13 }}>
80+
Supply
81+
</button>
82+
)}
83+
{onBorrow && (
84+
<button onClick={() => onBorrow(position)} style={{ flex: 1, padding: '8px 12px', background: colors.secondary, color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13 }}>
85+
Borrow
86+
</button>
87+
)}
88+
{onRepay && position.borrowed > 0 && (
89+
<button onClick={() => onRepay(position)} style={{ flex: 1, padding: '8px 12px', background: colors.warning, color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13 }}>
90+
Repay
91+
</button>
92+
)}
93+
{onWithdraw && position.supplied > 0 && (
94+
<button onClick={() => onWithdraw(position)} style={{ flex: 1, padding: '8px 12px', background: colors.surface, color: colors.text, border: `1px solid ${colors.border}`, borderRadius: 8, cursor: 'pointer', fontSize: 13 }}>
95+
Withdraw
96+
</button>
97+
)}
98+
</div>
99+
</div>
100+
);
101+
}

0 commit comments

Comments
 (0)