Skip to content

Commit f46d6be

Browse files
committed
feat: add dark mode toggle, PnL summary card, and key comparison view
- Add system preference detection and persistence for dark mode toggle (#844) - Add PnL summary card showing total invested, current value, and unrealised PnL (#842) - Add unit tests for PnL summary card calculations (#850) - Add key comparison view with comparison tray for up to 3 keys (#847)
1 parent 0891062 commit f46d6be

9 files changed

Lines changed: 571 additions & 22 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { X } from 'lucide-react';
2+
import { useNavigate } from 'react-router';
3+
import type { ComparisonKey } from '@/hooks/useKeyComparison';
4+
5+
interface ComparisonTrayProps {
6+
selectedKeys: ComparisonKey[];
7+
onRemoveKey: (id: string) => void;
8+
onClearAll: () => void;
9+
}
10+
11+
export default function ComparisonTray({
12+
selectedKeys,
13+
onRemoveKey,
14+
onClearAll,
15+
}: ComparisonTrayProps) {
16+
const navigate = useNavigate();
17+
18+
if (selectedKeys.length === 0) return null;
19+
20+
const handleCompare = () => {
21+
const keyIds = selectedKeys.map(k => k.id).join(',');
22+
navigate(`/compare?keys=${keyIds}`);
23+
};
24+
25+
return (
26+
<div
27+
data-testid="comparison-tray"
28+
className="fixed bottom-0 left-0 right-0 z-50 border-t border-white/10 bg-slate-950/95 backdrop-blur-md"
29+
>
30+
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-6 py-3">
31+
<div className="flex items-center gap-3">
32+
<span className="text-sm font-medium text-white/70">
33+
Compare ({selectedKeys.length}/3)
34+
</span>
35+
<div className="flex items-center gap-2">
36+
{selectedKeys.map(key => (
37+
<div
38+
key={key.id}
39+
className="flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-2 py-1"
40+
>
41+
<span className="text-xs text-white/80">{key.name}</span>
42+
<button
43+
type="button"
44+
onClick={() => onRemoveKey(key.id)}
45+
className="ml-1 text-white/40 hover:text-white/80"
46+
aria-label={`Remove ${key.name} from comparison`}
47+
>
48+
<X className="size-3" />
49+
</button>
50+
</div>
51+
))}
52+
</div>
53+
</div>
54+
<div className="flex items-center gap-2">
55+
<button
56+
type="button"
57+
onClick={onClearAll}
58+
className="text-xs text-white/50 hover:text-white/80"
59+
>
60+
Clear all
61+
</button>
62+
<button
63+
type="button"
64+
onClick={handleCompare}
65+
disabled={selectedKeys.length < 2}
66+
className="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-black transition-colors hover:bg-amber-400 disabled:opacity-50 disabled:cursor-not-allowed"
67+
>
68+
Compare
69+
</button>
70+
</div>
71+
</div>
72+
</div>
73+
);
74+
}

src/components/home/Header.tsx

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState } from 'react';
2-
import { Moon, Sun } from 'lucide-react';
2+
import { Moon, Sun, Monitor } from 'lucide-react';
33
import WalletStatusChip from '@/components/common/WalletStatusChip';
44
import NotificationBell from '@/components/common/NotificationBell';
55
import MarketplaceHeaderSearch from '@/components/common/MarketplaceHeaderSearch';
@@ -91,22 +91,20 @@ export default function Header() {
9191
Batch Buy
9292
</button>
9393
<BatchBuyModal open={batchOpen} onOpenChange={setBatchOpen} />
94-
<button
95-
type="button"
96-
onClick={toggleTheme}
97-
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
98-
className={`rounded-md p-1.5 transition-colors duration-200 ${
99-
scrolled
100-
? 'text-gray-600 hover:bg-black/5 hover:text-gray-900'
101-
: 'text-white/60 hover:text-white/90'
102-
}`}
103-
>
104-
{theme === 'dark' ? (
105-
<Sun className="size-4" aria-hidden="true" />
106-
) : (
107-
<Moon className="size-4" aria-hidden="true" />
108-
)}
109-
</button>
94+
<button
95+
type="button"
96+
onClick={toggleTheme}
97+
aria-label={`Current theme: ${theme}. Click to cycle themes.`}
98+
className={`rounded-md p-1.5 transition-colors duration-200 ${
99+
scrolled
100+
? 'text-gray-600 hover:bg-black/5 hover:text-gray-900'
101+
: 'text-white/60 hover:text-white/90'
102+
}`}
103+
>
104+
{theme === 'dark' && <Sun className="size-4" aria-hidden="true" />}
105+
{theme === 'light' && <Moon className="size-4" aria-hidden="true" />}
106+
{theme === 'system' && <Monitor className="size-4" aria-hidden="true" />}
107+
</button>
110108
{profile && (
111109
<NotificationBell
112110
userId={profile.id}

src/hooks/useKeyComparison.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useCallback, useState } from 'react';
2+
3+
export interface ComparisonKey {
4+
id: string;
5+
name: string;
6+
}
7+
8+
const MAX_COMPARISON_KEYS = 3;
9+
10+
export interface UseKeyComparisonResult {
11+
selectedKeys: ComparisonKey[];
12+
addKey: (key: ComparisonKey) => void;
13+
removeKey: (id: string) => void;
14+
clearKeys: () => void;
15+
canAddMore: boolean;
16+
}
17+
18+
export function useKeyComparison(): UseKeyComparisonResult {
19+
const [selectedKeys, setSelectedKeys] = useState<ComparisonKey[]>([]);
20+
21+
const addKey = useCallback((key: ComparisonKey) => {
22+
setSelectedKeys(prev => {
23+
if (prev.some(k => k.id === key.id)) return prev;
24+
if (prev.length >= MAX_COMPARISON_KEYS) {
25+
return [...prev.slice(1), key];
26+
}
27+
return [...prev, key];
28+
});
29+
}, []);
30+
31+
const removeKey = useCallback((id: string) => {
32+
setSelectedKeys(prev => prev.filter(k => k.id !== id));
33+
}, []);
34+
35+
const clearKeys = useCallback(() => {
36+
setSelectedKeys([]);
37+
}, []);
38+
39+
return {
40+
selectedKeys,
41+
addKey,
42+
removeKey,
43+
clearKeys,
44+
canAddMore: selectedKeys.length < MAX_COMPARISON_KEYS,
45+
};
46+
}

src/hooks/useTheme.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import { useCallback, useEffect, useState } from 'react';
22

3-
export type Theme = 'light' | 'dark';
3+
export type Theme = 'light' | 'dark' | 'system';
44

55
export const THEME_STORAGE_KEY = 'theme';
66

7+
function getSystemTheme(): 'light' | 'dark' {
8+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
9+
}
10+
711
function resolveInitialTheme(): Theme {
812
const stored = localStorage.getItem(THEME_STORAGE_KEY);
9-
if (stored === 'light' || stored === 'dark') return stored;
10-
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
13+
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
14+
return 'system';
1115
}
1216

1317
function applyTheme(theme: Theme): void {
14-
document.documentElement.classList.toggle('dark', theme === 'dark');
18+
const effectiveTheme = theme === 'system' ? getSystemTheme() : theme;
19+
document.documentElement.classList.toggle('dark', effectiveTheme === 'dark');
1520
}
1621

1722
export interface UseThemeResult {
@@ -27,8 +32,21 @@ export function useTheme(): UseThemeResult {
2732
localStorage.setItem(THEME_STORAGE_KEY, theme);
2833
}, [theme]);
2934

35+
useEffect(() => {
36+
if (theme !== 'system') return;
37+
38+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
39+
const handler = () => applyTheme('system');
40+
mediaQuery.addEventListener('change', handler);
41+
return () => mediaQuery.removeEventListener('change', handler);
42+
}, [theme]);
43+
3044
const toggleTheme = useCallback(() => {
31-
setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
45+
setTheme(prev => {
46+
if (prev === 'light') return 'dark';
47+
if (prev === 'dark') return 'system';
48+
return 'light';
49+
});
3250
}, []);
3351

3452
return { theme, toggleTheme };

src/pages/ComparePage.tsx

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { useSearchParams, Link } from 'react-router';
2+
import { ArrowLeft } from 'lucide-react';
3+
4+
interface KeyStats {
5+
id: string;
6+
name: string;
7+
price: string;
8+
volume: string;
9+
holders: string;
10+
supply: string;
11+
change24h: string;
12+
}
13+
14+
const DEMO_KEYS: KeyStats[] = [
15+
{
16+
id: '1',
17+
name: 'Creator Alpha',
18+
price: '1.25 XLM',
19+
volume: '5,000 XLM',
20+
holders: '150',
21+
supply: '10,000',
22+
change24h: '+5.2%',
23+
},
24+
{
25+
id: '2',
26+
name: 'Creator Beta',
27+
price: '0.85 XLM',
28+
volume: '3,200 XLM',
29+
holders: '89',
30+
supply: '8,000',
31+
change24h: '-2.1%',
32+
},
33+
{
34+
id: '3',
35+
name: 'Creator Gamma',
36+
price: '2.10 XLM',
37+
volume: '8,500 XLM',
38+
holders: '312',
39+
supply: '15,000',
40+
change24h: '+12.8%',
41+
},
42+
];
43+
44+
export default function ComparePage() {
45+
const [searchParams] = useSearchParams();
46+
const keyIds = searchParams.get('keys')?.split(',') ?? [];
47+
48+
const keysToShow = keyIds
49+
.map(id => DEMO_KEYS.find(k => k.id === id))
50+
.filter((k): k is KeyStats => k != null);
51+
52+
if (keysToShow.length === 0) {
53+
return (
54+
<div className="min-h-screen bg-black text-white">
55+
<div className="mx-auto max-w-5xl px-6 py-12">
56+
<Link
57+
to="/"
58+
className="mb-8 inline-flex items-center gap-2 text-sm text-white/60 hover:text-white/90"
59+
>
60+
<ArrowLeft className="size-4" />
61+
Back to Marketplace
62+
</Link>
63+
<h1 className="font-grotesque text-3xl font-black">Compare Keys</h1>
64+
<p className="mt-4 text-white/60">
65+
No keys selected for comparison. Go back to the marketplace and add
66+
keys to compare.
67+
</p>
68+
</div>
69+
</div>
70+
);
71+
}
72+
73+
return (
74+
<div className="min-h-screen bg-black text-white">
75+
<div className="mx-auto max-w-5xl px-6 py-12">
76+
<Link
77+
to="/"
78+
className="mb-8 inline-flex items-center gap-2 text-sm text-white/60 hover:text-white/90"
79+
>
80+
<ArrowLeft className="size-4" />
81+
Back to Marketplace
82+
</Link>
83+
<h1 className="font-grotesque text-3xl font-black">Compare Keys</h1>
84+
<p className="mt-2 text-white/60">
85+
Comparing {keysToShow.length} creator {keysToShow.length === 1 ? 'key' : 'keys'}
86+
</p>
87+
88+
<div className="mt-8 overflow-x-auto">
89+
<table className="w-full border-collapse">
90+
<thead>
91+
<tr>
92+
<th className="border-b border-white/10 py-4 pr-8 text-left text-sm font-medium text-white/50">
93+
Stat
94+
</th>
95+
{keysToShow.map(key => (
96+
<th
97+
key={key.id}
98+
className="border-b border-white/10 py-4 px-4 text-left text-sm font-medium text-white"
99+
>
100+
{key.name}
101+
</th>
102+
))}
103+
</tr>
104+
</thead>
105+
<tbody>
106+
<tr className="border-b border-white/5">
107+
<td className="py-4 pr-8 text-sm text-white/50">Price</td>
108+
{keysToShow.map(key => (
109+
<td key={key.id} className="py-4 px-4 text-sm font-medium">
110+
{key.price}
111+
</td>
112+
))}
113+
</tr>
114+
<tr className="border-b border-white/5">
115+
<td className="py-4 pr-8 text-sm text-white/50">Volume</td>
116+
{keysToShow.map(key => (
117+
<td key={key.id} className="py-4 px-4 text-sm">
118+
{key.volume}
119+
</td>
120+
))}
121+
</tr>
122+
<tr className="border-b border-white/5">
123+
<td className="py-4 pr-8 text-sm text-white/50">Holders</td>
124+
{keysToShow.map(key => (
125+
<td key={key.id} className="py-4 px-4 text-sm">
126+
{key.holders}
127+
</td>
128+
))}
129+
</tr>
130+
<tr className="border-b border-white/5">
131+
<td className="py-4 pr-8 text-sm text-white/50">Supply</td>
132+
{keysToShow.map(key => (
133+
<td key={key.id} className="py-4 px-4 text-sm">
134+
{key.supply}
135+
</td>
136+
))}
137+
</tr>
138+
<tr>
139+
<td className="py-4 pr-8 text-sm text-white/50">24h Change</td>
140+
{keysToShow.map(key => (
141+
<td
142+
key={key.id}
143+
className={`py-4 px-4 text-sm font-medium ${
144+
key.change24h.startsWith('+')
145+
? 'text-emerald-400'
146+
: key.change24h.startsWith('-')
147+
? 'text-red-400'
148+
: ''
149+
}`}
150+
>
151+
{key.change24h}
152+
</td>
153+
))}
154+
</tr>
155+
</tbody>
156+
</table>
157+
</div>
158+
</div>
159+
</div>
160+
);
161+
}

0 commit comments

Comments
 (0)