Skip to content

Commit 030e896

Browse files
Merge branch 'main' into feat/theme-support
2 parents c7cdfac + da3ab96 commit 030e896

5 files changed

Lines changed: 302 additions & 0 deletions

File tree

quantara/frontend/src/App.jsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import WithdrawAll from '@/pages/quantara/dashboard/withdraw-all/WithdrawAll';
2727
import { DefiSpringPage } from '@/pages/quantara/defi-spring/DefiSpring';
2828
import { AddDeposit } from '@/pages/add-deposit/AddDeposit';
2929
import Leaderboard from '@/pages/leaderboard/Leaderboard';
30+
import AddressBookPage from '@/pages/address-book/AddressBookPage';
3031
import NotFound from '@/pages/not-found/NotFound';
3132

3233
function App() {
@@ -137,6 +138,40 @@ function App() {
137138
)}
138139
</div>
139140
</ThemeProvider>
141+
<Header onConnectWallet={handleConnectWallet} onLogout={handleLogoutModal} />
142+
<main>
143+
<Routes>
144+
<Route index element={<QuantaraApp onConnectWallet={handleConnectWallet} onLogout={handleLogout} />} />
145+
<Route path="/dashboard" element={<Dashboard telegramId={window?.Telegram?.WebApp?.initData?.user?.id} />} />
146+
<Route path="/dashboard/position-history" element={<PositionHistory />} />
147+
<Route path="/dashboard/withdraw" element={<WithdrawAll />} />
148+
<Route path="/dashboard/deposit" element={<AddDeposit />} />
149+
<Route path="/withdraw" element={<Withdraw />} />
150+
<Route path="/overview" element={<OverviewPage />} />
151+
<Route path="/form" element={<Form />} />
152+
<Route path="/documentation" element={<Documentation />} />
153+
<Route path="/terms-and-conditions" element={<TermsAndConditionsPage />} />
154+
<Route path="/stake" element={<Stake />} />
155+
<Route path="/defispring" element={<DefiSpringPage />} />
156+
<Route path="/leaderboard" element={<Leaderboard />} />
157+
<Route path="/dashboard/address-book" element={<AddressBookPage />} />
158+
<Route path="*" element={<NotFound />} />
159+
</Routes>
160+
</main>
161+
<Footer />
162+
{isMobile && disableDesktopOnMobile && (
163+
<ActionModal
164+
isOpen={isMobileRestrictionModalOpen}
165+
title="Mobile website restriction"
166+
subTitle="Please, use desktop version or telegram mini-app"
167+
content={[]}
168+
cancelLabel="Cancel"
169+
submitLabel="Open in Telegram"
170+
submitAction={openTelegramBot}
171+
cancelAction={handleisMobileRestrictionModalClose}
172+
/>
173+
)}
174+
</div>
140175
);
141176
}
142177

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React, { useState, useRef } from 'react';
2+
import { useAddressBookStore } from '@/stores/useAddressBookStore';
3+
4+
function truncateAddress(addr) {
5+
if (addr.length <= 16) return addr;
6+
return `${addr.slice(0, 8)}...${addr.slice(-6)}`;
7+
}
8+
9+
function AddressBook() {
10+
const { addresses, addAddress, removeAddress, exportAddresses, importAddresses } =
11+
useAddressBookStore();
12+
const [name, setName] = useState('');
13+
const [address, setAddress] = useState('');
14+
const [importMessage, setImportMessage] = useState(null);
15+
const fileInputRef = useRef(null);
16+
17+
const handleAdd = (e) => {
18+
e.preventDefault();
19+
if (!name.trim() || !address.trim()) return;
20+
const success = addAddress(name, address);
21+
if (!success) {
22+
setImportMessage({ success: false, message: 'Address already exists' });
23+
setTimeout(() => setImportMessage(null), 3000);
24+
return;
25+
}
26+
setName('');
27+
setAddress('');
28+
};
29+
30+
const handleExport = () => {
31+
const json = exportAddresses();
32+
const blob = new Blob([json], { type: 'application/json' });
33+
const url = URL.createObjectURL(blob);
34+
const a = document.createElement('a');
35+
a.href = url;
36+
a.download = 'quantara-address-book.json';
37+
a.click();
38+
URL.revokeObjectURL(url);
39+
};
40+
41+
const handleImport = (e) => {
42+
const file = e.target.files?.[0];
43+
if (!file) return;
44+
const reader = new FileReader();
45+
reader.onload = (event) => {
46+
const result = importAddresses(event.target.result);
47+
setImportMessage(result);
48+
setTimeout(() => setImportMessage(null), 4000);
49+
};
50+
reader.readAsText(file);
51+
e.target.value = '';
52+
};
53+
54+
const handleCopy = (addr) => {
55+
navigator.clipboard.writeText(addr);
56+
};
57+
58+
return (
59+
<div className="flex flex-col gap-6 w-full">
60+
<form onSubmit={handleAdd} className="flex flex-col gap-3">
61+
<input
62+
type="text"
63+
placeholder="Name (e.g. My Wallet)"
64+
value={name}
65+
onChange={(e) => setName(e.target.value)}
66+
className="w-full rounded-lg border border-[#300734] bg-transparent px-4 py-3 text-sm text-white placeholder-gray outline-none focus:border-[#a855f7] transition-colors"
67+
/>
68+
<input
69+
type="text"
70+
placeholder="Wallet address (0x...)"
71+
value={address}
72+
onChange={(e) => setAddress(e.target.value)}
73+
className="w-full rounded-lg border border-[#300734] bg-transparent px-4 py-3 text-sm text-white placeholder-gray outline-none focus:border-[#a855f7] transition-colors"
74+
/>
75+
<button
76+
type="submit"
77+
disabled={!name.trim() || !address.trim()}
78+
className="w-full rounded-lg border border-[#a855f7] bg-transparent py-3 text-sm font-semibold text-white transition-colors hover:bg-[#a855f7]/20 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
79+
>
80+
Add Address
81+
</button>
82+
</form>
83+
84+
<div className="flex gap-2">
85+
<button
86+
onClick={handleExport}
87+
disabled={addresses.length === 0}
88+
className="flex-1 rounded-lg border border-light-purple bg-transparent py-2.5 text-xs font-semibold text-white transition-colors hover:border-[#a855f7] disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
89+
>
90+
Export JSON
91+
</button>
92+
<button
93+
onClick={() => fileInputRef.current?.click()}
94+
className="flex-1 rounded-lg border border-light-purple bg-transparent py-2.5 text-xs font-semibold text-white transition-colors hover:border-[#a855f7] cursor-pointer"
95+
>
96+
Import JSON
97+
</button>
98+
<input
99+
ref={fileInputRef}
100+
type="file"
101+
accept=".json"
102+
onChange={handleImport}
103+
className="hidden"
104+
/>
105+
</div>
106+
107+
{importMessage && (
108+
<div
109+
className={`rounded-lg px-4 py-2.5 text-xs ${
110+
importMessage.success
111+
? 'border border-green-500/40 text-green-400'
112+
: 'border border-red-500/40 text-red-400'
113+
}`}
114+
>
115+
{importMessage.message}
116+
</div>
117+
)}
118+
119+
{addresses.length === 0 ? (
120+
<p className="text-center text-sm text-gray py-8">
121+
No saved addresses yet. Add one above or import from a JSON file.
122+
</p>
123+
) : (
124+
<div className="flex flex-col gap-2">
125+
{addresses.map((entry) => (
126+
<div
127+
key={entry.id}
128+
className="flex items-center justify-between rounded-lg border border-[#300734] bg-transparent px-4 py-3"
129+
>
130+
<div className="flex flex-col gap-0.5 min-w-0">
131+
<span className="text-sm font-semibold text-white truncate">
132+
{entry.name}
133+
</span>
134+
<button
135+
onClick={() => handleCopy(entry.address)}
136+
className="text-xs text-gray hover:text-[#a855f7] transition-colors cursor-pointer text-left truncate max-w-[260px]"
137+
title="Click to copy"
138+
>
139+
{truncateAddress(entry.address)}
140+
</button>
141+
</div>
142+
<button
143+
onClick={() => removeAddress(entry.id)}
144+
className="ml-3 shrink-0 text-xs text-red-400 hover:text-red-300 transition-colors cursor-pointer px-2 py-1"
145+
>
146+
Delete
147+
</button>
148+
</div>
149+
))}
150+
</div>
151+
)}
152+
</div>
153+
);
154+
}
155+
156+
export default AddressBook;

quantara/frontend/src/pages/DashboardLayout.jsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import computerIcon from '@/assets/icons/computer-icon.svg';
66
import depositIcon from '@/assets/icons/deposit.svg';
77
import withdrawIcon from '@/assets/icons/withdraw.svg';
88
import formIcon from '@/assets/icons/form-icon.svg';
9+
import addressBookIcon from '@/assets/icons/dashboard-icon.svg';
910
import { useCheckMobile } from '@/hooks/useCheckMobile';
1011

1112
const dashboardItems = [
@@ -39,6 +40,12 @@ const dashboardItems = [
3940
link: '/dashboard/withdraw',
4041
icon: withdrawIcon,
4142
},
43+
{
44+
id: 'address-book',
45+
name: 'Address Book',
46+
link: '/dashboard/address-book',
47+
icon: addressBookIcon,
48+
},
4249
];
4350

4451
export default function DashboardLayout({ children, title = 'Position' }) {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import DashboardLayout from '../DashboardLayout';
2+
import AddressBook from '@/components/ui/address-book/AddressBook';
3+
4+
export default function AddressBookPage() {
5+
return (
6+
<DashboardLayout title="Address Book">
7+
<AddressBook />
8+
</DashboardLayout>
9+
);
10+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { create } from 'zustand';
2+
3+
const STORAGE_KEY = 'quantara-address-book';
4+
5+
function loadFromStorage() {
6+
try {
7+
const raw = localStorage.getItem(STORAGE_KEY);
8+
if (!raw) return [];
9+
const parsed = JSON.parse(raw);
10+
return Array.isArray(parsed) ? parsed : [];
11+
} catch {
12+
return [];
13+
}
14+
}
15+
16+
function saveToStorage(addresses) {
17+
localStorage.setItem(STORAGE_KEY, JSON.stringify(addresses));
18+
}
19+
20+
let nextId = Date.now();
21+
22+
export const useAddressBookStore = create((set, get) => ({
23+
addresses: loadFromStorage(),
24+
25+
addAddress: (name, address) => {
26+
const trimmed = address.trim();
27+
const exists = get().addresses.some(
28+
(a) => a.address.toLowerCase() === trimmed.toLowerCase()
29+
);
30+
if (exists) return false;
31+
32+
const entry = {
33+
id: String(nextId++),
34+
name: name.trim(),
35+
address: trimmed,
36+
createdAt: new Date().toISOString(),
37+
};
38+
const updated = [...get().addresses, entry];
39+
saveToStorage(updated);
40+
set({ addresses: updated });
41+
return true;
42+
},
43+
44+
removeAddress: (id) => {
45+
const updated = get().addresses.filter((a) => a.id !== id);
46+
saveToStorage(updated);
47+
set({ addresses: updated });
48+
},
49+
50+
exportAddresses: () => {
51+
const data = {
52+
version: 1,
53+
addresses: get().addresses,
54+
};
55+
return JSON.stringify(data, null, 2);
56+
},
57+
58+
importAddresses: (jsonString) => {
59+
try {
60+
const parsed = JSON.parse(jsonString);
61+
if (!parsed.addresses || !Array.isArray(parsed.addresses)) {
62+
return { success: false, message: 'Invalid file format' };
63+
}
64+
65+
const existing = get().addresses;
66+
const existingSet = new Set(
67+
existing.map((a) => a.address.toLowerCase())
68+
);
69+
let imported = 0;
70+
71+
for (const addr of parsed.addresses) {
72+
if (!addr.address || !addr.name) continue;
73+
if (existingSet.has(addr.address.toLowerCase())) continue;
74+
existingSet.add(addr.address.toLowerCase());
75+
existing.push({
76+
id: String(nextId++),
77+
name: addr.name,
78+
address: addr.address,
79+
createdAt: addr.createdAt || new Date().toISOString(),
80+
});
81+
imported++;
82+
}
83+
84+
saveToStorage(existing);
85+
set({ addresses: [...existing] });
86+
return {
87+
success: true,
88+
message: `Imported ${imported} address(es), skipped ${parsed.addresses.length - imported} duplicate(s)`,
89+
};
90+
} catch {
91+
return { success: false, message: 'Failed to parse JSON' };
92+
}
93+
},
94+
}));

0 commit comments

Comments
 (0)