-
Notifications
You must be signed in to change notification settings - Fork 232
feat: add Lobstr wallet, dynamic portfolio comparison, OG meta tags, … #1615
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' | ||
| import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react' | ||
| import { WalletPicker } from './WalletPicker' | ||
|
|
||
| const mockWalletManager = vi.hoisted(() => ({ | ||
| connect: vi.fn(), | ||
| disconnect: vi.fn(), | ||
| getWalletType: vi.fn(), | ||
| getPublicKey: vi.fn(), | ||
| })) | ||
|
|
||
| vi.mock('../utils/walletManager', () => ({ | ||
| walletManager: mockWalletManager, | ||
| })) | ||
|
|
||
| vi.mock('../lib/wallet', () => ({ | ||
| SUPPORTED_WALLETS: [ | ||
| { name: 'Freighter', type: 'freighter', installUrl: 'https://www.freighter.app/' }, | ||
| { name: 'Rabet', type: 'rabet', installUrl: 'https://rabet.io/' }, | ||
| { name: 'xBull', type: 'xbull', installUrl: 'https://xbull.app/' }, | ||
| { name: 'LOBSTR', type: 'lobstr', installUrl: 'https://lobstr.co/' }, | ||
| { name: 'WalletConnect', type: 'walletconnect', installUrl: 'https://walletconnect.com/' }, | ||
| ], | ||
| getLastUsedWallet: vi.fn(() => null), | ||
| setLastUsedWallet: vi.fn(), | ||
| clearLastUsedWallet: vi.fn(), | ||
| })) | ||
|
|
||
| describe('WalletPicker', () => { | ||
| let mockOnConnect: (publicKey: string, walletType: string) => void | ||
| let mockOnError: (error: string) => void | ||
|
|
||
| beforeEach(() => { | ||
| cleanup() | ||
| vi.restoreAllMocks() | ||
|
|
||
| mockOnConnect = vi.fn() | ||
| mockOnError = vi.fn() | ||
|
|
||
| mockWalletManager.getWalletType.mockReturnValue(null) | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| cleanup() | ||
| }) | ||
|
|
||
| it('renders all supported wallet options', () => { | ||
| render(<WalletPicker onConnect={mockOnConnect} onError={mockOnError} />) | ||
|
|
||
| expect(screen.getByText('Freighter')).toBeTruthy() | ||
| expect(screen.getByText('Rabet')).toBeTruthy() | ||
| expect(screen.getByText('xBull')).toBeTruthy() | ||
| expect(screen.getByText('LOBSTR')).toBeTruthy() | ||
| expect(screen.getByText('WalletConnect')).toBeTruthy() | ||
| }) | ||
|
|
||
| it('detects Lobstr as installed when window.lobstr is available', () => { | ||
| window.lobstr = {} as any | ||
| render(<WalletPicker onConnect={mockOnConnect} onError={mockOnError} />) | ||
| expect(screen.getByText('Installed')).toBeTruthy() | ||
| delete (window as any).lobstr | ||
| }) | ||
|
|
||
| it('calls walletManager.connect with lobstr when Lobstr is clicked', async () => { | ||
| window.lobstr = {} as any | ||
| const testPublicKey = 'GAlobstrtest1234567890abcdef1234567890abcdef1234567890abcdef' | ||
| mockWalletManager.connect.mockResolvedValue(testPublicKey) | ||
|
|
||
| render(<WalletPicker onConnect={mockOnConnect} onError={mockOnError} />) | ||
|
|
||
| const lobstrButton = screen.getByText('LOBSTR').closest('button') | ||
| expect(lobstrButton).toBeTruthy() | ||
|
|
||
| fireEvent.click(lobstrButton!) | ||
|
|
||
| await waitFor(() => { | ||
| expect(mockWalletManager.connect).toHaveBeenCalledWith('lobstr') | ||
| expect(mockOnConnect).toHaveBeenCalledWith(testPublicKey, 'LOBSTR') | ||
| expect(mockOnError).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| delete (window as any).lobstr | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,47 @@ | ||
| import { useState, useEffect } from 'react' | ||
| import { useState, useEffect, useMemo } from 'react' | ||
| import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts' | ||
| import { api, ENDPOINTS } from '../config/api' | ||
| import { appCopy } from '../content/uiCopy' | ||
|
|
||
| type WidgetSize = 'small' | 'medium' | 'large' | ||
| type WidgetTheme = 'light' | 'dark' | ||
|
|
||
| const VALID_SIZES: WidgetSize[] = ['small', 'medium', 'large'] | ||
| const VALID_THEMES: WidgetTheme[] = ['light', 'dark'] | ||
|
|
||
| const SIZE_CLASSES: Record<WidgetSize, { container: string; value: string; asset: string; label: string; footer: string }> = { | ||
| small: { | ||
| container: 'text-xs', | ||
| value: 'text-lg sm:text-xl', | ||
| asset: 'text-[10px] sm:text-xs', | ||
| label: 'text-[9px] sm:text-[10px]', | ||
| footer: 'text-[9px] sm:text-[10px]', | ||
| }, | ||
| medium: { | ||
| container: 'text-sm', | ||
| value: 'text-xl sm:text-2xl', | ||
| asset: 'text-xs sm:text-sm', | ||
| label: 'text-[10px] sm:text-xs', | ||
| footer: 'text-[10px] sm:text-xs', | ||
| }, | ||
| large: { | ||
| container: 'text-base', | ||
| value: 'text-2xl sm:text-3xl', | ||
| asset: 'text-sm sm:text-base', | ||
| label: 'text-xs sm:text-sm', | ||
| footer: 'text-xs sm:text-sm', | ||
| }, | ||
| } | ||
|
|
||
| export function parseWidgetParams(search?: string): { size: WidgetSize; theme: WidgetTheme } { | ||
| const params = new URLSearchParams(search ?? window.location.search) | ||
| const rawSize = params.get('size')?.toLowerCase() | ||
| const rawTheme = params.get('theme')?.toLowerCase() | ||
| const size = VALID_SIZES.includes(rawSize as WidgetSize) ? (rawSize as WidgetSize) : 'medium' | ||
| const theme = VALID_THEMES.includes(rawTheme as WidgetTheme) ? (rawTheme as WidgetTheme) : 'light' | ||
| return { size, theme } | ||
| } | ||
|
|
||
| interface PublicPortfolioData { | ||
| portfolio: { | ||
| id: string | ||
|
|
@@ -27,6 +66,9 @@ function EmbedWidget({ id }: EmbedWidgetProps) { | |
| const [performancePercent, setPerformancePercent] = useState<number | null>(null) | ||
| const [loading, setLoading] = useState(true) | ||
| const [error, setError] = useState<string | null>(null) | ||
| const { size, theme } = useMemo(() => parseWidgetParams(), []) | ||
| const s = SIZE_CLASSES[size] | ||
| const isDark = theme === 'dark' | ||
|
|
||
| useEffect(() => { | ||
| const fetchSharedPortfolio = async () => { | ||
|
|
@@ -60,15 +102,15 @@ function EmbedWidget({ id }: EmbedWidgetProps) { | |
|
|
||
| if (loading) { | ||
| return ( | ||
| <div className="h-screen w-full bg-white dark:bg-slate-950 flex items-center justify-center m-0 p-0 overflow-hidden"> | ||
| <div className={`h-screen w-full bg-white dark:bg-slate-950 flex items-center justify-center m-0 p-0 overflow-hidden ${s.container}`}> | ||
| <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" /> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| if (error || !data) { | ||
| return ( | ||
| <div className="h-screen w-full bg-white dark:bg-slate-950 flex items-center justify-center p-4 text-center m-0 overflow-hidden"> | ||
| <div className={`h-screen w-full bg-white dark:bg-slate-950 flex items-center justify-center p-4 text-center m-0 overflow-hidden ${s.container}`}> | ||
|
Comment on lines
+105
to
+113
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Honor
Proposed fix- <div className={`h-screen w-full bg-white dark:bg-slate-950 flex items-center justify-center m-0 p-0 overflow-hidden ${s.container}`}>
+ <div className={`h-screen w-full ${isDark ? 'bg-slate-950' : 'bg-white'} flex items-center justify-center m-0 p-0 overflow-hidden ${s.container}`}>
- <div className={`h-screen w-full bg-white dark:bg-slate-950 flex items-center justify-center p-4 text-center m-0 overflow-hidden ${s.container}`}>
- <p className="text-sm text-slate-500 dark:text-slate-400 font-medium">
+ <div className={`h-screen w-full ${isDark ? 'bg-slate-950' : 'bg-white'} flex items-center justify-center p-4 text-center m-0 overflow-hidden ${s.container}`}>
+ <p className={`${s.asset} ${isDark ? 'text-slate-400' : 'text-slate-500'} font-medium`}>🤖 Prompt for AI Agents |
||
| <p className="text-sm text-slate-500 dark:text-slate-400 font-medium">{error || 'Portfolio Unavailable'}</p> | ||
| </div> | ||
| ) | ||
|
|
@@ -90,21 +132,21 @@ function EmbedWidget({ id }: EmbedWidgetProps) { | |
| : 'Never' | ||
|
|
||
| return ( | ||
| <div className="h-screen w-full bg-white dark:bg-slate-950 overflow-hidden flex flex-col font-sans text-slate-900 dark:text-slate-50 m-0 p-0"> | ||
| <div className="p-4 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center bg-slate-50/50 dark:bg-slate-900/50 shrink-0"> | ||
| <div className={`h-screen w-full ${isDark ? 'bg-slate-950 text-slate-50' : 'bg-white text-slate-900'} overflow-hidden flex flex-col font-sans m-0 p-0 ${s.container}`}> | ||
| <div className={`p-4 border-b ${isDark ? 'border-slate-800' : 'border-slate-100'} flex justify-between items-center ${isDark ? 'bg-slate-900/50' : 'bg-slate-50/50'} shrink-0`}> | ||
| <div> | ||
| <div className="text-[10px] sm:text-xs text-slate-500 dark:text-slate-400 font-bold uppercase tracking-wider mb-1"> | ||
| <div className={`${s.label} font-bold uppercase tracking-wider mb-1 ${isDark ? 'text-slate-400' : 'text-slate-500'}`}> | ||
| Portfolio Value | ||
| </div> | ||
| <div className="text-xl sm:text-2xl font-black text-slate-900 dark:text-white tracking-tight"> | ||
| <div className={`${s.value} font-black tracking-tight ${isDark ? 'text-white' : 'text-slate-900'}`}> | ||
| ${data.portfolio.totalValue?.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0.00'} | ||
| </div> | ||
| </div> | ||
| <div className="text-right"> | ||
| <div className="text-[10px] sm:text-xs text-slate-500 dark:text-slate-400 font-bold uppercase tracking-wider mb-1"> | ||
| <div className={`${s.label} font-bold uppercase tracking-wider mb-1 ${isDark ? 'text-slate-400' : 'text-slate-500'}`}> | ||
| Performance | ||
| </div> | ||
| <div className={`text-sm sm:text-base font-bold ${ | ||
| <div className={`${s.asset} font-bold ${ | ||
| performancePercent === null ? 'text-slate-400' : | ||
| performancePercent >= 0 ? 'text-emerald-500' : 'text-red-500' | ||
| }`}> | ||
|
|
@@ -116,15 +158,15 @@ function EmbedWidget({ id }: EmbedWidgetProps) { | |
| </div> | ||
|
|
||
| <div className="flex-1 flex flex-row items-center justify-center p-2 sm:p-4 gap-4 sm:gap-8 overflow-hidden min-h-0"> | ||
| <div className="w-24 h-24 sm:w-36 sm:h-36 shrink-0 relative"> | ||
| <div className={`${size === 'small' ? 'w-16 h-16 sm:w-24 sm:h-24' : size === 'large' ? 'w-32 h-32 sm:w-48 sm:h-48' : 'w-24 h-24 sm:w-36 sm:h-36'} shrink-0 relative`}> | ||
| <ResponsiveContainer width="100%" height="100%"> | ||
| <PieChart> | ||
| <Pie | ||
| data={allocationData} | ||
| cx="50%" | ||
| cy="50%" | ||
| innerRadius={30} | ||
| outerRadius={45} | ||
| innerRadius={size === 'small' ? 22 : size === 'large' ? 40 : 30} | ||
| outerRadius={size === 'small' ? 32 : size === 'large' ? 60 : 45} | ||
| paddingAngle={4} | ||
| dataKey="value" | ||
| > | ||
|
|
@@ -137,30 +179,30 @@ function EmbedWidget({ id }: EmbedWidgetProps) { | |
| </div> | ||
|
|
||
| <div className="flex-1 min-w-0 max-w-[160px] sm:max-w-[200px]"> | ||
| <h3 className="text-[10px] font-bold text-slate-500 dark:text-slate-400 mb-2 uppercase tracking-wider border-b border-slate-100 dark:border-slate-800 pb-1"> | ||
| <h3 className={`${s.label} font-bold ${isDark ? 'text-slate-400' : 'text-slate-500'} mb-2 uppercase tracking-wider ${isDark ? 'border-slate-800' : 'border-slate-100'} border-b pb-1`}> | ||
| Top Assets | ||
| </h3> | ||
| <div className="space-y-1.5 sm:space-y-2"> | ||
| {topAssets.map(([asset, value], idx) => ( | ||
| <div key={asset} className="flex justify-between items-center text-xs sm:text-sm"> | ||
| <div key={asset} className={`flex justify-between items-center ${s.asset}`}> | ||
| <div className="flex items-center gap-1.5 sm:gap-2 truncate"> | ||
| <div className="w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full shrink-0" style={{ backgroundColor: COLORS[idx % COLORS.length] }} /> | ||
| <span className="font-semibold text-slate-700 dark:text-slate-300 truncate">{asset}</span> | ||
| <div className={`${size === 'small' ? 'w-1 h-1' : 'w-1.5 h-1.5'} sm:w-2 sm:h-2 rounded-full shrink-0`} style={{ backgroundColor: COLORS[idx % COLORS.length] }} /> | ||
| <span className={`font-semibold truncate ${isDark ? 'text-slate-300' : 'text-slate-700'}`}>{asset}</span> | ||
| </div> | ||
| <span className="text-slate-500 dark:text-slate-400 font-medium ml-2">{value}%</span> | ||
| <span className={`${isDark ? 'text-slate-400' : 'text-slate-500'} font-medium ml-2`}>{value}%</span> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="px-3 sm:px-4 py-2 sm:py-3 bg-slate-50/50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-800 text-[10px] sm:text-xs flex justify-between items-center shrink-0"> | ||
| <span className="text-slate-500 dark:text-slate-400 font-medium">Rebalanced: {lastRebalanceDate}</span> | ||
| <div className={`px-3 sm:px-4 py-2 sm:py-3 ${isDark ? 'bg-slate-900/50 border-slate-800' : 'bg-slate-50/50 border-slate-100'} border-t ${s.footer} flex justify-between items-center shrink-0`}> | ||
| <span className={`font-medium ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>Rebalanced: {lastRebalanceDate}</span> | ||
| <a | ||
| href={`/public/${id}`} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 font-semibold transition-colors" | ||
| className={`font-semibold transition-colors ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-500 hover:text-blue-600'}`} | ||
| > | ||
| View → | ||
| </a> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Cap URL-initialized selections as well.
Line 41 only limits clicks.
?portfolios=a,b,c,d,e,finitializes six IDs at Lines 20-23, displays6/5, and sends all six tousePortfolioCompare.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents